BXUP Track progress & test yourself — free
Software Engineering · Playbook · Core · ~4 min read
SOFTWARE ENGINEERING · PLAYBOOK · CORE

Characterization Tests Before Refactoring: Pin Current Behavior with AI Speed

Feathers' rule for legacy code — pin current behavior with tests before changing anything — was always right and always skipped, because writing tests for code you don't understand is miserable. AI removes the misery: it drafts pin-down tests fast.

Tier: Core · Workflow area: Refactoring & Migration

The catch is a discipline inversion you must hold on to: characterization tests assert what the code does, not what it should do. Bugs get pinned too, on purpose, and mutation testing tells you when the net is actually tight enough to refactor over.

When to use

  • Untested (or badly tested) code you must refactor, upgrade, or port — the phase-1 step of any pb-21 charter or pb-23 upgrade.
  • Before accepting an AI agent's large-scale changes to a module — the characterization suite is the harness that makes agent output verifiable.
  • Inheriting code whose behavior is the spec because no other spec survives.

Prerequisites

  • The target code runs locally with its dependencies stubbed or containerized (side-effectful code needs its I/O intercepted first).
  • A coverage tool (coverage.py, nyc, JaCoCo) and a mutation tool (mutmut, Stryker, PIT).
  • An assistant that can read the module and generate tests (Claude Code, Copilot, Cursor).

The workflow

  1. Choose the seam and freeze the surface. Pick the public surface you will refactor behind (a class, a module's exported functions, an HTTP handler). Characterize at the seam, not the internals — internal tests weld the current structure in place and will all break when you refactor, which defeats the point.

  2. Generate observation-first tests. The prompt must ban aspiration:

    Write characterization tests for the public functions of legacy/pricing.py.
    Rules:
    - Assert CURRENT behavior only. If behavior looks like a bug (e.g., rounding
      down where up is expected, silently swallowing invalid input), still pin
      it exactly, and add a comment: # PINNED: possible bug, see notes.
    - Do not "fix" anything by asserting intended behavior.
    - Cover: typical inputs, empty/None/zero, boundary values, invalid input
      (pin the actual exception type and message), and any branch on config.
    - If you cannot determine an output without running the code, write the
      test with assert result == TODO and list it — do not guess values.
    

    The TODO rule matters: models guess expected outputs from what code looks like it does, which silently converts characterization into specification.

  3. Fill TODOs by execution, not inspection. Run each TODO input against the real code and paste actual outputs back (pytest --tb=short output, or a quick REPL loop the assistant writes for you). Every assertion value must come from an execution, not from anyone's reading of the code — this is the step that makes them characterization tests at all.

  4. Measure the net with coverage, then mutation.

    coverage run -m pytest tests/characterization/ && coverage report --include="*/pricing.py"
    mutmut run --paths-to-mutate src/legacy/pricing.py
    

    Coverage says which lines execute; mutation score says whether the assertions would notice a change. Feed survivors back:

    These mutants survived: [mutmut results — e.g., line 88: `<=` changed to `<`
    not caught]. For each, write a test input that distinguishes original from
    mutant, executing through the public seam. Tell me if any mutant is
    genuinely unobservable at the seam.
    

    Uber's test-generation work uses exactly this loop: generate, measure, regenerate against the gaps. Target high mutation score on the code you are about to change, not the whole file.

  5. Log the pinned bugs, then refactor on the net. Collect every # PINNED: possible bug into tickets — decision needed later: fix (with a deliberate test change) or bless as behavior. Then refactor (or unleash the agent). The suite must stay green with zero test edits during the refactor: a red test means behavior changed; an edited test means you moved the goalposts. Bug fixes happen after, as separate commits that flip one pinned test each.

Remember:

During the refactor, the characterization suite is read-only. The moment you edit a pinned test to make the refactor pass, you are no longer refactoring — you are changing behavior without deciding to.

Tool variants

  • Claude Code: can run the full generate → execute TODOs → mutation → regenerate loop itself; keep it read-only on src/ and write-enabled only on tests/ during this phase.
  • Copilot (chat + agent mode): generate per-function in chat for tight control, or point agent mode at the seam with the same rules; free tier makes this the India-friendly default.
  • Approval-testing shortcut (ApprovalTests, Jest snapshots): for functions returning complex structures, snapshot the real outputs over a corpus of recorded inputs — fastest possible pinning, weakest at explaining what changed when it fails; pair with a few explicit assertions on the fields that matter.

Verification

  • Every assertion value traces to an actual execution (no guessed expected values — audit a sample).
  • Coverage on the to-be-changed code is high AND mutation survivors on it are zero or individually justified as unobservable at the seam.
  • Suite is deterministic: 10 consecutive runs, zero flakes (flaky pins are worse than no pins — they train you to re-run reds).
  • Pinned-bug list exists as tickets with owners, separate from the refactor work.

Pitfalls

  • The helpful model "fixes" the bug. The single most common failure: the AI asserts intended behavior for buggy code, the suite passes, and the refactor silently changes production behavior. The PINNED-comment rule plus execution-derived values is the defense.
  • Characterizing internals. Tests on private helpers all break during restructuring, and the team deletes them wholesale — pin at the seam you will preserve.
  • Trusting coverage alone. AI-generated tests reach high coverage with weak assertions easily; only mutation score tells you the net has teeth.
  • Non-determinism in outputs. Time, randomness, dict ordering, floats: freeze the clock, seed RNG, sort or approx-match before pinning, or the suite flakes from day one.

Restricted-environment variant

  • Test generation needs the source in-context; under client restrictions use the sanctioned tool (Copilot Business / client-tenant model). Local models (Qwen-Coder-class via Ollama) are genuinely serviceable here — characterization tests are boilerplate-shaped, the local-model sweet spot — with the same execution-derived-values rule doing the safety work.
  • Coverage and mutation tooling is all local/OSS (coverage.py, mutmut, Stryker, PIT) — the measurement loop runs identically everywhere.
  • No-AI fallback: approval tests over a recorded input corpus give 70% of the value with none of the data exposure; write explicit assertions only for the top boundary cases.

Success criteria

  • A previously untested module has an execution-derived characterization suite with high mutation score at the seam, built in hours not days.
  • The subsequent refactor/upgrade/agent run completes with the suite untouched and green — or a red caught a real behavior change before production did.
  • Known-bug behavior is documented and decided deliberately, never changed by accident.
This is the free reading view. Open BXUP to track your progress, run the hands-on labs, and prove it with original, exam-pattern assessments.
© Anvi Innovate · BXUP — Be Exponentially Upskilled · BXUP is an independent learning platform, not affiliated with or endorsed by AWS, Microsoft, Google, NVIDIA, IBM, Salesforce, Databricks, Oracle or Anthropic. All trademarks belong to their respective owners. · bxup.in