Refactoring unfamiliar code creates an uncomfortable problem: you want to improve the implementation, but you may not know which parts of its current behavior callers depend on. Existing documentation can be incomplete, and a thin test suite may describe only the obvious cases.
A characterization test helps by recording what the software does now. Instead of starting from a specification of what the code should do, you exercise existing behavior, observe the result, and turn that observation into a test. The test then warns you when a later change alters that behavior.
This technique is especially useful when working with legacy code that must change before you fully understand it. The goal is not to declare every current behavior correct. The goal is to create a temporary safety net strong enough to make deliberate changes distinguishable from accidental ones.
Start by observing before improving
Imagine an old function that calculates a cancellation fee. The implementation is tangled, the original requirements are unavailable, and several services call it.
You try a few representative inputs and observe these results:
cancellation_fee(total = 100, days_before = 10) -> 0
cancellation_fee(total = 100, days_before = 3) -> 20
cancellation_fee(total = 100, days_before = 0) -> 100You do not yet know whether these rules are ideal. You only know that this is the current behavior.
A small characterization test can capture one observation:
test "cancellation three days before costs twenty percent":
fee = cancellation_fee(total = 100, days_before = 3)
expect fee == 20That test makes a narrow statement: for this input, the current implementation returns 20. If you refactor the function and it starts returning 25, you have evidence that behavior changed.
The test does not tell you whether 20 is the correct business rule. That distinction matters throughout this technique.
Treat the test as a behavior detector
A specification test usually begins with an intended rule: “customers may cancel without a fee at least seven days before the booking.” The expected result comes from that rule.
A characterization test begins from the opposite direction:
existing code -> observed result -> regression testThis changes what the test can guarantee. It can tell you that your new implementation differs from the old one for a covered case. It cannot, by itself, tell you which implementation matches the business requirement.
That makes characterization tests useful for a specific question:
Did this change preserve the behavior we chose to preserve?
They are not a substitute for product requirements, domain knowledge, or tests derived from an authoritative contract.
Choose observations that reduce real uncertainty
It is tempting to call the function with many arbitrary values and save every result. More examples are not automatically more useful. A good characterization test targets behavior that could plausibly change during the work you are about to do.
Suppose inspection of the cancellation function reveals three branches:
if days_before >= 7:
...
else if days_before >= 1:
...
else:
...The boundaries deserve attention because small refactoring mistakes often occur there. Useful cases might include:
7 days before -> first branch
6 days before -> second branch
1 day before -> second branch
0 days before -> final branchYou may also need cases for values that affect arithmetic, such as a zero total, rounding boundaries, or unusually large totals, if those values are valid inputs.
The important principle is to choose cases from the code’s decision structure and known usage, not from a desire to maximize test count.
Test through the narrowest stable boundary you can use
Legacy code is often difficult to test because useful behavior is buried behind files, databases, clocks, network calls, or large object graphs. The first instinct may be to expose private helpers just so tests can reach them.
That can create a new problem. Tests attached to internal details may fail during harmless restructuring even when externally visible behavior stays the same.
Prefer the narrowest boundary that is both reachable and meaningful to a caller. For a pricing component, that may be a public calculation method. For a parser, it may be input text and the parsed result. For a batch process, it may be a small adapter around the part you intend to change.
If no useful boundary exists, adding a seam can be part of the preparation. A seam is a place where behavior or a dependency can be substituted without rewriting the surrounding system. For example, passing a clock into a function instead of reading the system clock directly can make time-dependent behavior controllable in a test.
Keep such preparation small. The purpose is to make behavior observable, not to redesign the entire module before you have protection.
Control unstable inputs before recording outputs
A characterization test is valuable only when the same relevant inputs produce a comparable result. Uncontrolled time, randomness, generated identifiers, network responses, or environment-dependent values can make the test fail even when the behavior you care about has not changed.
Consider a receipt generator that includes the current timestamp:
generate_receipt(order) ->
"Order 42 paid at 2026-09-05T10:30:12Z"Recording the entire string while using the real clock would produce a test that changes on every run. A better test supplies a fixed clock if the design allows it:
clock = fixed_clock("2026-09-05T10:30:12Z")
receipt = generate_receipt(order, clock)
expect receipt == "Order 42 paid at 2026-09-05T10:30:12Z"Another valid approach is to compare only the stable part of the result when the timestamp is irrelevant to the change. Which approach is appropriate depends on whether the timestamp itself is behavior you need to protect.
Do not normalize away unstable data blindly. If generated identifiers, ordering, or timestamps are part of a supported contract, removing them from the assertion can hide a real regression.
Capture enough output to detect the change you fear
Assertions can be too narrow or too broad.
Suppose a function returns:
InvoiceSummary(
subtotal = 100,
tax = 10,
total = 110,
display_label = "Total due"
)If you are refactoring tax calculation, asserting only total == 110 may miss a bug where subtotal and tax are both wrong but happen to add up to the same total.
At the other extreme, serializing every internal field of a large object may couple the test to details unrelated to the behavior under change.
A useful assertion captures the smallest set of observable facts that would reveal the regression you are concerned about. In the tax example, checking subtotal, tax, and total is more informative than checking only the final sum, while an internal cache key probably does not belong in the test.
This is why characterization testing still requires judgment. Recording output mechanically is easy; deciding which output represents meaningful behavior is the engineering work.
Refactor in small steps and classify every failure
Once the safety net exists, change the implementation in small increments. When a characterization test fails, do not immediately update the expected value.
First classify the difference:
- Accidental regression: the refactoring changed behavior that should remain stable. Fix the implementation.
- Intentional correction: the old behavior was wrong according to a trusted requirement. Update the behavior and replace or revise the test to express the corrected rule.
- Irrelevant implementation difference: the assertion captured something callers do not rely on. Narrow the test so it protects behavior rather than representation.
- Previously hidden ambiguity: the old behavior exists, but nobody knows whether it should be preserved. Stop that part of the change until the decision can be made explicitly.
This classification prevents a common failure mode: treating a red test as permission to regenerate expected output. If expected values are updated automatically whenever code changes, the tests stop protecting anything.
Do not freeze accidental behavior forever
Characterization tests deliberately preserve observed behavior, including behavior that may be accidental. That is useful during risky change, but it creates a maintenance responsibility.
Suppose the old cancellation function accepts days_before = -3 and charges the full amount. You can capture that result if preserving it is necessary while restructuring the code. Later, you learn that negative values indicate invalid input and should be rejected.
At that point, keeping the old characterization test unchanged would preserve a behavior you now know is undesirable. Replace it with a requirement-based test for the new rule:
test "negative days before cancellation is rejected":
expect cancellation_fee(100, -3) raises InvalidCancellationDateThe safety net should evolve as understanding improves. Characterization tests are a bridge from uncertain behavior toward explicit contracts, not a reason to make every historical quirk permanent.
Use broader snapshots carefully
Sometimes the observable result is large: a generated document, a serialized message, or a long report. Comparing the complete output to an approved example can be an efficient way to detect unexpected differences. This style is often called snapshot, golden-master, or approval testing depending on the tooling and workflow.
The trade-off is review cost. A large expected file can tell you that something changed without making the significance of that change obvious. If developers routinely accept large diffs without reading them, the test provides weak protection.
Broad comparisons work well when the output is deterministic, meaningful to review, and difficult to assert field by field. Prefer focused assertions when only a few facts matter or when the full representation changes frequently for harmless reasons.
Know when characterization tests are the wrong tool
You do not need characterization tests for every refactoring.
If a component already has strong tests derived from clear requirements, those tests may provide enough protection. Adding a second suite that merely records the same behavior can increase maintenance without reducing meaningful risk.
They are also a poor foundation for defining a brand-new feature. New behavior should normally be tested against intended requirements, because there is no valuable historical behavior to preserve.
Characterization tests are most useful when three conditions meet: existing behavior matters, your understanding of that behavior is incomplete, and you need to change the implementation safely.
Turn observations into understanding
The strongest outcome is not a permanent pile of tests named after legacy quirks. It is a clearer model of what the software is supposed to guarantee.
Start by observing current behavior around the area you need to change. Capture representative cases, especially important branches and boundaries. Stabilize external inputs so failures indicate meaningful differences. Refactor in small steps, and investigate every changed result instead of accepting it automatically.
As requirements become clear, keep tests that describe real contracts, rewrite tests whose intent can now be stated directly, and remove protection for behavior that no longer matters.
Used this way, characterization tests give you something legacy code often lacks: a controlled way to learn what the system does while changing how it does it.