ix
ix

informatix

PL version Book a discovery call
Home / Blog / Characterization Tests — Safeguarding Legacy Code [2026]
• 7 min read •

Characterization Tests — Safeguarding Legacy Code [2026]

Characterization tests document what old code does — bugs included — and give you a safety net for refactoring. The step-by-step method, PHP examples and the golden master variant.

Characterization tests are a technique for working with old code, described by Michael Feathers in "Working Effectively with Legacy Code." Instead of assuming how the system should behave, you describe in tests how it behaves today — including its bugs and quirks. That gives you a safety net that lets you refactor without the risk that a "fix" breaks something the business considers working.

Key takeaway: A characterization test does not judge whether the code is correct — it documents what the code actually does. It is a contract for the system's current behavior. Only with that contract in place can you safely refactor legacy code. The order is always the same: test first, change second.

The problem characterization tests solve

In a modern project you write a test before the feature: you know what it should do, so you describe it in a test. In old code it is the other way around — you do not know what the code does, and nobody remembers. Documentation (if it exists) lies, comments are years old, and the only source of truth is the running system.

In that situation the classic approach — "write unit tests, then refactor" — does not work, because you have no starting point. Characterization tests flip the direction: instead of testing what the code should do, you lock in what it does. Even if you lock in a bug, that is knowledge too. The goal is not "the code must be correct" but "the code must not change until I consciously decide otherwise."

This is the foundation of safe modernization. The whole process — from audit to CI/CD — is described in Legacy Code Modernization — Step by Step, and the refactoring techniques themselves in the legacy code refactoring guide.

How to write a characterization test step by step

The process for a single piece of code

  1. Pick the behavior to lock in — a function, a method, an endpoint. Start with a business-critical path, not the easiest one.
  2. Establish the input — prepare data that triggers this code in the real system. In legacy code this is often the hardest stage: data scattered across many tables and dependencies.
  3. Run it and record the result — call the code and see what it returns. Do not guess, do not fix, do not "improve." Record the result exactly as it is.
  4. Turn the result into an assertion — the test passes as long as the code returns exactly what it returned. That is your contract.
  5. Repeat for edge cases — empty data, missing record, invalid input. Error behaviors are behavior too, and refactoring could change them.

The first run usually ends with the discovery that the code does something completely different from what its name suggests. That is not a failure — it is the most valuable information in the whole process.

Characterization tests vs regression tests

Both protect against unwanted changes, but they play different roles

Aspect Characterization test Regression test
Purpose Lock in current behavior Guard against a bug returning
When written Before refactoring After finding and fixing a bug
Correctness judgment Does not judge — documents Assumes the fix is correct
Lifespan Partly retired after refactoring Permanent — stays for good

In practice both live together. You write characterization tests in bulk before modernizing a module, and regression tests get added continuously with every bug reported from production. Over time some characterization tests "graduate" — once you verify the locked-in behavior is correct, the test becomes an ordinary regression test.

A PHP example — locking in a discount calculator

The simplest example is a function with business logic that "nobody knows exactly how it works." Imagine a method calculating a discount where years of conditions have piled up: the discount depends on the amount, the customer category and the day of the week, and one condition looks like a bug. The characterization test looks like this:

public function test_discount_for_b2b_customer_on_10k_order(): void
{
    $calculator = new LegacyDiscountCalculator();

    // Behavior observed in production on 2026-10-01
    $discount = $calculator->calculate(10000.0, 'b2b', '2026-10-01');

    $this->assertSame(0.17, $discount);
}

The name says it all — this is not a test of "what the discount should be" but of "what the system gives today." If during refactoring it turns out 0.17 is the result of a bug, the decision to fix it will be conscious and will have its own test. Until then, every structural change must keep 0.17.

For larger fragments with complex output, there is a variant called golden master — you capture the full system output (a generated document, an endpoint response) and compare it byte-for-byte with a stored reference.

Golden master — when results are too complex for assertions

For fragments whose output is a document, an email, a PDF or a large JSON payload, hand-writing assertions is impractical. Golden master works differently: you run the code over a representative set of inputs, store the full outputs as reference files, and the test compares current output with the reference.

The advantage is obvious — full behavior coverage without describing every field. The risk too: the reference must be reviewed and consciously accepted, and updated with every legitimate behavior change. Golden master works best for modules frozen during refactoring — exactly where characterization tests have the most value.

When characterization tests are not worth it

The technique has limits. There is no point locking in behavior that

  • will be changed as part of the same modernization — the test would only get in the way,

  • is the output of a broken feature the business is consciously switching off,

  • depends on external systems you do not control (better to isolate with mocks and test your own logic),

  • belongs to a module that will be rewritten from scratch without compatibility guarantees.

  • The rule is simple: you lock in the behavior you want to keep. The decision about what survives modernization is made with the business before the first test is written.

From tests to refactoring

Characterization tests are the first step of the modernization process, not a goal in themselves. Their value materializes the moment you start changing code: you refactor a function, run the tests, see green — you deploy. Without that step, every change in legacy code is a bet, not engineering. The next stages — refactoring patterns, modular modernization, CI/CD — are described in the modernization action plan.

Frequently asked questions

How is a characterization test different from a normal unit test?

A unit test verifies that code works according to requirements. A characterization test documents how the code currently works — without judging whether that matches requirements. It is written when requirements are unknown or out of sync with reality, which means mainly in old systems.

Do characterization tests lock in bugs?

Yes, and that is a conscious decision. A locked-in bug becomes documented behavior that can be fixed in a controlled way — with its own test and a business decision. The alternative, fixing bugs while refactoring, leads to changes nobody can verify.

How many characterization tests do you need?

Enough to make the refactored module safe — in practice, coverage of critical paths and edge cases, usually a dozen to a few dozen tests per module. The point is not one hundred percent coverage, but coverage of what can break and what can be noticed.

Do characterization tests stay in the project forever?

Some do. Tests locking in behavior that turned out to be correct become ordinary regression tests and stay for good. Tests locking in temporary behavior or bugs get retired once the module is modernized and verified — so they do not block future, conscious changes.

Sources and tools

Free PDF

Web app requirements checklist

12 control questions to answer before talking to a vendor — faster estimates, fewer hidden costs.

Related posts