Daily driver 2+ years

BDD & Gherkin

Cucumber-driven BDD workflows — feature files as living documentation, typed step definitions, and hooks for setup and teardown.

CucumberGherkinBDDTypeScriptStep DefinitionsHooks

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