Changing old code is difficult when nobody can say with confidence which behaviours are intentional and which are accidents. Documentation may be incomplete, the original authors may be unavailable, and existing tests may cover only a small part of the system.

In that situation, writing tests for the design you wish the code had can be risky. Before improving the design, you first need evidence about what the software actually does today.

A characterization test records an observable behaviour of existing code so that later changes can reveal when that behaviour changes. Its first job is not to prove that the behaviour is correct. Its first job is to make the current behaviour visible and repeatable.

That distinction makes characterization tests especially useful when refactoring legacy code, replacing an implementation, or investigating unfamiliar behaviour.

Treat the current system as evidence

Suppose an old pricing component calculates a final amount from a subtotal and a customer type. The business rules are poorly documented, and several other parts of the application depend on it.

You want to simplify the component. The obvious temptation is to read the code, infer the intended rules, and write tests from that interpretation.

The problem is that your interpretation may be incomplete.

Perhaps the component rounds before applying a discount. Perhaps an empty customer type is treated like a regular customer. Perhaps a boundary value follows a rule that nobody remembers. Downstream code may depend on any of those behaviours even if they look unusual.

A characterization test starts from a different question:

For a meaningful input, what does the current system observably produce?

You run the existing code, capture that result, and turn it into a repeatable test. The test creates a baseline. When a later refactoring changes the result, you have a concrete difference to investigate instead of discovering it after release.

The smallest useful test captures one observable contract

Imagine the current component behaves like this:

calculatePrice(subtotal: 100, customerType: "member") -> 90

A small characterization test can record that behaviour:

test "member price for a subtotal of 100" {
    result = calculatePrice(100, "member")

    assert result == 90
}

This example is intentionally simple. The important point is what the assertion means.

It does not necessarily mean that 90 is the correct business answer. It means that 90 is the behaviour observed before the change.

If you later learn from an authoritative requirement that the result should be 92, then the existing behaviour is a defect. At that point, change the production behaviour deliberately and update the test to describe the corrected rule.

Until you have that evidence, the characterization test protects you from changing behaviour accidentally while trying to change structure.

Test through a stable observable boundary

Characterization tests are most useful when they observe behaviour that matters outside the implementation being changed.

Good boundaries may include:

  • a public function or module interface;
  • a service operation;
  • a generated document or serialized result;
  • state written through an application boundary;
  • messages emitted to another component.

Avoid asserting every internal method call or temporary value merely because it is easy to inspect. Those details often change during refactoring. If the test is coupled to them, it can fail even when externally meaningful behaviour remains identical.

For example, suppose a report generator currently uses three helper functions internally. If callers only care about the generated report, testing the helpers’ call order makes the implementation harder to reorganize. Testing the report output gives you more freedom to change the internal structure while still detecting meaningful differences.

The practical rule is simple: capture the narrowest observable behaviour that represents something another part of the system could depend on.

Choose cases that reveal behaviour, not every possible input

Legacy code can have a huge input space. Characterizing every combination before making any change is usually unrealistic.

Instead, choose cases that help you discover and preserve important behaviour.

Start with ordinary examples that represent common use. Then look for boundaries and branches in the existing implementation: zero values, empty collections, thresholds, optional values, special states, and inputs immediately around comparison points.

Suppose a fee changes when an order reaches 50 units. Testing only an order of 10 tells you little about that boundary. Cases such as 49, 50, and 51 are more informative because they reveal whether the threshold is inclusive and whether the implementation behaves consistently around it.

Production incidents can also identify valuable cases. If a particular input once caused a failure, capturing that scenario before refactoring helps ensure the structural change does not silently reintroduce or alter the behaviour.

The goal is not maximum test count. The goal is enough behavioural evidence to make the intended change with reasonable confidence.

Let surprising results teach you about the code

Characterization work often reveals results that look wrong.

Suppose you expect this:

formatName("", "Lee") -> "Lee"

but the current system produces:

formatName("", "Lee") -> " Lee"

Do not immediately “fix” the output as part of an unrelated refactoring.

First determine whether the leading space is externally visible and whether anything relies on it. Check requirements, callers, stored data, examples, or other trustworthy evidence. You may discover a genuine bug, or you may discover compatibility behaviour that must be changed through a separate decision.

This is one of the main benefits of characterization testing: a surprising result becomes a question you can investigate before it becomes an accidental breaking change.

Separate behaviour preservation from behaviour correction

A safe legacy change is easier to reason about when it has one purpose at a time.

Consider two different goals:

  1. reorganize the pricing implementation without changing results;
  2. correct an incorrect discount rule.

Combining both goals in one change makes failures harder to interpret. If a test changes from 90 to 92, was that an intended correction or an accidental side effect of the refactoring?

A clearer sequence is:

  1. characterize the relevant current behaviour;
  2. refactor while keeping those tests passing;
  3. make the intentional behaviour change separately;
  4. update or add tests that express the corrected expectation.

This separation reduces ambiguity. A failure during the refactoring phase usually means behaviour changed unexpectedly. A changed expectation during the correction phase is explicit and reviewable.

Snapshot-style comparisons can help, but use them carefully

Sometimes the observable result is too large for a few individual assertions. A renderer may produce a structured document, or a serializer may generate a sizeable representation. Capturing the complete output and comparing it with a stored baseline can quickly reveal differences.

This technique is useful when the output itself is the behaviour you need to preserve. It becomes less useful when the captured data contains unstable details such as timestamps, generated identifiers, nondeterministic ordering, or irrelevant formatting.

Large snapshots also have a review problem: developers may approve a changed baseline without understanding what changed.

Prefer focused assertions when a small number of properties express the important contract. Use a broader snapshot when the complete representation matters and reviewers can understand meaningful differences. Normalize genuinely irrelevant nondeterministic data rather than repeatedly accepting noisy changes.

Characterization tests are not a permanent excuse for unclear behaviour

A characterization suite describes what the system did when the tests were written. That is valuable evidence, but it does not automatically define what the system should do forever.

As understanding improves, classify important behaviours more deliberately.

Some observations become documented requirements. Some turn out to be defects and should change. Some are implementation details that disappear after a better boundary is introduced. Tests that no longer protect a meaningful contract can then be removed or replaced.

Keeping every exploratory characterization test forever can create a different maintenance problem: the suite may freeze accidental details and make legitimate improvements unnecessarily expensive.

The long-term goal is not to preserve legacy behaviour blindly. It is to replace uncertainty with explicit decisions.

Know when characterization tests are worth the effort

Characterization tests are especially useful when code is already in use, automated coverage is weak, behaviour is not fully documented, and you need to change the implementation without unintentionally changing its observable results.

They are less valuable when a component already has strong tests that clearly express its contract. In that case, existing tests may already provide the safety net you need.

They are also not a substitute for requirements when you are intentionally designing new behaviour. If the question is “what should this new feature do?”, derive tests from the desired behaviour rather than copying whatever an unrelated implementation happens to do.

Use characterization testing when the central risk is uncertainty about existing behaviour.

A practical workflow for an unfamiliar component

When you need to refactor a poorly understood component, a disciplined sequence keeps the work manageable:

  1. Identify the observable boundary affected by the planned change.
  2. Run a few representative inputs through the current implementation.
  3. Turn important observed results into focused tests.
  4. Add boundary or exceptional cases where the implementation suggests hidden behaviour.
  5. Investigate surprising outputs before deciding whether to preserve them.
  6. Make a small structural change.
  7. Run the characterization tests and inspect every unexpected difference.
  8. Repeat until the refactoring is complete.
  9. Revisit the tests and keep the ones that protect meaningful behaviour.

This workflow does not eliminate risk. It converts part of the risk from undocumented assumptions into observable differences that can be reviewed.

Conclusion

Legacy code is hardest to change when its behaviour is implicit. Characterization tests create a temporary map of that behaviour before you start moving things around.

The key idea is to separate observation from judgment. First capture what the system does at meaningful boundaries. Then use those tests to detect unexpected differences while refactoring. When evidence shows that existing behaviour is wrong, change it deliberately rather than accidentally.

That approach turns an unfamiliar codebase from something you must trust by intuition into something you can change with progressively stronger evidence.