Isolation & Contracts

Controlling Time and Randomness in Tests

A test that asserts an order expires "in 24 hours", or that a shuffled deck matches a golden sequence, or that a generated invoice ID equals a fixed string, is a test coupled to the wall clock and the global random state. It passes on the afternoon you wrote it and fails at 23:59 UTC, on a leap day, in a different timezone CI runner, or simply on the next run once pytest-randomly reseeds the generator. The symptom is the most corrosive kind of flake: green locally, intermittently red in CI, with no code change to blame. The cure is determinism — freeze time to a known instant, seed every random source, fix UUID generation, and, where you control the code, inject a clock so the dependency is explicit rather than ambient. This guide covers freezegun and time-machine, stdlib and numpy seeding, deterministic uuid, and the injectable-clock seam that makes most freezing unnecessary.

Prerequisites

  • python >= 3.9.
  • pytest >= 8.0.
  • Time control: freezegun >= 1.5 and/or time-machine >= 2.14.
  • Optional numerical stack: numpy >= 1.26 (for numpy.random.Generator).
Bash
pip install "pytest>=8.0" "freezegun>=1.5" "time-machine>=2.14" "numpy>=1.26"

This guide leans on the pytest monkeypatch fixture for the lightweight patching cases and on the namespace rules in Patching Strategies for Complex Codebases — getting the patch target right matters as much for datetime as for any other dependency.

Core concept

There are two strategies, and they sit at opposite ends of a design spectrum. The first is interception: a test-time library or monkeypatch swaps datetime.now, time.time, or random.random for a controlled version, leaving production code untouched. The second is injection: production code is written to receive its clock and random source as dependencies, so a test supplies fixed ones with no patching at all. Injection produces the cleanest tests and the most honest code, but interception is what you reach for when you cannot change the code under test.

The injectable-clock seam Code that calls datetime.now directly must be intercepted by a freezing library, whereas code that accepts a clock dependency takes a fixed clock straight from the test. Two ways to control time Ambient clock code calls datetime.now() directly test must freeze / monkeypatch freezegun / time-machine intercepts the global clock Injected clock code takes now=... as a parameter dependency is explicit test passes a fixed clock no patching library needed Same idea for randomness ambient: seed the global random / numpy state injected: pass a seeded Random or Generator instance injection removes hidden global state entirely
Code that reads an ambient clock or global RNG must be intercepted at test time; code that accepts a clock and a random source as dependencies takes fixed ones straight from the test, removing hidden global state.

Step-by-step implementation

1. Freeze time with freezegun

freezegun patches datetime.datetime, datetime.date, and time.time across all modules so any code reading the clock during the frozen block sees the same instant.

Python
from datetime import datetime, timezone, timedelta
from freezegun import freeze_time

def order_expiry(created: datetime) -> datetime:
    return created + timedelta(hours=24)

def is_expired() -> bool:
    # Reads the wall clock directly — the dependency is ambient.
    return datetime.now(timezone.utc) > datetime(2026, 1, 1, tzinfo=timezone.utc)

@freeze_time("2026-06-18T12:00:00Z")
def test_expiry_is_deterministic():
    now = datetime.now(timezone.utc)
    assert now == datetime(2026, 6, 18, 12, 0, tzinfo=timezone.utc)
    assert order_expiry(now) == datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc)
    assert is_expired() is True

Use the context-manager form when you need the clock to advance mid-test:

Python
from freezegun import freeze_time
from datetime import datetime

def test_clock_can_tick():
    with freeze_time("2026-06-18T12:00:00Z") as frozen:
        t0 = datetime.now()
        frozen.tick()                       # advance 1 second by default
        frozen.tick(delta=60)               # advance 60 seconds
        assert (datetime.now() - t0).total_seconds() == 61

The trade-offs and breakage points of freezegun versus a raw monkeypatch are dissected in Freezing Time: freezegun vs monkeypatch.

2. Reach for time-machine when speed or C extensions matter

time-machine patches at the CPython level (it hooks the datetime type and the underlying clock), making it dramatically faster than freezegun and able to fool C-extension code that calls the libc clock.

Python
import time
import datetime as dt
import time_machine

@time_machine.travel("2026-06-18 12:00 +0000")
def test_time_machine_freezes_everything():
    assert dt.datetime.now(dt.timezone.utc).year == 2026
    # time.time() is frozen too, including for C code that reads it.
    assert int(time.time()) == 1781870400

def test_time_machine_tick():
    # tick=False freezes; call .shift() to advance deliberately.
    with time_machine.travel("2026-06-18 12:00 +0000", tick=False) as traveller:
        t0 = time.time()
        traveller.shift(delta=30)
        assert time.time() - t0 == 30

3. Seed the standard-library RNG

The stdlib random module uses a global Mersenne Twister. Seed it for reproducibility, but prefer an explicit random.Random instance so one test cannot poison another's global state.

Python
import random

def shuffle_deck(cards: list[int], rng: random.Random) -> list[int]:
    out = list(cards)
    rng.shuffle(out)                        # uses the INJECTED generator
    return out

def test_shuffle_is_reproducible():
    rng = random.Random(0)                  # local, seeded generator
    assert shuffle_deck([1, 2, 3, 4, 5], rng) == [4, 2, 3, 5, 1]
    # Re-seeding reproduces the exact sequence.
    assert shuffle_deck([1, 2, 3, 4, 5], random.Random(0)) == [4, 2, 3, 5, 1]

4. Seed numpy with a Generator, not the legacy global

Modern numpy seeding uses default_rng. Avoid numpy.random.seed, which mutates a process-global legacy state shared across the suite.

Python
import numpy as np

def sample_weights(n: int, rng: np.random.Generator) -> np.ndarray:
    return rng.normal(size=n)               # injected Generator

def test_numpy_is_deterministic():
    rng = np.random.default_rng(42)         # modern, isolated bit generator
    first = sample_weights(3, rng)
    second = sample_weights(3, np.random.default_rng(42))
    np.testing.assert_array_equal(first, second)

5. Make uuid deterministic

uuid.uuid4() is random, so generated identifiers break golden-file assertions. Patch it with a counted factory, or — better — inject an id generator.

Python
import uuid
from itertools import count

def make_id_factory():
    counter = count(1)
    # Deterministic, sortable, reproducible identifiers.
    return lambda: uuid.UUID(int=next(counter))

def create_record(name: str, id_gen=uuid.uuid4) -> dict:
    return {"id": str(id_gen()), "name": name}   # id source is injectable

def test_uuid_is_deterministic():
    id_gen = make_id_factory()
    assert create_record("a", id_gen)["id"] == "00000000-0000-0000-0000-000000000001"
    assert create_record("b", id_gen)["id"] == "00000000-0000-0000-0000-000000000002"

6. Prefer the injectable-clock seam

The cleanest fix needs no library. Have production code accept its clock as a dependency; the test passes a fixed one.

Python
from datetime import datetime, timezone, timedelta
from collections.abc import Callable

class Session:
    # now is injected; defaults to the real clock in production.
    def __init__(self, now: Callable[[], datetime] = lambda: datetime.now(timezone.utc)):
        self._now = now
        self.created = self._now()

    def expired(self, ttl=timedelta(minutes=30)) -> bool:
        return self._now() > self.created + ttl

def test_session_expiry_with_injected_clock():
    clock = iter([
        datetime(2026, 6, 18, 12, 0, tzinfo=timezone.utc),   # creation
        datetime(2026, 6, 18, 12, 31, tzinfo=timezone.utc),  # check, 31 min later
    ])
    session = Session(now=lambda: next(clock))
    assert session.expired() is True        # no freezing library needed

Verification

  • Run any time- or random-dependent test twice with pytest -p randomly (pytest-randomly) enabled; identical results prove no ambient state leaks in.
  • Re-run the suite under TZ=Pacific/Kiritimati pytest and TZ=Etc/GMT+12 pytest; a test that only passes in one timezone is reading the wall clock somewhere you missed.
  • For numpy code, assert with np.testing.assert_array_equal against a stored golden array rather than eyeballing floats.
  • Confirm freeze_time/travel actually covers the call site by asserting datetime.now() inside the block equals the frozen instant before asserting business logic.

Troubleshooting

SymptomRoot causeFix
Test fails only at certain times of dayCode reads datetime.now() / time.time() un-frozenFreeze the block with freeze_time/time_machine.travel, or inject a clock
freeze_time has no effect on a library callThe library is a C extension reading the libc clock directlySwitch to time-machine, which patches at the CPython clock level
Random test still flaky after random.seedA second RNG (numpy, secrets, or a fresh Random()) is unseededSeed every generator, or inject seeded instances so none is ambient
numpy results differ across machinesnumpy.random.seed legacy global state was mutated elsewhereUse np.random.default_rng(seed) and pass the Generator in
Golden assertion breaks on uuiduuid.uuid4() is random per callPatch uuid.uuid4 or inject a counted id factory
Frozen time leaks into the next testfreeze_time().start() without stop()Use the decorator/context-manager form so teardown is automatic

Frequently Asked Questions

Why does my test pass locally but fail at midnight or in CI? The code reads the wall clock or a random source, so its output changes with the environment. Freeze time with freezegun or time-machine, seed the RNG, and inject a clock so the test produces the same result regardless of when or where it runs.

Should I use freezegun or time-machine to freeze time? Use freezegun for broad compatibility and a simple API; use time-machine when speed matters or when C-extension code calls the libc clock directly, because time-machine patches at the CPython datetime/clock level and is far faster. freezegun cannot intercept C code that bypasses Python's datetime module.

How do I make random and numpy produce the same values every run? Seed both generators before the code runs: random.seed(0) for the stdlib, and a numpy Generator created with numpy.random.default_rng(0) rather than the legacy global numpy.random.seed. Prefer passing an explicit seeded Generator into the code so global state cannot leak between tests.

What is the testability seam for time? An injectable clock: the code takes a callable like now=datetime.now as a parameter or constructor argument instead of calling datetime.now() directly. Tests pass a fixed clock, so no monkeypatching or freezing library is needed and the dependency is explicit.

← Back to Advanced Mocking & Test Doubles in Python