There is a 2% chance that quality will be legendary yet when printed 20 times every time it is run at least 1 item in almost every test run is legendary.
Your algorithm generates a "legendary" rarity whenever a random number in the range 0..1 rolls above 0.97. That's a 3% chance or a chance of one in 33. It's not a 2% chance because
random() generates double-precision floating-point numbers.
0.970000000023 is just as likely as
As DMGregory calculated in a comment, you will have at least one legendary in 20 samples 45.6% of the time.
If you want an event to happen every
nth times on average, do
if (random() < 1.0 / n). So to get something to happen once in 50, do
random() < 0.02. Or
random() > 0.98 when you prefer that form. Checking randomly-generated floating-point numbers for equality is quite pointless because of their high presision (by the way: this also applies to floating-point numbers in most other situations).
However, keep in mind that random numbers have no memory (ok, techncally they do, because pseudorandom number generators are actually deterministic, but they do their best to hide that fact). Everytime you roll you have the same chance of success (further reading Gambler's Fallacy). This leads to winning-streaks and losing-streaks which last far longer than you would expect intuitively and often have a tendency to feel much longer than the player finds plausible (this effect is known as the Clustering Illusion).
Although both are psychological phenomenons where the game is working as intended and the player's perception of the game is skewed, we as game designers do well when we work around these and instead try to make the randomness in our games feel right and not be right. We have an interesting question with various techniques to mitigate this streak-effect and have rare events happen with pauses between them.