open atlas
↑ Back to track
Code patterns & craft CP · 10 · 01

Characterization tests

Before refactoring legacy code you don't fully understand, characterization tests pin its current behavior — even the wrong parts — so a refactor can't change behavior silently. They are the net that makes refactoring safe and the record of what the code actually does.

CP Senior ◷ 20 min
Level
FoundationsJuniorMiddleSenior

You inherit a 300-line function that computes shipping cost. No tests, no docs, the author left two years ago, and it has paid invoices behind it — it is load-bearing. You need to refactor it to add a new carrier, but you don’t fully understand what it does. The textbook says “refactor under tests.” There are no tests. Writing tests requires knowing the correct answer. You don’t know the correct answer; you only know the current answer.

That gap is the whole problem of legacy code, and there is a precise move out of it. You don’t test what the code should do — you can’t, not yet. You test what it currently does, lock that in, and only then start changing structure. Those are characterization tests, and they are the safety net that makes a refactor of code-you-don’t-understand actually safe.

Goal

After this lesson you can pin the behavior of an untested legacy function with characterization (golden master) tests — capturing observed output rather than intended output; explain why this lets you refactor without silently changing behavior; and keep the two separate jobs apart, “pin so I can refactor safely” versus “later, deliberately change a behavior with its own test”, so that characterizing a bug never traps you into preserving it forever.

1

A refactoring is, by definition, a behavior-preserving change — so you need a way to detect if behavior moved. The point of refactoring is to improve structure without changing what the code does. The only way to make that guarantee instead of hoping for it is to have something that fails the moment behavior shifts. On code with a real test suite, that something is the suite. On untested legacy, you have nothing — so any “refactor” is actually an unverified rewrite, and the bugs you introduce surface in production, weeks later, far from the edit.

The net has to exist before the change. You cannot add it after you’ve already moved the behavior, because then you’ve baked the drift into the baseline.

2

A characterization test asserts the code’s actual output, not its correct output. This is the move that breaks the chicken-and-egg. You don’t need to know the right answer to write the test; you need the current answer, and the code will tell you that. Feathers’ technique: call the function, see what it returns, and write that exact value into the assertion — even if it looks wrong.

// You suspect the right answer is ~7.50, but you do not assert that.
// You run the code and assert whatever it actually produced.
test('characterizes shippingCost for a 2kg domestic parcel', () => {
  expect(shippingCost({ weightKg: 2, zone: 'domestic' })).toBe(6.99);
});

Where did 6.99 come from? You wrote .toBe(0), ran it, the failure message said Expected 0, Received 6.99, and you pasted 6.99 back in. The test now passes and pins that input-output pair. You are not claiming 6.99 is correct. You are claiming “this is what the system does today,” and now any refactor that changes it will fail loudly.

3

Pin the behavior that actually exercises the code, including the ugly branches — coverage of paths matters more than coverage of “happy” cases. A safety net with holes lets behavior fall through. The branches most likely to break under refactoring are the weird ones: the zero-weight edge, the unknown-zone fallback, the rounding quirk, the early return that nobody remembers. Characterize those deliberately. A fast way to find them is to feed varied inputs and snapshot the outputs in bulk — the “golden master” approach — so you pin a wide swath of behavior cheaply rather than hand-writing one assertion at a time.

// Golden master: pin many input/output pairs at once.
const cases = [
  { weightKg: 0,   zone: 'domestic' },
  { weightKg: 2,   zone: 'domestic' },
  { weightKg: 2,   zone: 'intl' },
  { weightKg: 99,  zone: 'unknown' }, // the fallback path
];
test('golden master: shippingCost across known paths', () => {
  const observed = cases.map((c) => ({ ...c, cost: shippingCost(c) }));
  expect(observed).toMatchSnapshot(); // first run records, later runs compare
});

The first run records the snapshot — that recording is the characterization. From then on the suite screams if any path’s output moves. Aim the net where the refactor is most likely to slip, not where the code is obviously simple.

4

Pinning current behavior pins the bugs too — and that is fine, as long as you keep “pin” and “fix” as two separate, separately-tested decisions. This is the senior failure mode. You characterize the function, the snapshot includes a line that says intl shipping for 0kg returns 4.20 when it obviously should be 0, and now your green suite enforces that bug. If you treat the characterization test as the spec, you will be afraid to ever fix it, because fixing it turns the suite red and “the tests were passing.”

The discipline: a characterization test is a temporary description of reality, not a requirement. Refactor under it — green means “I preserved behavior, including the wrong parts, while improving structure.” Then, as a distinct commit with its own intent, change the buggy behavior: write a new test asserting the correct 0, watch the old characterization line fail, update it on purpose, and note in the commit that this is a deliberate behavior change, not a regression. Refactoring keeps the net green; fixing turns it red on purpose. Conflating the two is how teams end up petrified of their own legacy code.

Worked example

Wrap an untested function, then refactor under the net. Here is the legacy function — tangled, but in production:

// legacy: nobody is sure what every branch does
function shippingCost(o: { weightKg: number; zone: string }): number {
  let c = 0;
  if (o.zone === 'domestic') c = 4.99 + o.weightKg * 1.0;
  else if (o.zone === 'intl') c = 4.2 + o.weightKg * 3.5; // 4.2 base even at 0kg?
  else c = 9.99; // unknown zones
  return Math.round(c * 100) / 100;
}

Step one — pin it. Don’t reason about correctness; run and paste:

test('characterization: shippingCost (current behavior)', () => {
  expect(shippingCost({ weightKg: 2, zone: 'domestic' })).toBe(6.99);
  expect(shippingCost({ weightKg: 2, zone: 'intl' })).toBe(11.2);
  expect(shippingCost({ weightKg: 0, zone: 'intl' })).toBe(4.2);   // looks wrong, pin it anyway
  expect(shippingCost({ weightKg: 5, zone: 'mars' })).toBe(9.99);  // the fallback
});

Step two — refactor structure with the net green. Extract the per-zone rules into a table; behavior must not move:

const RULES: Record<string, { base: number; perKg: number }> = {
  domestic: { base: 4.99, perKg: 1.0 },
  intl:     { base: 4.2,  perKg: 3.5 },
};
function shippingCost(o: { weightKg: number; zone: string }): number {
  const r = RULES[o.zone];
  const c = r ? r.base + o.weightKg * r.perKg : 9.99;
  return Math.round(c * 100) / 100;
}

The four characterization assertions still pass — including the 4.2-at-0kg line. Green means the restructuring preserved behavior, bug included. Adding the new “mars” carrier is now a one-row edit. The 4.2-at-0kg question is real, but it is a separate ticket: a new test asserting 0, the old characterization line updated on purpose, and a commit that says “fix: intl base no longer charged at 0kg” — not slipped silently into the refactor, where a reviewer could never tell a fix from a regression.

Why this works

Why assert behavior you believe is wrong, instead of just fixing it while you’re in there? Because a refactor and a behavior change have different risk profiles and need different evidence. If you fix the bug and restructure in the same pass with no net, you have no way to prove the restructure didn’t introduce a second, unintended change hiding behind the one you meant to make. Pinning current behavior first isolates the variable: under a green characterization net, any failure is something you moved, so the refactor’s correctness is verifiable. The bug doesn’t get forgotten — it gets a ticket. Mixing the two is how a “cleanup” PR quietly ships three behavior changes nobody reviewed.

Common mistake

The trap is treating the characterization snapshot as the specification forever. Six months on, someone tries to fix the obvious 4.2-at-0kg bug, the golden master goes red, and the reflex is “I broke the tests, revert.” Now the bug is protected by tests — the worst outcome, because the net meant to enable change now forbids it. Guard against this by labeling: name them characterization tests, leave a comment that the asserted value is observed, not endorsed, and treat a red characterization test during a deliberate fix as expected — you update it on purpose. A test that exists to describe reality must never be confused with a test that exists to defend a requirement.

Check yourself
Quiz

You write a characterization test on legacy code and it asserts intl shipping at 0kg returns 4.2, which you're fairly sure is a bug. You run your structural refactor and the suite stays green. What does green mean here, and what should you do about the 4.2?

Recap

A refactor must preserve behavior, so it needs a net that fails the instant behavior moves — and on untested legacy that net doesn’t exist yet. Characterization tests create it by asserting the code’s actual output rather than its correct output: run the function, paste what it produced into the assertion, lock it in. Cover the ugly branches and use a golden master snapshot to pin many paths cheaply. The net then makes a refactor verifiable — green means “behavior preserved, including the bugs.” That last clause is the failure mode: a characterization test describes reality, it is not a requirement, so keep “pin so I can refactor” strictly separate from a later, deliberately-tested “change this behavior on purpose.” Done right, you can finally restructure code you don’t fully understand without changing what it does silently — and without becoming afraid to ever fix it.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.