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 — 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.
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.
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.
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
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.
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()andtime.perf_counter()are intentionally decoupled from the wall clock and are not patched byfreezegun.time-machinedoes control them, but code that measures elapsed durations should take an injected timer rather than rely on either library.asyncioevent-loop time (loop.time()) derives from the monotonic clock, soasyncio.sleepdurations are unaffected byfreeze_time— freezing the wall clock will not fast-forward a sleeping coroutine.secrets.token_hex,os.urandom, anduuid.uuid4draw from the OS cryptographic RNG, which norandom.seedor 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 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.
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
| 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 |
Elapsed-time check still varies under freeze_time | Code uses time.monotonic()/perf_counter(), which freezegun leaves running | Switch to time-machine, or inject the timer and pass a fixed one |
secrets/os.urandom output changes despite seeding | These read the OS CSPRNG, not the seeded PRNG | Patch the call or inject the token source; seeding random has no effect |
await asyncio.sleep(...) ignores the frozen clock | Event-loop time comes from the monotonic clock, not the wall clock | Inject 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.
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:
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.
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.
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.