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.5and/ortime-machine >= 2.14. - Optional numerical stack:
numpy >= 1.26(fornumpy.random.Generator).
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.
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.
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:
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.
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.
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.
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.
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.
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 pytestandTZ=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_equalagainst a stored golden array rather than eyeballing floats. - Confirm
freeze_time/travelactually covers the call site by assertingdatetime.now()inside the block equals the frozen instant before asserting business logic.
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
| Test fails only at certain times of day | Code reads datetime.now() / time.time() un-frozen | Freeze the block with freeze_time/time_machine.travel, or inject a clock |
freeze_time has no effect on a library call | The library is a C extension reading the libc clock directly | Switch to time-machine, which patches at the CPython clock level |
Random test still flaky after random.seed | A second RNG (numpy, secrets, or a fresh Random()) is unseeded | Seed every generator, or inject seeded instances so none is ambient |
| numpy results differ across machines | numpy.random.seed legacy global state was mutated elsewhere | Use np.random.default_rng(seed) and pass the Generator in |
Golden assertion breaks on uuid | uuid.uuid4() is random per call | Patch uuid.uuid4 or inject a counted id factory |
| Frozen time leaks into the next test | freeze_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.
Related guides
- The focused comparison in Freezing Time: freezegun vs monkeypatch shows exactly where each approach breaks and how
tick()behaves. - The injectable clock is a special case of dependency injection for testability, which generalizes the seam to any external dependency.
- When you do patch
datetime.nowdirectly, getting the target namespace right is the same discipline covered in patching strategies for complex codebases. - For the underlying
MagicMock/patchmechanics behind a deterministicuuiddouble, see the deep dive into unittest.mock. - To generate randomized-but-reproducible inputs rather than seed a single sequence by hand, drive tests with property-based and fuzz testing strategies, whose Hypothesis layer manages its own deterministic seeding.