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 — a focused application of dependency injection for testability — 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.

Interception versus injection With interception the code keeps calling datetime.now directly and a freezing library or monkeypatch swaps the global clock at test time; with injection the code accepts a clock as a dependency and the test passes a fixed one, so no patching library is involved. The same split applies to randomness: seed the global generator, or pass a seeded instance in. Two ways to gain control Interception code calls datetime.now() directly the ambient dependency is left in place a freezing library / monkeypatch swaps the global clock at test time Injection code takes now=... as a parameter the dependency is named and explicit the test passes a fixed clock in no patching library is involved The same split governs randomness intercept by seeding the global generator, or inject a seeded Random / Generator instance
Interception leaves the ambient dependency in place and swaps it at test time; injection makes the clock a named argument the test supplies directly. The identical choice applies to every random source.

A third fact governs both strategies: not every time or random source is patchable. freezegun freezes the wall clock (datetime.now, date.today, time.time) but deliberately leaves time.monotonic() and time.perf_counter() running, and the OS cryptographic RNG behind secrets and os.urandom answers to no seed. Knowing which sources you can freeze, which you must seed, and which you can only inject is what separates a durable fix from one that quietly leaks state.

Three source classes, three control levers The wall clock is frozen with freezegun or time-machine; pseudo-random generators like random and numpy are seeded with a local instance; the monotonic clock and the OS cryptographic RNG behind secrets, os.urandom and uuid4 answer to neither freezing nor seeding and can only be injected. Match the lever to what the source reads every non-deterministic input falls into exactly one of these three lanes WALL CLOCK reads the calendar & clock datetime.now() date.today() time.time() FREEZE freezegun / time-machine intercepts the global clock PRNG pseudo-random, seedable random.random() random.shuffle() numpy Generator SEED Random(0) / default_rng(0) use a local, isolated instance MONOTONIC & OS CSPRNG no seed, no freeze reaches it time.monotonic() perf_counter() secrets, os.urandom uuid.uuid4() INJECT ONLY cannot freeze or seed it pass the source in as a dependency
Before you reach for a tool, classify the source: freeze the wall clock, seed the PRNG, and inject everything backed by the monotonic clock or the OS cryptographic RNG — no seed or freeze_time can touch that third lane.

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

Injection is not confined to a now callable. Pass a seeded random.Random, a numpy Generator, and an id factory the same way, and the object under test carries zero hidden global state — every non-deterministic input is a named constructor argument you can pin from the test.

7. Freeze once per test with a fixture

When a whole module needs the same instant, wrap the freeze in a fixture rather than decorating every test. The fixture yields the frozen-time handle so individual tests can still advance the clock.

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

@pytest.fixture
def frozen_clock():
    with freeze_time("2026-06-18T12:00:00Z") as clock:
        yield clock                         # teardown lifts the freeze automatically

def test_uses_frozen_fixture(frozen_clock):
    assert datetime.now(timezone.utc).hour == 12
    frozen_clock.tick(delta=3600)           # advance one hour within this test only
    assert datetime.now(timezone.utc).hour == 13

Prefer this over an autouse=True freeze: an always-on frozen clock silently hides tests that genuinely depend on real time, and the leak surfaces far from its cause. Keep the freeze opt-in per test that needs it. The fixture wiring here follows the same lifecycle rules covered in mastering pytest fixtures.

What neither freezing nor seeding can reach

Some sources sit outside the reach of both strategies, so treat them as injection-only:

  • time.monotonic() and time.perf_counter() are intentionally decoupled from the wall clock and are not patched by freezegun. time-machine does control them, but code that measures elapsed durations should take an injected timer rather than rely on either library.
  • asyncio event-loop time (loop.time()) derives from the monotonic clock, so asyncio.sleep durations are unaffected by freeze_time — freezing the wall clock will not fast-forward a sleeping coroutine.
  • secrets.token_hex, os.urandom, and uuid.uuid4 draw from the OS cryptographic RNG, which no random.seed or numpy seed can influence. Patch or inject them explicitly, as in step 5.
  • Third-party generators keep their own state: seed Faker with Faker.seed_instance(0) on the instance you use, not the class, so parallel tests do not share a sequence.

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.

One more control belongs in the same fixture as the clock: the timezone. TZ is read by C-level time functions at first use, so a test that formats a local timestamp passes in UTC-based CI and fails on a developer laptop in Europe. Set it explicitly with monkeypatch.setenv("TZ", "UTC") followed by time.tzset() on POSIX, or keep every stored timestamp timezone-aware and never call astimezone() without an explicit target. The failure this prevents — an hour-off assertion that appears only in March and October — is one of the hardest to reproduce on demand, because it depends on the machine's DST rules rather than on anything in the test.

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
Elapsed-time check still varies under freeze_timeCode uses time.monotonic()/perf_counter(), which freezegun leaves runningSwitch to time-machine, or inject the timer and pass a fixed one
secrets/os.urandom output changes despite seedingThese read the OS CSPRNG, not the seeded PRNGPatch the call or inject the token source; seeding random has no effect
await asyncio.sleep(...) ignores the frozen clockEvent-loop time comes from the monotonic clock, not the wall clockInject the delay or use an async test helper; do not rely on freezing

Making randomness reproducible without freezing it

Time is only half the non-determinism budget. The other half is randomness, and the failure it produces is worse: a test that fails once in two hundred runs, on a machine you do not have, with a seed nobody recorded.

Three sources need separate treatment, because they hold separate state. random uses a module-level Random instance; numpy.random has both a legacy global RandomState and the modern Generator API; and secrets/os.urandom read the OS entropy pool and are deliberately unseedable. Code that must be reproducible in tests should never call the third group directly — inject a source instead.

Python
import random
import numpy as np
import pytest

@pytest.fixture(autouse=True)
def deterministic_randomness(request):
    """Seed every generator from the test's own id so runs are reproducible."""
    seed = abs(hash(request.node.nodeid)) % (2 ** 32)   # stable per test, varies across tests
    random.seed(seed)
    np.random.seed(seed)                                 # legacy global RandomState
    rng = np.random.default_rng(seed)                    # modern Generator, injectable
    print(f"randomness seed for this test: {seed}")      # shown by pytest on failure
    yield rng

Seeding from the test id rather than from a constant matters. A single shared seed makes every test draw the same values, which hides bugs that only appear for particular inputs and creates false coupling between tests that happen to draw in sequence. A per-test seed gives you reproducibility and diversity at once, and because pytest captures stdout and prints it only for failing tests, the seed appears exactly when you need it.

For code paths where the values themselves are the specification — a shuffle that must be stable, a sampling routine whose boundaries matter — do not seed at all. Inject the generator and pass a deterministic stub:

Python
class SequenceRandom:
    """A drop-in random source that yields scripted values, then repeats the last."""
    def __init__(self, values):
        self._values = list(values)
        self._i = 0

    def random(self) -> float:
        value = self._values[min(self._i, len(self._values) - 1)]
        self._i += 1
        return value

def choose_shard(key: str, rng) -> int:
    return int(rng.random() * 8)                # 0..7

def test_shard_boundaries():
    rng = SequenceRandom([0.0, 0.999])
    assert choose_shard("a", rng) == 0          # lower boundary
    assert choose_shard("b", rng) == 7          # upper boundary, no flakiness

Two operational notes close the loop. Record the seed in the failure output, not in a log file — a seed you have to grep for is a seed nobody uses. And keep PYTHONHASHSEED fixed in CI when any test depends on set or dict iteration order, because hash randomisation is a fourth entropy source that no library-level seeding touches.

Four sources of randomness and how to pin each A table of four randomness sources - the random module, numpy legacy global state, numpy Generator objects, and secrets or os.urandom - with the way each is made deterministic and whether seeding is appropriate at all. Four sources of randomness and how to pin each Criterion How to pin it Seed in tests? random module random.seed(n) yes, per test numpy legacy global np.random.seed(n) yes, per test numpy Generator default_rng(n), injected yes, injected secrets / os.urandom inject a source object never seedable
The first three are seedable global state; the fourth is not, so it must be injected behind a seam or the test can never be deterministic.

A last piece of hygiene ties the two halves together. Time and randomness both leak through the same door — module-level state captured at import — so audit them together. Grep the codebase for datetime.now(, time.time(, random. and uuid4( outside of the modules that are allowed to own them; every hit outside that list is a seam waiting to be added. Teams that do this once typically find the same three offenders: a serializer stamping created_at, a retry helper computing jitter, and an id generator. Fixing those three removes most clock-and-dice flakiness from a suite, and the remaining cases are genuinely worth a freeze_time block.

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