Characterization Tests for Legacy Code
Changing code that has no tests is like editing a contract written in a language you half understand: you cannot tell which of your edits change its meaning. A characterization test solves this by recording what the code actually does today — not what the specification says, not what anyone thinks it should do — so that any change in behavior shows up as a failing test. With that safety net in place, refactoring becomes routine instead of nerve-wracking.
The term comes from Michael Feathers' Working Effectively with Legacy Code, together with its companion idea, the seam: a place where you can change what code does without editing it there, which lets you bring stubborn code under test. This guide covers both, as the first practical step of Software Maintenance and Legacy Modernization.
TL;DR
- A characterization test asserts current behavior, including behavior that looks wrong.
- Write it by asserting a guess, letting the failure show the real value, and recording that value.
- For code with many outputs, use a golden master: run many inputs, store the outputs, and compare future runs against them.
- Choose inputs from boundaries in the code, coverage reports, and real production data.
- Introduce seams with small, mechanical edits to isolate the clock, randomness, databases, and network calls.
- Do not fix bugs while characterizing; record them, and fix them later as deliberate behavior changes.
Quick Example
A legacy shipping calculator with rules nobody remembers. Assert something you know is wrong and let the failure reveal the truth:
Then record what the code really does:
A handful of tests like this, around the code you are about to change, turns refactoring from guesswork into a sequence of safe steps — see Refactoring.
Core Concepts
Characterization Versus Specification
Characterization tests are a means, not an end. Once the code is understood and restructured, intention-revealing unit tests should replace most of them.
Golden Masters and Approval Tests
When a function has many inputs or produces large outputs — reports, rendered documents, pricing tables — write one test that runs a broad set of inputs and compares the results with a stored, reviewed copy:
Fifty-six combinations now guard every refactoring step. Approval-testing libraries (ApprovalTests, and snapshot tools such as syrupy, Jest snapshots, and Verify) add conveniences: readable diffs, a review step to approve new output, and scrubbers that mask timestamps and generated IDs.
Seams
A seam is a place where behavior can be substituted without editing the code at that point. Legacy code is hard to test mostly because it reaches directly for things tests cannot control: the system clock, random numbers, the database, the network, global state. Opening a seam makes those replaceable:
Common seam techniques include parameters with production defaults, constructor injection, extracting a method and overriding it in a test subclass, and wrapping static or global calls behind a small interface. See Mocking & Stubbing.
Choosing Inputs
Characterization is only as good as the inputs it exercises:
- Read the code for boundaries: every
if weight < 5, every currency branch, every null check suggests inputs on both sides. - Use coverage as a guide: run the tests with branch coverage and add inputs until the code you will change is exercised — see Test Coverage.
- Borrow real data: sanitized production inputs exercise combinations nobody would invent.
- Generate variety: property-based generators can feed many inputs into a golden master; the property is simply "the output matches what the old code produced" — see Property-Based Testing.
- Check the tests' strength: mutation testing tools (mutmut, Stryker, PIT) change the code slightly and report changes no test noticed.
Focus on the area you are about to change and the code its effects can reach. Characterizing an entire system before touching one function wastes effort.
Handling Non-Determinism
Outputs that contain timestamps, random IDs, unordered collections, or floating-point noise make golden masters flaky. Control or normalize them:
- Fix the clock and random seeds through seams.
- Sort collections whose order does not matter.
- Mask volatile values — replace generated IDs and timestamps with placeholders before comparing.
- Round floating-point values to the precision that matters.
When You Find a Bug
Characterization regularly surfaces behavior that looks wrong: rounding that favors the company, a discount applied twice, a time zone ignored. Resist fixing it on the spot:
- Record it as-is in the test, with a name that says so —
test_rounds_half_down_current_behavior. - File it with the evidence the test provides.
- Fix it later as a deliberate behavior change, agreed with whoever owns the behavior, updating the test in the same commit.
Somebody may depend on the odd behavior — a downstream report, a partner integration, a customer's spreadsheet. With enough users, every observable behavior is depended on by somebody, an observation known as Hyrum's Law.
Best Practices
Introduce Seams With Mechanical Edits
Before tests exist, change as little as possible and as safely as possible: automated IDE refactorings, adding parameters with defaults, and preserving existing signatures. The first edits are the riskiest because nothing protects them yet.
Start at the Level That Is Testable
If a function is too entangled to call on its own, characterize at a higher level — an HTTP endpoint against a seeded database, or a batch job against fixture files — then work inward as seams appear.
Review Golden Files Like Code
Commit golden and approved files, and read their diffs in review. An approval step that people click through without reading provides no protection.
Retire Characterization Tests Deliberately
As refactoring clarifies behavior, replace characterization tests with specification tests that explain intent, and delete golden masters that change legitimately with every feature.
Common Mistakes
Fixing Bugs While Characterizing
Approving Snapshots Without Reading Them
Golden Masters Full of Volatile Data
Risky Edits Before the Safety Net
FAQ
What is a characterization test?
A test that documents what existing code currently does, without judging whether that behavior is correct. Its purpose is to detect unintended behavior changes while the code is being refactored or modified.
How is it different from a snapshot or regression test?
Snapshot and approval testing are techniques; characterization is a purpose. A characterization test often uses a snapshot or golden master, but what defines it is that the expected values come from observing the current code rather than from a specification.
How many characterization tests do I need before refactoring?
Enough to cover the code you are about to change and the behavior it can affect. Use branch coverage on that area as a guide, and mutation testing if you want stronger assurance. You do not need to characterize the whole system first.
What if the current behavior is clearly wrong?
Characterize it anyway, name the test so the oddity is obvious, and record the issue. Fixing it is a behavior change that deserves its own commit, review, and possibly communication with people who rely on it.
What is a seam?
A place where you can alter a program's behavior without editing the code at that point — for example, a parameter that defaults to the real clock but accepts a fixed date in tests. Seams let you isolate legacy code from its dependencies so it can be tested.
Related Topics
- Refactoring — Changing structure safely once behavior is pinned down
- Software Maintenance and Legacy Modernization — The wider strategy for legacy systems
- Unit Testing — Specification tests that replace characterization tests
- Test Coverage — Finding untested branches before you change them
- Mocking & Stubbing — Substituting dependencies through seams
- Property-Based Testing — Generating inputs to compare old and new behavior