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.
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().
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.
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.
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.
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.
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().
pytest-bdd lets pytest collect Gherkin features and inject step functions as fixtures. You keep pytest’s reporting and stay in Python. Same rule as Cucumber: the feature is for humans; the steps only bind sentences.
pytest-bdd parses the feature, maps steps with parsers.parse, and builds fixtures for Given/When values. Here those fixtures call reserve_room() from samples/python-calc. No server is started.
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/python-calc/calc.py · run pytest in examples/unit/bdd-given-when-then/pytest-bdd
Code under test · samples/python-calc/calc.py
"""Tiny Python twin of the JS counter SUT."""
from typing import Optional
def add(a: float, b: float) -> float:
# Pure arithmetic — no I/O, no side effects
return a + b
def clamp(n: float, min_value: float, max_value: float) -> float:
# Guard: a flipped range is a caller bug, not a silent no-op
if min_value > max_value:
raise ValueError("min must be <= max")
# Raise floor first, then lower the ceiling
return min(max_value, max(min_value, n))
def price_with_tax(tax_service, amount: float, region: str) -> float:
# Ask the dependency for the rate (easy to mock in unit tests)
rate = tax_service.get_tax_rate(region)
# Gross = net × (1 + rate), e.g. 100 at 10% → 110
return amount * (1 + rate)
def apply_coupon(subtotal: float, coupon: Optional[str] = None) -> float:
"""Apply a published checkout coupon. Twin of JS applyCoupon()."""
if subtotal < 0:
raise ValueError("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" and subtotal >= 50:
return subtotal * 0.8
# Missing, unknown, or SAVE20-below-minimum → pay the original subtotal
return subtotal
def reserve_room(party_size: int, seats_free: int) -> dict:
"""Reserve seats. Twin of JS reserveRoom() — used only by the BDD scenario."""
if party_size < 1:
raise ValueError("partySize must be at least 1")
if seats_free < 0:
raise ValueError("seatsFree must be a non-negative number")
if party_size > seats_free:
return {"confirmed": False, "seats_free": seats_free}
return {"confirmed": True, "seats_free": seats_free - party_size}
Test · examples/unit/bdd-given-when-then/room.feature · MIT
· run in examples/unit/bdd-given-when-then/pytest-bdd: pytest
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/pytest-bdd/test_room_steps.py · MIT
# Glue only — the examples live in room.feature so facilities/QA can read them.
import re
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[4]
sys.path.insert(0, str(ROOT / "samples" / "python-calc"))
from calc import reserve_room
FEATURE = Path(__file__).resolve().parent.parent / "room.feature"
try:
from pytest_bdd import given, parsers, scenarios, then, when
scenarios(str(FEATURE))
@given(parsers.parse("{seats:d} seats are free"), target_fixture="seats_free")
def given_seats_free(seats):
return seats
@given(parsers.parse("a party of {size:d}"), target_fixture="party_size")
def given_party_size(size):
return size
@when("I reserve the room", target_fixture="result")
def reserve(party_size, seats_free):
return reserve_room(party_size, seats_free)
@then("the reservation is confirmed")
def confirmed(result):
assert result["confirmed"] is True
@then("the reservation is refused")
def refused(result):
assert result["confirmed"] is False
@then(parsers.parse("{left:d} seats remain"))
def seats_remain(result, left):
assert result["seats_free"] == left
except ImportError:
_SCENARIO = re.compile(r"^\s+Scenario:\s+(.+)$")
_SEATS = re.compile(r"(\d+) seats are free")
_PARTY = re.compile(r"a party of (\d+)")
_REMAIN = re.compile(r"(\d+) seats remain")
def _cases():
rows = []
name = seats = party = remain = None
confirmed = None
for line in FEATURE.read_text().splitlines():
heading = _SCENARIO.match(line)
if heading:
if name:
rows.append((name, party, seats, confirmed, remain))
name = heading.group(1).strip()
seats = party = remain = confirmed = None
continue
found = _SEATS.search(line)
if found:
seats = int(found.group(1))
found = _PARTY.search(line)
if found:
party = int(found.group(1))
if "reservation is confirmed" in line:
confirmed = True
if "reservation is refused" in line:
confirmed = False
found = _REMAIN.search(line)
if found:
remain = int(found.group(1))
if name:
rows.append((name, party, seats, confirmed, remain))
return rows
@pytest.mark.parametrize("name,party,seats,confirmed,remain", _cases())
def test_feature_scenario(name, party, seats, confirmed, remain):
result = reserve_room(party, seats)
assert result["confirmed"] is confirmed
assert result["seats_free"] == remain
Same room.feature as Cucumber.js. Uses pytest-bdd step fixtures when installed; otherwise pytest reads the same Gherkin examples.