Legacy code is difficult to change when its important behaviour is poorly understood and existing tests do not provide enough confidence. The risk is not only that a refactoring introduces a bug. The deeper problem is that developers may not know which behaviours are intentional, accidental, or relied on by other parts of the system.

Characterization tests help reduce that uncertainty. Instead of beginning with a specification of what the code should do, they capture what the code does today. That behavioural baseline can make structural improvement safer while the team learns the system.

The goal is not to preserve every historical quirk forever. The goal is to separate understanding current behaviour from deliberately changing it.

Start at the boundary you need to change

Do not try to characterize an entire legacy system before making one improvement. Start around the behaviour affected by the planned change.

Suppose an order-pricing component contains years of accumulated rules. A developer needs to extract discount calculation into a separate module. Useful characterization cases might cover:

  • an order without discounts;
  • an order with one qualifying discount;
  • overlapping discount rules;
  • boundary values around minimum quantities;
  • rounding behaviour;
  • invalid or incomplete input that callers currently send.

These tests create a local safety net around the intended refactoring. They do not need to prove that the whole application is correct.

Observe before you simplify

Legacy code often contains branches that look redundant until a production case exercises them.

Before removing or combining behaviour, identify representative inputs and record observable outputs. Depending on the component, observable behaviour may include:

  • returned values;
  • raised errors;
  • state transitions;
  • calls to a dependency;
  • emitted events;
  • persisted records.

Prefer observations at a stable boundary. A test that asserts every private method call can freeze the current implementation and make refactoring harder.

For example, a pricing test should usually assert the calculated total and relevant business result rather than the exact sequence of internal helper calls.

Treat surprising behaviour as evidence

A characterization test may reveal behaviour that appears incorrect:

Input: quantity = 10, subtotal = 100.00
Observed discount: 9.99
Expected from current business description: 10.00

Do not silently “correct” the result while establishing the baseline. First determine whether callers depend on the existing behaviour and whether changing it belongs to the current task.

If the behaviour is a confirmed defect, make that correction as a separate behavioural change with a test expressing the desired result. Keeping structural refactoring and behaviour changes distinct makes failures easier to diagnose and reviews easier to reason about.

Create seams around difficult dependencies

Legacy code is often hard to test because business logic directly creates or reaches into external dependencies such as clocks, network clients, file systems, or process-wide state.

A seam is a place where behaviour can be substituted without rewriting the whole component. It might be a function parameter, interface, wrapper, factory, or small adapter.

Consider code that obtains the current time deep inside a pricing function. Introducing a clock dependency can make time-sensitive rules deterministic:

before:
    calculate_price(order) -> reads system clock internally

after:
    calculate_price(order, clock) -> asks clock for current time

The first change should preserve behaviour. Once tests can control time, later changes to date-sensitive rules become easier to verify.

Create the smallest seam that enables useful testing. Introducing a large abstraction framework before understanding the code can replace one form of complexity with another.

Refactor in behaviour-preserving steps

Once the baseline is useful, make small structural changes and run the tests after each meaningful step.

A safe sequence might be:

  1. rename a misleading local variable;
  2. extract a calculation into a function;
  3. move that function behind a clearer module boundary;
  4. replace duplicated branches with one shared operation;
  5. introduce a dependency explicitly instead of obtaining it globally.

Each step should leave externally observable behaviour unchanged unless the change is intentionally behavioural.

Small steps reduce the number of possible causes when a test fails. They also make it easier to revert a questionable transformation without discarding unrelated progress.

Do not confuse characterization with approval

A passing characterization test says that behaviour is unchanged. It does not say that the behaviour is desirable.

This distinction matters for defects, confusing error messages, historical compatibility rules, and awkward edge cases. Mark suspicious cases clearly in test names or nearby documentation when their status matters:

preserves_legacy_rounding_for_half_cent_values

Later, when the team decides to change that rule, replace the characterization expectation with a test for the intended behaviour.

Characterization tests are scaffolding for understanding and change, not an argument that every old behaviour must survive.

Prefer meaningful examples over snapshots of everything

It can be tempting to capture huge outputs and compare them byte for byte. Large snapshots are quick to create, but they can hide what the test actually protects.

A better characterization test usually names the behaviour that matters and asserts the smallest stable result that demonstrates it.

Broad snapshots can still be useful when a component has a large structured output and no practical narrower boundary. If you use them, review the captured data carefully and avoid including volatile values such as timestamps or generated identifiers unless those values are themselves part of the contract.

Add specification tests as understanding improves

Characterization tests answer, “What happens now?” As the code becomes better understood, add tests that answer, “What behaviour do we require?”

That transition is important. A mature test suite should communicate business and engineering expectations rather than merely preserve an implementation’s history.

For example, an initial test might capture the current handling of an expired promotion. After confirming the rule with stakeholders, replace or supplement it with a specification-oriented test:

expired promotions do not reduce the order total

The test now explains intent rather than only documenting observation.

Remove tests that only protect obsolete structure

Not every characterization test deserves permanent ownership cost.

After a refactoring, review the temporary safety net. Keep tests that protect meaningful behaviour or important edge cases. Remove tests that only describe implementation details that no longer exist, especially when stronger tests now cover the same contract at a better boundary.

Tests are code. Redundant or brittle tests can make future changes slower even when production code has improved.

Keep behavioural changes visible

When a refactoring uncovers a bug or an opportunity to improve behaviour, resist folding everything into one opaque change.

A clearer workflow is:

  1. capture current behaviour;
  2. refactor while the baseline remains green;
  3. add a test for the desired new behaviour;
  4. change the implementation;
  5. remove obsolete characterization expectations if necessary.

This sequence creates a useful history. Reviewers can distinguish code movement from product or domain changes, and future maintainers can see when behaviour changed intentionally.

Use characterization tests to buy understanding

The main value of characterization testing is not test count. It is the ability to learn a risky area while reducing the chance of accidental change.

A practical legacy-code workflow is therefore incremental:

  • identify the boundary affected by the next change;
  • capture representative current behaviour;
  • introduce only the seams needed for control and observation;
  • refactor in small behaviour-preserving steps;
  • separate deliberate behaviour changes from structural changes;
  • evolve the tests toward clear specifications as understanding improves.

Legacy code becomes easier to maintain when engineers can change it with evidence rather than intuition. Characterization tests provide that evidence long enough to create better boundaries, clearer design, and a test suite that eventually describes what the software is meant to do.