DEV Community

Dakota Huang
Dakota Huang

Posted on

Intentional Drift: Updating Characterization Tests When the Refactor Fixes a Bug

Characterization tests freeze current behavior. They freeze the bugs too. When a refactor fixes a bug on purpose, the contract breaks.

Most teams react in one of two ways. They delete the test and lose the safety net. Or they keep it and block the fix. Both responses are wrong. Deleting hides the change. Keeping it hides the reason.

The fix is a three-step drift protocol. Prove the change is intended. Review the exact diff. Update the oracle with a written reason. This article walks through the protocol with a real rounding bug.

The Setup: A Pinned Bug

Here is the legacy function. It lives in the messy middle of a payment module.

# legacy.py — the messy middle
def apply_discount(price, rate):
    # TODO: rounding is wrong for some prices
    return round(price * (1 - rate), 2)
Enter fullscreen mode Exit fullscreen mode

Python's round uses banker's rounding. Float representation makes 2.675 slightly smaller than its decimal name. So round(2.675, 2) returns 2.67, not 2.68.

A previous refactor added a characterization test. It pinned the current output.

def test_apply_discount_pins_legacy_output():
    assert apply_discount(2.675, 0.0) == 2.67
Enter fullscreen mode Exit fullscreen mode

The test passes. It also pins a bug. The new refactor fixes the bug with Decimal. Now the test fails.

Step 1: Prove the Drift Is Intended

Write a failing test for the new behavior first. This is test-driven refactoring, not feature development. You are documenting intent.

def test_apply_discount_uses_decimal():
    assert apply_discount(2.675, 0.0) == Decimal("2.675")
Enter fullscreen mode Exit fullscreen mode

Run the full suite. The new test fails. The characterization test fails too. Compare both failures. They must describe the same behavior change. If they do not, stop and investigate.

Step 2: Review the Diff, Then Accept

Never regenerate the golden file blindly. Regeneration hides drift. Review every changed line first.

The script below enforces that review. It compares current output with the frozen oracle. It refuses to update without a reason.

#!/usr/bin/env bash
# drift.sh — update an oracle only with a reviewed reason
set -euo pipefail

GOLDEN="tests/golden/apply_discount.txt"
ACTUAL="tests/actual/apply_discount.txt"
REASON="${DRIFT_REASON:-}"

python -c "from legacy import apply_discount; print(apply_discount(2.675, 0.0))" > "$ACTUAL"

if diff -u "$GOLDEN" "$ACTUAL" > /tmp/drift.patch; then
  echo "PASS: behavior matches the oracle"
  exit 0
fi

if [ -z "$REASON" ]; then
  echo "FAIL: behavior drifted without a reason"
  echo "Review /tmp/drift.patch before accepting"
  exit 1
fi

cp "$ACTUAL" "$GOLDEN"
echo "Oracle updated. Reason: $REASON"
Enter fullscreen mode Exit fullscreen mode

Run it with the accepted reason.

DRIFT_REASON="fix banker's rounding; use Decimal" ./drift.sh
Enter fullscreen mode Exit fullscreen mode

Without a reason, the script exits non-zero. That is the whole point. The reason string becomes the audit trail.

Step 3: Record the Decision in the Test

The golden file is machine-readable. The test file is human-readable. Put the decision in both.

def test_apply_discount_pins_legacy_output():
    # 2026-08-28: intentional drift.
    # Old float behavior: 2.67. Refactor 42: Decimal("2.675").
    assert apply_discount(2.675, 0.0) == Decimal("2.675")
Enter fullscreen mode Exit fullscreen mode

Git history keeps the old assertion. git diff proves the change covers one behavior. Reviewers see exactly what moved and why.

The comment is not decoration. It answers the question every reviewer will ask. Why did this oracle change? Six months later, the answer is one line away.

The Decision Table

Use this table when the drift check fails.

Diff result Action
Only the intended fix Accept with DRIFT_REASON
Unintended extra change Fix the code, keep the oracle
Harness noise (ordering, locale) Fix the harness, keep the oracle
No diff Pass, no action

The table forces a classification. Classification prevents lazy regeneration.

Where a Free Model Fits

The loop has two expensive steps. Drafting the new oracle. Reviewing the drift patch. Both are pattern-matching tasks. A free model handles them well.

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

MonkeyCode's free model access can draft the Decimal oracle from the failing test. The free server option can run the verification loop in CI. That combination fits small legacy repos with no local test runner. The script above needs only a shell and two text files.

The model does not decide. The reviewer decides. The model only proposes the new oracle and flags suspicious lines in the patch.

Who Should Not Use This

Skip this protocol if your team skips code review. The reason string is only as honest as the reviewer. Skip it if your characterization suite has dead probes. Dead probes pass without asserting anything. Fix those first.

The protocol scales to one intentional drift at a time. A refactor that changes forty behaviors needs forty reviewed diffs. That is slow on purpose. Speed returns when the suite stays trustworthy.

This protocol assumes the repo is under version control. No git, no audit trail. The reason string is worthless without history.

The Takeaway

Characterization tests are not permanent walls. They are contracts that need explicit amendments. Fix the bug, then amend the contract. Never delete the contract silently.

If you try this on a legacy repo, start with one function. One bug, one oracle, one reason string. MonkeyCode's free model access and free server option are enough for that scale.

Top comments (0)