Changing unfamiliar code creates a difficult question: how do you know a refactor preserved behavior when nobody can state exactly what the current behavior is?
Existing unit tests may be sparse. Documentation may describe the intended rules but not the edge cases the system actually implements. Some odd behavior may even have become a dependency for callers.
A characterization test helps in this situation. Instead of starting from what the code ought to do, it records what the code does now for a carefully chosen input. That gives you a behavioral reference point before you change the implementation.
This article shows how to use characterization tests as temporary or lasting safety rails, how to choose useful observations, and how to avoid accidentally declaring every legacy bug correct forever.
Start by separating two questions
When working with existing code, two questions are easy to mix together:
- What does the system do today?
- What should the system do after the change?
A normal specification-oriented test usually starts with the second question. You know a requirement and write a test that expresses it.
A characterization test starts with the first question. Its immediate job is to make current observable behavior explicit.
Suppose a shipping function has no tests:
shippingFee(weightKg):
if weightKg <= 1:
return 5
if weightKg <= 5:
return 8
return 12Before reorganizing it, you might capture a few current results:
shippingFee(0.5) == 5
shippingFee(1) == 5
shippingFee(2) == 8
shippingFee(5) == 8
shippingFee(6) == 12These tests do not prove that the prices are correct business policy. They establish a narrower fact: these inputs currently produce these outputs.
That distinction matters. Characterization protects known behavior while you change structure; it does not automatically justify that behavior.
Observe behavior at a useful boundary
The most valuable characterization tests usually observe behavior that another part of the system can actually notice.
Imagine a report generator that reads orders, calculates totals, formats rows, writes a file, and logs progress. A test could inspect every helper call, but doing so would tie the test to the current implementation. A harmless refactor could then break the test even though the generated report is unchanged.
Prefer a boundary that represents a meaningful contract. Depending on the code, that might be:
- a returned value;
- a state transition visible through the component’s public interface;
- a generated document or message;
- a call to an external boundary, captured with a test double;
- an error returned for a particular input.
For the report generator, comparing the produced report may be more useful than asserting which private formatter methods ran.
The principle is simple: capture behavior you want confidence about, not incidental steps the implementation happens to take today.
Choose examples around decisions, not at random
A few representative examples are usually more informative than many arbitrary inputs.
Return to the shipping function. The important decisions occur around 1 kg and 5 kg. Inputs near those boundaries tell you much more than ten values between 2 and 4.
A focused set might cover:
0.5 below first boundary
1.0 exactly first boundary
1.1 just above first boundary
5.0 exactly second boundary
5.1 just above second boundaryYou should also include cases that have caused incidents, appear frequently in production, or exercise suspicious branches you plan to change.
The goal is not to discover every possible behavior before touching the code. That can become a separate reverse-engineering project. The goal is to reduce uncertainty around the behavior affected by the planned change.
Let the current code reveal the initial expectation
In poorly understood code, you may not know the expected result before running it. That is normal for characterization work.
A practical sequence is:
- Choose an input relevant to the change.
- Run the existing code and observe the result.
- Check whether the observation is stable and meaningful.
- Record it as a test expectation.
- Repeat for important boundaries and branches.
Suppose shippingFee(0) unexpectedly returns 5. Before writing shippingFee(0) == 5, investigate enough to classify the observation.
Perhaps zero-weight shipments are valid placeholders and callers rely on the result. Perhaps zero is invalid and the function is missing validation. Those lead to different tests and different changes.
Do not mechanically snapshot every output. Observation is evidence, not a specification handed down by the code.
Characterize first, then refactor in small steps
Once the relevant behavior is covered, the tests become a comparison mechanism.
Assume you want to replace the condition chain with explicit rate bands. The safe workflow is:
current implementation
|
+--> characterization tests pass
|
+--> small structural change
|
+--> same tests pass
|
+--> next structural changeIf a test fails after one small edit, the search space is narrow. Either the implementation changed observable behavior, or the test depended on an implementation detail you did not intend to preserve.
This is why small refactoring steps matter. A large rewrite can produce the same failing test, but it leaves many possible causes.
Characterization tests are especially useful before extracting functions, moving responsibilities, replacing a dependency, or simplifying tangled conditionals. They give you evidence that structural changes did not silently alter selected behaviors.
Treat nondeterminism before recording outputs
Some behavior cannot be characterized reliably until you control unstable inputs.
Consider code that embeds the current time in an invoice number. If a test records the complete output while the clock remains uncontrolled, the expected value changes between runs. The test does not provide a stable reference point.
Instead, introduce the smallest control needed for the observation:
invoiceNumber(order, clock)Then supply a fixed clock in the test.
The same issue appears with random values, network responses, environment-dependent paths, unordered collections, and concurrency. Depending on the case, you can inject a controllable dependency, normalize irrelevant variation, or assert only the stable part of the result.
Be careful with normalization. If ordering is part of the real contract, sorting output only in the test can hide a behavior change. Remove variation only when you have decided that the variation is irrelevant to the behavior being protected.
Snapshot tests are one technique, not the definition
Large outputs sometimes make example-by-example assertions impractical. A snapshot or golden-master test can record an entire serialized result and compare future runs against it.
This can be useful for generated documents, compiler-like transformations, or large structured responses. It can also create noisy tests.
Suppose a snapshot contains 500 lines and a refactor changes one timestamp plus one important pricing field. A reviewer must notice which difference matters. If snapshots are routinely updated without reading the diff, they stop acting as useful alarms.
Use broad snapshots when the whole output is a meaningful contract and reviewers can understand changes. Otherwise, prefer smaller assertions around important properties or split one large observation into focused cases.
A characterization test is defined by its purpose—capturing existing behavior—not by a particular assertion style.
Do not preserve a known bug by accident
The most important judgment comes when current behavior is wrong.
Imagine investigation confirms that shippingFee(0) should reject the input, but production currently returns 5. A characterization test that permanently asserts 5 would fight the bug fix.
There are two useful ways to proceed.
If you need to refactor surrounding code before fixing the bug, you can temporarily characterize the current behavior, complete the behavior-preserving refactor, then replace that expectation with a specification test for the corrected rule.
If the bug can be fixed safely now, write the desired expectation directly and make the test pass through the fix.
The key is to label your intent mentally and in test names where useful. “This is what happens” and “this is what must happen” are not the same claim.
Know what characterization tests cannot prove
Passing characterization tests does not prove that two implementations are equivalent for every input. The tests cover only the observations and cases you selected.
They can miss:
- untested input regions;
- timing or concurrency behavior;
- side effects you did not observe;
- performance or resource changes;
- interactions that appear only in a larger system.
Choose additional checks according to the risk of the change. A pure calculation may need boundary examples. A component that writes messages may need integration coverage around the message boundary. A performance-sensitive path may need separate measurement.
Characterization testing reduces uncertainty; it does not remove the need to reason about the change.
Remove tests that protect implementation accidents
After a successful refactor, review the characterization tests themselves.
Some now express durable behavior that callers genuinely rely on. Keep those tests, and improve their names if necessary so the contract is clear.
Others may have existed only to help you cross a risky change. For example, a test may assert an internal call sequence that was useful while extracting a dependency but is no longer part of any meaningful contract. Keeping it can make future refactoring harder for no user-visible benefit.
Ask of each test: what regression would this test prevent, and does that behavior matter?
If there is no good answer, deleting or rewriting the test may improve the suite.
When characterization testing is worth the effort
Characterization tests are most useful when all three conditions are present:
- the code already exists and its behavior is not fully understood;
- you need to change it without unintentionally changing selected behavior;
- existing tests do not provide enough confidence around that change.
They add less value when the behavior is already specified by clear, trustworthy tests, or when you are deliberately replacing the old behavior wholesale. In those cases, tests derived from the intended contract may be more direct.
Do not characterize an entire legacy system because it is old. Characterize the smallest useful surface around the change you need to make.
Conclusion
Characterization tests turn unknown existing behavior into explicit evidence before a risky change.
Start at a meaningful observable boundary. Choose cases around the decisions and edge conditions your change can affect. Record stable current behavior, investigate surprising results instead of blindly approving them, and refactor in small steps while the tests remain green.
Most importantly, remember what these tests mean. They tell you what selected behavior looked like before the change. You still decide which of that behavior is a contract worth preserving and which is a bug or implementation accident that should be changed.