Lesson 01 / 13
The Anatomy of a Unit Test
Converting a defect report's reproduction steps into a runnable test, the arrange-act-assert parts of a test body, and the expected value coming from a source independent of the implementation.
Contents
The Quality and Testing Fundamentals course closed with a defect’s reporting, prioritization, and the verification of its fix. That course established what a test is and which test to write: equivalence classes, boundary values, the test oracle, the defect report. What remained was a document — steps a person read and carried out by hand.
This course takes up how those same steps are written. The “reproduction steps” field in a defect report is, in effect, the outline of a program: a specific starting state, a single operation, and an observed result. Once that triple is made runnable, no one has to check by hand that the defect has not come back. This lesson’s question is: what exactly does that translation look like, and what parts make up a test’s body?
Reproduction Steps
The same domain runs through the course: a small library module that applies library checkout rules. The rules are written down. Student members may borrow a book for twenty-eight days, regular members for fourteen. A late return is charged a daily rate, the first few days are free, and the total fee has a cap. Dates are represented as day numbers; calendar arithmetic is not this course’s subject, and a plain day counter keeps the examples deterministic.
// library.mjs — version 1 export const RULES = { student: { loanDays: 28, maxLoans: 10 }, member: { loanDays: 14, maxLoans: 5 }, }; export const LATE_FEE = { graceDays: 3, dailyRate: 2, cap: 40 }; export function dueDate(checkoutDay, memberType) { const rule = RULES[memberType]; if (rule === undefined) throw new Error(`unknown member type: ${memberType}`); return checkoutDay + rule.loanDays; } export function lateFee(dueDay, returnDay) { const daysLate = returnDay - dueDay; if (daysLate <= LATE_FEE.graceDays) return 0; return (daysLate - LATE_FEE.graceDays) * LATE_FEE.dailyRate; }
The defect report from a library clerk says: a member who checks out a book on day one thousand and returns it forty days after the due date is charged a fee of seventy-four units, even though the written rule caps it at forty. The report’s reproduction steps are three lines, and each line corresponds to a single program statement.
// reproduction.mjs — manual walkthrough of the defect report's steps import { dueDate, lateFee } from './library.mjs'; const due = dueDate(1000, 'member'); console.log(`due day : ${due}`); console.log(`return day : ${due + 40}`); console.log(`calculated fee : ${lateFee(due, due + 40)}`); console.log(`expected cap: 40`);
due day : 1014 return day : 1054 calculated fee : 74 expected cap: 40
This script reproduces the defect, but it is not a test. A person has to look at the result — an eye is needed to compare forty against seventy-four. What turns the script into a test is that the comparison itself enters the program.
Arrange, Act, Assert
A unit test’s body is divided into three parts, and these parts come in the same order in every test.
The arrange part sets up the starting state the operation will run against: inputs, objects, dependencies. The act part calls the operation under test exactly once. The assert part compares the observed result with the expected one and fails the test on a mismatch.
// late-fee.test.mjs — the defect report's reproduction steps import { test } from 'node:test'; import assert from 'node:assert/strict'; import { dueDate, lateFee } from './library.mjs'; test('late fee does not exceed the cap', () => { const due = dueDate(1000, 'member'); const fee = lateFee(due, due + 40); assert.equal(fee, 40); });
The three parts are separated by blank lines; a separate comment line is not needed. The value of this layout is not aesthetic. The act part being a single line states, without ambiguity, which call the test is exercising. With two calls, a failing test does not say which one failed. A lengthening arrange part is a separate signal: it shows the unit under test depends on too many things. The fifth lesson measures that signal.
Tests run with the Node runtime’s built-in test runner. The runner’s verbose output also includes duration, file paths, and process information, which vary with the environment. Throughout the course, only the result lines are filtered out, because those are what teach.
node --test --test-reporter=tap late-fee.test.mjs | grep -E '^(ok|not ok|# (tests|pass|fail))'
not ok 1 - late fee does not exceed the cap # tests 1 # pass 0 # fail 1
The test failed, and this is the expected result: the defect has not been fixed yet. A failing run is proof that the test actually tests something. A test that has never failed cannot say whether it is green because it is correct or because it was written wrong.
Where the Expected Value Comes From
The number forty in the assert part came from the written rule. This detail carries the test’s entire value. When the expected value is derived from the implementation itself, the test becomes a tautology: it confirms that what the code does is what the code does.
// tautological.test.mjs — a test that computes the expected value the same way as the implementation import { test } from 'node:test'; import assert from 'node:assert/strict'; import { LATE_FEE, lateFee } from './library.mjs'; test('late fee is calculated correctly', () => { const daysLate = 40; const expected = (daysLate - LATE_FEE.graceDays) * LATE_FEE.dailyRate; assert.equal(lateFee(1014, 1014 + daysLate), expected); });
node --test --test-reporter=tap tautological.test.mjs | grep -E '^(ok|not ok|# (tests|pass|fail))'
ok 1 - late fee is calculated correctly # tests 1 # pass 1 # fail 0
This test came back green on the code the defect stands in. The formula producing the expected value is the same formula inside the function under test; both forget the cap. The test oracle concept introduced in the Quality and Testing Fundamentals course names exactly this point: the source of the expected result must be independent of the code under test. That source can be a written rule, a calculation done by hand, a known reference value, or a result produced some other way — but it cannot be the tested function’s own logic.
The Fix and Comparing Two Tests
The fix is a single line: take the smaller of the raw fee and the cap.
// library.mjs — version 2: cap is applied export const RULES = { student: { loanDays: 28, maxLoans: 10 }, member: { loanDays: 14, maxLoans: 5 }, }; export const LATE_FEE = { graceDays: 3, dailyRate: 2, cap: 40 }; export function dueDate(checkoutDay, memberType) { const rule = RULES[memberType]; if (rule === undefined) throw new Error(`unknown member type: ${memberType}`); return checkoutDay + rule.loanDays; } export function lateFee(dueDay, returnDay) { const daysLate = returnDay - dueDay; if (daysLate <= LATE_FEE.graceDays) return 0; const raw = (daysLate - LATE_FEE.graceDays) * LATE_FEE.dailyRate; return Math.min(raw, LATE_FEE.cap); }
When the two test files are run together, both of their outcomes reverse.
node --test --test-reporter=tap late-fee.test.mjs tautological.test.mjs | grep -E '^(ok|not ok|# (tests|pass|fail))'
ok 1 - late fee does not exceed the cap not ok 2 - late fee is calculated correctly # tests 2 # pass 1 # fail 1
The rule-bound test turned green on the correct behavior. The implementation-bound test, on the other hand, failed on correct code. If a test fails when the code is fixed, what it tests is not behavior. This is the plainest form of the flakiness that is the eighth lesson’s subject; here it is enough just to name it.
The Boundary of a Unit
The word “unit” names a boundary, not a size. A unit test sets up a context in which nothing outside the code under test affects the result. In practice this means keeping three things out: out-of-process resources such as disk and network, the system clock and sources of randomness, and state left behind by other tests.
This constraint is not arbitrary. Every resource kept out adds a share to the test’s duration and stability. The test above runs in under a millisecond, and its result does not depend on the machine, the clock, or run order. If the same test had to write to a database, a failing run could be caused by the code, the configuration, the network, or leftover data — choosing among four possibilities is far more expensive than reading a single one.
The dueDate call staying in the arrange part is an example of this boundary. The unit
under test is the lateFee function; dueDate only produces input. Even though both are
in the same file, the test’s intent is single, and a failing run points to one function.
Summary
- A defect report’s reproduction steps can be converted into a runnable test; what completes the conversion is that the comparison itself enters the program.
- A test body consists of arrange, act, and assert parts; the act part being a single call removes ambiguity about the source of a failing run.
- A failing run is proof that the test actually tests something; a test that has never failed cannot say why it is green.
- The expected value must come from a source independent of the code under test; a test that repeats the implementation’s formula also comes back green on code the defect stands in.
- A unit is a boundary, not a size: out-of-process resources, the clock, randomness, and state left behind by other tests all stay outside that boundary.
Next Step
This lesson used a single assertion: assert.equal. That was enough for the equality of
two numbers, because the function under test returned a number. When a loan operation
returns an object, an error, or a list, an equality check either does not work or measures
the wrong thing. The next lesson takes up assertion choice: which assertion catches which
defect, how much signal an overly broad assertion loses, and how that loss is measured.
To keep your progress and take notes, Log in
My notes
Log in to take notes.