PRACTICAL GUIDE / cucumber BDD tutorial

Cucumber BDD Tutorial: From Feature File to Test Run

Follow this Cucumber BDD tutorial to write feature files, step definitions, hooks, tags, data tables, and maintainable automation for teams.

By The Testing AcademyUpdated July 10, 202610 min read
All field guides
In this guide8 sections
  1. Start with an example the team can challenge
  2. Create a narrow Cucumber-JS project
  3. Put scenario state in a custom World
  4. Implement steps around domain actions
  5. Use tables and outlines for different problems
  6. Control setup, cleanup, hooks, and tags
  7. Run the right scenarios in the pipeline
  8. Review Cucumber as a collaboration system

What you will learn

  • Start with an example the team can challenge
  • Create a narrow Cucumber-JS project
  • Put scenario state in a custom World
  • Implement steps around domain actions

The checkout team agreed that a declined card must leave the basket unchanged. Product wrote that rule in a feature file, QA automated it, and the scenario passed for months. In production, a decline reserved stock for fifteen minutes. The Gherkin said “the order is rejected,” but the step definition checked only an error banner. A shared sentence had created the appearance of agreement without testing the agreement itself.

Cucumber is useful when examples force precise conversations and the executable layer proves the same observable outcomes. It becomes expensive ceremony when feature files paraphrase tickets, steps hide weak assertions, or nobody outside automation reviews them. The following Cucumber BDD tutorial builds a small order example and shows the engineering controls needed to keep it trustworthy.

Start with an example the team can challenge

Before creating a framework, hold a short example workshop with someone who understands the business rule, someone who will implement it, and someone who will test it. Discuss concrete inputs and outputs instead of “happy path” labels. For card declines, ask whether an order record exists, whether inventory changes, which status code the API returns, and what the customer may safely retry.

A useful feature describes one capability and uses domain language:

GHERKIN
@orders @api
Feature: Place an order
  Customers must not lose basket contents when payment is declined.

  Background:
    Given the catalog contains SKU "MUG-01" with 5 units

  Scenario: Payment is declined before inventory is reserved
    Given customer "c-17" has SKU "MUG-01" in the basket
    And the payment provider will decline the charge
    When the customer places the order
    Then the order request is rejected as "PAYMENT_DECLINED"
    And SKU "MUG-01" still has 5 available units
    And the basket for customer "c-17" still contains SKU "MUG-01"

Every Then represents evidence relevant to the rule. The provider behavior is explicit, so the scenario does not depend on a real third party. The example deliberately avoids screen details because this risk can be proved faster at the service boundary. A separate UI scenario can check that the error is presented accessibly.

Do not automate the first wording immediately. Ask reviewers to identify ambiguity. If “places the order” could mean clicking a button or sending a request, choose the domain event that matters. Examples are specifications only after the team agrees what their terms mean.

Record unresolved questions beside the story rather than encoding guesses in steps. Suppose the payment provider accepts a charge but the response is lost. That is neither a normal approval nor a decline, and the expected inventory behavior may differ. Adding a separate example for “authorization outcome unknown” makes the missing product rule visible before code chooses an accidental policy. Cucumber adds value at this point even if that example is eventually automated as a service test outside the Cucumber runner.

Create a narrow Cucumber-JS project

Keep the first slice small enough that a new contributor can trace one scenario from text to assertion. A TypeScript layout might be:

Example
features/
  place-order.feature
  step-definitions/order.steps.ts
  support/hooks.ts
  support/world.ts
cucumber.mjs
package.json
tsconfig.json

The runner configuration should make discovery and reporting explicit:

JavaScript
export default {
  default: {
    import: ["features/**/*.ts"],
    loader: ["ts-node/esm"],
    format: ["progress", "json:reports/cucumber.json"],
    parallel: 2,
    publishQuiet: true
  }
};

A matching script keeps local and CI commands identical:

JSON
{
  "scripts": {
    "test:bdd": "cucumber-js --config cucumber.mjs",
    "test:bdd:smoke": "cucumber-js --config cucumber.mjs --tags '@smoke and not @wip'"
  }
}

Pin compatible package versions in the real repository and commit the lockfile. Run the command from a clean checkout before adding more scenarios. A framework that works only from one engineer’s globally installed tools is not reproducible.

Keep configuration values outside feature text. Base URLs, credentials, worker counts, and report locations belong in environment-aware runner configuration. Validate required variables at startup and print safe configuration such as environment name and service version. Never print access tokens. A scenario should describe the same behavior locally and in CI; only its adapters should point at different deployments.

Decide where this project lives based on ownership. Feature files close to the service code are easier to change with an API contract. A separate acceptance repository can suit workflows spanning several independently released products, but it needs explicit versioning and owners. Do not create a central repository simply to make BDD look organization-wide.

Put scenario state in a custom World

Cucumber creates a World for each scenario. Use it for scenario-scoped clients, IDs, inputs, and responses. Do not store mutable scenario state in module globals, especially when parallel execution is enabled.

TypeScript
import { IWorldOptions, setWorldConstructor, World } from "@cucumber/cucumber";

export class OrderWorld extends World {
  apiBaseUrl = process.env.API_BASE_URL ?? "http://localhost:3000";
  customerId = "";
  response?: Response;

  constructor(options: IWorldOptions) {
    super(options);
  }

  async post(path: string, body: unknown): Promise<Response> {
    return fetch(`${this.apiBaseUrl}${path}`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(body)
    });
  }
}

setWorldConstructor(OrderWorld);

The World is an adapter between readable steps and technical interfaces, not a second application framework. Keep domain operations small. If it accumulates selectors, SQL, request signing, and business assertions, extract focused clients or helpers and inject them into the World.

Use arrow functions cautiously in step definitions. Cucumber binds this to the World for normal function expressions. Typed this makes that dependency visible to reviewers.

Implement steps around domain actions

Step definitions should translate a domain sentence into one setup, action, or observation. They should not contain arbitrary waits or call other step definitions.

TypeScript
import assert from "node:assert/strict";
import { Given, Then, When } from "@cucumber/cucumber";
import { OrderWorld } from "../support/world.js";

Given(
  "customer {string} has SKU {string} in the basket",
  async function (this: OrderWorld, customerId: string, sku: string) {
    this.customerId = customerId;
    const response = await this.post("/test-support/baskets", { customerId, sku });
    assert.equal(response.status, 201);
  }
);

When("the customer places the order", async function (this: OrderWorld) {
  this.response = await this.post("/orders", { customerId: this.customerId });
});

Then(
  "the order request is rejected as {string}",
  async function (this: OrderWorld, expectedCode: string) {
    assert.ok(this.response, "order response was not captured");
    assert.equal(this.response.status, 422);
    const body = (await this.response.json()) as { code: string };
    assert.equal(body.code, expectedCode);
  }
);

The catalog, provider, inventory, and basket steps follow the same shape, using test-support endpoints or direct service clients owned by the test environment. Those interfaces must be protected from production access.

Prefer a small vocabulary over near-duplicates such as “submits an order,” “confirms checkout,” and “places purchase.” Search existing steps before adding one. Reuse is healthy only when the sentence retains the same business meaning. A generic step like When I click {string} is reusable mechanically but makes the feature dependent on implementation details.

Parameter types also deserve boundaries. Cucumber expressions make strings and numbers convenient, but domain values such as currency, order status, or date often need validation before reaching a client. Register a parameter type or parse in one helper so invalid feature text fails with a specific message. Do not let NaN or an unknown status travel through several requests before producing an unrelated assertion failure.

Use tables and outlines for different problems

A data table belongs inside one scenario when a step needs structured input. For example, an order containing several items can be expressed as a table and converted into typed rows:

GHERKIN
Given the basket contains:
  | sku     | quantity |
  | MUG-01  | 2        |
  | CARD-04 | 1        |
TypeScript
import { DataTable, Given } from "@cucumber/cucumber";

Given("the basket contains:", async function (table: DataTable) {
  const items = table.hashes().map((row) => ({
    sku: row.sku,
    quantity: Number(row.quantity)
  }));
  if (items.some((item) => !item.sku || !Number.isInteger(item.quantity))) {
    throw new Error("basket table requires sku and integer quantity");
  }
  await this.post("/test-support/baskets", { customerId: this.customerId, items });
});

A scenario outline repeats the whole behavior for a short set of meaningful examples. It is appropriate for rules such as supported payment decisions. It is not a substitute for hundreds of boundary combinations. Put exhaustive calculations in unit or parameterized service tests, where failures are faster and easier to locate.

Name examples by business category, not row number. When an outline fails in CI, the report should say “expired card” rather than “example 7.”

Control setup, cleanup, hooks, and tags

Hooks handle technical lifecycle work shared across scenarios. They should not conceal business preconditions. Starting a trace, attaching a response on failure, and deleting scenario-created records are hook concerns. Creating a premium customer belongs in a Given because it changes the meaning of the example.

TypeScript
import { After, Before, Status } from "@cucumber/cucumber";
import { OrderWorld } from "./world.js";

Before(function (this: OrderWorld, scenario) {
  this.customerId = `bdd-${scenario.pickle.id}`;
});

After(async function (this: OrderWorld, scenario) {
  if (scenario.result?.status === Status.FAILED && this.response) {
    await this.attach(
      JSON.stringify({ status: this.response.status, url: this.response.url }, null, 2),
      "application/json"
    );
  }
  await fetch(`${this.apiBaseUrl}/test-support/customers/${this.customerId}`, {
    method: "DELETE"
  });
});

Cleanup must be idempotent because setup can fail halfway through. Prefer unique records per scenario over a shared “automation user.” Shared data creates order dependence and makes parallel runs unreliable.

Tags are routing metadata, not decoration. Use capability tags such as @orders, execution tags such as @smoke, and constraint tags such as @serial only when the runner acts on them. Avoid tagging people or sprint numbers. Review tag expressions in CI so not @wip does not quietly hide permanent failures.

Run the right scenarios in the pipeline

On a pull request, run deterministic scenarios that cover changed services and a small smoke set. Run the wider suite after merge or against an integrated environment. Cucumber does not determine the test layer, so report API and browser scenarios separately even if they share the runner.

A useful job preserves machine-readable output and returns Cucumber’s exit code. Do not swallow failure with || true. Publish the JSON report plus application logs keyed by a correlation ID. Redact tokens and customer data before attaching payloads.

Parallel execution is a design test. If enabling two workers causes collisions, inspect ports, accounts, clocks, queues, and cleanup before increasing capacity. Retrying an entire failed scenario can distinguish a transient dependency problem, but automatic retries must not convert flaky checks into green gates. Record both attempts and create a repair owner.

To verify the slice, run it once with the intended service available, once with the decline stub deliberately returning approval, and once with inventory changed unexpectedly. The latter two runs must fail at the statements that explain the violated rule.

Review Cucumber as a collaboration system

Feature files deserve the same review discipline as code. A pull request should show which rule changed, who confirmed the example, what layer executes it, and what evidence a failure produces. Product reviewers need not approve step mechanics, but they should be able to challenge the scenario’s language and outcomes.

During review, execute a new scenario against both the intended implementation and a deliberately broken variant. Mutation does not need special tooling: temporarily remove the inventory assertion from the application behavior, change the provider result, or return the wrong status. If the scenario remains green, its wording promises more than its assertions prove. Restore the application, then keep a lower-level regression check for the discovered gap when appropriate.

Watch for warning signs: feature files written after implementation, scenarios that only check page text, steps with branching logic, broad Background sections, sleeps, shared mutable accounts, and phrases no stakeholder uses. Remove scenarios that duplicate stronger lower-level checks without protecting a distinct risk.

A healthy Cucumber suite is not measured by feature count. Trace a recently changed rule from conversation to example, from step to observable assertion, and from pipeline failure to diagnosis. If each handoff preserves the same meaning, Cucumber is doing useful BDD work. If the meaning changes between layers, repair that path before adding more prose.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 10, 2026 / Reviewed July 10, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Gherkin reference

    Cucumber

    Official feature, scenario, rule, example, and step syntax.

  2. 02
    WebDriver standard

    W3C

    The browser automation protocol specification used by major automation stacks.

FAQ / QUICK ANSWERS

Questions testers ask

What should happen before a team creates its first Cucumber framework?

Hold a short workshop with business, implementation, and testing perspectives. Challenge one concrete example by naming preconditions, external effects, error semantics, and unchanged state. Resolve ambiguous domain phrases before automating them, and record unanswered rules rather than encoding guesses. Cucumber earns its cost when the conversation and the executable assertions preserve the same meaning.

Why should mutable Cucumber scenario data live in a custom World?

Cucumber creates a separate World for each scenario, making it the proper place for clients, unique IDs, inputs, and responses. Module globals leak state and break parallel execution. Keep the World as a thin adapter, extract focused helpers when it accumulates unrelated responsibilities, and use normal function expressions when steps depend on the bound World context.

When should a Cucumber test use a data table instead of a Scenario Outline?

Use a data table when one step needs structured input, such as several basket items. Use a Scenario Outline when the entire behavior repeats for a small set of meaningful business categories. Put exhaustive boundary combinations in faster parameterized service or unit tests, and name outline examples by category so CI reports explain the failed rule.

What belongs in Cucumber hooks, and what should remain in Given steps?

Hooks should manage technical lifecycle concerns such as tracing, failure attachments, and idempotent cleanup. Business preconditions that change the example, such as creating a premium customer, belong in visible Given steps. Use unique records instead of shared automation accounts, and reserve tags for execution policy that the runner actually enforces rather than people or sprint labels.

How can a team prove that a Cucumber scenario tests what its wording promises?

Run the scenario against the intended implementation and a deliberately broken variant. Change the provider result, inventory effect, or returned status and confirm the failure names the violated business outcome. If the scenario stays green, its step definitions assert less than the prose claims. Preserve machine-readable reports, correlation IDs, and relevant attachments for diagnosis.