Lesson 13 / 13
Bringing Legacy Code Under Test
Recording a seamless function's current behavior with characterization tests, making it changeable by opening a parameter seam, and the same tests staying green after the seam.
Contents
Everything built in this topic started from a blank page: the test was written first, the code came after. That order cannot be established in an existing codebase. A function that has run for years is already there, its behavior is not written down, and most of the time nobody knows exactly what it does.
The order for changing such a function is reversed: first what it does is put on record, then it is made changeable. The tool for the first step is characterization tests, for the second it is the seam.
Seamless Code
The function below produces a late report. It keeps records at module level, reads the day from the system clock, and writes the result directly to the output stream.
// legacy-report.mjs — the version that has run for years, with no tests const RECORDS = [ { bookNo: 'K-903', memberNo: 'U-17', dueDay: 1010, branch: 'central', returnBranch: 'central', returnDay: 1020 }, { bookNo: 'K-101', memberNo: 'U-42', dueDay: 1024, branch: 'central', returnBranch: 'hillside', returnDay: 1024 }, { bookNo: 'K-555', memberNo: 'U-58', dueDay: 1000, branch: 'hillside', returnBranch: 'hillside', returnDay: 1100 }, ]; export function writeLateReport() { const today = Math.floor(Date.now() / 86400000); let total = 0; console.log(`late report — day ${today}`); for (const record of RECORDS) { const late = record.returnDay - record.dueDay; let fee = 0; if (late > 3) fee = (late - 3) * 2; if (record.returnBranch !== record.branch) fee += 5; if (fee > 40) fee = 40; total += fee; console.log(`${record.bookNo} ${record.memberNo} late ${late} fee ${fee}`); } console.log(`total ${total}`); }
In a piece of code, a seam is a point where behavior can be altered without changing the source. This function has no such point. It has no input — the records are internal. It cannot take time from outside — it reads the clock itself. It does not return its result — it writes it. The measurement in the Testable Design lesson named exactly these three dependencies; here all three are combined in a single function.
There is still one point of observation: the lines it writes. By mutating global objects, both the clock can be fixed and the writes can be collected. The Testable Design lesson said this path is expensive — here, temporarily, it is the only path.
// observation.mjs — recording the legacy function's output for today import { writeLateReport } from './legacy-report.mjs'; const realNow = Date.now; Date.now = () => 1020 * 86400000; try { writeLateReport(); } finally { Date.now = realNow; }
late report — day 1020 K-903 U-17 late 10 fee 14 K-101 U-42 late 0 fee 5 K-555 U-58 late 100 fee 40 total 59
There is something noticeable in the second line: the K-101 record has zero lateness and a fee of five. The Coverage Measurement lesson had determined this is a bug — the out-of-branch fee is being applied to a book returned on time. The characterization test’s stance here is decisive.
Characterization Tests
A characterization test is a test that records not what the code should do, but what it does right now. It does not take its expected value from a specification; it takes it from a run. Its purpose is not to verify but to make a future change noticeable.
This is a deliberate exception to the rule set in the Anatomy of a Unit Test lesson. There it was said that the expected value must come from a source independent of the code under test; here the expected value comes directly from the code under test. The difference is in purpose: these tests carry no claim of correctness — they establish a baseline.
// characterization.test.mjs — tests that record current behavior as it is import { test } from 'node:test'; import assert from 'node:assert/strict'; import { writeLateReport } from './legacy-report.mjs'; function capture(today) { const realNow = Date.now; const realLog = console.log; const lines = []; Date.now = () => today * 86400000; console.log = (line) => lines.push(line); try { writeLateReport(); } finally { Date.now = realNow; console.log = realLog; } return lines; } test('the report heading carries the day number for today', () => { assert.equal(capture(1020)[0], 'late report — day 1020'); }); test('the report lines are produced as they stand today', () => { assert.deepEqual(capture(1020).slice(1), [ 'K-903 U-17 late 10 fee 14', 'K-101 U-42 late 0 fee 5', 'K-555 U-58 late 100 fee 40', 'total 59', ]); }); test('the report content is not affected by the day number for today', () => { assert.deepEqual(capture(2000).slice(1), capture(1020).slice(1)); });
node --test --test-reporter=tap characterization.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
ok 1 - the report heading carries the day number for today ok 2 - the report lines are produced as they stand today ok 3 - the report content is not affected by the day number for today # tests 3 # pass 3 # fail 0
The second test also records the known bug: the line K-101 U-42 late 0 fee 5 is inside
the expected result. This is done on purpose. If the bug were fixed during the
characterization stage, two changes would have been made at once — the structural change
and the behavior change would blend together, and a failing test could not say which one
it came from.
The third test closes off an uncertainty: that the report’s content does not depend on today is recorded by comparing results taken with two different day numbers. Questions like this are asked systematically while writing characterization tests, because an unknown dependency is what breaks the next step.
Opening a Seam
Once the baseline is established, the structure can change. The cheapest kind of seam is the parameter seam: a value read internally is moved to a parameter, and a thin wrapper that fills that parameter is left behind for old callers.
// legacy-report.mjs — version 2: parameter seam opened, behavior kept exactly const RECORDS = [ { bookNo: 'K-903', memberNo: 'U-17', dueDay: 1010, branch: 'central', returnBranch: 'central', returnDay: 1020 }, { bookNo: 'K-101', memberNo: 'U-42', dueDay: 1024, branch: 'central', returnBranch: 'hillside', returnDay: 1024 }, { bookNo: 'K-555', memberNo: 'U-58', dueDay: 1000, branch: 'hillside', returnBranch: 'hillside', returnDay: 1100 }, ]; export function reportLines(records, today) { const lines = [`late report — day ${today}`]; let total = 0; for (const record of records) { const late = record.returnDay - record.dueDay; let fee = 0; if (late > 3) fee = (late - 3) * 2; if (record.returnBranch !== record.branch) fee += 5; if (fee > 40) fee = 40; total += fee; lines.push(`${record.bookNo} ${record.memberNo} late ${late} fee ${fee}`); } lines.push(`total ${total}`); return lines; } export function writeLateReport() { const today = Math.floor(Date.now() / 86400000); for (const line of reportLines(RECORDS, today)) console.log(line); }
Three things were done at once: the records and the day were moved to parameters, writing the output was separated from the calculation, and the old external interface was preserved exactly. This is a refactor — behavior must not change. Its verification is rerunning the characterization tests completely unchanged.
node --test --test-reporter=tap characterization.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
ok 1 - the report heading carries the day number for today ok 2 - the report lines are produced as they stand today ok 3 - the report content is not affected by the day number for today # tests 3 # pass 3 # fail 0
The parameter seam is not the only option. An object seam supplies the dependency as an object field — the service setup in the Testable Design lesson is an example of this. A module seam makes the called module itself replaceable. What the three have in common is that they create a point where behavior can be altered without changing the source; what differs is cost and readability.
Tests That Pass Through the Seam
Tests written after the seam is opened do not touch global objects. The clock is a parameter, the records are a parameter, the result is a return value.
// seam.test.mjs — tests that pass through the seam: clock and records given from outside import { test } from 'node:test'; import assert from 'node:assert/strict'; import { reportLines } from './legacy-report.mjs'; const record = (extra) => ({ bookNo: 'K-903', memberNo: 'U-17', dueDay: 1010, branch: 'central', returnBranch: 'central', ...extra, }); test('lateness within the fee-free day allowance is not charged', () => { const lines = reportLines([record({ returnDay: 1013 })], 1020); assert.equal(lines[1], 'K-903 U-17 late 3 fee 0'); }); test('an on-time return to a different branch is charged five units', () => { const lines = reportLines([record({ returnDay: 1010, returnBranch: 'hillside' })], 1020); assert.equal(lines[1], 'K-903 U-17 late 0 fee 5'); }); test('an empty record list gives a total of zero', () => { assert.deepEqual(reportLines([], 1020), ['late report — day 1020', 'total 0']); });
node --test --test-reporter=tap seam.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
ok 1 - lateness within the fee-free day allowance is not charged ok 2 - an on-time return to a different branch is charged five units ok 3 - an empty record list gives a total of zero # tests 3 # pass 3 # fail 0
The second test’s name names the known bug openly. This is the final form of what carries over from the characterization stage: the behavior is no longer a hidden side effect but a named, tested rule. When the decision to fix it is made, the work to do is clear — the test’s name and expectation change, then the implementation; and this is now just the ordinary red-green round from the Behavior-Driven Development lesson.
This is why the order matters. In a seam-opening operation done without characterization tests, nothing says whether behavior was preserved. Tests are written first, the structure changes after, and the fix comes last.
Summary
- A seam is a point where a code’s behavior can be altered without changing the source; in seamless code, the only point of observation may be global objects.
- A characterization test records not what the code should do but what it does right now; it takes its expected value from a run, not a specification.
- Known bugs are included in the baseline too; a fix and a structural change are not made at the same time.
- A parameter seam moves an internally read value to a parameter and preserves the old interface with a thin wrapper; object seams and module seams are other, differently costly forms of the same purpose.
- Tests that pass through the seam do not touch global objects; the order is always characterization, seam, fix.
Course Wrap-Up
This course took up a single question from two directions across two topics: what does a unit test prove, and how is that proof kept trustworthy?
The Unit Testing in Practice topic turned the test into a measurement instrument. The test body was split into arrange, act, and assert sections; an assertion’s signal strength was measured by the number of mutations it caught, a name’s value by the information a failing run carries, independence by order permutation, fixture cost by line count, coverage by hand-counting three separate units, and flakiness by the number of tests that fail against a refactor. Five kinds of test double were written by hand, and the question each one answers was separated out.
The Test-Driven Development topic reversed the order. The red-green-refactor cycle was run step by step, step size’s effect on feedback time was measured by search space, rules were moved into example tables written in the domain’s language, acceptance criteria were built as the outer loop, and finally, code with no tests was set on a baseline with characterization tests and opened with a seam.
There is an assumption carried silently throughout the course, and it needs to be named. Every test here ran in-process and without a real dependency. The catalog was a map, the store was a fake, the notification was a spy, the clock was a function. These test doubles were assumed to match their real counterparts — but that was never tested anywhere. A fake catalog does not carry a real database’s constraints; a spy notification does not produce a real notification channel’s failures; the world the driver builds contains none of the boundaries that would be met in a real deployment.
Testing that assumption is the subject of a separate test layer. The Integration, Contract and End-to-End Testing course takes up these boundaries by actually running against them: which dependency uses the real thing, how a test double and the real thing are assured to honor the same contract, and how tests that run across the whole application are kept stable.
To keep your progress and take notes, Log in
My notes
Log in to take notes.