DEV Community

Dakota Huang
Dakota Huang

Posted on

Freeze the Clock Before You Refactor: Characterization Tests for Impure Code

Legacy code is rarely untestable. It is usually just unseeded. A characterization test that reads the wall clock will flake on day two. So freeze the inputs first, then pin the outputs.

This is the order that survives review. Below is a reproducible loop: inventory impurity, insert one text-only boundary, record behavior, freeze it, prove the test can fail, then make the smallest safe change.

The rule

A characterization test freezes behavior you have, not behavior you want. That makes it a poor fit for anything nondeterministic. Seeding is not a testing nicety here. It is the precondition for having a baseline at all.

Two impurity classes need different handling. Process-local impurity (clock, RNG, UUIDs, env vars) is patchable in-process. External impurity (network, database, filesystem) needs a recorded fixture instead.

Step 1 — Inventory impurity with text search

Search the source, not the profiler. A text search is faster. It also finds exactly what a test would flake on.

rg -n --no-heading -g '!tests/' \
  -e '\btime\.(time|gmtime|localtime|strftime)\b' \
  -e '\bdatetime\.(now|utcnow|today)\b' \
  -e '\brandom\.' -e '\buuid\.' \
  -e 'os\.environ' -e 'requests\.' -e '\bopen\(' src/
Enter fullscreen mode Exit fullscreen mode

Count the hits and write the number into the commit message. Your boundary commit must not change that count. If it does, you changed logic while claiming to change plumbing.

Map each hit to one strategy:

Impurity found Pin it with Flake it prevents
time.time, datetime.now patched clock returning a fixed instant "today" leaking into output, month-boundary failures
time.monotonic patched counter returning 0.0 retry and timeout branches firing randomly
random.* random.seed(...) plus patched choice row order shuffling between runs
uuid.uuid4 deterministic counter generator goldens that never match twice
os.environ monkeypatch.setenv per test CI and laptop disagreeing
requests.* one recorded response file per endpoint tests needing a live service
direct open on config fixture file in tests/fixtures tests depending on repo state

The table doubles as a review checklist. Any unlisted impurity is a gap you will hit later.

Step 2 — Insert one boundary module, text-only

Do not refactor the messy module. Add a thin module and substitute calls.

# boundary.py
from __future__ import annotations
import os, random, time, uuid

def now_date() -> str:
    return time.strftime("%Y-%m-%d", time.gmtime())

def new_id() -> str:
    return uuid.uuid4().hex

def pick(seq):
    return random.choice(seq)

def env(key: str, default: str = "") -> str:
    return os.environ.get(key, default)
Enter fullscreen mode Exit fullscreen mode

Now substitute, mechanically and nothing else:

rg -c 'time\.strftime\("%Y-%m-%d", time\.gmtime\(\)\)' src/legacy/invoice.py
# edit: replace that exact expression with now_date()
rg -c '\bnow_date\(' src/legacy/invoice.py
Enter fullscreen mode Exit fullscreen mode

Keep this in its own commit. Review the diff by eye. The substitution must be textually identical in behavior, or you have lost your baseline before you had one.

This is the honest caveat of the whole approach: the boundary insert is already a change. Keep it small enough that git diff --word-diff is readable in one screen.

Step 3 — Record first, assert second

Do not hand-write expectations for code you do not yet understand. Record the golden, then read it.

# tests/test_characterization_invoice.py
import json, pathlib, pytest
from legacy import invoice

HERE = pathlib.Path(__file__).parent
GOLDEN = HERE / "golden" / "invoice_render.json"

def test_render_is_unchanged(monkeypatch, frozen):
    monkeypatch.setenv("INVOICE_TZ", "UTC")
    rows = json.loads((HERE / "fixtures" / "invoice_rows.json").read_text())
    got = json.loads(invoice.render(rows))
    if not GOLDEN.exists():
        GOLDEN.parent.mkdir(parents=True, exist_ok=True)
        GOLDEN.write_text(json.dumps(got, indent=2, sort_keys=True))
        pytest.skip("golden recorded; review by hand, then re-run")
    assert got == json.loads(GOLDEN.read_text())
Enter fullscreen mode Exit fullscreen mode

The first run writes and skips. The second run asserts. Between those two runs, a human reads the file. That review is where you find existing bugs, before they get frozen into the fixture.

Use sort_keys=True so key order is not part of the contract. Use dict comparison, not string comparison, so whitespace is not part of it either.

Step 4 — One freeze fixture, not autouse

# tests/conftest.py
import random, time, uuid
import pytest

@pytest.fixture
def frozen(monkeypatch):
    monkeypatch.setattr(time, "time", lambda: 1_767_225_600.0)  # 2026-01-01T00:00:00Z
    monkeypatch.setattr(time, "monotonic", lambda: 0.0)
    ids = iter("00000000-0000-4000-8000-%012d" % i for i in range(10_000))
    monkeypatch.setattr(uuid, "uuid4", lambda: uuid.UUID(next(ids)))
    random.seed(1337)
    yield
Enter fullscreen mode Exit fullscreen mode

Do not make this autouse. Patching time.time process-wide also freezes log timestamps and anything else in the same interpreter. Scope it to tests that need a stable instant.

If the module also touches a database, record the rows instead of seeding one. A JSON fixture of five rows beats a live connection in every review.

Step 5 — Teeth check: prove the test can fail

A characterization test that cannot fail is documentation. Mutation-test it, crudely and quickly.

#!/usr/bin/env bash
# teeth_check.sh — run on a throwaway branch, never on main
set -uo pipefail
TARGET="${1:?usage: teeth_check.sh path/to/module.py}"
cp "$TARGET" "$TARGET.orig"
killed=0; survived=0
while IFS= read -r line; do
  cp "$TARGET.orig" "$TARGET"
  python - "$TARGET" "$line" <<'PY'
import re, sys
path, lineno = sys.argv[1], int(sys.argv[2])
src = open(path).read().splitlines(keepends=True)
src[lineno-1] = re.sub(r'\b([1-9]\d*)\b',
                       lambda m: str(int(m.group(1)) + 1),
                       src[lineno-1], count=1)
open(path, "w").writelines(src)
PY
  if pytest -q -x tests/test_characterization_invoice.py >/dev/null 2>&1; then
    survived=$((survived+1)); echo "SURVIVED line $line"
  else
    killed=$((killed+1))
  fi
done < <(grep -nE '\b[1-9][0-9]*\b' "$TARGET" | cut -d: -f1 | sort -un)
cp "$TARGET.orig" "$TARGET"
echo "killed=$killed survived=$survived"
Enter fullscreen mode Exit fullscreen mode

This is crude mutation testing on integer literals only. That is deliberate. It runs in seconds and it answers one question: does a changed constant reach the assertion?

Ignore the total score. Read the SURVIVED list. Any survivor sitting on the line you are about to edit means your baseline does not cover your plan. Write one more fixture row before continuing.

Step 6 — Then make the smallest safe change

Now, and only now, edit the messy module. One behavior per commit.

pytest -q tests/test_characterization_invoice.py
Enter fullscreen mode Exit fullscreen mode

Three outcomes matter:

  1. Green — behavior preserved. The change was safe. Commit.
  2. Red on the line you intended to change — you fixed a bug. Keep the red case as a new explicit test named after the bug, and update the golden in its own commit.
  3. Red anywhere else — regression. Revert, do not debug forward.

Outcome 2 is the one people get wrong. Do not silently regenerate the golden. A regenerated golden erases the only evidence you had.

Where the free tier genuinely fits

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Two steps in this loop are high-volume and low-judgment. Both are good candidates for free model access.

  • Drafting candidate assertion names from a golden diff. Feed it the diff, get names like test_render_handles_empty_rows. You keep or discard each one.
  • Running the mutation sweep. It is a pytest loop, not interactive work, so it does not need your laptop. MonkeyCode's free server option is a reasonable place to park it while you keep editing locally.

Limits, stated plainly. The model cannot know which frozen behavior is a bug and which is a contract. It cannot read your product intent. Never merge a generated assertion without reading the observed value yourself, and do not wire a free tier into permanent CI, because availability is not something this article can promise.

Limitations and who should skip this

  • Skip if outputs are genuinely nondeterministic, such as ML scores or live time-series. Goldens will churn and teach you nothing.
  • Skip if the module has import-time side effects, like singleton connections opened at import. Fix that first; one seam at a time.
  • Skip if the golden is huge. A 200 KB fixture is unreviewable. Slice the function before recording.
  • Skip if you cannot read the recorded behavior by hand in one sitting. Unreviewed goldens are just snapshots of unknown bugs.
  • If you already have property-based tests on this module, this adds little. Move on.

The short version

Freeze the clock, seed the RNG, fix the UUIDs, set the env. Record the output. Read it. Mutate a constant and confirm the test notices. Then change exactly one thing.

If you want to try the record-then-freeze loop on a file you have been avoiding, that is a fine first experiment, and MonkeyCode's free model access is enough to draft the first golden diff for your review.

Top comments (0)