Lesson 12 / 13
Acceptance Test-Driven Development
Writing a user request's acceptance criteria in step form, automating them with a driver that binds the steps to the implementation, and a two-layer cycle whose outer loop runs on the acceptance criterion and whose inner loop runs on the unit test.
Contents
In the previous lesson, examples described a single function’s contract. A user request is broader than that. The sentence “let a member extend their own book’s loan” does not describe a function but a flow: a record is found, a rule is applied, the result becomes permanent, and the member is notified. The criteria that say this flow is complete come along with the request itself.
Acceptance test-driven development makes those criteria the starting point of development. The criterion is written first, turned into an automated check, and stays red; what says the work is done is that check turning green. Unit tests do not disappear — they get written where the acceptance criterion points.
The Format of an Acceptance Criterion
A criterion consists of three kinds of step. Given steps set up the starting state, a when step triggers a single action, then steps check the result. This split is the flow-level counterpart of the arrange, act, and assert sections from the Anatomy of a Unit Test lesson.
cat > acceptance.txt <<'KABUL' # acceptance criteria — let a member extend their own loan story: Let a member extend the loan on the book they are holding criterion: A valid loan renewed moves the due day forward and sends the member a notification given: member no=U-17 type=student given: loan bookNo=K-903 memberNo=U-17 dueDay=1028 when: renewal memberNo=U-17 bookNo=K-903 today=1020 then: loan bookNo=K-903 dueDay=1048 renewalCount=1 then: notification memberNo=U-17 message="new due day: 1048" criterion: Renewing a reserved book is rejected and the due day does not change given: member no=U-42 type=member given: loan bookNo=K-101 memberNo=U-42 dueDay=1024 reserved=yes when: renewal memberNo=U-42 bookNo=K-101 today=1020 then: rejection reason="a reserved book cannot be renewed" then: loan bookNo=K-101 dueDay=1024 renewalCount=0 KABUL
There are two differences from the previous lesson’s example lines. The steps are ordered and share state: a criterion tells a scenario, not a single call. Second, the words used in the steps are the application’s concepts — member, loan, notification, rejection — not a specific function’s signature.
// acceptance-parser.mjs — turns acceptance criteria into a list of steps const FIELD = /(\w+)=("[^"]*"|\S+)/g; const STEP_KEYS = ['given', 'when', 'then']; const convert = (raw) => { const value = raw.startsWith('"') ? raw.slice(1, -1) : raw; if (value === 'yes') return true; if (value === 'no') return false; return /^-?\d+$/.test(value) ? Number(value) : value; }; const fields = (text) => Object.fromEntries( [...text.matchAll(FIELD)].map(([, name, raw]) => [name, convert(raw)]), ); export function parse(text) { const criteria = []; for (const raw of text.split('\n')) { const line = raw.trim(); if (line === '' || line.startsWith('#') || line.startsWith('story:')) continue; if (line.startsWith('criterion:')) { criteria.push({ title: line.slice(10).trim(), steps: [] }); continue; } const [key, ...rest] = line.split(':'); if (STEP_KEYS.includes(key) === false) throw new Error(`unrecognized step: ${line}`); if (criteria.length === 0) throw new Error(`step without a criterion: ${line}`); const body = rest.join(':').trim(); criteria.at(-1).steps.push({ name: `${key} ${body.split(/\s+/)[0]}`, fields: fields(body), text: line, }); } return criteria; }
Implementation and the Driver
The unit the criteria touch is not a single rule function but the service that runs the flow. The service loads the record, applies the rule, and saves the result. In this first version, sending the notification is absent; that is where the criterion stays red.
// renewal.mjs — the renewal rules export const RULES = { student: { loanDays: 28 }, member: { loanDays: 14 } }; export const MAX_RENEWALS = 2; 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('at most 2 renewals allowed'); 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, }; }
// service.mjs — version 1: loads the record, applies the rule, saves the result import { renew } from './renewal.mjs'; export function createService({ store, notification }) { return { renew(memberNo, bookNo, today) { const member = store.findMember(memberNo); const loan = store.findLoan(bookNo); if (member === undefined || loan === undefined || loan.memberNo !== memberNo) { return { status: 'rejected', reason: 'loan not found' }; } try { const updated = renew({ ...loan, memberType: member.type }, today); store.saveLoan(updated); return { status: 'accepted', loan: updated }; } catch (error) { return { status: 'rejected', reason: error.message }; } }, }; }
The link between the text and the implementation is the driver: a table that maps each step type to a piece of code. The driver builds a “world” with the test doubles introduced in the Test Doubles lesson, and runs the steps on that world.
// acceptance.test.mjs — turns acceptance criteria into a runnable test with the driver import { readFileSync } from 'node:fs'; import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; import { parse } from './acceptance-parser.mjs'; import { createService } from './service.mjs'; function createWorld() { const members = new Map(); const loans = new Map(); const notifications = []; const store = { addMember: (member) => members.set(member.no, member), findMember: (no) => members.get(no), findLoan: (bookNo) => loans.get(bookNo), saveLoan: (loan) => loans.set(loan.bookNo, loan), }; const notification = { send: (memberNo, message) => notifications.push([memberNo, message]) }; return { store, notifications, service: createService({ store, notification }), result: undefined }; } const DEFAULT_LOAN = { renewalCount: 0, reserved: false }; const DRIVER = { 'given member': (w, a) => w.store.addMember({ no: a.no, type: a.type }), 'given loan': (w, a) => w.store.saveLoan({ ...DEFAULT_LOAN, ...a }), 'when renewal': (w, a) => { w.result = w.service.renew(a.memberNo, a.bookNo, a.today); }, 'then loan': (w, a) => { const { bookNo, ...expected } = a; const record = w.store.findLoan(bookNo); for (const [field, value] of Object.entries(expected)) { assert.equal(record[field], value, `${bookNo}.${field} mismatch`); } }, 'then notification': (w, a) => assert.deepEqual(w.notifications, [[a.memberNo, a.message]]), 'then rejection': (w, a) => { assert.equal(w.result.status, 'rejected'); assert.equal(w.result.reason, a.reason); }, }; const text = readFileSync(new URL('./acceptance.txt', import.meta.url), 'utf8'); for (const criterion of parse(text)) { describe(criterion.title, () => { const world = createWorld(); for (const step of criterion.steps) { test(step.text, () => { const driver = DRIVER[step.name]; if (driver === undefined) throw new Error(`step with no driver: ${step.name}`); driver(world, step.fields); }); } }); }
Every step runs as a separate test and shares the same world. The independence rule set up in the Fast and Independent Tests lesson is applied here at the criterion level: sharing stays within a criterion, and every criterion builds its own world.
The Outer Loop: A Red Criterion
node --test --test-reporter=tap acceptance.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
ok 1 - given: member no=U-17 type=student
ok 2 - given: loan bookNo=K-903 memberNo=U-17 dueDay=1028
ok 3 - when: renewal memberNo=U-17 bookNo=K-903 today=1020
ok 4 - then: loan bookNo=K-903 dueDay=1048 renewalCount=1
not ok 5 - then: notification memberNo=U-17 message="new due day: 1048"
not ok 1 - A valid loan renewed moves the due day forward and sends the member a notification
ok 1 - given: member no=U-42 type=member
ok 2 - given: loan bookNo=K-101 memberNo=U-42 dueDay=1024 reserved=yes
ok 3 - when: renewal memberNo=U-42 bookNo=K-101 today=1020
ok 4 - then: rejection reason="a reserved book cannot be renewed"
ok 5 - then: loan bookNo=K-101 dueDay=1024 renewalCount=0
ok 2 - Renewing a reserved book is rejected and the due day does not change
# tests 10
# pass 9
# fail 1
The second criterion is satisfied, the first is not. More valuable is that which step is not satisfied is visible: the due day advanced correctly, the record updated correctly, the only thing missing is the notification. The acceptance criterion thus became not a red step itself but the red step’s address.
The Inner Loop: A Unit Test
An acceptance criterion is not translated directly into an implementation. A unit test is written where it points, and the cycle turns there. This two-layer structure is called the outer loop and the inner loop: the outer loop stays red for the length of a user request, the inner loop turns from red to green many times within that span.
// service.test.mjs — inner loop: the unit the acceptance criterion points to import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createService } from './service.mjs'; function setup() { const loans = new Map([['K-903', { bookNo: 'K-903', memberNo: 'U-17', dueDay: 1028, renewalCount: 0, reserved: false }]]); const notifications = []; const store = { findMember: () => ({ no: 'U-17', type: 'student' }), findLoan: (bookNo) => loans.get(bookNo), saveLoan: (loan) => loans.set(loan.bookNo, loan), }; return { service: createService({ store, notification: { send: (m, i) => notifications.push([m, i]) } }), notifications }; } test('an accepted renewal notifies the member of the new due day', () => { const { service, notifications } = setup(); service.renew('U-17', 'K-903', 1020); assert.deepEqual(notifications, [['U-17', 'new due day: 1048']]); });
node --test --test-reporter=tap service.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
not ok 1 - an accepted renewal notifies the member of the new due day # tests 1 # pass 0 # fail 1
The green step is one line.
// service.mjs — version 2: an accepted renewal sends the member a notification import { renew } from './renewal.mjs'; export function createService({ store, notification }) { return { renew(memberNo, bookNo, today) { const member = store.findMember(memberNo); const loan = store.findLoan(bookNo); if (member === undefined || loan === undefined || loan.memberNo !== memberNo) { return { status: 'rejected', reason: 'loan not found' }; } try { const updated = renew({ ...loan, memberType: member.type }, today); store.saveLoan(updated); notification.send(memberNo, `new due day: ${updated.dueDay}`); return { status: 'accepted', loan: updated }; } catch (error) { return { status: 'rejected', reason: error.message }; } }, }; }
node --test --test-reporter=tap service.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))' node --test --test-reporter=tap acceptance.test.mjs | grep -E '^(ok|not ok|# (tests|pass|fail))'
ok 1 - an accepted renewal notifies the member of the new due day # tests 1 # pass 1 # fail 0 ok 1 - A valid loan renewed moves the due day forward and sends the member a notification ok 2 - Renewing a reserved book is rejected and the due day does not change # tests 10 # pass 10 # fail 0
The inner loop turned green, and the outer loop closed along with it. The second command filters to only the unindented lines; viewed at the criterion level, two lines are the entire information that says the work is done.
The Division of Labor Between the Two Loops
The difference between the outer and inner loop is speed and scope, and that difference is kept deliberately.
The outer loop is slow and wide. A criterion touches the whole flow, so on its own it does not say where things broke when it fails — the step-by-step reporting in the run above partly covers that gap. It is few in number: a handful of criteria per user request.
The inner loop is fast and narrow. A unit test checks a single rule and gives its address when it fails. It is many in number, and the signal strength measured in the Assertions lesson is what is sought there.
Confusing the two loops leads to two typical mistakes. When acceptance criteria are multiplied like unit tests, the run slows down and the criteria list becomes unreadable; the property of being shareable with the user is lost. In the other direction, when no unit tests are written at all and everything is left to the acceptance criterion, a failing criterion starts a long search — the search-space measurement from the Progressing in Small Steps lesson holds here too.
What says an acceptance criterion is done is also clear: when the criterion turns green, the work is considered accepted. This differs from a criterion of “the developer says it is done,” because the definition of done is written together with the party requesting the work, and written in advance.
Summary
- An acceptance criterion is a check, written in advance, that says a user request has been satisfied; it consists of given, when, and then steps.
- A criterion’s steps are ordered and share state; the sharing stays within the criterion, and every criterion builds its own world.
- The driver maps the step types in the text to implementation calls; a step with no driver is not silently skipped — it throws.
- A red acceptance criterion is not the red step itself but its address; the unit test written in the inner loop goes to that address.
- The outer loop is slow, wide, and few in number; the inner loop is fast, narrow, and many in number. Confusing the two produces either an unreadable criteria list or a long search space.
Next Step
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, has no tests, and creates its dependencies internally must first have what it does put on record, then be made changeable. The next lesson does this work in three stages and closes the course.
To keep your progress and take notes, Log in
My notes
Log in to take notes.