Lesson 10 / 13
Progressing in Small Steps
Measuring the paths that reach the same rule set with two different step sizes, the relationship between the number of feedback points and the search space for a bug, and the cost of returning to the last green point.
Contents
In the previous lesson the cycle turned twice, and each round handled a single rule. That was not a requirement — it was a choice: the same work could have been carried out in a single round covering all four rules at once. Step size is test-driven development’s most debated and least measured variable.
This lesson ties step size to something measurable. The same target is reached by two different paths, and two quantities are recorded at every stop: how many tests are failing, and how many lines have changed since the last stop. The second quantity matters, because when a test fails, that is exactly the space where the bug has to be searched for.
The Target: Four Rules
The renewal rules have grown to four. A renewal moves the due day forward from today by the loan period for the member’s type, and it increments the renewal count. A reserved book cannot be renewed. A loan can be renewed at most twice. An overdue loan cannot be renewed; a renewal made on the due day itself is accepted.
// renewal.mjs — all four rules export const RULES = { student: { loanDays: 28 }, member: { loanDays: 14 } }; export const MAX_RENEWALS = 2; export const LIMIT_MESSAGE = `at most ${MAX_RENEWALS} renewals allowed`; export function renew(record, today) { if (record.reserved) throw new Error('a reserved book cannot be renewed'); if (record.renewalCount >= MAX_RENEWALS) throw new Error(LIMIT_MESSAGE); if (today > record.dueDay) throw new Error('an overdue loan cannot be renewed'); return { ...record, dueDay: today + RULES[record.memberType].loanDays, renewalCount: record.renewalCount + 1, }; }
The four rules correspond to seven tests. The edge case — a renewal made on the due day itself — stands as a separate test, because choosing the wrong comparison operator shows up right there.
// renewal.test.mjs — seven tests for all four rules import { test } from 'node:test'; import assert from 'node:assert/strict'; import { renew } from './renewal.mjs'; const record = (extra = {}) => ({ memberNo: 'U-17', memberType: 'student', dueDay: 1028, renewalCount: 0, reserved: false, ...extra, }); test('a student renewal moves the due day twenty-eight days out from today', () => { assert.equal(renew(record(), 1020).dueDay, 1048); }); test('a standard member renewal moves the due day fourteen days out from today', () => { assert.equal(renew(record({ memberType: 'member' }), 1020).dueDay, 1034); }); test('the renewal count goes up by one', () => { assert.equal(renew(record(), 1020).renewalCount, 1); }); test('a reserved book cannot be renewed', () => { assert.throws(() => renew(record({ reserved: true }), 1020), /reserved/); }); test('at most two renewals are allowed', () => { assert.throws(() => renew(record({ renewalCount: 2 }), 1020), /at most 2/); }); test('an overdue loan cannot be renewed', () => { assert.throws(() => renew(record(), 1029), /overdue/); }); test('a renewal made on the due day is accepted', () => { assert.equal(renew(record(), 1028).dueDay, 1056); });
node --test --test-reporter=tap renewal.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
ok 1 - a student renewal moves the due day twenty-eight days out from today ok 2 - a standard member renewal moves the due day fourteen days out from today ok 3 - the renewal count goes up by one ok 4 - a reserved book cannot be renewed ok 5 - at most two renewals are allowed ok 6 - an overdue loan cannot be renewed ok 7 - a renewal made on the due day is accepted # tests 7 # pass 7 # fail 0
This is the destination. The question is how it is reached.
Measuring the Two Paths
The script below holds the path’s intermediate stops as implementation versions. The big-step path has two stops: the version where the first rule is written and the finished version. The small-step path has five stops, and each stop adds one rule. The same seven checks run at every stop, and the number of body lines that changed between two consecutive stops is also counted.
// step-size.mjs — measuring the path to the same target with two step sizes import assert from 'node:assert/strict'; import { renew, RULES, MAX_RENEWALS, LIMIT_MESSAGE } from './renewal.mjs'; function S1(record, today) { return { ...record, dueDay: today + RULES[record.memberType].loanDays, }; } function S2(record, today) { return { ...record, dueDay: today + RULES[record.memberType].loanDays, renewalCount: record.renewalCount + 1, }; } function S3(record, today) { if (record.reserved) throw new Error('a reserved book cannot be renewed'); return { ...record, dueDay: today + RULES[record.memberType].loanDays, renewalCount: record.renewalCount + 1, }; } function S4(record, today) { if (record.reserved) throw new Error('a reserved book cannot be renewed'); if (record.renewalCount >= MAX_RENEWALS) throw new Error(LIMIT_MESSAGE); return { ...record, dueDay: today + RULES[record.memberType].loanDays, renewalCount: record.renewalCount + 1, }; } function S5Hatali(record, today) { if (record.reserved) throw new Error('a reserved book cannot be renewed'); if (record.renewalCount >= MAX_RENEWALS) throw new Error(LIMIT_MESSAGE); if (today >= record.dueDay) throw new Error('an overdue loan cannot be renewed'); return { ...record, dueDay: today + RULES[record.memberType].loanDays, renewalCount: record.renewalCount + 1, }; } const record = (extra = {}) => ({ memberNo: 'U-17', memberType: 'student', dueDay: 1028, renewalCount: 0, reserved: false, ...extra, }); const TESTS = { 'student period': (r) => assert.equal(r(record(), 1020).dueDay, 1048), 'member period': (r) => assert.equal(r(record({ memberType: 'member' }), 1020).dueDay, 1034), 'count increments': (r) => assert.equal(r(record(), 1020).renewalCount, 1), 'reserved rejection': (r) => assert.throws(() => r(record({ reserved: true }), 1020), /reserved/), 'renewal limit': (r) => assert.throws(() => r(record({ renewalCount: 2 }), 1020), /at most 2/), 'delay rejection': (r) => assert.throws(() => r(record(), 1029), /overdue/), 'due day acceptance': (r) => assert.equal(r(record(), 1028).dueDay, 1056), }; const TOTAL = Object.keys(TESTS).length; function failing(version) { return Object.entries(TESTS).filter(([, t]) => { try { t(version); return false; } catch { return true; } }).map(([name]) => name); } // The signature line is not counted; only body lines are compared. function changedLines(before, after) { const body = (f) => f.toString().split('\n').slice(1).map((s) => s.trim()).filter(Boolean); const remaining = body(before); let added = 0; for (const line of body(after)) { const i = remaining.indexOf(line); if (i === -1) added += 1; else remaining.splice(i, 1); } return added + remaining.length; } const PATHS = { 'big step': [S1, renew], 'small step': [S1, S2, S3, S4, renew] }; for (const [name, stops] of Object.entries(PATHS)) { console.log(name); let largest = 0; stops.forEach((version, i) => { const diff = i === 0 ? 0 : changedLines(stops[i - 1], version); largest = Math.max(largest, diff); console.log(` stop ${i + 1} failing ${failing(version).length}/${TOTAL} changed lines ${diff}`); }); console.log(` feedback points ${stops.length}, largest search space ${largest} lines`); } const wrongDiff = { 'big step': changedLines(S1, S5Hatali), 'small step': changedLines(S4, S5Hatali) }; console.log(`test failing when the wrong operator is written at the last stop: ${failing(S5Hatali).join(', ')}`); for (const [name, diff] of Object.entries(wrongDiff)) { console.log(` ${name}: search space to check ${diff} lines`); }
big step stop 1 failing 4/7 changed lines 0 stop 2 failing 0/7 changed lines 4 feedback points 2, largest search space 4 lines small step stop 1 failing 4/7 changed lines 0 stop 2 failing 3/7 changed lines 1 stop 3 failing 2/7 changed lines 1 stop 4 failing 1/7 changed lines 1 stop 5 failing 0/7 changed lines 1 feedback points 5, largest search space 1 lines test failing when the wrong operator is written at the last stop: due day acceptance big step: search space to check 4 lines small step: search space to check 1 lines
Reading the Measurement
Both paths arrive at the same place, and at the destination both tables pass all seven tests. The difference is in the path.
On the big-step path, the failing-test count drops from four to zero in a single jump. Inside that jump are four lines of writing, and none of the four was verified on its own. If all four are correct there is no problem; if one is wrong, which line failed which test can only be found by picking through it by hand.
On the small-step path, the failing-test count drops one at a time: four, three, two, one, zero. Every drop corresponds to a single line of writing. This is the measurement’s real finding — the search space drops from four lines to one.
The last three lines show this directly. When the comparison operator in the overdue check is chosen wrong — greater-than-or-equal instead of greater-than — only one test fails: the acceptance of a renewal made on the due day. The failing test is the same on both paths. What differs is the space that test points to: four lines on the big-step path, one line on the small-step path.
This is where the concept of feedback time comes from. Feedback time is the distance between the moment a writing mistake is made and the moment it is noticed; its unit is not seconds but the number of lines written in that interval. As step size shrinks, that distance shortens.
Returning to the Last Green Point
The second gain of the small step is the cost of turning back. If a piece of writing turns unexpectedly complicated, the cheapest fix is often to throw it away and start over. The cost of that decision is the size of the work being thrown away.
On the big-step path, the last green point is the first stop: turning back means throwing away all four lines. On the small-step path, the last green point is always one line back. The same holds for interpreting a failing test: on the small-step path, “the line I just wrote” is always a single statement.
This has a cost, and it shows in the table: the small-step path has five feedback points, the big-step path has two. Every point means a run. If the run is cheap — fast and independent tests in the sense measured in the Fast and Independent Tests lesson — this cost is negligible. If the run is expensive, step size grows on its own and feedback time lengthens. That is the link between the two lessons: the speed of the test suite determines the development cycle’s step size.
Choosing a Step Size
Step size is not a fixed rule; it is a variable adjusted to uncertainty. If what is being written is familiar, a big step is cheap and intermediate stops are wasted time. If what is being written is uncertain — a new domain, an unfamiliar interface, a complex edge case — the step shrinks.
In practice, the criterion is this: if the number of failing tests at the end of a step comes out different from what was expected, the step is too big. In the small-step table above, every stop turns exactly one test green; that shows the step size fits the grain of the rule.
Summary
- Step size is measured by the number of lines written between two feedback points, and the space to search for a bug is exactly that number.
- In the measurement, the big-step path dropped the failing-test count from four to zero in one jump; the small-step path dropped it one at a time.
- The same writing mistake failed a single test on both paths, but the search space was four lines on the big-step path and one line on the small-step path.
- On the small step, the last green point is always one line back; the cost of a turning-back decision is that distance.
- The cost of the small step is more runs; if the test suite is slow, step size grows on its own and feedback time lengthens.
Next Step
The seven tests in this lesson described the rules with separate sentences, and each sentence became a code block. As rules multiply, this way of writing repeats itself: the same fixture, the same call, only the input and expected result change. The rule itself stays embedded in code and cannot be read while talking with the library clerk. The next lesson moves the rules into example tables, defines a format for those tables, and writes a parser that turns a table into a runnable test.
To keep your progress and take notes, Log in
My notes
Log in to take notes.