How to Add Tests to Legacy Code: Characterization Tests and Seams
Advanced4 minInnovialabs editorial team
In short
First lock in current behaviour with characterization or snapshot tests, then use seams to cut off the database, network and clock, and only then change code in small verified steps.
The core idea
In legacy code you cannot start by writing tests for "how it should be": nobody knows exactly how it should be, and the code is already running for users. So the order is different:
- Lock in current behaviour, even if it looks odd.
- Find seams to isolate the code from the database, network, clock and files.
- Change code in small steps, running the tests after each one.
The goal of the first tests is not to prove correctness but to install an alarm: if behaviour changes, you find out immediately.
Characterization tests
A characterization test describes what the code does right now. The technique is simple:
- Call the function with realistic input.
- Write an assertion with a deliberately wrong expected value.
- Run the test, read the actual result and paste it into the expectation.
If the result looks like a bug, do not fix it right away. Keep the test, add a comment and file a task: fixing a bug is a separate, deliberate change.
Choose inputs that go through different branches. A coverage report shows which branches are still untouched.
Snapshot and approval testing
When the output is large — HTML, JSON, a report, a PDF as text — writing expectations by hand is slow. This is where these help:
- Snapshot tests — Jest
toMatchSnapshot, or syrupy in Python, for example. The first run saves the output, later runs compare against it. - Approval testing — the ApprovalTests family of libraries. The output is written to a
receivedfile; you review the diff and "approve" it, turning it intoapproved. - Golden master — running many inputs (for example, real data exports with personal information removed) and saving every output.
Make sure volatile data does not end up in the snapshot: the current time, random IDs, key order. Replace or normalise them, or the tests will flicker.
Seams and breaking dependencies
A seam is a place where you can change the code's behaviour without editing it at that spot. The main kinds:
| Seam | What it looks like |
|---|---|
| Object | pass a different object into a constructor or function |
| Module | replace an import: jest.mock, monkeypatch in pytest |
| Configuration | a different database URL or an environment flag for tests |
Typical legacy code pulls in its dependencies by itself:
def send_reminders():
for inv in db.query_unpaid():
if inv.due_date < datetime.now() - timedelta(days=7):
mailer.send(inv.email, render(inv))
A safe move is to extract parameters and keep the old function as a wrapper so callers stay untouched:
def send_reminders_core(invoices, now, send):
for inv in invoices:
if inv.due_date < now - timedelta(days=7):
send(inv.email, render(inv))
def send_reminders():
send_reminders_core(db.query_unpaid(), datetime.now(), mailer.send)
Now send_reminders_core can be tested with an in-memory list, a fixed date and a fake send function. Do mechanical steps like this with IDE tooling: automated refactoring is less likely to slip.
Two more useful techniques:
- Sprout method — write new logic in a separate, tested function and call it from the old code.
- Wrap method — wrap the old function in a new one that adds behaviour before or after it.
Refactoring in small steps
- One change, one test run, one commit.
- Do not mix refactoring and behaviour changes in the same commit.
- If a test fails and the reason is unclear, roll back instead of fixing forward.
- As the code becomes clearer, replace snapshots with precise unit tests and meaningful assertions.
- Start with the areas that change or break most often, not the whole project at once.
Common mistakes
- A big "while we are at it" refactor before tests exist.
- Snapshots that get updated without review, so they protect nothing.
- Mocks for every call, so the test mirrors the implementation and breaks on any edit.