Skip to content
academia.sh

Lesson 11 / 13

Behavior-Driven Development

Defining rules with example tables written in the domain's language, writing a format and a parser for that table, and sustaining the cycle with data-driven tests generated from the table.

Contents

The seven tests in the previous 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. More importantly, the rule itself stays embedded in code. The person who sets the loan rules is the library clerk, but the place where the rules are written is a test file, and that file cannot be opened while talking with the clerk.

Behavior-driven development aims to close that gap. The cycle is the same cycle — red, green, refactor — but the unit of conversation is not a test function, it is an example written in the domain’s language. An example carries three parts: a given starting state, an action taken, and an expected result. This triad is the arrange, act, and assert sections from the Anatomy of a Unit Test lesson translated into the domain’s language.

The Format of the Example Table

The format the examples are written in is not tied to a standard; the only thing it needs to carry is that both the clerk can read it and a program can parse it. The format below recognizes two kinds of line: a rule heading and the examples that belong to that rule.

cat > examples.txt <<'EX'
# renewal contract — every rule is defined by the examples that demonstrate it
rule: A renewal moves the due day forward from today by the loan period for the member's type
  example: memberType=student today=1020 dueDay=1028 -> dueDay=1048
  example: memberType=member today=1020 dueDay=1024 -> dueDay=1034

rule: A reserved book cannot be renewed
  example: today=1020 dueDay=1028 reserved=yes -> error=reserved

rule: A loan can be renewed at most twice
  example: today=1020 dueDay=1028 renewalCount=1 -> renewalCount=2
  example: today=1020 dueDay=1028 renewalCount=2 -> error=at most 2

rule: An overdue loan cannot be renewed, a renewal made on the due day is accepted
  example: today=1029 dueDay=1028 -> error=overdue
  example: today=1028 dueDay=1028 -> dueDay=1056
EX

The left side of the arrow is the given state, the right side is the expected result; the action taken is singular and is clear from the file’s name. Unspecified fields take their default values, so each example shows only the fields relevant to its own rule.

The Parser

The format does not run on its own; it needs a parser that turns it into a data structure. A parser under fifty lines is enough for this job, and it matters that it throws on a line it does not recognize instead of staying silent — a silently skipped line means a rule that never runs.

// parser.mjs — turns the example file into a list of rules and examples
const value = (text) => {
  if (text === 'yes') return true;
  if (text === 'no') return false;
  return /^-?\d+$/.test(text) ? Number(text) : text;
};

const fields = (chunk) => Object.fromEntries(
  chunk.trim().split(/\s+/).filter(Boolean).map((pair) => {
    const [name, ...rest] = pair.split('=');
    return [name, value(rest.join('='))];
  }),
);

export function parse(text) {
  const rules = [];
  for (const raw of text.split('\n')) {
    const line = raw.trim();
    if (line === '' || line.startsWith('#')) continue;
    if (line.startsWith('rule:')) {
      rules.push({ title: line.slice(5).trim(), examples: [] });
      continue;
    }
    if (line.startsWith('example:')) {
      if (rules.length === 0) throw new Error(`example without a rule: ${line}`);
      const body = line.slice(8);
      const [input, output] = body.split('->');
      if (output === undefined) throw new Error(`missing arrow: ${line}`);
      const expected = output.trim();
      rules.at(-1).examples.push({
        text: body.trim(),
        input: fields(input),
        error: expected.startsWith('error=') ? expected.slice(6) : undefined,
        result: expected.startsWith('error=') ? undefined : fields(expected),
      });
      continue;
    }
    throw new Error(`unrecognized line: ${line}`);
  }
  return rules;
}

The library under test is the version that implements the previous lesson’s four rules.

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

Tests Generated From the Table

Tests are no longer written by hand; they are generated from the parsed table. This way of writing is called a data-driven test: the test body is singular, and the input set comes from the data.

// example.test.mjs — data-driven tests generated from the example table
import { readFileSync } from 'node:fs';
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { parse } from './parser.mjs';
import { renew } from './renewal.mjs';

const DEFAULTS = {
  memberNo: 'U-17', memberType: 'student', dueDay: 1028, renewalCount: 0,
  reserved: false, nextInLine: 'U-99',
};

const text = readFileSync(new URL('./examples.txt', import.meta.url), 'utf8');

for (const rule of parse(text)) {
  describe(rule.title, () => {
    for (const example of rule.examples) {
      test(example.text, () => {
        const { today, ...overrides } = example.input;
        const record = { ...DEFAULTS, ...overrides };

        if (example.error !== undefined) {
          assert.throws(() => renew(record, today), new RegExp(example.error));
          return;
        }

        const updated = renew(record, today);
        for (const [field, expected] of Object.entries(example.result)) {
          assert.equal(updated[field], expected, `${field} mismatch`);
        }
      });
    }
  });
}
node --test --test-reporter=tap example.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
    ok 1 - memberType=student today=1020 dueDay=1028 -> dueDay=1048
    ok 2 - memberType=member today=1020 dueDay=1024 -> dueDay=1034
ok 1 - A renewal moves the due day forward from today by the loan period for the member's type
    ok 1 - today=1020 dueDay=1028 reserved=yes -> error=reserved
ok 2 - A reserved book cannot be renewed
    ok 1 - today=1020 dueDay=1028 renewalCount=1 -> renewalCount=2
    ok 2 - today=1020 dueDay=1028 renewalCount=2 -> error=at most 2
ok 3 - A loan can be renewed at most twice
    ok 1 - today=1029 dueDay=1028 -> error=overdue
    ok 2 - today=1028 dueDay=1028 -> dueDay=1056
ok 4 - An overdue loan cannot be renewed, a renewal made on the due day is accepted
# tests 7
# pass 7
# fail 0

The observation from the Test Naming lesson reaches its strongest form here: the list of passing tests is a rule document. The unindented lines give the rules, the indented lines give the examples that demonstrate that rule. This output and the example file carry the same text — one is the source’s expression, the other is the run’s.

The Red Step Is Now a Line of Text

Suppose a conversation with the clerk produces a new rule: a reserved book can be renewed if the member first in line for the reservation is the very member holding the book. This rule’s red step no longer requires writing code.

cat >> examples.txt <<'EX'

rule: If the member first in line for the reservation is the one currently holding the loan, the renewal is accepted
  example: today=1020 dueDay=1028 reserved=yes nextInLine=U-17 -> dueDay=1048
EX
node --test --test-reporter=tap example.test.mjs | grep -E '^ *(not ok|# (tests|pass|fail))'
    not ok 1 - today=1020 dueDay=1028 reserved=yes nextInLine=U-17 -> dueDay=1048
not ok 5 - If the member first in line for the reservation is the one currently holding the loan, the renewal is accepted
# tests 8
# pass 7
# fail 1

Not a single line was added to the test file. What started the red step is two lines of text written in the domain’s language. The green step, though, is still in code.

// renewal.mjs — version 2: rule added for the member first in line holding the loan
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 && record.nextInLine !== record.memberNo) {
    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,
  };
}
node --test --test-reporter=tap example.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
    ok 1 - memberType=student today=1020 dueDay=1028 -> dueDay=1048
    ok 2 - memberType=member today=1020 dueDay=1024 -> dueDay=1034
ok 1 - A renewal moves the due day forward from today by the loan period for the member's type
    ok 1 - today=1020 dueDay=1028 reserved=yes -> error=reserved
ok 2 - A reserved book cannot be renewed
    ok 1 - today=1020 dueDay=1028 renewalCount=1 -> renewalCount=2
    ok 2 - today=1020 dueDay=1028 renewalCount=2 -> error=at most 2
ok 3 - A loan can be renewed at most twice
    ok 1 - today=1029 dueDay=1028 -> error=overdue
    ok 2 - today=1028 dueDay=1028 -> dueDay=1056
ok 4 - An overdue loan cannot be renewed, a renewal made on the due day is accepted
    ok 1 - today=1020 dueDay=1028 reserved=yes nextInLine=U-17 -> dueDay=1048
ok 5 - If the member first in line for the reservation is the one currently holding the loan, the renewal is accepted
# tests 8
# pass 8
# fail 0

The old reservation example is still green, because the next-in-line member defaults to someone else. The new rule did not invalidate the old one — it narrowed it, and what confirms that is that both still stand in the table.

The Limits of Examples

The example table has two gains, and both are limited.

The first gain is a shared language: the rule is written so that someone who does not read code can verify it. Its limit is that the table only works this way as long as it is written in a spoken language. Once a class name replaces a domain word, or a method signature replaces a rule sentence, the table just turns into a more cumbersome test file.

The second gain is that multiple examples for the same rule get cheaper. A new edge case is one line. Its limit is that not every behavior fits a table: for behavior that depends on run order, on time, or on a multi-step scenario, the table format falls short and a hand-written test is clearer. The two formats are used side by side, not as a replacement for each other.

A third warning concerns the parser itself. The code that generates tests from the table is the single point every test’s correctness depends on. A parser that silently skips a line, or resolves the expected result wrong, is as harmful as assert.ok in the Assertions lesson: everything looks green. This is why the parser throws on a line it does not recognize, and is tested itself.

Summary

  • Behavior-driven development uses the same cycle, but the unit of conversation is an example written in the domain’s language: a given state, an action taken, an expected result.
  • The example format is not tied to a standard; the only thing it needs to carry is that a person can read it and a program can parse it.
  • In a data-driven test the test body is singular and the input set comes from the data; the list of passing tests directly yields a rule document.
  • A new rule’s red step is two lines added to the table; the test file is not touched.
  • The table format provides a shared language and cheap edge cases, but for multi-step behavior that depends on order or time, a hand-written test is clearer.

Next Step

In this lesson, examples described a single function’s contract: the renewal rules. A user request is broader than that — the sentence “let a member extend their own book’s loan” does not describe a function but an end-to-end flow, and it carries criteria that say it has been accepted. The next lesson turns those criteria into an automated check and builds a two-layer cycle whose outer loop runs on the acceptance criterion and whose inner loop runs on the unit test.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close