Math.random() returns a number from 0 up to, but not including, 1, a different one on each call. The numbers aren't truly random. They're pseudo-random: a formula works each one out from a hidden internal state, which the engine sets up itself. Code that makes numbers this way is a pseudo-random number generator, or PRNG. JavaScript gives you no way to choose where Math.random starts, so you can't get the same numbers twice. For tests, that's a problem: code that uses Math.random does something different on every run, and a failure you saw once may never come back.
A seeded generator lets you choose the starting point, called the seed. It's a small function that, from a given seed, produces a fixed sequence of numbers from 0 up to 1. The same seed always gives the same numbers in the same order, on every run, and a different seed gives a different sequence. The numbers still look random: spread evenly, with no pattern you'd notice. Picture a book of random digits: the seed is the page you start reading from, and starting on the same page gives you the same digits.
Here's one in use: seeded(seed), a small, widely copied generator called mulberry32. It returns a function that works like Math.random:
| Code | Returns |
|---|---|
const random = seeded(42) | A function to call, like Math.random |
random() | 0.6011037519201636 |
random() | 0.44829055899754167 |
random() | 0.8524657934904099 |
seeded(42)() | 0.6011037519201636: a new generator with the same seed starts the same sequence again |
seeded(7)() | 0.011704753153026104: another seed, another sequence |
That's why code that relies on randomness often takes its random source as a parameter, with Math.random as the default. Real callers leave it out and get Math.random. A test passes in a seeded generator, to get the same results on every run, or a function that always returns the same number, to force one outcome:
function pickVariant(random = Math.random) {
return random() < 0.5 ? "A" : "B"; // which version of a page a visitor sees
}
pickVariant(); // "A" or "B", by chance
pickVariant(() => 0.2); // "A", every time
pickVariant(() => 0.7); // "B", every time
// Is it fair? Run it many times with a seeded generator and count.
const random = seeded(42);
const counts = { A: 0, B: 0 };
for (let i = 0; i < 10_000; i++) counts[pickVariant(random)]++;
counts; // { A: 5022, B: 4978 }: about half each, and the same counts on every run
A single result can't show that something random is fair, since any one result is allowed. So a test runs it many times, counts how often each outcome comes up, and checks that every count is close to its fair share: here, about 5,000 out of 10,000, so a test might accept anything from 4,800 to 5,200. With a seeded generator, the counts are the same on every run, so the test can't pass one day and fail the next by bad luck.
Neither Math.random nor a seeded generator is safe when the numbers must not be guessed, as for tokens or prizes: they come from a formula, and anyone who knows a seed knows every number it gives. For those, use crypto.getRandomValues, built into browsers and Node, which fills a typed array with cryptographically strong random values. Seeds also show up outside tests: a game that builds its worlds from a seed builds the same world from the same seed, and fast-check, a library that tests code with random inputs, prints the seed of a failing run so you can replay it.
Write shuffle(array, random). It returns a new array with the same items as array, in a random order. array itself never changes.
random is optional and defaults to Math.random. It takes no arguments and returns a number from 0 up to, but not including, 1. Get every random number from random, never from Math.random directly, so a test can pass in a predictable one.
Every order must be equally likely, as long as random is fair: shuffling 3 items, each of their 6 orders should come up about once in 6 shuffles, and each item should land in each position about as often as any other. The tests shuffle thousands of times with a seeded random and count.
shuffle([1, 2, 3, 4, 5]); // for example [3, 5, 1, 2, 4]
shuffle(["a", "b", "c"], () => 0); // the same order every time, since random always returns 0
const input = [1, 2, 3, 4, 5];
const result = shuffle(input, () => 0.5);
expect(result).not.toBe(input);
expect(result).toHaveLength(5);
expect([...result].sort((x, y) => x - y)).toEqual([1, 2, 3, 4, 5]);
const input = ["a", "b", "c", "d"];
shuffle(input, () => 0);
shuffle(input);
expect(input).toEqual(["a", "b", "c", "d"]);
const item = { id: 1 };
const result = shuffle([item, "x", "x", item], () => 0.3);
expect(result.filter((value) => value === item)).toHaveLength(2);
expect(result.filter((value) => value === "x")).toHaveLength(2);
expect(shuffle([])).toEqual([]);
expect(shuffle([7], () => 0.9)).toEqual([7]);
const realRandom = Math.random;
const fake = spy(() => realRandom());
Math.random = fake;
let result;
try {
result = shuffle([1, 2, 3, 4, 5]);
} finally {
Math.random = realRandom;
}
expect(fake).toHaveBeenCalled();
expect([...result].sort((x, y) => x - y)).toEqual([1, 2, 3, 4, 5]);
const realRandom = Math.random;
Math.random = () => {
throw new Error("shuffle called Math.random instead of random");
};
let result;
try {
result = shuffle([1, 2, 3, 4, 5, 6], () => 0.42);
} finally {
Math.random = realRandom;
}
expect([...result].sort((x, y) => x - y)).toEqual([1, 2, 3, 4, 5, 6]);
for (const value of [0, 0.9999999999]) {
const result = shuffle([1, 2, 3, 4, 5], () => value);
expect([...result].sort((x, y) => x - y)).toEqual([1, 2, 3, 4, 5]);
}
// A seeded random number generator (mulberry32): the same seed always gives the same numbers.
function seeded(seed) {
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), seed | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const random = seeded(42);
const counts = new Map();
for (let run = 0; run < 6000; run++) {
const order = shuffle(["a", "b", "c"], random).join("");
counts.set(order, (counts.get(order) ?? 0) + 1);
}
expect([...counts.keys()].sort()).toEqual(["abc", "acb", "bac", "bca", "cab", "cba"]);
for (const count of counts.values()) {
// 6,000 shuffles over 6 orders: about 1,000 each.
expect(count).toBeGreaterThan(850);
expect(count).toBeLessThan(1150);
}
// A seeded random number generator (mulberry32): the same seed always gives the same numbers.
function seeded(seed) {
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), seed | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const random = seeded(7);
const items = [0, 1, 2, 3, 4];
const counts = items.map(() => items.map(() => 0));
for (let run = 0; run < 5000; run++) {
shuffle(items, random).forEach((item, position) => counts[item][position]++);
}
for (const row of counts) {
for (const count of row) {
// 5,000 shuffles over 5 positions: about 1,000 each.
expect(count).toBeGreaterThan(850);
expect(count).toBeLessThan(1150);
}
}
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Fisher–Yates shuffle. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.