Lesson 09 / 13
Red-Green-Refactor
Running each step of the three-step cycle in which the test is written before the code, generalizing a green passed with a fixed value through triangulation, and the guarantee condition of the refactor step.
Contents
In the Unit Testing in Practice topic, the code came first in every test: a bug report, a rule, or a refactor found an implementation already there to test. The test verified that implementation, measured it, or exposed its fragility.
This topic reverses that order. When the test is written first, it stops being a verification tool and becomes a design tool: the test decides which function will exist, under what signature, what input it takes, and what it returns. This reversal is called test-driven development, and it works through a three-step cycle.
The new rule to build is loan renewal. A member can extend the loan on a book they are holding; a renewed loan’s due day moves forward from the day of renewal by the loan period for that member’s type.
Red: Testing Code That Does Not Exist Yet
The cycle begins with a failing test. For the test to run at all, the function under test must exist; so the first thing written is an empty implementation, just enough to compile.
// renewal.mjs — version 0: function exists, no behavior export function renew(record, today) { return record; }
The test itself describes behavior that has not been written yet.
// renewal.test.mjs — red step: single rule, no implementation yet import { test } from 'node:test'; import assert from 'node:assert/strict'; import { renew } from './renewal.mjs'; test('a student loan renewal moves the due day twenty-eight days past today', () => { const record = { memberNo: 'U-17', memberType: 'student', dueDay: 1028 }; const updated = renew(record, 1020); assert.equal(updated.dueDay, 1048); });
node --test --test-reporter=tap renewal.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
not ok 1 - a student loan renewal moves the due day twenty-eight days past today # tests 1 # pass 0 # fail 1
Three decisions were made in this step at once, and none of them while writing the
implementation. The function’s name is renew, its inputs are a loan record and a day
number, its output is an updated record. This is the design side of test-driven
development: the interface is derived from the code that uses it.
The guarantee of the red run is the thing named in the first lesson: it was observed that the test can fail. A test that is green the moment it is written may be testing the wrong thing, or testing nothing at all; that possibility is ruled out only by watching a run fail.
Green: The Shortest Path
The second step has a single criterion: pass the test. Correct design, a general solution, or clean code are not this step’s goal; those belong to the third step.
// renewal.mjs — version 1: shortest way to pass the test export function renew(record, today) { return { ...record, dueDay: 1048 }; }
node --test --test-reporter=tap renewal.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
ok 1 - a student loan renewal moves the due day twenty-eight days past today # tests 1 # pass 1 # fail 0
This version, which returns a fixed value, is obviously wrong, and deliberately so. What it accomplishes is proving that the test really does check that value: the reason the run turns from red to green is the line that was written, and nothing else.
This step is also a diagnostic tool. If the test still does not turn green even after the fixed value is written, the problem is not in the implementation — it is in the test itself.
Triangulation
What exposes the fixed value’s wrongness is not code — it is a second example. Writing the same rule with a different input forces the implementation to generalize. This technique is called triangulation.
// renewal.test.mjs — version 2: second member type added import { test } from 'node:test'; import assert from 'node:assert/strict'; import { renew } from './renewal.mjs'; test('a student loan renewal moves the due day twenty-eight days past today', () => { const record = { memberNo: 'U-17', memberType: 'student', dueDay: 1028 }; const updated = renew(record, 1020); assert.equal(updated.dueDay, 1048); }); test('a standard member loan renewal moves the due day fourteen days past today', () => { const record = { memberNo: 'U-42', memberType: 'member', dueDay: 1014 }; const updated = renew(record, 1020); assert.equal(updated.dueDay, 1034); });
node --test --test-reporter=tap renewal.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
ok 1 - a student loan renewal moves the due day twenty-eight days past today not ok 2 - a standard member loan renewal moves the due day fourteen days past today # tests 2 # pass 1 # fail 1
The cycle is back to red, and this time what red points to is clear: the fixed value is not enough. Generalizing is no longer a guess — it is a necessity imposed by the failing test.
// renewal.mjs — version 2: rule table instead of a fixed value export const RULES = { student: { loanDays: 28 }, member: { loanDays: 14 } }; export function renew(record, today) { return { ...record, dueDay: today + RULES[record.memberType].loanDays }; }
node --test --test-reporter=tap renewal.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
ok 1 - a student loan renewal moves the due day twenty-eight days past today ok 2 - a standard member loan renewal moves the due day fourteen days past today # tests 2 # pass 2 # fail 0
Refactor: Working on Top of Green
The third step has a precise definition: refactoring is improving internal structure without changing observable behavior. No new rule is added, no new case is handled, no bug is fixed. Only structure changes.
// renewal.mjs — version 3: duration lookup moved into a named function export const RULES = { student: { loanDays: 28 }, member: { loanDays: 14 } }; export function loanPeriod(memberType) { return RULES[memberType].loanDays; } export function renew(record, today) { return { ...record, dueDay: today + loanPeriod(record.memberType) }; }
node --test --test-reporter=tap renewal.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
ok 1 - a student loan renewal moves the due day twenty-eight days past today ok 2 - a standard member loan renewal moves the due day fourteen days past today # tests 2 # pass 2 # fail 0
What makes this step possible is the two steps before it. Without a green test suite, refactoring is a rewrite, and its correctness can only be checked by eye. Every structural change made while the tests are green is verified by the run itself.
The measurement from the Flaky Tests lesson finds its counterpart here. If the tests are bound to the implementation, this step turns red and refactoring is punished. If the tests are bound to behavior, the third step is free. Because test-driven development derives its tests from the code that uses them, it makes writing behavior-bound tests easier — but it does not guarantee it; the line between a contract and an internal detail is still drawn by the person writing the test.
The Questions the Three Steps Separate
The cycle’s value is that its steps separate three questions that would otherwise have to be considered all at once.
In the red step there is a single question: what do I want? What is the rule, how will the interface be used, what is the expected result? Implementation is not considered at all.
In the green step there is a single question: how do I make this test pass? The shortest path is enough; design concerns are a distraction in this step.
In the refactor step there is a single question: how does the code at hand get better? Whether the answer to that question is correct is reported by the tests.
Considering all three questions at once is a constant occurrence in an ordinary programming session, and its cost is a cost of attention. The cycle lowers that cost by splitting it into three.
Summary
- In test-driven development, the test is not a verification tool but a design tool: the function’s name, inputs, and output are derived from the test that uses it.
- The guarantee of the red step is seeing that the test can fail; a test that is green the moment it is written may be testing the wrong thing.
- The green step has a single criterion: pass the test; a version that returns a fixed value proves that the line just written is the reason for the pass.
- Triangulation forces the implementation to generalize with a second example; generalizing is not a guess but something the failing test demands.
- Refactoring improves internal structure without changing behavior, and it is only safe on top of a green test suite.
Next Step
In this lesson the cycle turned twice, and each round handled a single rule. Step size is a choice: the same work could have been carried out in a single round covering three rules at once. That choice has a measurable cost — where a failing test points, how many lines changed between two runs, and the distance back to the last green point. The next lesson builds the same feature with two different step sizes and compares the two runs.
To keep your progress and take notes, Log in
My notes
Log in to take notes.