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

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:

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:

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:

  1. Record it as-is in the test, with a name that says so — test_rounds_half_down_current_behavior.
  2. File it with the evidence the test provides.
  3. 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

References