Skip to content
rusthacks.dev
Testing

Testing Code That Uses Randomness in Rust Reliably

Make code that uses random numbers testable: inject the generator, seed it, test properties instead of values, and keep production randomness unpredictable.

3 min read
Summarize in AI
Flat illustration for the post “Testing Code That Uses Randomness in Rust Reliably”, composed around testing.

Random numbers make tests either flaky or useless: a test that passes 999 times out of 1000 fails a build at random, and a test that ignores the random part tests nothing. The cure is to make the generator an input. Everything compiles on Rust 1.90 with edition 2024 and rand 0.9.

The hidden-input version

rust
use rand::Rng;

pub fn roll_dice() -> u8 {
    rand::rng().random_range(1..=6)
}

A test can only assert 1..=6, and that tells you little. Anything built on this (a game turn, a retry delay with jitter) inherits the problem.

Inject the generator

rust
use rand::Rng;

pub fn roll_dice(rng: &mut impl Rng) -> u8 {
    rng.random_range(1..=6)
}

pub fn retry_delay_ms(attempt: u32, rng: &mut impl Rng) -> u64 {
    let base = 100u64 * 2u64.pow(attempt.min(6));
    let jitter = rng.random_range(0..=base / 2);
    base + jitter
}

#[cfg(test)]
mod tests {
    use super::*;
    use rand::{rngs::StdRng, SeedableRng};

    #[test]
    fn same_seed_same_rolls() {
        let mut a = StdRng::seed_from_u64(7);
        let mut b = StdRng::seed_from_u64(7);
        let xs: Vec<u8> = (0..10).map(|_| roll_dice(&mut a)).collect();
        let ys: Vec<u8> = (0..10).map(|_| roll_dice(&mut b)).collect();
        assert_eq!(xs, ys);
    }

    #[test]
    fn delay_stays_in_bounds() {
        let mut rng = StdRng::seed_from_u64(1);
        for attempt in 0..10 {
            let d = retry_delay_ms(attempt, &mut rng);
            let base = 100u64 * 2u64.pow(attempt.min(6));
            assert!(d >= base && d <= base + base / 2);
        }
    }
}

The first test shows that the seed controls the stream. The second checks the property that matters, bounds, across many draws. Production code calls roll_dice(&mut rand::rng()), which uses the thread-local generator seeded from the operating system.

Properties beat values

Asserting roll_dice(&mut rng) == 4 for seed 7 passes today and may fail after a rand upgrade that changes the algorithm. A property such as "all values in 1..=6, and over 600 rolls every face appears" survives upgrades. For shuffles and samples:

rust
use rand::seq::SliceRandom;
use rand::{rngs::StdRng, SeedableRng};

#[test]
fn shuffle_is_a_permutation() {
    let mut rng = StdRng::seed_from_u64(99);
    let mut v: Vec<u32> = (0..50).collect();
    v.shuffle(&mut rng);
    let mut sorted = v.clone();
    sorted.sort();
    assert_eq!(sorted, (0..50).collect::<Vec<_>>());
    assert_ne!(v, sorted);
}

The last assertion could in theory fail with probability 1/50!, which is not a practical concern for a fixed seed: it either passes now and always, or you pick another seed.

Let a library search many seeds

proptest runs a property over many generated inputs and shrinks failures, so you get breadth without choosing seeds by hand (property testing). When it finds a failure it prints the seed to replay.

What not to do

  • Seeding the global generator in production for reproducibility; security tokens, session ids and nonces must come from the OS generator (secrets handling).
  • Sharing one global generator among tests; use one per test.
  • Using the system time as the seed in a test. It makes failures unreproducible.

Time is the other hidden input and has its own treatment in injected clocks.

Taggedrand
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.