---
title: 'Progressing in Small Steps'
source: 'https://academia.sh/en/courses/unit-testing/progressing-in-small-steps'
course: 'Unit Testing and Test-Driven Development'
language: en
updated: '2026-08-23T14:25:20+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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);
});
```

```sh
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.

```js
// 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.
