---
title: 'Acceptance Test-Driven Development'
source: 'https://academia.sh/en/courses/unit-testing/acceptance-test-driven-development'
course: 'Unit Testing and Test-Driven Development'
language: en
updated: '2026-08-23T14:25:20+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

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

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

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

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

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

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

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

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

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

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