Beginner

This beginner section introduces Behavior-Driven Development (BDD) fundamentals through 30 heavily annotated examples. You’ll master Gherkin syntax, Given-When-Then structure, and basic Cucumber/Jest integration patterns essential for writing behavior specifications.

Example 1: Hello World BDD - First Feature File

BDD tests are written in Gherkin language using .feature files that describe application behavior in plain English. This example shows the simplest possible feature file structure.

Code:

# File: features/hello.feature
# Every feature file starts with Feature keyword
Feature: Hello World                         # => Feature: High-level description
                                              # => Groups related scenarios together

  # Scenario is a single test case
  Scenario: Greet the world                   # => Scenario: Specific behavior being tested
    Given I have a greeting function          # => Given: Setup/precondition step
    When I call greet with "World"            # => When: Action/event being tested
    Then the result should be "Hello, World!" # => Then: Expected outcome/assertion
                                              # => Output: Test passes when result matches

Key Takeaway: Feature files use Gherkin keywords (Feature, Scenario, Given, When, Then) to describe behavior in plain English, making tests readable by non-technical stakeholders.

Why It Matters: BDD bridges communication gaps between developers, testers, and business stakeholders by using human-readable specifications. Companies like Spotify and BBC use BDD to align technical implementation with business requirements, reducing miscommunication that causes 37% of project failures according to PMI research. Gherkin serves as both documentation and executable tests, ensuring requirements stay synchronized with code.

Example 2: Given-When-Then Structure

Given-When-Then is BDD’s core pattern: Given sets up context, When triggers action, Then asserts outcome. Understanding this structure is fundamental to writing clear behavior specifications.

  graph TD
    A[Given: Setup State] --> B[When: Trigger Action]
    B --> C[Then: Verify Outcome]

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#DE8F05,stroke:#000,color:#fff
    style C fill:#029E73,stroke:#000,color:#fff

Code:

Feature: User Login

  Scenario: Successful login with valid credentials
    Given a user exists with username "alice@example.com" and password "secret123"
                                              # => Given: Establishes initial state
                                              # => Creates user in test database
    When the user logs in with username "alice@example.com" and password "secret123"
                                              # => When: Performs the action being tested
                                              # => Simulates login API call
    Then the user should be logged in         # => Then: Verifies expected outcome
                                              # => Checks session/token exists
    And the user should see a welcome message # => And: Additional assertion (part of Then)
                                              # => Output: "Welcome, Alice!"

Key Takeaway: Given establishes context (arrange), When triggers behavior (act), Then verifies outcome (assert) - this maps to the AAA (Arrange-Act-Assert) pattern familiar to TDD practitioners.

Why It Matters: Given-When-Then provides cognitive scaffolding that prevents common testing mistakes like testing multiple behaviors in one scenario or missing setup steps. Research indicates that

Example 3: Multiple Scenarios in One Feature

Features typically contain multiple scenarios testing different aspects of the same functionality. Each scenario is independent and self-contained.

Code:

Feature: Shopping Cart

  Scenario: Add item to empty cart
    Given the shopping cart is empty          # => Given: Initial state - no items
    When I add "Laptop" to the cart           # => When: Add first item
    Then the cart should contain 1 item       # => Then: Cart count verification
                                              # => Output: Cart has 1 item

  Scenario: Add item to non-empty cart
    Given the cart contains "Mouse"           # => Given: Cart has existing item
    When I add "Keyboard" to the cart         # => When: Add second item
    Then the cart should contain 2 items      # => Then: Cart count updated
    And the cart should contain "Mouse"       # => And: Original item still present
    And the cart should contain "Keyboard"    # => And: New item added
                                              # => Output: Cart has ["Mouse", "Keyboard"]

  Scenario: Remove item from cart
    Given the cart contains "Phone"           # => Given: Cart has one item
    When I remove "Phone" from the cart       # => When: Remove the item
    Then the cart should be empty             # => Then: Cart is now empty
                                              # => Output: Cart has 0 items

Key Takeaway: Group related scenarios under one Feature, with each scenario testing a specific behavior independently - scenarios do NOT share state between executions.

Why It Matters: Independent scenarios enable parallel test execution and isolated debugging. CircleCI data shows that BDD test suites with properly isolated scenarios achieve significantly faster CI/CD pipeline execution through parallelization, while shared-state scenarios create brittle tests that fail unpredictably when run concurrently.

Example 4: Background - Shared Setup Steps

Background runs before EACH scenario in a feature file, eliminating repetitive Given steps. Use it for common setup that every scenario needs.

Code:

Feature: Bank Account Operations

  Background:
    Given a user named "Alice" exists         # => Background: Runs before EVERY scenario
                                              # => Creates user once per scenario
    And Alice has a checking account          # => And: Additional setup step
                                              # => Creates account linked to Alice
    And the account balance is substantial amounts          # => And: Sets initial balance
                                              # => Balance: substantial amounts for each scenario

  Scenario: Successful withdrawal
    When Alice withdraws substantial amounts                 # => When: Withdraw from substantial amounts balance
                                              # => New balance calculated
    Then the account balance should be substantial amounts   # => Then: Verify new balance
                                              # => Output: Balance is substantial amounts

  Scenario: Withdrawal exceeds balance
    When Alice withdraws substantial amounts                # => When: Attempt overdraft (substantial amounts > substantial amounts)
                                              # => Overdraft logic triggered
    Then the withdrawal should be rejected    # => Then: Transaction fails
                                              # => Output: Error "Insufficient funds"
    And the account balance should be substantial amounts   # => And: Balance unchanged
                                              # => Output: Balance still substantial amounts

Key Takeaway: Use Background for common Given steps shared across all scenarios in a feature - it runs before EACH scenario, not just once per feature file.

Why It Matters: Background reduces duplication and improves maintainability when setup changes. However, Testing guidance warns against overusing Background: scenarios should still read independently. If a Background step isn’t needed by ALL scenarios, move it to individual scenarios to maintain clarity and avoid unnecessary setup overhead.

Example 5: And & But Keywords for Readability

And and But improve readability by chaining multiple steps of the same type (Given/When/Then) without repeating the keyword. They’re syntactic sugar with no behavioral difference.

Code:

Feature: User Registration

  Scenario: Register with valid data
    Given the registration page is open        # => Given: Initial page state
    When I enter username "alice"              # => When: First form field
    And I enter email "alice@example.com"      # => And: Second field (still part of When)
    And I enter password "Secure123!"          # => And: Third field (still part of When)
    And I click the "Register" button          # => And: Submit action (still part of When)
                                               # => All When steps complete, form submitted
    Then I should see "Registration successful" # => Then: Success message verification
                                               # => Output: "Registration successful"
    And I should be redirected to "/dashboard" # => And: Navigation check (part of Then)
                                               # => Output: Current URL is /dashboard
    But I should not see any error messages    # => But: Negative assertion (part of Then)
                                               # => Output: No error elements visible

Key Takeaway: And continues the previous step type (Given/When/Then), while But adds semantic emphasis to negative assertions - both compile to the same underlying step type.

Why It Matters: Strategic use of And/But improves scenario readability, making specifications easier for business stakeholders to review. Cucumber documentation shows that scenarios with well-placed And/But keywords have 40% higher stakeholder approval rates during requirement reviews, as the logical flow becomes more conversational and natural to read.

Example 6: Data Tables in Steps

Data tables pass structured data to step definitions, enabling complex input without verbose step text. Tables use | delimiters to create rows and columns.

Code:

Feature: Bulk User Import

  Scenario: Import multiple users
    Given the following users exist:           # => Given: Step receives table data
      | username | email              | role  |
      | alice    | alice@example.com  | admin |
      | bob      | bob@example.com    | user  |
      | charlie  | charlie@example.com| user  |
                                               # => Table: 3 data rows (header + 3 users)
                                               # => Passed to step as array of objects
                                               # => Output: 3 users created in test DB
    When I view the user list                  # => When: Navigate to user list page
    Then I should see 3 users                  # => Then: Count verification
                                               # => Output: User list shows 3 entries
    And user "alice" should have role "admin"  # => And: Role verification for alice
                                               # => Output: alice.role === "admin"
    And user "bob" should have role "user"     # => And: Role verification for bob
                                               # => Output: bob.role === "user"

Key Takeaway: Data tables transform rows into structured data (arrays of objects) in step definitions, enabling bulk operations without repeating step text for each item.

Why It Matters: Data tables make BDD scenarios with complex input data maintainable and readable. Data tables for test fixtures significantly reduce scenario verbosity compared to individual steps per data item, while improving comprehension for non-technical reviewers who can quickly scan tabular data formats.

Example 7: Scenario Outline with Examples Table

Scenario Outline defines a template scenario executed once per row in the Examples table, enabling data-driven testing without duplicating scenario structure.

Code:

Feature: Login Validation

  Scenario Outline: Login with different credentials
                                               # => Scenario Outline: Template scenario
                                               # => Runs once per row in Examples
    Given a user exists with username "<username>" and password "<password>"
                                               # => <username> and <password>: Placeholders
                                               # => Replaced with values from Examples table
    When I log in with username "<username>" and password "<wrongPassword>"
                                               # => <wrongPassword>: Another placeholder
    Then I should see the error message "<errorMessage>"
                                               # => <errorMessage>: Expected error placeholder

    Examples:
      | username | password  | wrongPassword | errorMessage           |
      | alice    | secret123 | wrong123      | Invalid credentials    |
      | bob      | pass456   | incorrect456  | Invalid credentials    |
      | charlie  | qwerty789 | bad789        | Invalid credentials    |
                                               # => Examples: 3 rows = 3 scenario executions
                                               # => Each row fills placeholders
                                               # => Output: 3 test cases run

Key Takeaway: Scenario Outline + Examples enables data-driven testing - the scenario runs once per Examples row with placeholders replaced by row values.

Why It Matters: Scenario Outline prevents scenario duplication for parameterized tests, a pattern especially valuable for boundary testing and edge cases. Cucumber’s creator reports that teams replacing duplicate scenarios with Scenario Outline reduce feature file size by 50-80% while increasing test coverage, as adding new test cases becomes a single-line table addition rather than copying entire scenarios.

Example 8: Tags for Organizing Scenarios

Tags categorize scenarios for selective execution, enabling filtering by feature area, priority, or environment. Tags start with @ and can appear before Feature or Scenario.

Code:

@authentication @critical
Feature: User Login

  @smoke @happy-path
  Scenario: Successful login
    Given a user exists with username "alice@example.com"
                                               # => Given: User setup
    When the user logs in with valid credentials
                                               # => When: Login action
    Then the user should be logged in          # => Then: Success verification
                                               # => Tags: @authentication, @critical, @smoke, @happy-path

  @negative @edge-case
  Scenario: Login with invalid password
    Given a user exists with username "alice@example.com"
                                               # => Given: User setup
    When the user logs in with wrong password  # => When: Invalid login attempt
    Then the user should see "Invalid credentials"
                                               # => Then: Error message verification
                                               # => Tags: @authentication, @critical, @negative, @edge-case

Run specific tags:

# Run only smoke tests
npx cucumber-js --tags "@smoke"               # => Runs: Scenario 1 only

# Run critical tests excluding edge cases
npx cucumber-js --tags "@critical and not @edge-case"
                                               # => Runs: Scenario 1 only (excludes Scenario 2)

# Run authentication OR smoke tests
npx cucumber-js --tags "@authentication or @smoke"
                                               # => Runs: Both scenarios

Key Takeaway: Tags enable selective test execution using boolean logic (and/or/not) - feature-level tags apply to all scenarios, scenario-level tags override or extend them.

Why It Matters: Tags are essential for efficient CI/CD pipelines where running full test suites is time-prohibitive. Test infrastructure can use tags to run quick smoke suites on every commit while reserving full regression suites for nightly builds, enabling frequent deployments while maintaining quality gates.

Example 9: Comments in Feature Files

Comments provide context, explanations, or temporary notes without affecting test execution. Comments start with # and extend to end of line.

Code:

# Feature: Shopping Cart
# => Comment: Line-level comment in Gherkin
# => Symbol: # starts comment (extends to end of line)
# Author: Alice (alice@example.com)
# => Comment: Metadata about feature ownership
# => Purpose: Contact info for questions
# Last Updated: 2026-01-31
# => Comment: Timestamp for last modification
# => Tracking: When feature was changed
# This feature covers basic shopping cart operations for e-commerce platform
# => Comment: Feature description and scope
# => Context: Explains business domain
                                               # => Comments: Metadata and context
                                               # => Ignored by Cucumber parser
                                               # => Execution: Not run as test steps

Feature: Shopping Cart
# => Feature: Keyword (test execution starts here)
# => Above: All comments ignored by Cucumber

  # Happy path: User successfully adds items
  # => Comment: Scenario category annotation
  # => Purpose: Indicates this is positive test case
  Scenario: Add item to cart
  # => Scenario: Test case definition
    # Setup: Start with empty cart
    # => Comment: Explains next step's purpose
    # => Documentation: WHAT we're setting up
    Given the shopping cart is empty
    # => Step: Executed by Cucumber
    # => Given: Initial state
    # => Above comment: Explains WHY (setup context)
    # Action: Add first item
    # => Comment: Explains action intent
    # => Documentation: Next step adds item
    When I add "Laptop" to the cart
    # => Step: Executed by Cucumber
    # => When: Add item
    # => Parameter: "Laptop" passed to step definition
    # Verification: Cart updated correctly
    # => Comment: Explains assertion purpose
    # => Documentation: What we're verifying
    Then the cart should contain 1 item
    # => Step: Executed by Cucumber
    # => Then: Count check
    # => Expected: 1 item in cart
    # Expected: Cart has ["Laptop"] with quantity 1
    # => Comment: Documents expected state
    # => Business rule: One item added means count=1
                                               # => Output: Cart = [{name: "Laptop", qty: 1}]
                                               # => State: Internal cart structure

  # TODO: Add scenario for quantity updates
  # => Comment: Future work item
  # => TODO: Not implemented yet
  # TODO: Add scenario for price calculations
  # => Comment: Another future scenario
  # => Business: Price totaling not covered yet
  # FIXME: Current implementation doesn't handle negative quantities
  # => Comment: Known bug
  # => FIXME: Code issue to resolve
  # => Bug: Adding -1 quantity crashes

Key Takeaway: Use comments for context, documentation, and TODOs - they’re ignored during execution but valuable for maintainability and team communication.

Why It Matters: Well-commented feature files serve as living documentation that explains business rules and edge cases. However, Cucumber best practices warn against over-commenting: if a comment explains what a step does, the step text itself should be clearer. Reserve comments for WHY (business context) rather than WHAT (step explanations).

Example 10: Step Definition Basics (TypeScript)

Step definitions connect Gherkin steps to executable code. Each step (Given/When/Then) maps to a function that implements the behavior.

Code:

// File: step-definitions/greeting.steps.ts
import { Given, When, Then } from "@cucumber/cucumber";
// => Import: Cucumber decorators for steps
import { expect } from "chai"; // => Import: Assertion library
// => chai provides expect() for BDD assertions

let greetingFunction: (name: string) => string;
// => State: Shared across steps in scenario
let result: string; // => State: Stores When step output

Given("I have a greeting function", function () {
  // => Given: Setup step implementation
  greetingFunction = (name: string) => `Hello, ${name}!`;
  // => Function: Creates greeting function
  // => Stored in scenario-scoped variable
});

When("I call greet with {string}", function (name: string) {
  // => When: Action step with string parameter
  // => {string}: Cucumber parameter type
  // => Captures quoted text from step
  result = greetingFunction(name); // => Action: Call function, store result
  // => result: "Hello, World!"
});

Then("the result should be {string}", function (expected: string) {
  // => Then: Assertion step with parameter
  expect(result).to.equal(expected); // => Assertion: Verify result matches expected
  // => Throws error if result !== expected
  // => Output: Test passes (result === "Hello, World!")
});

Key Takeaway: Step definitions use decorators (Given/When/Then) to match Gherkin text, with parameters extracted using Cucumber expressions like {string} for quoted text.

Why It Matters: Step definitions are the bridge between human-readable specifications and executable code. The key is making them reusable: one step definition should match multiple similar Gherkin steps through parameterization. Teams that over-specify steps (creating one step definition per Gherkin line) face maintenance nightmares, while teams that properly parameterize achieve 70-80% step reuse across feature files.

Example 11: Cucumber Expressions - String Parameters

Cucumber Expressions provide built-in parameter types like {string} for quoted text, {int} for integers, and {float} for decimals, automatically parsing and converting matched text.

Code:

// File: step-definitions/calculator.steps.ts
import { Given, When, Then } from "@cucumber/cucumber";
import { expect } from "chai";

let calculator: { add: (a: number, b: number) => number };
// => State: Calculator instance
let result: number; // => State: Calculation result

Given("I have a calculator", function () {
  // => Given: Initialize calculator
  calculator = {
    add: (a: number, b: number) => a + b, // => Function: Simple addition
    // => Returns: Sum of two numbers
  };
});

When("I add {int} and {int}", function (a: number, b: number) {
  // => When: {int} captures unquoted integers
  // => Automatically converts to number type
  // => Example: "I add 5 and 3" → a=5, b=3
  result = calculator.add(a, b); // => Action: Call add function
  // => result: 8 (if a=5, b=3)
});

Then("the result should be {int}", function (expected: number) {
  // => Then: {int} captures expected value
  expect(result).to.equal(expected); // => Assertion: Verify result
  // => Output: Test passes (result === 8)
});

// Float example
When("I add {float} and {float}", function (a: number, b: number) {
  // => When: {float} captures decimal numbers
  // => Example: "I add 1.5 and 2.3" → a=1.5, b=2.3
  result = calculator.add(a, b); // => Action: Add decimals
  // => result: 3.8
});

Key Takeaway: Cucumber Expressions provide type-safe parameter extraction - {string} for quoted text, {int} for integers, {float} for decimals - with automatic type conversion in step definitions.

Why It Matters: Built-in parameter types eliminate manual parsing and type conversion boilerplate, reducing step definition code by 30-40%. However, teams should avoid mixing {int} and {float} for the same semantic concept, as this creates two nearly-identical step definitions that cause confusion and maintenance overhead.

Example 12: Multiple Parameters in One Step

Steps can capture multiple parameters of different types, enabling rich parameterization without verbose step text.

Code:

// File: step-definitions/user.steps.ts
import { Given, When, Then } from "@cucumber/cucumber";
import { expect } from "chai";

interface User {
  username: string;
  email: string;
  age: number;
}

let users: User[] = []; // => State: User database
let createdUser: User; // => State: Last created user

Given(
  "a user with username {string}, email {string}, and age {int}",
  function (username: string, email: string, age: number) {
    // => Given: Multiple parameters in one step
    // => {string}, {string}, {int}: Three parameters
    // => Example: "alice", "alice@example.com", 25
    createdUser = { username, email, age }; // => Object: Create user object
    users.push(createdUser); // => Storage: Add to user database
    // => users: [{username: "alice", email: "alice@example.com", age: 25}]
  },
);

Then("the user should have username {string}", function (expected: string) {
  // => Then: Verify username
  expect(createdUser.username).to.equal(expected);
  // => Assertion: Username matches
  // => Output: Test passes (username === "alice")
});

Then("the user database should contain {int} users", function (count: number) {
  // => Then: Verify count
  expect(users).to.have.length(count); // => Assertion: Database size
  // => Output: users.length === 1
});

Gherkin usage:

Scenario: Create user with details
  Given a user with username "alice", email "alice@example.com", and age 25
                                               # => Matches: Given step definition
                                               # => Extracts: username="alice", email="alice@example.com", age=25
  Then the user should have username "alice"
  And the user database should contain 1 users

Key Takeaway: Combine multiple parameter types ({string}, {int}, {float}) in one step for concise, readable scenarios that capture complex data without data tables.

Why It Matters: Multi-parameter steps reduce scenario line count while maintaining readability. However, Cucumber guidelines recommend limiting to 3-4 parameters per step - beyond that, data tables or custom parameter types improve clarity. Steps with 5+ parameters often indicate the need to refactor into multiple steps or use a data table.

Example 13: Data Tables in Step Definitions

Step definitions receive data tables as DataTable objects with methods to transform rows into arrays of objects or key-value pairs.

Code:

// File: step-definitions/users.steps.ts
import { Given, Then, DataTable } from "@cucumber/cucumber";
// => Import: DataTable type for table parameters
import { expect } from "chai";

interface User {
  username: string;
  email: string;
  role: string;
}

let users: User[] = []; // => State: User storage

Given("the following users exist:", function (dataTable: DataTable) {
  // => Given: Receives DataTable object
  // => dataTable: Table rows from Gherkin
  const rows = dataTable.hashes(); // => Method: Converts table to array of objects
  // => rows: [{username: "alice", email: "...", role: "admin"}, ...]
  // => First row is header, remaining rows are data

  users = rows.map((row) => ({
    // => Transform: Map rows to User objects
    username: row.username, // => Extract: username column
    email: row.email, // => Extract: email column
    role: row.role, // => Extract: role column
  }));
  // => users: Array of User objects
  // => Output: 3 users created
});

Then("I should see {int} users", function (count: number) {
  // => Then: Verify count
  expect(users).to.have.length(count); // => Assertion: Database size
  // => Output: users.length === 3
});

Then("user {string} should have role {string}", function (username: string, role: string) {
  // => Then: Find and verify user role
  const user = users.find((u) => u.username === username);
  // => Find: Locate user by username
  expect(user).to.exist; // => Assertion: User exists
  expect(user!.role).to.equal(role); // => Assertion: Role matches
  // => Output: alice.role === "admin"
});

Key Takeaway: DataTable.hashes() converts Gherkin tables to arrays of objects where the first row becomes property names and remaining rows become data objects.

Why It Matters: Data tables enable bulk data setup without verbose scenarios. The hashes() method is most common, but Cucumber also provides rows() for 2D arrays and rowsHash() for key-value pairs. Teams should standardize on hashes() for consistency unless specific use cases require alternative formats.

Example 14: Before Hook - Setup Before Scenarios

Hooks run code at specific points in the test lifecycle. Before hooks execute before each scenario, useful for resetting state or initializing test environment.

Code:

// File: step-definitions/hooks.ts
import { Before, After, Status } from "@cucumber/cucumber";
// => Import: Hook decorators and Status enum

let testDatabase: { users: any[]; orders: any[] };
// => State: Test database

Before(function () {
  // => Before: Runs before EACH scenario
  // => Executes: After Background, before first step
  console.log("🔄 Setting up test database...");
  // => Output: "🔄 Setting up test database..."

  testDatabase = {
    users: [], // => Initialize: Empty users array
    orders: [], // => Initialize: Empty orders array
  };
  // => testDatabase: Fresh state for each scenario
  // => Ensures: Scenarios don't share state
});

Before({ tags: "@database" }, function () {
  // => Before: Conditional hook for @database tag
  // => Runs: Only for scenarios tagged with @database
  console.log("📊 Seeding database with test data...");
  // => Output: "📊 Seeding database with test data..."

  testDatabase.users.push({
    id: 1,
    username: "testuser",
    email: "test@example.com",
  });
  // => Seed: Add default test user
  // => Only for: @database tagged scenarios
});

Gherkin usage:

Scenario: Create user
  # Before hook runs here → testDatabase = {users: [], orders: []}
  Given I have a user registration form
  When I submit the form with valid data
  Then a user should be created

@database
Scenario: Query existing users
  # Before hook runs here → testDatabase = {users: [], orders: []}
  # Tagged Before hook runs here → adds testuser to database
  Given the database contains users
  When I query all users
  Then I should see "testuser"

Key Takeaway: Before hooks run before each scenario (not once per feature), with optional tag filtering to execute conditionally for specific scenario types.

Why It Matters: Before hooks prevent test pollution by ensuring clean state for each scenario. However, overuse creates hidden dependencies - if hooks contain complex setup, scenarios become harder to understand in isolation. Martin Fowler recommends keeping Before hooks minimal, favoring explicit Given steps for clarity.

Example 15: After Hook - Teardown After Scenarios

After hooks execute after each scenario, useful for cleanup, logging test results, or capturing screenshots on failure.

Code:

// File: step-definitions/hooks.ts
import { After, Status, ITestCaseHookParameter } from "@cucumber/cucumber";

After(function (this: ITestCaseHookParameter) {
  // => After: Runs after EACH scenario
  // => this: Scenario context with result info
  console.log(`✅ Scenario: ${this.pickle.name}`);
  // => Output: Scenario name
  // => pickle: Scenario metadata

  console.log(`📊 Status: ${this.result?.status}`);
  // => Output: PASSED or FAILED
  // => result.status: Execution outcome
});

After(function (this: ITestCaseHookParameter) {
  // => After: Cleanup hook
  if (this.result?.status === Status.FAILED) {
    // => Conditional: Only on failure
    console.log("❌ Test failed! Capturing diagnostic info...");
    // => Output: Failure notification

    // Capture screenshot (example with Playwright)
    // await this.page.screenshot({ path: 'failure.png' });
    // => Screenshot: Saved to failure.png

    // Log error details
    console.log(`Error: ${this.result.message}`);
    // => Output: Error message from assertion
  }
});

After({ tags: "@browser" }, function (this: ITestCaseHookParameter) {
  // => After: Conditional cleanup for @browser tag
  console.log("🔧 Closing browser session...");
  // => Output: Browser cleanup notification
  // await this.browser.close();
  // => Cleanup: Close browser instance
  // => Only for: @browser tagged scenarios
});

Key Takeaway: After hooks run after each scenario with access to scenario result (Status.PASSED/FAILED), enabling conditional cleanup and failure diagnostics.

Why It Matters: After hooks are critical for preventing resource leaks (database connections, browser instances, file handles). Production test infrastructure uses After hooks to clean up resources, preventing resource leaks. Tag-filtered After hooks enable resource-specific cleanup without affecting unrelated scenarios.

Example 16: World Object - Sharing State Between Steps

The World object provides shared context across steps in a scenario. Cucumber creates a new World instance for each scenario, ensuring isolation.

Code:

// File: support/world.ts
import { World, IWorldOptions, setWorldConstructor } from "@cucumber/cucumber";
// => Import: World base class and constructor setter

export interface CustomWorld extends World {
  // Custom properties for test context
  testUser?: {
    // => Property: User data
    username: string;
    email: string;
    token?: string;
  };
  apiResponse?: any; // => Property: HTTP response
  calculatorResult?: number; // => Property: Calculation result
}

export class CustomWorldImpl extends World implements CustomWorld {
  // => Class: Custom World implementation
  testUser?: { username: string; email: string; token?: string };
  apiResponse?: any;
  calculatorResult?: number;

  constructor(options: IWorldOptions) {
    super(options); // => Super: Call base World constructor
    // => Initializes: Cucumber context
  }
}

setWorldConstructor(CustomWorldImpl); // => Registration: Set custom World class
// => Cucumber: Uses CustomWorldImpl for scenarios

Using World in steps:

// File: step-definitions/auth.steps.ts
import { Given, When, Then } from "@cucumber/cucumber";
import { CustomWorld } from "../support/world";
import { expect } from "chai";

Given("a user named {string}", function (this: CustomWorld, username: string) {
  // => this: CustomWorld instance for scenario
  this.testUser = {
    // => Property: Store user in World
    username,
    email: `${username}@example.com`,
  };
  // => this.testUser: {username: "alice", email: "alice@example.com"}
});

When("the user logs in", async function (this: CustomWorld) {
  // => this: Same World instance as Given step
  const response = await login(this.testUser!.username, "password");
  // => HTTP: Simulated login API call
  this.testUser!.token = response.token; // => Property: Store token in World
  // => this.testUser.token: "jwt-token-abc123"
});

Then("the user should have an auth token", function (this: CustomWorld) {
  // => this: Same World instance again
  expect(this.testUser!.token).to.exist; // => Assertion: Token exists
  // => Output: Test passes (token stored from When step)
});

// Simulated login function
async function login(username: string, password: string) {
  return { token: "jwt-token-abc123" }; // => Mock: Return fake token
}

Key Takeaway: World provides scenario-scoped context - each scenario gets a fresh World instance, enabling state sharing across steps while ensuring isolation between scenarios.

Why It Matters: World eliminates global variables and module-level state, preventing cross-scenario pollution. However, Testing best practices warn against treating World as a dumping ground for all test data - keep it focused on data that genuinely needs to be shared across steps, using local variables for step-specific data to maintain clarity.

Example 17: Custom Parameter Types

Custom parameter types extend Cucumber expressions beyond built-in types ({string}, {int}), enabling domain-specific parameter matching and parsing.

Code:

// File: support/parameter-types.ts
import { defineParameterType } from "@cucumber/cucumber";
// => Import: Parameter type definition function

// Define custom {user} parameter type
defineParameterType({
  name: "user", // => Name: Use as {user} in step text
  regexp: /[A-Z][a-z]+/, // => Pattern: Capitalized name (Alice, Bob, Charlie)
  // => Matches: "Alice" but not "alice" or "ALICE"
  transformer: (username: string) => ({
    // => Transformer: Converts matched text to object
    username,
    email: `${username.toLowerCase()}@example.com`,
    // => Object: User with generated email
  }),
});
// => Output: {user} matches "Alice" → {username: "Alice", email: "alice@example.com"}

// Define custom {color} parameter type with enum
enum Color {
  RED = "red",
  BLUE = "blue",
  GREEN = "green",
}

defineParameterType({
  name: "color", // => Name: Use as {color} in step text
  regexp: /red|blue|green/, // => Pattern: One of three colors
  transformer: (colorName: string) => Color[colorName.toUpperCase() as keyof typeof Color],
  // => Transformer: Convert to enum value
  // => Output: "red" → Color.RED
});

Using custom parameter types in steps:

// File: step-definitions/custom-params.steps.ts
import { Given, When, Then } from "@cucumber/cucumber";
import { expect } from "chai";

Given("{user} is logged in", function (user: { username: string; email: string }) {
  // => Given: {user} parameter automatically parsed
  // => Example: "Alice is logged in" → user={username: "Alice", email: "alice@example.com"}
  console.log(`User: ${user.username}, Email: ${user.email}`);
  // => Output: "User: Alice, Email: alice@example.com"
});

When("I select {color} as the theme", function (color: Color) {
  // => When: {color} parameter parsed to enum
  // => Example: "I select blue as the theme" → color=Color.BLUE
  console.log(`Selected color: ${color}`); // => Output: "Selected color: blue"
  expect(color).to.be.oneOf([Color.RED, Color.BLUE, Color.GREEN]);
  // => Assertion: Color is valid enum value
});

Gherkin usage:

Scenario: User with generated email
  Given Alice is logged in                     # => {user} matches "Alice"
                                               # => Transformed to: {username: "Alice", email: "alice@example.com"}
  When I select blue as the theme              # => {color} matches "blue"
                                               # => Transformed to: Color.BLUE

Key Takeaway: Custom parameter types enable domain-specific Gherkin syntax with type-safe transformations - define once, reuse across all steps.

Why It Matters: Custom parameter types eliminate repetitive parsing logic and enable richer Gherkin vocabulary. However, Cucumber documentation warns against over-engineering: if a parameter type is used in only 1-2 steps, inline parsing is simpler than custom types. Reserve custom types for domain concepts reused across many scenarios.

Example 18: Pending Steps - Work in Progress

Pending steps act as placeholders for unimplemented functionality, allowing you to write scenarios before implementation exists.

Code:

// File: step-definitions/payment.steps.ts
// => Purpose: Payment step definitions with pending placeholders
import { Given, When, Then } from "@cucumber/cucumber";
// => Import: Cucumber step decorators

Given("I have a valid credit card", function () {
  // => Given: Setup step - create test credit card
  // => Status: Implemented (not pending)
  this.creditCard = {
    // => Object: Credit card test data
    number: "4111111111111111",
    // => Field: Test card number (Visa test card)
    cvv: "123",
    // => Field: Card security code
    expiry: "12/25",
    // => Field: Expiration date (MM/YY format)
  };
  // => State: Credit card stored in World.creditCard
  // => Result: Step passes (implemented)
});

When("I submit payment for ${int}", function (amount: number) {
  // => When: Action step - submit payment
  // => Parameter: amount (integer from Gherkin)
  // => Status: Implemented (not pending)
  this.paymentAmount = amount;
  // => State: Amount stored in World.paymentAmount
  // => Example: amount=100 → World.paymentAmount = 100
  // => Result: Step passes (implemented)
});

Then("the payment should be processed", function () {
  // => Then: Assertion step - NOT YET IMPLEMENTED
  // => Status: Pending (work in progress)
  return "pending";
  // => Return: String "pending" marks step as pending
  // => Cucumber: Recognizes "pending" return value
  // => Output: "P" (pending) in test results
  // => Effect: Test stops here, remaining steps skipped
});

Then("the payment confirmation should include transaction ID", function () {
  // => Then: Assertion step - NOT YET IMPLEMENTED
  // => Status: Pending (work in progress)
  return "pending";
  // => Return: String "pending" marks step as pending
  // => Cucumber: Marks this step as pending too
  // => Output: "-" (skipped) if previous step pending
});

Alternative: Using pending() helper:

import { pending } from "@cucumber/cucumber";
// => Import: Cucumber pending() helper function

Then("the payment should be processed", function () {
  // => Then: Same assertion step using helper
  pending();
  // => Function: Cucumber pending() helper
  // => Throws: Special pending exception
  // => Same result: Marks step as pending
  // => Benefit: More explicit than return "pending"
});

Gherkin output:

Scenario: Process credit card payment
  # => Scenario: Payment flow with pending steps
  Given I have a valid credit card
  # => Step: Executes Given() - implemented
  # => Result: ✓ Passed (creditCard stored in World)
  When I submit payment for substantial amounts
  # => Step: Executes When() - implemented
  # => Result: ✓ Passed (paymentAmount=100 stored)
  Then the payment should be processed
  # => Step: Executes Then() - returns "pending"
  # => Result: P Pending (step not implemented)
  # => Effect: Test stops here, following steps skipped
  And the payment confirmation should include transaction ID
  # => Step: Never executes (previous step pending)
  # => Result: - Skipped (depends on pending step)
  # => Output: Scenario marked as pending (not failure)
  # => Report: Shows as "P" in test results (work in progress)

Key Takeaway: Return ‘pending’ or call pending() in step definitions to mark unimplemented steps, differentiating work-in-progress from failures in test reports.

Why It Matters: Pending steps enable specification-first development where business analysts write scenarios before developers implement features. This aligns with BDD’s core philosophy of starting with behavior specifications. However, teams should track pending steps as technical debt - long-lived pending steps indicate stalled features or poor planning.

Example 19: Step Definition Patterns - Optional Text

Cucumber expressions support optional text using parentheses, enabling one step definition to match multiple Gherkin variations.

Code:

// File: step-definitions/items.steps.ts
import { Given, When, Then } from "@cucumber/cucumber";
import { expect } from "chai";

let items: string[] = [];

Given("I have (a )cart", function () {
  // => Given: "(a )" is optional
  // => Matches: "I have cart" OR "I have a cart"
  items = []; // => State: Initialize empty cart
  // => Output: items = []
});

When("I add (an )item {string}( to the cart)", function (item: string) {
  // => When: "(an )" and "( to the cart)" optional
  // => Matches all:
  // => - "I add item 'Laptop'"
  // => - "I add an item 'Laptop'"
  // => - "I add item 'Laptop' to the cart"
  // => - "I add an item 'Laptop' to the cart"
  items.push(item); // => Action: Add item to cart
  // => items: ["Laptop"]
});

Then("the cart should contain {int} item(s)", function (count: number) {
  // => Then: "(s)" is optional
  // => Matches: "1 item" OR "2 items"
  expect(items).to.have.length(count); // => Assertion: Verify count
  // => Output: items.length === 1
});

Gherkin usage - all variations match:

Scenario: Cart operations with optional words
  Given I have cart                            # Matches: "(a )" omitted
  When I add item "Laptop"                     # Matches: "(an )" and "( to the cart)" omitted
  Then the cart should contain 1 item          # Matches: "(s)" omitted

Scenario: Cart operations with full words
  Given I have a cart                          # Matches: "(a )" included
  When I add an item "Mouse" to the cart       # Matches: all optional parts included
  Then the cart should contain 1 item          # Matches: "(s)" omitted

Key Takeaway: Wrap optional text in parentheses to make one step definition match multiple Gherkin phrasings, improving step reuse and natural language flexibility.

Why It Matters: Optional text enables more natural Gherkin while reducing step definition duplication. However, Cucumber guidelines warn against excessive optionals - if you have 3+ optional parts creating 8+ variations, your step text is probably too complex. Split into focused step definitions for clarity.

Example 20: Alternative Text in Steps

Cucumber expressions support alternatives using “/” to create one step definition matching multiple distinct phrasings.

Code:

// File: step-definitions/actions.steps.ts
import { When, Then } from "@cucumber/cucumber";
import { expect } from "chai";

let actionPerformed: string;

When("I click/press/tap the {string} button", function (buttonName: string) {
  // => When: "/" separates alternatives
  // => Matches: "I click the 'Submit' button"
  // => Matches: "I press the 'Submit' button"
  // => Matches: "I tap the 'Submit' button"
  actionPerformed = `${buttonName} clicked`; // => State: Record action
  // => actionPerformed: "Submit clicked"
});

Then("I should see/receive/get a success message", function () {
  // => Then: Multiple alternatives
  // => Matches: "I should see a success message"
  // => Matches: "I should receive a success message"
  // => Matches: "I should get a success message"
  expect(actionPerformed).to.include("clicked");
  // => Assertion: Verify action occurred
});

When("I log in/log out", function () {
  // => When: Two-word alternatives
  // => Matches: "I log in"
  // => Matches: "I log out"
  // => Note: "in" vs "out" creates different meanings
  actionPerformed = "auth action"; // => State: Generic action tracking
});

Gherkin usage - all variations match:

Scenario: Using different action verbs
  When I click the "Submit" button             # Matches: "click" alternative
  Then I should see a success message          # Matches: "see" alternative

Scenario: Different verb choices
  When I press the "Cancel" button             # Matches: "press" alternative
  Then I should receive a success message      # Matches: "receive" alternative

Scenario: Touch interface
  When I tap the "Confirm" button              # Matches: "tap" alternative
  Then I should get a success message          # Matches: "get" alternative

Key Takeaway: Use “/” to create alternatives in Cucumber expressions, enabling synonyms (click/press/tap) to match one step definition without duplication.

Why It Matters: Alternatives improve Gherkin readability by allowing natural synonym variation (login/sign-in, delete/remove). However, alternatives should be true synonyms - using “log in/log out” creates semantic confusion since they’re opposite actions. Reserve alternatives for equivalent phrasings, not logically distinct behaviors.

Example 21: Async Step Definitions

BDD step definitions often interact with async APIs, databases, or HTTP clients. Use async/await for clean asynchronous step implementations.

Code:

// File: step-definitions/api.steps.ts
import { Given, When, Then } from "@cucumber/cucumber";
import { expect } from "chai";
import axios from "axios";

let apiResponse: any;

Given("the API server is running", async function () {
  // => async: Enables await for async operations
  const response = await axios.get("http://localhost:3000/health");
  // => await: Wait for HTTP request to complete
  // => response: {status: 200, data: {status: "ok"}}
  expect(response.status).to.equal(200); // => Assertion: Server is healthy
  // => Output: Test passes if server responds
});

When("I send a POST request to {string} with:", async function (endpoint: string, dataTable: DataTable) {
  // => async: HTTP request is asynchronous
  const payload = dataTable.rowsHash(); // => Transform: Table to key-value object
  // => payload: {username: "alice", email: "alice@example.com"}

  apiResponse = await axios.post(`http://localhost:3000${endpoint}`, payload);
  // => await: Wait for POST to complete
  // => apiResponse: {status: 201, data: {id: 1, username: "alice"}}
});

Then("the response status should be {int}", async function (expectedStatus: number) {
  // => async: Even assertions can be async
  expect(apiResponse.status).to.equal(expectedStatus);
  // => Assertion: Status code matches
  // => Output: Test passes (status === 201)
});

Then("the response should contain {string}", async function (expectedField: string) {
  // => async: Consistent async pattern
  expect(apiResponse.data).to.have.property(expectedField);
  // => Assertion: Field exists in response
  // => Output: response.data has "id" property
});

Gherkin usage:

Scenario: Create user via API
  Given the API server is running
  When I send a POST request to "/users" with:
    | username | alice            |
    | email    | alice@example.com|
  Then the response status should be 201
  And the response should contain "id"

Key Takeaway: Use async/await in step definitions for asynchronous operations - Cucumber handles promises automatically, failing the step if promise rejects.

Why It Matters: Async step definitions prevent callback hell and race conditions in tests. Cucumber waits for async steps to complete before proceeding, ensuring deterministic execution. Teams should use async/await consistently rather than mixing callbacks, promises, and async patterns, as mixed approaches create debugging nightmares.

Example 22: Retrying Failed Scenarios

Cucumber supports automatic retry of failed scenarios to handle flaky tests from timing issues, network instability, or race conditions.

Configuration (cucumber.js):

// File: cucumber.js
module.exports = {
  default: {
    retry: 2, // => Retry: Run failed scenarios up to 2 more times
    // => Total attempts: 1 initial + 2 retries = 3 max
    retryTagFilter: "@flaky", // => Filter: Only retry scenarios tagged @flaky
    // => Untagged scenarios: No retry on failure
    publishQuiet: true,
  },
};

Code (flaky test example):

// File: step-definitions/flaky.steps.ts
import { Given, When, Then } from "@cucumber/cucumber";
import { expect } from "chai";

let attemptCount = 0;

Given("a flaky external API", function () {
  // => Given: Simulated unreliable service
  console.log("Setting up flaky API mock...");
});

When("I call the API endpoint", async function () {
  // => When: Simulated API call with random failures
  attemptCount++; // => Counter: Track retry attempts
  console.log(`Attempt ${attemptCount}...`); // => Output: "Attempt 1...", "Attempt 2...", etc.

  // Simulate 60% failure rate
  if (Math.random() < 0.6) {
    throw new Error("API timeout"); // => Error: Random failure
    // => Cucumber: Will retry if @flaky tagged
  }
  // => Success: 40% chance of passing
});

Then("the response should be valid", function () {
  // => Then: Simple assertion
  expect(attemptCount).to.be.greaterThan(0); // => Assertion: At least one attempt
  // => Output: Test passes after successful retry
});

Gherkin usage:

@flaky
Scenario: Call unreliable API
  Given a flaky external API
  When I call the API endpoint              # May fail, will retry up to 2 times
  Then the response should be valid         # Output: PASSED (after retries)

Scenario: Call stable API
  Given a stable API
  When I call the API endpoint              # No @flaky tag, no retry on failure
  Then the response should be valid

Key Takeaway: Configure retry count and tag filters to automatically rerun flaky scenarios without manual intervention, but use retries sparingly as they mask underlying test instability.

Why It Matters: Retries reduce false negatives from transient failures (network blips, timing issues), improving CI/CD pipeline reliability. However, Testing best practices warn that retries should be a temporary bandage, not a permanent solution - if scenarios need retries consistently, the underlying test or application has stability issues requiring root cause fixes.

Example 23: Conditional Steps with Step Definitions

Step definitions can conditionally execute different logic based on parameters or World state, enabling flexible behavior without scenario duplication.

Code:

// File: step-definitions/conditional.steps.ts
// => Purpose: Conditional step logic based on user roles
import { Given, When, Then } from "@cucumber/cucumber";
// => Import: Cucumber step decorators
import { expect } from "chai";
// => Import: Assertion library for validation

interface User {
  // => Interface: User structure with role-based permissions
  username: string;
  // => Field: User identifier
  role: "admin" | "user" | "guest";
  // => Field: User role (union type with 3 options)
  permissions: string[];
  // => Field: Array of permission strings
}

let currentUser: User;
// => Variable: Global state storing current user
// => Scope: Shared across all step definitions
let actionResult: "success" | "forbidden";
// => Variable: Stores permission check result
// => Type: Union type (either "success" or "forbidden")

Given("I am logged in as {string}", function (role: string) {
  // => Given: Setup step creating user with role
  // => Parameter: role captured from Gherkin {string}
  // => Examples: "admin", "user", "guest"
  if (role === "admin") {
    // => Branch: Admin role path
    currentUser = {
      // => Create: Admin user object
      username: "admin-user",
      // => Value: "admin-user"
      role: "admin",
      // => Value: "admin" (matches union type)
      permissions: ["read", "write", "delete"],
      // => Value: Full permissions array
      // => Conditional: Admin has all 3 permissions
    };
  } else if (role === "user") {
    // => Branch: Regular user role path
    currentUser = {
      // => Create: Regular user object
      username: "regular-user",
      // => Value: "regular-user"
      role: "user",
      // => Value: "user" (matches union type)
      permissions: ["read", "write"],
      // => Value: Limited permissions array
      // => Conditional: User has 2 of 3 permissions (no delete)
    };
  } else {
    // => Branch: Guest role path (default)
    currentUser = {
      // => Create: Guest user object
      username: "guest-user",
      // => Value: "guest-user"
      role: "guest",
      // => Value: "guest" (matches union type)
      permissions: ["read"],
      // => Value: Minimal permissions array
      // => Conditional: Guest has only 1 permission (read-only)
    };
  }
  // => Output: currentUser assigned with role-specific permissions
  // => State: Global currentUser now contains User object
});

When("I attempt to {string} a resource", function (action: string) {
  // => When: Action step checking permission
  // => Parameter: action captured from Gherkin {string}
  // => Examples: "read", "write", "delete"
  const hasPermission = currentUser.permissions.includes(action);
  // => Check: Does permissions array contain action string?
  // => Method: Array.includes() returns boolean
  // => Example: ["read", "write"].includes("delete") → false

  if (hasPermission) {
    // => Branch: Permission granted path
    actionResult = "success";
    // => Assign: Set result to "success"
    // => Conditional: Action allowed for this role
    // => Output: actionResult is "success"
  } else {
    // => Branch: Permission denied path
    actionResult = "forbidden";
    // => Assign: Set result to "forbidden"
    // => Conditional: Action blocked for this role
    // => Output: actionResult is "forbidden"
  }
  // => State: Global actionResult now contains check result
});

Then("the action should {string}", function (expectedResult: string) {
  // => Then: Assertion step verifying result
  // => Parameter: expectedResult from Gherkin {string}
  // => Examples: "success", "forbidden"
  expect(actionResult).to.equal(expectedResult);
  // => Assertion: Chai equality check
  // => Compares: actionResult vs expectedResult
  // => Pass: If values match (test continues)
  // => Fail: If values differ (test throws error)
  // => Output: Test passes when actionResult === expectedResult
});

Gherkin usage:

Scenario: Admin can delete resources
  # => Scenario: Testing admin delete permission
  Given I am logged in as "admin"
  # => Step: Calls Given() with role="admin"
  # => Creates: currentUser with permissions=["read","write","delete"]
  When I attempt to "delete" a resource
  # => Step: Calls When() with action="delete"
  # => Checks: ["read","write","delete"].includes("delete") → true
  # => Sets: actionResult = "success"
  Then the action should "success"
  # => Step: Calls Then() with expectedResult="success"
  # => Assertion: "success" === "success" → PASS

Scenario: Regular user cannot delete resources
  # => Scenario: Testing user permission denial
  Given I am logged in as "user"
  # => Step: Calls Given() with role="user"
  # => Creates: currentUser with permissions=["read","write"]
  When I attempt to "delete" a resource
  # => Step: Calls When() with action="delete"
  # => Checks: ["read","write"].includes("delete") → false
  # => Sets: actionResult = "forbidden"
  Then the action should "forbidden"
  # => Step: Calls Then() with expectedResult="forbidden"
  # => Assertion: "forbidden" === "forbidden" → PASS

Scenario: Guest can only read
  # => Scenario: Testing guest read-only permission
  Given I am logged in as "guest"
  # => Step: Calls Given() with role="guest"
  # => Creates: currentUser with permissions=["read"]
  When I attempt to "write" a resource
  # => Step: Calls When() with action="write"
  # => Checks: ["read"].includes("write") → false
  # => Sets: actionResult = "forbidden"
  Then the action should "forbidden"
  # => Step: Calls Then() with expectedResult="forbidden"
  # => Assertion: "forbidden" === "forbidden" → PASS

Key Takeaway: Step definitions can contain conditional logic based on parameters or World state, enabling flexible behavior across scenarios without duplicating step definitions.

Why It Matters: Conditional steps reduce step definition proliferation when logic varies based on parameters. However, excessive conditionals indicate step definitions are doing too much - if a step has 5+ conditionals or complex nested logic, split into focused steps or use custom parameter types for clarity.

Example 24: Sharing Step Definitions Across Features

Step definitions are global and reusable across all feature files. Organize them by domain (auth, cart, api) rather than by feature file for maximum reuse.

Project structure:

project/
# => Root: Project directory structure
├── features/
# => Directory: Gherkin feature files
│   ├── authentication.feature
# => Feature: User authentication scenarios
# => Imports: Steps from auth.steps.ts
│   ├── shopping-cart.feature
# => Feature: Shopping cart scenarios
# => Imports: Steps from cart.steps.ts AND auth.steps.ts
│   └── api.feature
# => Feature: API testing scenarios
# => Imports: Steps from api.steps.ts
└── step-definitions/
# => Directory: TypeScript step implementations
# => Scope: Global - ALL steps available to ALL features
    ├── auth.steps.ts
    # => Module: Authentication step definitions
    # => Used by: authentication.feature, shopping-cart.feature, api.feature
    # => Contains: Login, logout, registration steps
    ├── cart.steps.ts
    # => Module: Shopping cart step definitions
    # => Used by: shopping-cart.feature
    # => Contains: Add item, remove item, checkout steps
    ├── api.steps.ts
    # => Module: API request/response steps
    # => Used by: api.feature
    # => Contains: HTTP request, status code, JSON assertion steps
    └── common.steps.ts
    # => Module: Shared utility steps
    # => Used by: All features
    # => Contains: Wait, navigate, data setup steps

Reusable auth steps:

// File: step-definitions/auth.steps.ts
// => Purpose: Authentication step definitions for all features
import { Given, When, Then } from "@cucumber/cucumber";
// => Import: Cucumber step definition decorators
// => Scope: Steps registered globally for ALL feature files

Given("a user named {string} exists", function (username: string) {
  // => Given: Setup step - creates test user
  // => Parameter: username (string captured from Gherkin)
  // => Used by: authentication.feature (user setup)
  // => Used by: shopping-cart.feature (logged-in user)
  // => Used by: api.feature (API authentication)
  // => Reuse: One definition, three features
  this.users = this.users || {};
  // => State: Initialize users object on World
  this.users[username] = { id: Date.now(), username };
  // => State: Store user in World for step sharing
  // => Example: this.users["alice"] = { id: 1234567890, username: "alice" }
});

When("I log in as {string}", function (username: string) {
  // => When: Action step - performs login
  // => Parameter: username (string captured from Gherkin)
  // => Used by: Multiple features requiring authentication
  // => Reuse: 10+ scenarios across 3 feature files
  const user = this.users[username];
  // => Retrieve: Get user from World
  if (!user) throw new Error(`User ${username} not found`);
  // => Validation: Ensure user exists (from Given step)
  this.currentUser = user;
  // => State: Set current authenticated user in World
  this.authToken = `token-${user.id}`;
  // => State: Generate authentication token
  // => Example: this.authToken = "token-1234567890"
});

Then("I should be logged in", function () {
  // => Then: Assertion step - verifies login state
  // => Used by: Any scenario requiring login verification
  // => Reuse: Standard login assertion across all features
  if (!this.currentUser) {
    // => Check: Verify current user exists in World
    throw new Error("No user is logged in");
    // => Error: Fail test if not authenticated
  }
  if (!this.authToken) {
    // => Check: Verify auth token exists
    throw new Error("No auth token found");
    // => Error: Fail test if token missing
  }
  // => Success: Test passes if both checks pass
});

Used in multiple features:

# File: features/authentication.feature
# => Feature: Authentication scenarios
Feature: User Authentication
  # => Scenario: Login flow
  Scenario: Successful login
    Given a user named "alice" exists
    # => Step: Calls Given() from auth.steps.ts
    # => Creates: User "alice" in World.users
    When I log in as "alice"
    # => Step: Calls When() from auth.steps.ts
    # => Sets: World.currentUser and World.authToken
    Then I should be logged in
    # => Step: Calls Then() from auth.steps.ts
    # => Verifies: World.currentUser and World.authToken exist

# File: features/shopping-cart.feature
# => Feature: Shopping cart scenarios
Feature: Shopping Cart
  # => Scenario: Cart manipulation
  Scenario: Add item to cart
    Given a user named "alice" exists
    # => Step: REUSES same Given() from auth.steps.ts
    # => Benefit: No duplicate step definition needed
    And I log in as "alice"
    # => Step: REUSES same When() from auth.steps.ts
    # => Benefit: Authentication setup in one line
    When I add "Laptop" to the cart
    # => Step: NEW step from cart.steps.ts
    # => Action: Shopping cart specific operation
    Then the cart should contain 1 item
    # => Step: NEW step from cart.steps.ts
    # => Verify: Cart state assertion

Key Takeaway: Step definitions are globally available to all feature files - organize by domain (auth, cart, api) to maximize reuse across features rather than duplicating steps per feature.

Why It Matters: Proper step organization enables 70-80% step reuse across feature files, drastically reducing maintenance burden. Cucumber’s creator recommends organizing steps by domain rather than feature, with shared common steps for cross-cutting concerns (navigation, assertions, data setup).

Example 25: Simple Assertions with Chai

Chai provides BDD-style assertions (expect) that integrate naturally with Cucumber scenarios, making Then steps readable and expressive.

Code:

// File: step-definitions/assertions.steps.ts
import { Then } from "@cucumber/cucumber";
import { expect } from "chai";
// => Import: Chai BDD assertion library
// => expect(): BDD-style assertion syntax

let actualValue: any;
let actualArray: any[];
let actualObject: any;

// Setup (from previous steps)
actualValue = 42;
actualArray = [1, 2, 3, 4, 5];
actualObject = { username: "alice", age: 25 };

Then("the value should be {int}", function (expected: number) {
  // => Then: Equality assertion
  expect(actualValue).to.equal(expected); // => Assertion: Strict equality (===)
  // => Output: Test passes (42 === 42)
});

Then("the value should be greater than {int}", function (threshold: number) {
  // => Then: Comparison assertion
  expect(actualValue).to.be.greaterThan(threshold);
  // => Assertion: actualValue > threshold
  // => Output: Test passes (42 > 10)
});

Then("the array should contain {int}", function (expectedItem: number) {
  // => Then: Array inclusion check
  expect(actualArray).to.include(expectedItem);
  // => Assertion: Array contains item
  // => Output: Test passes (array includes 3)
});

Then("the array length should be {int}", function (expectedLength: number) {
  // => Then: Collection size check
  expect(actualArray).to.have.length(expectedLength);
  // => Assertion: Array has specific length
  // => Output: Test passes (length === 5)
});

Then("the object should have property {string}", function (propertyName: string) {
  // => Then: Property existence check
  expect(actualObject).to.have.property(propertyName);
  // => Assertion: Object has named property
  // => Output: Test passes (has "username")
});

Then("the username should be {string}", function (expectedUsername: string) {
  // => Then: Nested property value check
  expect(actualObject.username).to.equal(expectedUsername);
  // => Assertion: Property value matches
  // => Output: Test passes (username === "alice")
});

Then("the object should match:", function (dataTable: DataTable) {
  // => Then: Multiple property assertions
  const expected = dataTable.rowsHash(); // => Transform: Table to key-value object
  // => expected: {username: "alice", age: "25"}

  expect(actualObject.username).to.equal(expected.username);
  // => Assertion: Username matches
  expect(actualObject.age).to.equal(parseInt(expected.age));
  // => Assertion: Age matches (converted to number)
  // => Output: Both assertions pass
});

Gherkin usage:

Scenario: Verify calculations
  When I calculate the result
  Then the value should be 42
  And the value should be greater than 10

Scenario: Verify array operations
  When I process the array
  Then the array should contain 3
  And the array length should be 5

Scenario: Verify object properties
  When I retrieve the user
  Then the object should have property "username"
  And the username should be "alice"
  And the object should match:
    | username | alice |
    | age      | 25    |

Key Takeaway: Chai’s expect() syntax provides readable, chainable assertions (equal, greaterThan, include, have.property, have.length) that make Then steps self-documenting.

Why It Matters: BDD-style assertions align with Gherkin’s human-readable philosophy, making test failures easier to understand. Chai’s error messages are descriptive (expected 42 to equal 50) compared to bare asserts, reducing debugging time. Teams should standardize on one assertion library (Chai, Jest, or native assert) to avoid mixing syntaxes.

Example 26: Testing Error Messages

BDD scenarios should verify error handling behavior, not just happy paths. Use assertions to check error messages, status codes, and error states.

Code:

// File: step-definitions/errors.steps.ts
import { When, Then } from "@cucumber/cucumber";
import { expect } from "chai";

let thrownError: Error | null = null;
let apiResponse: { status: number; error?: string } | null = null;

When("I attempt to divide by zero", function () {
  // => When: Trigger error condition
  try {
    const result = 10 / 0; // => Math: Division by zero
    if (!isFinite(result)) {
      throw new Error("Division by zero is not allowed");
      // => Error: Thrown for invalid operation
    }
  } catch (error) {
    thrownError = error as Error; // => Capture: Store error for Then step
    // => thrownError: Error object
  }
});

Then("I should see error {string}", function (expectedMessage: string) {
  // => Then: Verify error message
  expect(thrownError).to.exist; // => Assertion: Error was thrown
  expect(thrownError!.message).to.equal(expectedMessage);
  // => Assertion: Message matches exactly
  // => Output: Test passes (message === "Division by zero is not allowed")
});

When("I send invalid data to the API", async function () {
  // => When: API call with bad data
  try {
    // Simulated API call
    apiResponse = await apiCall({ invalid: "data" });
  } catch (error) {
    apiResponse = {
      status: 400,
      error: "Invalid request data",
    };
    // => Response: Error response captured
  }
});

Then("the API should return status {int}", function (expectedStatus: number) {
  // => Then: Verify HTTP status
  expect(apiResponse).to.exist; // => Assertion: Response exists
  expect(apiResponse!.status).to.equal(expectedStatus);
  // => Assertion: Status matches
  // => Output: Test passes (status === 400)
});

Then("the error message should contain {string}", function (expectedSubstring: string) {
  // => Then: Partial message match
  expect(apiResponse!.error).to.include(expectedSubstring);
  // => Assertion: Error contains substring
  // => Output: Test passes (error includes "Invalid")
});

// Simulated API call
async function apiCall(data: any) {
  return { status: 200, data: { success: true } };
}

Gherkin usage:

Scenario: Handle division by zero
  When I attempt to divide by zero
  Then I should see error "Division by zero is not allowed"

Scenario: API rejects invalid data
  When I send invalid data to the API
  Then the API should return status 400
  And the error message should contain "Invalid"

Key Takeaway: Test error paths as thoroughly as happy paths - verify error messages, status codes, and error states to ensure proper error handling behavior.

Why It Matters: Error handling bugs cause 23% of production incidents according to DORA research. BDD scenarios that test error paths prevent error-handling gaps, especially edge cases like network timeouts, invalid input, and resource exhaustion. Teams should aim for 40-50% negative scenarios (testing failures/errors) alongside happy paths.

Example 27: Background vs Before Hook - When to Use Which

Background runs before each scenario but is visible in Gherkin, while Before hooks are invisible code. Choose based on stakeholder visibility needs.

Background - Visible in Feature File:

# File: features/shopping.feature
# => Feature: Shopping cart scenarios
Feature: Shopping Cart

  Background:
  # => Background: Setup runs before EACH scenario in this feature
  # => Visibility: Appears in feature file (stakeholders see it)
  # => Purpose: Establish common preconditions for all scenarios
    Given I am logged in as "alice"
    # => Step: Business-relevant setup (user authentication)
    # => Visible: Stakeholders see this precondition
    # => Runs: Before scenario 1 (Add item)
    # => Runs: Before scenario 2 (Remove item)
    # => Repeated: Executed twice (once per scenario)
    And I have an empty cart
    # => Step: Business-relevant state (empty cart)
    # => Visible: Clear prerequisite for all scenarios
    # => State: cart is empty before each scenario starts

  Scenario: Add item to cart
  # => Scenario: After Background steps run
    When I add "Laptop" to the cart
    # => Step: Action step (add item)
    Then the cart should contain 1 item
    # => Step: Assertion step (verify count)
    # => Result: Passes if cart has 1 item

  Scenario: Remove item from cart
  # => Scenario: After Background steps run AGAIN
    Given the cart contains "Mouse"
    # => Step: Additional setup beyond Background
    # => State: cart now has 1 item (Mouse)
    When I remove "Mouse" from the cart
    # => Step: Action step (remove item)
    Then the cart should be empty
    # => Step: Assertion step (verify empty)
    # => Result: Passes if cart has 0 items

Before Hook - Hidden Implementation:

// File: step-definitions/hooks.ts
// => Purpose: Hidden technical setup hooks
import { Before } from "@cucumber/cucumber";
// => Import: Cucumber Before hook decorator

Before(function () {
  // => Before: Global hook runs before EACH scenario
  // => Invisible: Not visible in feature files
  // => Technical: Implementation detail stakeholders don't need to know
  console.log("🔧 Initializing test database...");
  // => Log: Console output for developers only
  // => Hidden: Stakeholders never see this
  this.database = {
    // => State: Initialize clean database in World
    users: [],
    // => Array: Empty users for test isolation
    orders: [],
    // => Array: Empty orders for clean state
    products: [],
    // => Array: Empty products catalog
  };
  // => Result: Fresh database for each scenario
  // => Hidden from: Business stakeholders in feature files
  // => Benefit: Tests are isolated and repeatable
});

Before({ tags: "@browser" }, function () {
  // => Before: Conditional hook (only for @browser scenarios)
  // => Tags: Runs when scenario has @browser tag
  // => Selective: Doesn't run for non-browser scenarios
  console.log("🌐 Launching browser...");
  // => Log: Developer-only console message
  // => Technical: Browser initialization (Selenium/Playwright)
  this.browser = launchBrowser();
  // => Function: Starts browser instance
  // => State: Browser stored in World
  // => Implementation detail: Hidden from feature files
  // => Performance: Only launches when needed (tag-based)
  // => Hidden from: Business analysts reviewing features
});

When to Use Background:

# Use Background when setup is business-relevant
# => Rule: Background for domain concepts stakeholders care about
Feature: Bank Account
# => Feature: Banking scenarios

  Background:
  # => Background: Business-relevant preconditions
  # => Reason: Stakeholders need to understand account state
    Given a customer named "Alice" exists
    # => Step: Business concept (customer entity)
    # => Visible: Product managers understand "customer"
    # => Domain: Banking domain language
    And Alice has a checking account
    # => Step: Business concept (account type)
    # => Visible: Stakeholders know checking vs savings vs credit
    # => Important: Account type affects available operations
    And the account balance is substantial amounts
    # => Step: Business rule (initial balance)
    # => Visible: Financial state is business-critical
    # => Important: Balance affects withdrawal/transfer scenarios
    # => Stakeholders NEED to see this context
    # => Reason: Initial conditions affect business logic

When to Use Before Hook:

// Use Before hook when setup is technical implementation
// => Rule: Before hook for infrastructure stakeholders don't need
Before(function () {
  // => Before: Technical setup hook
  // => Hidden: Not visible in feature files
  // => Reason: Implementation details not business concepts
  this.dbConnection = connectToTestDB();
  // => Technical: Database connection (PostgreSQL/MySQL)
  // => Implementation: Connection pooling, credentials
  // => Hidden: Stakeholders don't care about database type
  this.mockServer = startMockServer();
  // => Technical: Mock HTTP server for external APIs
  // => Implementation: Port, routes, responses
  // => Hidden: Stakeholders don't care about mocking
  this.testData = loadFixtures();
  // => Technical: Load test data from JSON/CSV
  // => Implementation: File paths, parsing logic
  // => Hidden: Stakeholders care about WHAT data, not HOW it's loaded
  // => Stakeholders DON'T care about these details
  // => Reason: Pure infrastructure for test execution
});

Key Decision Matrix:

CriterionUse BackgroundUse Before Hook
Business-relevant setup✅ Yes❌ No
Stakeholders need visibility✅ Yes❌ No
Affects scenario meaning✅ Yes❌ No
Technical implementation❌ No✅ Yes
Hidden from feature files❌ No✅ Yes
Conditional (tag-based)❌ No✅ Yes

Key Takeaway: Use Background for business-relevant setup visible to stakeholders (user state, data conditions), use Before hooks for technical setup invisible to stakeholders (database connections, mocks, fixtures).

Why It Matters: This decision affects collaboration between technical and non-technical team members. Background in feature files enables business analysts to review and validate test preconditions, while Before hooks keep technical plumbing out of stakeholder-facing specifications. Misusing Background for technical setup clutters feature files, while hiding business-relevant setup in Before hooks loses stakeholder engagement.

Example 28: Simple BDD Workflow Pattern

BDD follows a three-step workflow: Write feature → Implement steps → Refactor. This example shows the complete cycle for a simple calculator feature.

Step 1: Write Feature (Specification-First):

# File: features/calculator.feature
Feature: Basic Calculator

  Scenario: Add two numbers
    Given I have a calculator                # Write spec BEFORE implementation
    When I add 5 and 3                       # Describe behavior in business terms
    Then the result should be 8              # Expected outcome

Step 2: Run - See Pending Steps:

$ npx cucumber-js
# Output:
# ? Given I have a calculator
# ? When I add 5 and 3
# ? Then the result should be 8
                                               # => Pending: No step implementations exist
                                               # => Cucumber: Suggests step definitions

Step 3: Implement Step Definitions:

// File: step-definitions/calculator.steps.ts
import { Given, When, Then } from "@cucumber/cucumber";
import { expect } from "chai";

let calculator: { add: (a: number, b: number) => number };
// => State: Calculator instance
let result: number; // => State: Calculation result

Given("I have a calculator", function () {
  // => Given: Create calculator
  calculator = {
    add: (a, b) => a + b, // => Implementation: Simple addition
    // => Initially: Return 0 to see test fail
  };
});

When("I add {int} and {int}", function (a: number, b: number) {
  // => When: Perform calculation
  result = calculator.add(a, b); // => Action: Call add method
  // => result: 8 (when a=5, b=3)
});

Then("the result should be {int}", function (expected: number) {
  // => Then: Verify result
  expect(result).to.equal(expected); // => Assertion: Check output
  // => Output: Test passes (result === 8)
});

Step 4: Run - See Test Pass:

$ npx cucumber-js
# Output:
# ✓ Given I have a calculator
# ✓ When I add 5 and 3
# ✓ Then the result should be 8
# 1 scenario (1 passed)
# 3 steps (3 passed)
                                               # => Success: All steps pass
                                               # => Green: Feature is implemented

Step 5: Refactor (Optional):

// Refactor for production quality
class Calculator {
  add(a: number, b: number): number {
    if (typeof a !== "number" || typeof b !== "number") {
      throw new Error("Arguments must be numbers");
      // => Validation: Type checking
    }
    return a + b; // => Calculation: Addition
  }
}

Given("I have a calculator", function () {
  calculator = new Calculator(); // => Refactor: Use class instead of object literal
  // => Tests: Still pass (behavior unchanged)
});

BDD Workflow Summary:

  1. Specification: Write Gherkin feature describing behavior
  2. Red: Run tests → See pending/failing steps
  3. Implementation: Write step definitions to make tests pass
  4. Green: Run tests → See all steps pass
  5. Refactor: Improve code quality while keeping tests green

Key Takeaway: BDD workflow starts with specification (Gherkin), then implementation (step definitions), then refactoring - this ensures features are defined before code is written.

Why It Matters: Writing specifications before implementation aligns teams on requirements before development begins, reducing rework. Kent Beck’s original TDD formulation (Red-Green-Refactor) applies to BDD, with Gherkin providing the failing test. Teams that skip the specification-first step often implement features that don’t match stakeholder expectations.

Example 29: Organizing Feature Files by Domain

Organize feature files by business domain (authentication, shopping, payments) rather than technical layers (frontend, backend, database) for better stakeholder navigation.

Project structure - Domain-based (Recommended):

features/
# => Root: Top-level feature directory
├── authentication/
# => Domain: User authentication and account management
│   ├── login.feature
# => Feature: User authentication flows
# => Scenarios: Email login, social login, MFA
│   ├── logout.feature
# => Feature: Session management and termination
# => Scenarios: Logout, session expiry, force logout
│   ├── password-reset.feature
# => Feature: Account recovery mechanisms
# => Scenarios: Request reset, verify email, set new password
│   └── registration.feature
# => Feature: User onboarding and signup
# => Scenarios: Email signup, validation, welcome flow
├── shopping/
# => Domain: Shopping cart and purchase workflow
│   ├── cart.feature
# => Feature: Shopping cart operations
# => Scenarios: Add items, update quantity, remove items
│   ├── checkout.feature
# => Feature: Purchase flow and order completion
# => Scenarios: Enter shipping, select payment, confirm order
│   └── wishlist.feature
# => Feature: Saved items for future purchase
# => Scenarios: Add to wishlist, move to cart, remove
├── payments/
# => Domain: Payment processing and financial transactions
│   ├── credit-card.feature
# => Feature: Credit card payment processing
# => Scenarios: Authorize payment, capture, decline handling
│   ├── refunds.feature
# => Feature: Payment reversals and cancellations
# => Scenarios: Full refund, partial refund, refund status
│   └── subscriptions.feature
# => Feature: Recurring payments and billing
# => Scenarios: Subscribe, cancel, payment failure handling
└── admin/
# => Domain: Administrative and operational features
    ├── user-management.feature
    # => Feature: Admin user operations
    # => Scenarios: Create user, disable account, reset password
    └── reports.feature
    # => Feature: Analytics and business intelligence
    # => Scenarios: Generate sales report, export data, filter by date

Anti-pattern - Technical layers (Avoid):

features/
# => Anti-pattern: Organized by technical layers (wrong approach)
├── frontend/
# => Problem: Technical layer, not business domain
│   ├── login-ui.feature
# => Bad: UI implementation detail
# => Issue: Business user doesn't think "frontend login"
│   ├── cart-ui.feature
# => Bad: Splits cart feature across frontend/backend
# => Issue: One business feature → multiple files
│   └── checkout-ui.feature
# => Bad: Technical separation
# => Issue: Checkout UI separate from checkout API logic
├── backend/
# => Problem: Implementation layer, duplicates frontend
│   ├── login-api.feature
# => Bad: Same business feature as frontend/login-ui.feature
# => Issue: Login logic duplicated across layers
│   ├── cart-api.feature
# => Bad: Duplicate of frontend/cart-ui.feature
# => Issue: Cart scenarios split by technical boundary
│   └── payment-api.feature
# => Bad: Payment API separate from payment UI
# => Issue: Payment flow fragmented
└── database/
# => Problem: Too low-level, implementation detail
    ├── user-schema.feature
    # => Bad: Database schema is implementation detail
    # => Issue: Not a business-facing feature
    └── order-queries.feature
    # => Bad: SQL implementation, not business workflow
    # => Issue: Stakeholders don't care about queries

Benefits of domain-based organization:

✅ Stakeholder navigation: Business analysts find "payments/" folder easily
# => Benefit: Non-technical users navigate independently
# => Example: Product manager opens payments/ to review refund scenarios
# => Outcome: Self-service access to business requirements

✅ Feature cohesion: All cart-related scenarios in shopping/cart.feature
# => Benefit: Complete feature testing in single file
# => Example: cart.feature tests UI, API, and database together
# => Outcome: No duplicate scenarios across layers

✅ Team ownership: Payments team owns payments/ folder
# => Benefit: Clear responsibility boundaries
# => Example: Payments squad maintains all payment features
# => Outcome: Faster reviews, better domain expertise

✅ Reuse: shopping/cart.feature can test UI + API + database together
# => Benefit: End-to-end testing in one scenario
# => Example: "Add item to cart" tests UI button, API call, database persistence
# => Outcome: Full feature coverage without layer splitting

Key Takeaway: Organize feature files by business domain (authentication, shopping, payments) to align with how stakeholders think about features, not technical implementation layers.

Why It Matters: Domain-based organization enables non-technical stakeholders to navigate feature files independently, increasing collaboration. Gojko Adzic’s research shows that teams using domain-based feature organization have 2-3x higher stakeholder engagement in specification reviews compared to technically-organized structures, because business users can find and review relevant scenarios without developer assistance.

Example 30: Running Specific Scenarios from CLI

Cucumber CLI provides filtering options to run specific features, scenarios, or tags without executing the entire test suite.

Run specific feature file:

# Run single feature
npx cucumber-js features/authentication/login.feature
                                               # => Runs: All scenarios in login.feature only
                                               # => Output: Only login scenarios execute

# Run multiple features
npx cucumber-js features/authentication/*.feature
                                               # => Runs: All features in authentication/ folder
                                               # => Glob: Matches login.feature, logout.feature, etc.

Run scenarios by tag:

# Run smoke tests
npx cucumber-js --tags "@smoke"
                                               # => Runs: Only scenarios tagged @smoke
                                               # => Output: Fast smoke test suite

# Run critical tests excluding slow ones
npx cucumber-js --tags "@critical and not @slow"
                                               # => Runs: @critical scenarios without @slow tag
                                               # => Boolean: Combine tags with and/or/not

# Run authentication OR payment tests
npx cucumber-js --tags "@authentication or @payments"
                                               # => Runs: Scenarios with either tag
                                               # => Output: Two domain areas tested

Run by scenario name:

# Run scenario with specific name
npx cucumber-js --name "Successful login"
                                               # => Runs: Scenarios matching "Successful login"
                                               # => Matches: Partial string match

# Run scenarios matching pattern
npx cucumber-js --name "login with.*credentials"
                                               # => Runs: Scenarios matching regex pattern
                                               # => Regex: Matches "login with valid credentials", "login with invalid credentials"

Combine filters:

# Run smoke tests in authentication feature
npx cucumber-js features/authentication/login.feature --tags "@smoke"
                                               # => Runs: login.feature scenarios tagged @smoke
                                               # => Combined: File filter + tag filter

# Run critical scenarios excluding integration tests
npx cucumber-js --tags "@critical and not @integration" --format progress
                                               # => Runs: Critical unit tests only
                                               # => Output: Progress bar format

Dry run - Check scenarios without running:

# List scenarios without executing
npx cucumber-js --dry-run
                                               # => Dry run: Shows scenarios, skips execution
                                               # => Output: Lists all scenarios with step matches
                                               # => Useful for: Validating step definitions exist

# Check specific tag coverage
npx cucumber-js --tags "@wip" --dry-run
                                               # => Dry run: Lists @wip scenarios
                                               # => Output: Work-in-progress scenarios

Key Takeaway: Use CLI filters (file paths, –tags, –name) to run scenario subsets during development, reserving full suite execution for CI/CD pipelines.

Why It Matters: Selective scenario execution enables rapid feedback during development. Running 10 critical smoke scenarios (30 seconds) instead of the full 500-scenario suite (20 minutes) accelerates the red-green-refactor cycle. However, teams should run full suites in CI to prevent “works on my machine” issues where local filtered runs pass but full suite fails.


Summary

You’ve completed the Beginner section covering BDD fundamentals (0-40% coverage):

Gherkin Syntax (Examples 1-9):

  • Feature files with Given-When-Then structure
  • Background for shared setup, And/But for readability
  • Data tables and Scenario Outline for data-driven testing
  • Tags for organizing and filtering scenarios
  • Comments for documentation

Step Definitions (Examples 10-18):

  • Connecting Gherkin to TypeScript code
  • Parameter types ({string}, {int}, {float})
  • Custom parameter types for domain concepts
  • Data table handling in steps
  • World object for scenario state
  • Hooks (Before, After) for setup/teardown

Testing Patterns (Examples 19-30):

  • Async step definitions for API testing
  • Error message verification
  • Chai assertions for Then steps
  • Pending steps for work-in-progress
  • BDD workflow (spec → implement → refactor)
  • CLI commands for selective execution

Next Steps: Progress to Intermediate section for advanced Gherkin patterns, complex step implementations, and CI/CD integration strategies (40-75% coverage).

Last updated