Refactors die from scope creep. Characterization tests give you a safety net. They do not keep the diff small. That requires a budget.
A change budget is a fixed limit on what a refactor may touch. It forces discipline. It turns "clean up this function" into "move one branch without changing behavior."
This article shows a practical workflow. It uses characterization tests first. Then it enforces the smallest safe change with a script and a review checklist. The tooling examples are generic. You can run them with any test runner.
Step 1: Write characterization tests before touching code
Characterization tests capture current behavior. They are not unit tests written from a spec. They lock in outputs as they exist today. Run them before your change. Then run them after. If outputs stay the same, the behavior did not move.
Start with the function that has the most callers. Use whatever test framework is already in the repo. Save the test file in a legacy_tests directory. Later you can replace it with a real contract test.
def test_legacy_discount_stays_stable():
cart = Cart(items=[{"price": 100, "qty": 2}])
assert legacy_discount(cart) == 20
Write a dozen cases that cover edges. Include empty inputs, nulls, and boundary numbers. Add cases that look weird. If the code produces a SQL query, assert the query string. If it sends an email, mock the transport and assert the payload.
This step creates your oracle. Without an oracle, "safe change" is just an opinion.
Step 2: Define your safe-change contract
A safe change is small enough to review. Write down its contract before coding. Then do not violate it.
Good contracts look like this:
- Change one function.
- Do not change its signature.
- Do not change error-handling behavior.
- Do not touch callers.
- Diff fewer than 50 added lines.
- Diff fewer than 30 deleted lines.
- No test file changes except added characterization tests.
Put these rules in a file called change-budget.sh. Run it in CI before the refactor lands. If the diff grows, the script fails.
Here is a small script that checks an allowlist and line limits:
#!/bin/bash
# change-budget.sh
set -euo pipefail
BASE="${1:-main}"
ALLOW_DIR="${2:-src/legacy/}"
MAX_ADDED="${3:-50}"
MAX_DELETED="${4:-30}"
git diff --numstat "$BASE"...HEAD | while read -r added deleted file; do
case "$file" in
"$ALLOW_DIR"*) ;;
*) echo "Blocked file: $file"; exit 1 ;;
esac
if [ "$added" -gt "$MAX_ADDED" ]; then
echo "Too many additions in $file"; exit 1
fi
if [ "$deleted" -gt "$MAX_DELETED" ]; then
echo "Too many deletions in $file"; exit 1
fi
done
echo "Change budget respected"
Run it with:
chmod +x change-budget.sh
./change-budget.sh main src/legacy/ 50 30
Modify the numbers to fit your repo. The point is not the specific limits. The point is that limits exist and are enforced.
Step 3: Use a model to speed up test generation
Writing characterization tests is mechanical. A free coding model can generate the first pass from a function body. Ask for test cases, not clever abstractions.
MonkeyCode offers free model access and a free server option, so you can generate candidate tests and run them without setting up a paid compute environment. Use the free server to execute your suite. Inspect every generated test. Models hallucinate. They also miss edge cases.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Keep the model output in a separate file. Delete test cases that do not compile. Merge duplicate assertions. Keep the ones that match observed behavior. Then treat the final set as your oracle.
Step 4: Make the smallest change you can actually ship
After the baseline tests pass, open the function in your editor. Ask one question: "What is the smallest edit that preserves every test?" Then make only that edit.
For example, if the function has two nested conditionals, do not extract them in one session. Extract the outer if into a helper. Run the suite. If green, stop. Commit. Then extract the inner if in a separate commit.
Each commit must contain:
- One behavioral invariant unchanged.
- Characterization tests proving that invariant.
- A diff within the change budget.
If you need to touch a second file, stop. Go back and split your change. One commit, one function, one reason.
Step 5: Review the diff, not just the tests
Green tests are not enough. A wrong but stable output can pass. Read the diff manually.
Focus on what changed outside the target. Did import ordering change? Did whitespace shift? Did error types silently change? Those are the risky moves. A change budget catches them by limiting the files. It cannot catch a bad rename inside the allowed file. So read it carefully.
A short review checklist:
- [ ] The diff touches only the allowlisted directory.
- [ ] No call sites changed.
- [ ] No exception is swallowed or added.
- [ ] Logging and side effects remain identical.
- [ ] The diff is larger than 10 lines only in the target function.
Tick every box before merging.
Limitations
This workflow works best for a messy function with high fan-in. It becomes noise for greenfield code. You do not need characterization tests for a brand-new module. You already know its spec.
The approach also fails when the current behavior is obviously wrong. Characterization tests freeze bugs. They do not fix them. If the function should calculate VAT differently, you need behavior-changing tests and a decision owner.
A diff budget can be gamed. Formatting changes can inflate line counts. Someone can rename variables to dodge the reviewer. That is why the review checklist exists. The script enforces size. The human enforces meaning.
Finally, model-generated tests require scrutiny. They are a starting point, not an oracle. Treat them the same way you treat any code written by a tool: read it, run it, and question it.
Who should not use this workflow
Skip this if you are building a new feature. Skip it if you have a real bug in production that needs one clear fix. Skip it if you have a spec-based test suite with full coverage. Characterization tests only add value when current behavior is undocumented and callers depend on it.
For that messy middle, the workflow is powerful. The tests pin behavior. The budget keeps you honest. The smallest safe change is the one you can ship and revert without drama.
Top comments (0)