Characterization Tests
Characterization tests capture what legacy code actually does — including its bugs — as a safety net for refactoring, pinning current behavior before changing it.
Definition
A characterization test asserts the current observed behavior of code, not the specified or desired behavior. They are written when touching legacy systems with no tests: record what happens for representative inputs, lock it in, and refactor underneath the net. Bugs become pinned facts, marked for later correction as separate changes.
Context and scope
Used in legacy rescue operations before refactoring or migration. Michael Feathers working effectively with legacy code defines them as the tool for putting seams under untested code.
Common misconceptions
- Characterization tests verify correctness — they verify stability; asserting observed behavior including defects is the point
- Write them for the whole system first — characterize the slice you are about to change; broad nets take longer than the refactor
- They become permanent regression tests — many are scaffolding to be deleted after the refactor lands
Anti-patterns
- Fixing bugs while characterizing and never knowing whether the refactor or the fix changed behavior
- Characterizing only happy paths and refactoring error handling blind
- Keeping thousands of characterization tests forever and inheriting a maintenance backlog from the rescue
Related concepts
Related examples
Authoritative source
What it is
Legacy code is code without tests, and changing it unverified is archaeology with a hammer. Characterization tests turn observed behavior into executable facts so that refactoring has a tripwire.
How it works
Choose the seam you will change. Feed representative inputs, capture actual outputs, and write assertions against them — including the surprising ones. Mark bug-pinned assertions explicitly so they are fixable later as deliberate behavior changes. Refactor in the net; delete the scaffolding tests that no longer earn their keep.
When to use them
Before any structural change to untested code: dependency extraction, performance rewrites, framework migration. The alternative — refactor freehand and eyeball the diffs — is how behavior silently changes during cleanups.
Pitfalls
Over-pinning: characterizing incidental details freezes quirks into contract. Characterizing at the wrong level — implementation internals rather than observable boundaries — makes every legitimate refactor trip the net. Snapshot-style bulk characterization amplifies both problems.