BDD & Gherkin
Cucumber-driven BDD workflows — feature files as living documentation, typed step definitions, and hooks for setup and teardown.
How I use it
BDD with Cucumber lets me write tests that serve as executable specifications.
Feature files are written before implementation — they define the contract in plain
language that stakeholders, developers, and QAs can all read and challenge.
Step definitions back each scenario with real HTTP calls and typed assertions,
and a strongly-typed World class keeps scenario state explicit and safe.
Feature file: restaurant booking flow
Each scenario covers a distinct slice of the booking domain — create, cancel, modify,
operating-hours rejection, and availability conflict. The Background block
handles shared pre-conditions so individual scenarios stay focused on the variation
being tested.
Feature: Restaurant Booking Management
As a registered customer
I want to manage my table reservations
So that I can plan my dining experience in advance
Background:
Given I am authenticated as a registered customer
Scenario: Successfully create a booking
When I request a table for 2 on "2026-05-15" at "19:30"
Then the booking should be confirmed
And I should receive a booking reference number
Scenario: Cancel an existing booking
Given I have an active booking for 2 on "2026-05-15" at "19:30"
When I cancel my booking
Then the booking status should be "cancelled"
And a cancellation confirmation should be issued
Scenario: Modify the party size of an existing booking
Given I have an active booking for 2 on "2026-05-15" at "19:30"
When I update the party size to 4
Then the booking should reflect 4 guests
And the table assignment should be updated accordingly
Scenario Outline: Reject a booking outside operating hours
When I request a table for 2 on "2026-05-20" at "<time>"
Then the booking should be rejected
And the error message should contain "<reason>"
Examples:
| time | reason |
| 07:00 | Outside operating hours |
| 23:30 | Outside operating hours |
Scenario: Attempt to book a fully booked time slot
Given the restaurant is fully booked on "2026-05-20" at "20:00"
When I request a table for 2 on "2026-05-20" at "20:00"
Then the booking should be rejected
And the error message should contain "No availability" World context — typed shared state
The World class holds all state that steps share within a scenario.
Typing it in TypeScript forces explicit intent — if a step writes bookingId,
the next step that reads it gets a compile error if the field doesn't exist on World.
The Axios instance lives here too, pre-configured with the base URL and a permissive
status validator so steps can assert on error responses without Axios throwing first.
import { setWorldConstructor, World, IWorldOptions } from '@cucumber/cucumber';
import axios, { type AxiosInstance } from 'axios';
export interface ApiResponse {
status: number;
body: Record<string, unknown>;
}
export class BookingWorld extends World {
readonly api: AxiosInstance;
authToken = '';
customerId = '';
bookingId: string | null = null;
bookingStatus = '';
lastResponse: ApiResponse | null = null;
constructor(options: IWorldOptions) {
super(options);
this.api = axios.create({
baseURL: 'https://api.tablebook.local/v1',
validateStatus: () => true,
});
}
get authHeaders() {
return { Authorization: `Bearer ${this.authToken}` };
}
}
setWorldConstructor(BookingWorld); Step definitions
Steps import the typed BookingWorld and use this to read
and write shared state across a scenario. Each step makes exactly one assertion or
one side effect — this keeps failures precise and readable without digging into
stack traces. I prefer assert.strictEqual over matcher libraries for
its unambiguous output.
import { Given, When, Then } from '@cucumber/cucumber';
import assert from 'node:assert/strict';
import type { BookingWorld } from '../support/world';
Given('I am authenticated as a registered customer',
async function (this: BookingWorld) {
const res = await this.api.post('/auth/token', {
username: 'test.customer@tablebook.local',
password: 'Test@Secure99',
});
assert.strictEqual(res.status, 200, 'Auth failed — check test credentials');
this.authToken = res.data.accessToken;
this.customerId = res.data.customerId;
}
);
Given('I have an active booking for {int} on {string} at {string}',
async function (this: BookingWorld, guests: number, date: string, time: string) {
const res = await this.api.post('/bookings', {
customerId: this.customerId,
date,
time,
partySize: guests,
}, { headers: this.authHeaders });
assert.strictEqual(res.status, 201);
this.bookingId = res.data.bookingId;
this.bookingStatus = res.data.status;
}
);
Given('the restaurant is fully booked on {string} at {string}',
async function (this: BookingWorld, date: string, time: string) {
const res = await this.api.get(
`/availability?date=${date}&time=${time}`,
{ headers: this.authHeaders }
);
assert.strictEqual(
res.data.available,
false,
'Expected slot to be fully booked — seed data may be missing'
);
}
);
When('I request a table for {int} on {string} at {string}',
async function (this: BookingWorld, guests: number, date: string, time: string) {
const res = await this.api.post('/bookings', {
customerId: this.customerId,
date,
time,
partySize: guests,
}, { headers: this.authHeaders });
this.lastResponse = { status: res.status, body: res.data };
if (res.status === 201) {
this.bookingId = res.data.bookingId;
this.bookingStatus = res.data.status;
}
}
);
When('I cancel my booking', async function (this: BookingWorld) {
assert.ok(this.bookingId, 'bookingId must be set before cancelling');
const res = await this.api.delete(
`/bookings/${this.bookingId}`,
{ headers: this.authHeaders }
);
this.lastResponse = { status: res.status, body: res.data };
this.bookingStatus = res.data.status;
});
When('I update the party size to {int}',
async function (this: BookingWorld, newSize: number) {
assert.ok(this.bookingId, 'bookingId must be set before updating');
const res = await this.api.patch(
`/bookings/${this.bookingId}`,
{ partySize: newSize },
{ headers: this.authHeaders }
);
this.lastResponse = { status: res.status, body: res.data };
if (res.status === 200) {
this.bookingStatus = res.data.status;
}
}
);
Then('the booking should be confirmed', function (this: BookingWorld) {
assert.strictEqual(this.lastResponse?.status, 201);
assert.strictEqual(this.bookingStatus, 'confirmed');
});
Then('I should receive a booking reference number', function (this: BookingWorld) {
assert.ok(this.bookingId, 'Expected a bookingId in the response');
assert.match(this.bookingId, /^BKG-[A-Z0-9]{8}$/, 'bookingId format invalid');
});
Then('the booking status should be {string}',
function (this: BookingWorld, expectedStatus: string) {
assert.strictEqual(this.bookingStatus, expectedStatus);
}
);
Then('a cancellation confirmation should be issued', function (this: BookingWorld) {
assert.strictEqual(this.lastResponse?.status, 200);
assert.ok(
this.lastResponse?.body.cancelledAt,
'cancelledAt timestamp missing from cancellation response'
);
});
Then('the booking should reflect {int} guests',
function (this: BookingWorld, expected: number) {
assert.strictEqual(this.lastResponse?.status, 200);
assert.strictEqual(this.lastResponse?.body.partySize, expected);
}
);
Then('the table assignment should be updated accordingly', function (this: BookingWorld) {
assert.ok(this.lastResponse?.body.tableId, 'Expected a tableId in the response');
});
Then('the booking should be rejected', function (this: BookingWorld) {
const status = this.lastResponse?.status;
assert.ok(
status === 409 || status === 422,
`Expected 409 or 422, got ${status}`
);
});
Then('the error message should contain {string}',
function (this: BookingWorld, phrase: string) {
const message = this.lastResponse?.body.message as string;
assert.ok(
message?.includes(phrase),
`Expected "${phrase}" in "${message}"`
);
}
); Hooks: health check, tracing, and cleanup
Hooks handle infrastructure concerns separately from business logic. BeforeAll
guards the entire run against a dead environment. Before attaches a
correlation ID to every API call so failed scenarios can be traced in server logs.
After deletes any booking created during the scenario — regardless of
pass or fail — so the environment stays clean across runs. AfterAll
signals the API to purge any orphaned test data for this run.
import { Before, After, BeforeAll, AfterAll } from '@cucumber/cucumber';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { BookingWorld } from './world';
const TEST_RUN_ID = process.env.TEST_RUN_ID ?? randomUUID();
BeforeAll(async function () {
const res = await fetch('https://api.tablebook.local/v1/health');
assert.strictEqual(
res.status,
200,
'API health check failed — aborting test run'
);
});
Before(async function (this: BookingWorld) {
const correlationId = randomUUID();
this.api.defaults.headers.common['x-correlation-id'] = correlationId;
this.api.defaults.headers.common['x-test-run-id'] = TEST_RUN_ID;
});
After(async function (this: BookingWorld) {
if (!this.bookingId) return;
await this.api.delete(
`/bookings/${this.bookingId}`,
{ headers: this.authHeaders }
);
this.bookingId = null;
});
AfterAll(async function () {
await fetch('https://api.tablebook.local/v1/test/purge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-test-run-id': TEST_RUN_ID,
},
body: JSON.stringify({ runId: TEST_RUN_ID }),
});
}); What I focus on
- Scenarios as specifications — feature files are written before implementation; they define the contract, not just verify it
- Typed World — sharing state through a strongly-typed class surfaces integration errors at compile time, not in CI at midnight
- One assertion per Then — precise step names produce readable failure messages; you know exactly what broke without reading a stack trace
- Idempotent cleanup — the After hook always runs even on failure, so environments stay consistent across the full suite
- Scenario Outlines for data variation — tables replace copy-paste scenarios while keeping Gherkin as the single source of truth
- Hooks for infrastructure, steps for business logic — health checks and tracing headers live in hooks; authentication and domain actions live in steps