thientranhung

legacy-testing

How to get existing untested code under test — characterisation tests that pin current behaviour, and creating a seam before TDD can attach. Use when you need to change code that has no tests, when the code under a ticket has no seam to test through, when a test would require the whole system to run, or when tdd cannot start because there is nothing to write a test against.

thientranhung 4 Updated 2mo ago
GitHub

Install

npx skillscat add thientranhung/astragentic/harness-claude-skills-legacy-testing

Install via the SkillsCat registry.

SKILL.md

Getting legacy code under test

tdd writes a failing test first. That assumes a seam — a place where you can substitute
what the code depends on. Existing code often has none: the function reaches straight for the
clock, the network, the database, or a module-level singleton. tdd says nothing about that
case, and this is that case.

The order is fixed, and it is the opposite of greenfield:

characterise what it does now  →  create a seam  →  then tdd

1. Characterisation tests — pin what it does, including the bugs

A characterisation test records current behaviour. It is not a specification, and it
passes on day one by construction.

Write one by calling the code with real inputs, observing what comes back, and asserting
exactly that. Where the output surprises you, assert the surprising value anyway and mark
it:

// CHARACTERISATION: returns "" for a missing user rather than throwing.
// Pinned as-is; behaviour not yet judged correct. See ticket ABC-123.

That comment is the whole discipline. A characterisation test that quietly asserts what the
code should do fails immediately and tells you nothing about what is safe to change — and
one that silently blesses a bug as intended is how a bug becomes a requirement. Recording it
as pinned-but-unjudged keeps both readings alive.

Pick inputs by coverage, not by intuition. Run the suite with coverage and aim your
inputs at the branches the change will touch. Characterising the whole file is rarely worth
it; characterising the paths your change can break always is.

These tests are scaffolding with a real lifespan: they protect the refactor, then most of
them are replaced by the behavioural tests tdd produces once a seam exists.

2. Create the seam

A seam is where you can change behaviour without editing the code under test. Reach for the
smallest one that unblocks the ticket:

Technique Use when Cost
Parameterise the dependency (pass the clock, the client, the config in) the call site is reachable and few lowest
Extract an interface over the collaborator several call sites, one collaborator low
Sprout — write the new logic in a fresh, tested function and call it from the old code the old function resists testing entirely low, leaves the tangle
Wrap — put a tested layer around the untestable unit the unit is genuinely immovable medium
Break the static dependency (singleton, module-level state, direct new) that is the only thing preventing substitution highest

Make the seam change under characterisation tests, and make it behaviour-preserving.
Seam creation and behaviour change in one commit is the move that makes a regression
impossible to locate: if the tests break, you want to know it was the seam.

A seam is a design decision, so it is the Shaper's when it is large. A parameter added to
one function is yours. A new interface that several modules will depend on shapes the module
boundaries, and that belongs where the whole picture is in context — report it to Thomas
rather than deciding it inside one ticket. codebase-design is the vocabulary for that
conversation.

3. Then TDD

With a seam and characterisation tests in place, tdd works normally: red, green, refactor,
with the characterisation tests as the net underneath. As behavioural tests take over a
path, retire the characterisation tests covering it — and where one of them was pinning a
bug, that pinned comment is now a ticket rather than a deletion.

When to stop and report

Some code cannot be seamed inside one ticket: the dependency is a framework the whole app
boots through, the untested surface is far larger than the change, or the seam would commit
the project to an architecture nobody has agreed. Say so to Thomas with what you found —
the paths, the blocker, and the smallest seam you can see. That is a real result. Forcing a
seam under ticket pressure produces the architecture nobody agreed, and it arrives without
anyone noticing it was decided.

For a repo where this is the normal case rather than the exception, the tangle itself is the
problem: untangle is the path for that.