DEV Community

Dakota Wu
Dakota Wu

Posted on

Refactor a Messy Repo Without Breaking It: Characterization Tests, Then the Smallest Safe Change

You inherit a checkout service with a 900-line pricing function, zero tests, and a README that describes what the code was supposed to do back in 2019. The real business rules live in commit messages and in the head of the engineer who left last quarter, and the last two cleanup attempts ended in production rollbacks. Nobody touches the file anymore, so the team routes every change around it instead of through it, which makes the mess grow sideways. This is the exact moment where a rewrite feels like the only reasonable option, and it is almost always the wrong first move.

The current AI discussion on DEV keeps circling a related pattern: agents deliver code faster, while legacy modules accumulate even more undocumented behavior underneath the review queue. Faster delivery does not make an unknown codebase safer to change, it just increases the surface you can break. The missing piece is a cheap, mechanical way to pin down what the messy code actually does before you touch it, and that is where characterization tests come in.

What a characterization test actually does

A characterization test captures what the code does today without judging whether that behavior is correct. Michael Feathers popularized the technique for legacy systems: you record real inputs and outputs, then assert that they never change while you refactor. The goal is not to validate the business logic, it is to freeze the logic long enough to move it somewhere better.

Once the suite is in place, refactoring stops being a leap of faith and becomes a search problem. Every unexpected test failure is one behavior you did not know existed, and every green run gives you permission to make the next tiny edit. Without that pin, a refactor is just a rewrite with better intentions.

A minimal characterization harness you can run today

Start by finding the files with the most churn and the most branches, because those are the cheapest wins.

git log --oneline -- checkout/pricing.py | wc -l
radon cc checkout/pricing.py -s
Enter fullscreen mode Exit fullscreen mode

The full Feathers workflow feels heavy for a first attempt, so this article uses a lighter artifact: a recording decorator that logs real calls, plus a pytest suite that replays them. You drop the decorator into the messy module, run your normal workflows, and the suite freezes whatever you just executed. The capture side serializes calls as JSON so the recorded file stays human-readable.

# capture.py - drop into your project during the recording phase
from __future__ import annotations

import atexit
import functools
import json
import os
from pathlib import Path
from typing import Any, Callable

RECORDING: dict[str, list[dict[str, Any]]] = {}
ENABLED = os.getenv("CHAR_CAPTURE") == "1"


def capture(fn: Callable) -> Callable:
    @functools.wraps(fn)
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        result = fn(*args, **kwargs)
        if ENABLED:
            key = f"{fn.__module__}.{fn.__qualname__}"
            RECORDING.setdefault(key, []).append({
                "args": [_jsonable(a) for a in args],
                "kwargs": {k: _jsonable(v) for k, v in kwargs.items()},
                "expected": _jsonable(result),
            })
        return result

    return wrapper


def _jsonable(value: Any) -> Any:
    if isinstance(value, (str, int, float, bool)) or value is None:
        return value
    return repr(value)


def flush_recordings(path: Path = Path("recordings.json")) -> None:
    if ENABLED and RECORDING:
        path.write_text(json.dumps(RECORDING, indent=2))


atexit.register(flush_recordings)
Enter fullscreen mode Exit fullscreen mode

The replay side converts every recorded call into one pytest case, so the suite grows automatically as you exercise more paths.

# test_characterization.py - replay every recorded call
import importlib
import json
from pathlib import Path

RECORDINGS = json.loads(Path("recordings.json").read_text())


def build_replay(qualname: str, case: dict):
    module_path, _, func_name = qualname.rpartition(".")
    mod = importlib.import_module(module_path)

    def replay():
        fn = getattr(mod, func_name)
        assert fn(*case["args"], **case["kwargs"]) == case["expected"]

    return replay


for qualname, cases in RECORDINGS.items():
    for index, case in enumerate(cases):
        test_name = f"test_{qualname.replace('.', '_')}_{index}"
        globals()[test_name] = build_replay(qualname, case)
Enter fullscreen mode Exit fullscreen mode

Usage is three steps. First, decorate the functions you want to pin, then run the real workflows with capture enabled, and finally execute the replay suite.

CHAR_CAPTURE=1 python -m your_package.run_some_workflow
pytest test_characterization.py -q
Enter fullscreen mode Exit fullscreen mode

Before you trust the suite, read the recorded values once, because a characterization test you have not read is just a more elaborate way to be wrong.

Where a free model and a free server actually help

Writing the decorator by hand is straightforward, but generating one replay case per function for a forty-function module is pure mechanical work. That is the part worth delegating to a coding agent: MonkeyCode's free model access can draft the harness and the refactor ticket list, while you review the diffs instead of typing boilerplate. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The free server option matters for a less obvious reason: a messy repo produces hundreds of recorded calls, and every workflow you exercise multiplies the suite size. Running that replay loop on a free hosted runner keeps your laptop free and gives the pin a place to live while you work on something else. As of late August 2026, MonkeyCode advertises a free token allowance (ten million tokens when this article was drafted) and a free server tier, but both terms can change, so confirm the current numbers on the project page before you plan capacity around them.

The smallest safe change, proven by the suite

With the pin in place, pick one tiny transformation, run the suite, and commit before you move on. Extract the discount branch into its own function, rename a misleading variable, invert a nested condition, or delete a branch that the recordings prove is dead. Each step should stay under a few minutes.

# before
def compute_total(items, code):
    subtotal = sum(i["price"] * i["qty"] for i in items)
    if code == "SAVE10" and subtotal > 100:
        subtotal = subtotal * 0.9
    return round(subtotal, 2)

# after
def compute_total(items, code):
    return round(_apply_discount(sum(i["price"] * i["qty"] for i in items), code), 2)


def _apply_discount(subtotal, code):
    if code == "SAVE10" and subtotal > 100:
        return subtotal * 0.9
    return subtotal
Enter fullscreen mode Exit fullscreen mode

If the replay suite turns red, you have found a behavior that was never documented, which is information rather than failure. Keep the loop going until the module is small enough to understand without a map, and let the growing test suite do the documentation work.

Limitations and who should not use this approach

  • Functions with non-deterministic output (time, random values, network calls) produce recordings that flake on replay, so pin pure or near-pure functions first.
  • The JSON capture converts complex objects to repr strings, so the harness suits primitive arguments and return values; larger objects need pickle or hand-written serializers and accept extra fragility.
  • Characterization tests freeze bugs along with good behavior, so they are a refactoring net rather than a correctness oracle; fix known bugs deliberately and regenerate the recordings afterwards.
  • Security-critical and performance-critical code needs extra gates, such as boundary analysis against the recorded inputs and benchmark comparisons instead of equality assertions.
  • Greenfield projects do not need this workflow at all; it exists to reduce risk where behavior is already unknown, and if nobody can exercise real call paths, the recordings will only cover what you actually ran.

If you have a cursed module on your refactor backlog, start by recording what it does instead of rewriting what it should do. The harness in this article fits into one afternoon, and every generated test is an argument for the next small change. Once the pin is green, free tokens are better spent having an agent draft those small change tickets while you stay in the review seat.

Top comments (0)