unit

Specify a behavior with BDD

A meeting-room story specified in Gherkin before any runner files. Read the story and the collaboration steps below first.

SUT: js-counter / python-calc · id: unit.bdd-given-when-then

Cached CI results from 10/3/2026, 10:14:14 AM (ci · d7784ca)

The story

The office has one bookable meeting room. Facilities posts remaining seats on a board by the door. Product and QA have already agreed the customer-facing rules in plain language: if a party fits, the reservation is confirmed and the board drops by that many seats; if the party is larger than what is left, the reservation is refused and the board does not change.

Nobody treats a helper name as the source of truth. The sentences are the spec — the same words a facilities lead would say in standup. That is the condition that makes BDD worth doing: someone outside engineering will read the feature file, the behavior is user-visible and stable, and each step is a domain fact (party size, seats free, confirmed or refused).

This is not the checkout-coupon TDD page. Room reservations live only here. If the only readers will be the people who wrote the step glue, or the examples would encode CSS and URLs, write a unit test or spike instead — Gherkin would be a second copy of the code.

How BDD is exercised

The feature file is the shared example. The step files only bind those sentences. Walk this collaboration before opening a runner tab.

  1. Agree the examples in the team’s language

    Write two scenarios on a whiteboard or in room.feature: a party of 4 with 6 seats free is confirmed and 2 remain; a party of 5 with 3 seats free is refused and 3 remain. Facilities should be able to read both without seeing reserveRoom().

  2. Challenge the wording

    Ask whether “confirmed” and “refused” are the words the board uses. If product wants “booked” instead, change the feature — do not hide a synonym in the glue. The feature stays the source of truth.

  3. Watch the runner fail on missing steps

    Run Cucumber or pytest-bdd against the feature before the glue exists. Undefined steps are the red bar for BDD: the examples are real, the binding is not.

  4. Bind Given / When / Then only

    Map “N seats are free” and “a party of N” to data, “I reserve the room” to one function call, and “confirmed / refused / N seats remain” to assertions. No CSS, no URLs, no helper names in the feature.

  5. Grow the function until the feature is green

    Implement reserveRoom() so both scenarios pass. If a new rule appears (“VIP always fits”), add a scenario to the feature first — do not only patch the function.

  6. When to stop

    Stop when Gherkin would only clone unit-test rows, when no one outside engineering will review the file, or when you are about to automate a UI path you have not agreed on in words. BDD proves the examples the team signed, not every branch of the helper.

Technical implementation

The tabs below are the runnable artifacts — tool primer, code under test, and the examples that CI captured. Read the story and the exercise steps first so the files have a job.

Testing tool

Cucumber.js · BDD runner (Gherkin + JS steps) · MIT

Cucumber.js runs .feature files written in Gherkin (Given / When / Then) and binds each sentence to a JavaScript step. The OSS package is enough here — no CucumberStudio. Use it when the feature file is a shared spec, not a second copy of unit tests.

cucumber-js loads the feature, matches each step to a Given/When/Then function, and fails the process if a step throws. In this scenario the steps call reserveRoom() in-process. The feature file is the specification; the step file is only glue.

Testing architecture

Unit tests call one function (or a small graph of functions) in the same process. They avoid I/O so failures point at logic, not the environment. The SUT is imported; the runner never starts Express or a browser.

One Gherkin feature is the specification. Each variant only supplies step glue that calls reserveRoom() / reserve_room() in-process (no browser, no HTTP). Conditions that make it effective: shared vocabulary, examples as the source of truth, and steps that hide implementation.

SUT: js-counter / python-calc · samples/js-counter/src/index.js · run npm test in examples/unit/bdd-given-when-then/cucumber

Code under test · samples/js-counter/src/index.js
/** Tiny shared system under test for unit/UX demos. */

/**
 * Add two numbers and return their sum.
 * @param {number} a - First operand
 * @param {number} b - Second operand
 */
export function add(a, b) {
  // Pure arithmetic — no I/O, no side effects
  return a + b;
}

/**
 * Keep n inside the inclusive range [min, max].
 * @param {number} n - Value to clamp
 * @param {number} min - Lower bound
 * @param {number} max - Upper bound
 */
export function clamp(n, min, max) {
  // Guard: a flipped range is a caller bug, not a silent no-op
  if (min > max) {
    throw new Error("min must be <= max");
  }
  // Raise floor first, then lower the ceiling
  return Math.min(max, Math.max(min, n));
}

/**
 * Apply a region tax rate from an injected service.
 * @param {{ getTaxRate: (code: string) => number }} taxService - Collaborator that owns tax rates
 * @param {number} amount - Pre-tax amount
 * @param {string} region - Region code passed to the tax service
 */
export function priceWithTax(taxService, amount, region) {
  // Ask the dependency for the rate (easy to mock in unit tests)
  const rate = taxService.getTaxRate(region);
  // Gross = net × (1 + rate), e.g. 100 at 10% → 110
  return amount * (1 + rate);
}

/**
 * Apply a published checkout coupon to a subtotal.
 * Grown via TDD in unit.tdd-red-green (this scenario only — BDD uses reserveRoom).
 * @param {number} subtotal - Cart total before the coupon
 * @param {string} [coupon] - Published code, or empty / unknown
 */
export function applyCoupon(subtotal, coupon) {
  if (typeof subtotal !== "number" || subtotal < 0) {
    throw new Error("subtotal must be a non-negative number");
  }
  // SAVE10: always 10% off
  if (coupon === "SAVE10") {
    return subtotal * 0.9;
  }
  // SAVE20: 20% off only when the cart is at least 50
  if (coupon === "SAVE20" && subtotal >= 50) {
    return subtotal * 0.8;
  }
  // Missing, unknown, or SAVE20-below-minimum → pay the original subtotal
  return subtotal;
}

/**
 * Reserve seats in a meeting room.
 * Specified in Gherkin in unit.bdd-given-when-then (this scenario only — TDD uses applyCoupon).
 * @param {number} partySize - Guests who want the room
 * @param {number} seatsFree - Seats still open
 * @returns {{ confirmed: boolean, seatsFree: number }}
 */
export function reserveRoom(partySize, seatsFree) {
  if (typeof partySize !== "number" || partySize < 1) {
    throw new Error("partySize must be at least 1");
  }
  if (typeof seatsFree !== "number" || seatsFree < 0) {
    throw new Error("seatsFree must be a non-negative number");
  }
  if (partySize > seatsFree) {
    return { confirmed: false, seatsFree };
  }
  return { confirmed: true, seatsFree: seatsFree - partySize };
}
Test · examples/unit/bdd-given-when-then/room.feature · MIT · run in examples/unit/bdd-given-when-then/cucumber: npm test
Feature: Meeting room reservations
  Facilities, product, and QA share this file. Sentences name guests and
  seats — not APIs, CSS, or helper names.

  These examples stand on their own. They are not a Gherkin restatement
  of the TDD coupon tests. They are effective when someone outside
  engineering will read them, the rule is user-visible and stable, and
  each step is a domain fact. They waste time when they encode layout
  or restate every unit-test row.

  Scenario: a party that fits is confirmed
    Given 6 seats are free
    And a party of 4
    When I reserve the room
    Then the reservation is confirmed
    And 2 seats remain

  Scenario: a party that does not fit is refused
    Given 3 seats are free
    And a party of 5
    When I reserve the room
    Then the reservation is refused
    And 3 seats remain
Also · examples/unit/bdd-given-when-then/cucumber/steps/room.steps.js · MIT
// Glue only — the examples live in room.feature so facilities/QA can read them.
import { Given, When, Then } from "@cucumber/cucumber";
import assert from "node:assert/strict";
import { reserveRoom } from "../../../../../samples/js-counter/src/index.js";

Given("{int} seats are free", function (seats) {
  this.seatsFree = seats;
});

Given("a party of {int}", function (size) {
  this.partySize = size;
});

When("I reserve the room", function () {
  this.result = reserveRoom(this.partySize, this.seatsFree);
});

Then("the reservation is confirmed", function () {
  assert.equal(this.result.confirmed, true);
});

Then("the reservation is refused", function () {
  assert.equal(this.result.confirmed, false);
});

Then("{int} seats remain", function (expected) {
  assert.equal(this.result.seatsFree, expected);
});

The feature file is the spec. The step file only binds sentences to reserveRoom().