Characterization tests fail for a boring reason: the code reads the wall clock.
You cannot pin behavior you cannot reproduce twice.
So count the clocks first, then move code.
There is a second trap. unittest.mock.patch targets a dotted path, not an object. A refactor that moves a module silently breaks the patch. The test still passes. The freeze silently stops working.
This article covers one artifact that catches both problems: a nondeterminism census with a patch-target inventory.
Step 1: Census every clock, seed, and host read
Run this on the repo root. It parses ASTs, so it does not need imports to succeed.
#!/usr/bin/env python3
# nondet_census.py - list every clock, seed, and host read in a repo.
import ast
import json
import pathlib
import sys
WATCH = {
"time": {"time", "monotonic", "perf_counter", "time_ns"},
"datetime": {"now", "utcnow", "today"},
"random": {"random", "randint", "choice", "shuffle"},
"uuid": {"uuid1", "uuid4"},
"os": {"urandom", "getpid", "getenv"},
"secrets": {"token_hex", "token_bytes", "randbelow"},
"socket": {"gethostname", "gethostbyname"},
}
SKIP = {".git", ".venv", "venv", "build", "dist", "node_modules"}
def module_name(path, root):
parts = list(path.relative_to(root).with_suffix("").parts)
if parts[-1] == "__init__":
parts.pop()
return ".".join(parts)
def scan(path, root):
tree = ast.parse(path.read_text(encoding="utf-8"))
mod = module_name(path, root)
aliases, rows = {}, []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for a in node.names:
if a.name in WATCH:
aliases[a.asname or a.name] = a.name
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module in WATCH:
for a in node.names:
if a.name in WATCH[node.module]:
rows.append({
"file": str(path), "line": node.lineno,
"kind": "local_copy",
"symbol": f"{node.module}.{a.name}",
"patch_target": f"{mod}.{a.asname or a.name}",
})
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
owner = node.func.value
if isinstance(owner, ast.Name) and owner.id in aliases:
real = aliases[owner.id]
if node.func.attr in WATCH[real]:
rows.append({
"file": str(path), "line": node.lineno,
"kind": "module_attr",
"symbol": f"{real}.{node.func.attr}",
"patch_target": f"{mod}.{owner.id}.{node.func.attr}",
})
return rows
if __name__ == "__main__":
root = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
out = []
for path in sorted(root.rglob("*.py")):
if SKIP & set(path.parts):
continue
out.extend(scan(path, root))
pathlib.Path("nondet_targets.json").write_text(json.dumps(out, indent=2))
print(f"{len(out)} rows -> nondet_targets.json", file=sys.stderr)
python nondet_census.py .
# 61 rows -> nondet_targets.json
Sixty-one rows is normal. Most are harmless. You need the list anyway, because the freeze step consumes it.
Step 2: Sort rows by what the patch can actually reach
Each row is one of three shapes. The shape decides your first move.
| Row kind | Example | Patch target | First move |
|---|---|---|---|
module_attr |
import time; time.time() |
app.reports.build.time.time |
Patch at the importing module |
local_copy |
from time import time |
app.reports.build.time |
Patch the local name |
server_side |
SELECT now(), C extension clocks |
none | Pin inputs and outputs instead |
The local_copy row is the quiet one. Patching time.time does nothing if the module did a from-import. Your test looks deterministic and is not.
Rows also cluster. Ten call sites in one module usually mean one seam, not ten fixes. Group by file before you touch anything.
The classification is mechanical, and a language model is decent at it. I paste the JSON rows into MonkeyCode's free model access with a strict output schema: {line, bucket, reason}, bucket in freeze|inject|leave.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Keep the assertions. The model only labels rows. Every label gets a human yes before it enters the target file.
Step 3: Freeze from the inventory, not from memory
Your conftest.py should read the census output. Hand-written patch lists drift in a week.
# conftest.py
import json
import os
import pathlib
import random
import pytest
ROWS = json.loads(pathlib.Path("nondet_targets.json").read_text())
SEED = int(os.environ.get("GATE_SEED", "0"))
FROZEN = 1_700_000_000.0
@pytest.fixture(autouse=True)
def frozen_world(monkeypatch):
random.seed(SEED)
for row in ROWS:
if row["kind"] == "module_attr":
monkeypatch.setattr(row["patch_target"], lambda: FROZEN)
elif row["kind"] == "local_copy":
monkeypatch.setattr(row["patch_target"], lambda: FROZEN)
yield
monkeypatch.setattr accepts dotted strings and imports the module for you. See the pytest docs on monkeypatch.
One caveat: datetime.now() needs a datetime return value. Branch on row["symbol"] and return datetime.fromtimestamp(FROZEN) instead of a float.
Step 4: Add a test that the patch still lands
This is the gate that survives a refactor. It resolves every target and checks object identity against the real stdlib function.
# tests/test_patch_targets.py
import importlib
import json
import pathlib
import time as stdlib_time
import pytest
ROWS = json.loads(pathlib.Path("nondet_targets.json").read_text())
STDLIB = {
"time.time": stdlib_time.time,
"time.monotonic": stdlib_time.monotonic,
"time.perf_counter": stdlib_time.perf_counter,
}
def resolve(dotted):
mod, _, attr = dotted.rpartition(".")
obj = importlib.import_module(mod)
for part in attr.split("."):
obj = getattr(obj, part)
return obj
@pytest.mark.parametrize("row", ROWS, ids=lambda r: r["patch_target"])
def test_target_resolves(row):
try:
assert resolve(row["patch_target"]) is not None
except (ImportError, AttributeError) as exc:
pytest.fail(f"patch target gone: {row['patch_target']} ({exc})")
@pytest.mark.parametrize("row", [r for r in ROWS if r["kind"] == "module_attr"])
def test_patch_hits_stdlib_object(row):
assert resolve(row["patch_target"]) is STDLIB[row["symbol"]]
Now a module move produces a red test with the old dotted path in the name. That is the failure you want. Compare with the unittest.mock docs on where patches are applied: docs.python.org.
Step 5: Run the stability gate
Twenty seeds, fixed hash seed, same command. Flaky characterization tests are worse than none.
#!/usr/bin/env bash
set -euo pipefail
mkdir -p runs
for seed in $(seq 1 20); do
GATE_SEED=$seed PYTHONHASHSEED=$seed \
pytest -q tests/characterization --tb=no > "runs/seed_$seed.txt" \
|| { echo "nondeterministic at seed=$seed"; exit 1; }
done
echo "20/20 stable"
The loop is CPU-light and wall-clock heavy. Twenty runs of a slow suite is hours on a laptop. This is where a hosted runner earns its keep, and MonkeyCode's free server option is one place to put it. Treat that as an option, not a benchmark.
Save the runs/ folder. It is the baseline you diff against after the change.
Step 6: Make the smallest safe change, then re-run three things
Do the extraction. Then run, in this order:
-
python nondet_census.py .and diff the new JSON against the old one. -
pytest -q tests/test_patch_targets.py. - The 20-seed loop again.
A clean diff means no clock moved into a new module. A failing target test means a patch path died. A flaky seed means you moved code across a freeze boundary.
If the census diff shows a target count change, stop. That is a behavior change hiding inside a move.
Limitations and who should skip this
- Child processes do not inherit patches. Subprocesses that call the clock need env-var injection.
- C extensions and database
now()are invisible to AST scans. Pin their inputs and outputs instead. - Threads plus global monkeypatching can race. Prefer an injected clock object in threaded code.
-
PYTHONHASHSEEDfixes hash order only. It says nothing about set iteration in other runtimes. - If the repo has no runnable test suite, the census still helps, but characterization comes first.
Skip this if your nondeterminism is the specification. Fuzzers and property tests want randomness, not a frozen clock.
Also skip it if nobody will review the model's row labels. An unreviewed leave bucket is an unfrozen test waiting to flake in CI.
What this buys you
Characterization tests are only as stable as the clocks under them. A census makes that stability checkable, and a patch-target test makes it survive the refactor.
The script is the part worth stealing. Run it on your messiest module before you promise anyone a safe change.
If you want a second opinion on row classification, the free model access is enough for that job. Keep the tests yours.
Top comments (0)