The Testing Academy · BDD Masterclass

Cucumber & BDD,
done properly.

Behaviour-Driven Development isn't a tool — it's a way of agreeing on what software should do, in language everyone can read. Cucumber is what turns those plain-English agreements into runnable tests. Here's the whole picture, built for SDETs.

TrackEnd-to-end BDD
StackCucumber-JS · Playwright
LanguageTypeScript
AudienceQA / SDET
CoversUI & API
01 — THE IDEA

What is BDD?

Behaviour-Driven Development is Test-Driven Development with the conversation moved to the front. TDD asks "does this function do what I coded?" BDD reframes it to "does the system behave the way the business expects?" — and it writes that expectation down, in plain language, before a line of automation exists.

The shift sounds small but it changes who's in the room. In classic test automation, QA writes tests after requirements are handed over — and gaps in those requirements only surface as bugs weeks later. BDD pulls three people together at the start, often called the Three Amigos: someone from the business (a product owner or BA), a developer, and a tester. Together they take a fuzzy requirement and turn it into concrete, agreed examples written as Given / When / Then.

Each amigo brings a different lens, and that's the whole point of doing it together:

BDD = one shared conversation the "three amigos" turn a requirement into agreed examples Business / PO what & why? Developer feasible? how? Tester / SDET what breaks? Gherkin scenario Given · When · Then living docs + executable test
one scenario, three jobs: requirement · test · documentation

The most important property of a BDD scenario is that it describes behaviour, never implementation. It says "Then the user reaches their dashboard" — not "then the element with id #dash-root becomes visible." That single discipline is why BDD scenarios survive UI refactors: change the markup all you like, the behaviour the business agreed on still holds, so the scenario still reads true.

TDD

Developer-facing. Red → green → refactor at the unit level. Tests verify code.

ATDD

Acceptance-Test-Driven. Define acceptance criteria up front. BDD is one popular way to do ATDD.

BDD

Behaviour-facing. Same up-front spirit as TDD, but in business language everyone reads. Tests verify behaviour.

Why it matters

The real deliverable of BDD isn't the .feature files — it's the shared understanding reached while writing them. The files are just the by-product that happens to be executable. A team that writes beautiful Gherkin but never has the conversation has missed the point entirely.

02 — THE TOOL

Meet Cucumber

If BDD is the idea, Cucumber is the engine that makes it run. It reads those plain-English scenarios and executes each line as real test code. It started in Ruby back in 2008 and the idea spread so well that almost every language now has a Cucumber-style runner.

Three pieces work together, and keeping them straight is the key to never being confused by Cucumber again:

The analogy

Think of a restaurant. The customer writes an order in plain English — "medium-rare steak, no onions." A waiter reads it and translates it into precise kitchen instructions. The kitchen cooks. Everyone can read the same ticket, so nothing gets lost. In Cucumber: the feature file is the order ticket, the step definitions are the waiter, and your automation code is the kitchen. At the end you get the bill — a pass/fail report.

The same idea, every ecosystem

You'll meet Cucumber under different names depending on the stack. The Gherkin you write is nearly identical across all of them — only the glue-code language changes.

LanguageCucumber implementationGlue code looks like
JavaCucumber-JVM (+ JUnit/TestNG)Annotated @Given methods
JavaScript / TypeScript@cucumber/cucumber (Cucumber-JS)Given('...', fn)
Pythonbehave / pytest-bdd@given('...') decorators
.NET / C#Reqnroll (formerly SpecFlow)[Given(...)] attributes
Rubycucumber-ruby (the original)Given(/.../) do ... end

For this lesson we'll use the stack most modern SDET teams reach for: Cucumber-JS + Playwright + TypeScript. The concepts transfer one-to-one if you're on Java or any other runner.

terminal
# the three packages that make a Cucumber + Playwright project
npm init -y
npm i -D @cucumber/cucumber @playwright/test typescript ts-node
npx playwright install
03 — EXECUTION

How a test actually runs

This is the part that makes Cucumber click. A sentence a product owner can read is also a sentence a machine can run — and the bridge between the two is the matching layer.

How Cucumber runs one test login.feature Gherkin · plain English 1 · reads each step Step definitions each step ⇒ a function 2 · runs matching glue Automation code Playwright drives app / API 3 · acts & asserts Report ✓ pass · ✗ fail order ticket → waiter → kitchen → the bill
the three layers, top to bottom — the whole job of Cucumber is steps 1 and 2

Walk through what happens when you run npx cucumber-js:

  1. The runner parses every .feature file into a list of scenarios and steps.
  2. For each step, it scans every registered step definition looking for one whose pattern matches the step's text.
  3. It executes that function. The function uses Playwright to act on the page (or call an API) and assert the outcome.
  4. Pass/fail results roll up into a report. A thrown assertion fails the step; a failed step fails the scenario.
Two failure modes to recognise

If no step definition matches a line, Cucumber reports an undefined step and helpfully prints a snippet you can paste in. If two or more definitions match the same line, you get an ambiguous step error. Both are matching problems — not application bugs — and both are extremely common when you're learning.

04 — THE LANGUAGE

Gherkin, in full

Gherkin is a small, structured language — about a dozen keywords. The keywords give the file its shape; everything after a keyword is free text that you write to read like a sentence.

Feature Rule Background Scenario Given When Then And / But Scenario Outline Examples """ Doc String | Data Table @tag

Here's one feature file that exercises nearly all of them. Read it top to bottom — notice you don't need to be a programmer to understand what the cart is supposed to do.

features/cart.feature
# a comment — Cucumber ignores this line
@cart @regression
Feature: Shopping cart
  As a shopper
  I want to manage items in my cart
  So that I can buy what I need

  Background:
    Given the user is logged in as "standard_user"
    And the cart is empty

  @smoke
  Scenario: Add a single item
    When the user adds "Sauce Labs Backpack" to the cart
    Then the cart badge should show 1

  Scenario: Add several items from a table
    When the user adds the following items:
      | item                  | qty |
      | Sauce Labs Backpack   | 1   |
      | Sauce Labs Bike Light | 2   |
    Then the cart badge should show 3

  Scenario Outline: Removing items updates the badge
    Given the cart contains <start> items
    When the user removes <removed> items
    Then the cart badge should show <left>

    Examples:
      | start | removed | left |
      | 3     | 1       | 2    |
      | 2     | 2       | 0    |

  Scenario: Checkout shows a confirmation message
    Given the cart contains 1 item
    When the user completes checkout
    Then the confirmation message should read:
      """
      Thank you for your order!
      Your order has been dispatched.
      """

What each keyword does

KeywordPurpose
FeatureNames the capability. The indented free text below it is a narrative for humans — Cucumber ignores it.
RuleOptional (Gherkin 6+). Groups scenarios that illustrate one business rule.
BackgroundSteps that run before every scenario in the file. Shared setup, written once.
Scenario / ExampleOne concrete, self-contained case of the behaviour.
GivenPreconditions — the state of the world before the action. Past/present tense.
WhenThe single action or event under test. Aim for one per scenario.
ThenThe expected, observable outcome. This is where assertions live.
And / ButContinue the previous Given/When/Then so it reads naturally instead of repeating the keyword.
Scenario OutlineA template scenario, run once per row of its Examples table.
ExamplesThe data table that feeds a Scenario Outline. Column names match the <placeholders>.
""" Doc StringA multi-line block of text passed to a step as one argument.
| Data TableTabular data passed to a step as one argument.
@tagA label on a Feature or Scenario, used to group and filter at run time.
The Given-When-Then rhythm

Read it as: Given some context, When something happens, Then I expect a result. Keep When to a single action and keep Then to observable outcomes a user could see. If a Then step is checking a database row nobody can see in the UI, ask whether it belongs in the scenario at all.

The last two scenarios show step arguments — extra data attached to a single step. A Data Table (the items grid) arrives in your step definition as rows you can loop over, and a Doc String (the triple-quoted block) arrives as one multi-line string. You'll see how to read both in the next sections.

05 — END TO END

A real login, wired up

Let's take one scenario from a plain sentence all the way to a running Playwright test against a live demo site — the-internet.herokuapp.com. Two files: the feature, and the glue.

1. The feature file — what

features/login.feature
Feature: Secure area login
  As a registered user
  I want to log in with valid credentials
  So that I can reach the secure area

  Background:
    Given the user is on the login page

  @smoke
  Scenario: Successful login
    When the user logs in as "tomsmith" with password "SuperSecretPassword!"
    Then the secure area should be displayed
    And the message "You logged into a secure area!" should be shown

  @regression
  Scenario Outline: Login is rejected for bad credentials
    When the user logs in as "<username>" with password "<password>"
    Then the error "<message>" should be shown

    Examples:
      | username | password             | message                   |
      | wrong    | SuperSecretPassword! | Your username is invalid! |
      | tomsmith | wrong                | Your password is invalid! |

2. The step definitions — how

features/steps/login.steps.ts
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from '@playwright/test';

Given('the user is on the login page', async function () {
  await this.page.goto('https://the-internet.herokuapp.com/login');
});

When('the user logs in as {string} with password {string}',
  async function (username: string, password: string) {
    await this.page.fill('#username', username);
    await this.page.fill('#password', password);
    await this.page.click('button[type="submit"]');
  });

Then('the secure area should be displayed', async function () {
  await expect(this.page).toHaveURL(/secure/);
});

Then('the message {string} should be shown', async function (text: string) {
  await expect(this.page.locator('#flash')).toContainText(text);
});

Then('the error {string} should be shown', async function (text: string) {
  await expect(this.page.locator('#flash')).toContainText(text);
});

Two things are doing a lot of work here. First, the {string} placeholder captures each quoted value and passes it in as an argument — which is exactly why one step definition serves both rows of the Examples table. Write it once, run it across all the data. Second, this.page is the live Playwright page. It isn't imported — it lives on Cucumber's World object, set up in hooks, which is the next thing we'll unpack.

Run just this feature

Once the World and config are in place (sections 7 and 9), you'd run the whole thing with npx cucumber-js, or only the happy path with npx cucumber-js --tags @smoke. The Scenario Outline alone produces two reported tests — one per Examples row.

06 — MATCHING

How steps match — and capture values

A step definition's pattern decides which Gherkin lines it handles. Cucumber-JS gives you two ways to write that pattern: Cucumber Expressions (readable, the default) and full regular expressions (maximum control).

Cucumber Expressions

These read almost like the sentence itself. The magic is the {...} tokens — they both match part of the text and capture it as a typed argument handed to your function.

TokenMatchesPassed to your function as
{string}Text inside single or double quotesstring
{int}An integer like 42number
{float}A decimal like 9.99number
{word}A single word, no spacesstring
{}Anything (anonymous capture)string
(text)Optional text — item(s) matches both— (not captured)
a/bAlternative words — logged in/out— (not captured)
expressions — readable patterns
// captures a number — note the {int}, and Then receives it as a number
Then('the cart badge should show {int}', async function (count: number) {
  await expect(this.page.locator('.cart-badge')).toHaveText(String(count));
});

// optional 's' and a captured word, all in one readable line
Given('I have {int} item(s) of type {word}', async function (n: number, type: string) {
  // matches "I have 1 item of type book" AND "I have 3 items of type pen"
});

Regular expressions, when you need them

Wrap the pattern in slashes and Cucumber treats it as a regex — capture groups become arguments. Reach for this only when an expression can't express the match you need.

regex — full control
When(/^the user adds "(.+)" to the cart$/, async function (item: string) {
  await this.page.getByRole('button', { name: `Add ${item}` }).click();
});

Custom parameter types

When a concept repeats across your suite — a priority, a currency, a user role — register it once as a parameter type. Now {priority} works in any step, and you get a properly typed, validated value.

features/support/parameters.ts
import { defineParameterType } from '@cucumber/cucumber';

defineParameterType({
  name: 'priority',
  regexp: /low|medium|high/,
  transformer: (s) => s as 'low' | 'medium' | 'high',
});

// now usable anywhere:
// When('I create a {priority} bug', async function (priority) { ... })
//   matches: When I create a high bug

Reading Data Tables and Doc Strings

Remember the cart table and the checkout message from section 4? Their step definitions take an extra final argument — a DataTable object or a plain multi-line string.

features/steps/cart.steps.ts
import { When, Then, DataTable } from '@cucumber/cucumber';
import { expect } from '@playwright/test';

When('the user adds the following items:', async function (table: DataTable) {
  // .hashes() → [{ item: 'Sauce Labs Backpack', qty: '1' }, ...]
  for (const row of table.hashes()) {
    const qty = Number(row.qty);
    for (let i = 0; i < qty; i++) {
      await this.page.getByRole('button', { name: `Add ${row.item}` }).click();
    }
  }
});

Then('the confirmation message should read:', async function (docString: string) {
  await expect(this.page.locator('#message')).toContainText(docString.trim());
});
07 — STATE & SETUP

Hooks & the World object

A step definition is just a loose function — so how does this.page get there, and how does one scenario avoid polluting the next? The answer is two of Cucumber's most important concepts: the World and hooks.

The World is the this inside every step. Cucumber creates a brand-new World instance for every single scenario, which is what keeps scenarios isolated — the browser, page, API context, and test data you hang on this in one scenario can't leak into another. Hooks are functions that run at fixed points around your scenarios, and they're where you build up and tear down that World.

A scenario's lifecycle ↺ repeats for each scenario BeforeAll once Before fresh page Background shared Given Steps G · W · T After cleanup AfterAll once this = the World object one fresh instance per scenario → holds page, context, data
BeforeAll / AfterAll wrap the whole run · Before / After wrap each scenario

Defining a custom World

Subclass World so TypeScript knows what lives on this, then register it once.

features/support/world.ts
import { setWorldConstructor, World, IWorldOptions } from '@cucumber/cucumber';
import { Browser, BrowserContext, Page } from '@playwright/test';

export class CustomWorld extends World {
  browser!: Browser;
  context!: BrowserContext;
  page!: Page;

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

setWorldConstructor(CustomWorld);

The hooks that fill it

Launch the browser once for the whole run, but give every scenario its own fresh context and page — that's the isolation that stops one test's cookies or state from breaking the next.

features/support/hooks.ts
import { BeforeAll, Before, After, AfterAll, setDefaultTimeout } from '@cucumber/cucumber';
import { chromium, Browser } from '@playwright/test';
import { CustomWorld } from './world';

setDefaultTimeout(30 * 1000); // 30s per step

let browser: Browser;

BeforeAll(async function () {
  browser = await chromium.launch({ headless: true });
});

Before(async function (this: CustomWorld) {
  this.context = await browser.newContext();
  this.page = await this.context.newPage(); // ← this is the this.page your steps use
});

After(async function (this: CustomWorld) {
  await this.page?.close();
  await this.context?.close();
});

AfterAll(async function () {
  await browser?.close();
});
Tagged hooks

Hooks can be scoped to tags, so setup only runs where it's needed — e.g. Before({ tags: '@mobile' }, async function () { /* set a phone viewport */ }) runs only before scenarios tagged @mobile. There are also BeforeStep / AfterStep hooks if you need to act around individual steps (handy for screenshots on failure).

08 — DATA-DRIVEN

One scenario, many rows of data

Most real testing is the same flow with different inputs. A Scenario Outline is the template; its Examples table is the data. Cucumber runs the outline once per row, substituting each <placeholder> — and each row becomes its own reported test.

One Scenario Outline → many runs Scenario Outline: login When login as <user> / <pass> Then see <result> Examples: user pass result bob x123 success bob bad error ' ' x123 error Run 1 bob · x123 → success ✓ Run 2 bob · bad → error ✗ Run 3 ' ' · x123 → error ✗
three data rows · three independent tests · each with its own fresh World

You can also split data into multiple Examples blocks and tag them — useful for separating happy paths from edge cases so you can run them independently.

features/login.feature
  Scenario Outline: Login outcomes
    When the user logs in as "<username>" with password "<password>"
    Then the page should show "<result>"

    @valid
    Examples: Accepted
      | username | password             | result      |
      | tomsmith | SuperSecretPassword! | Secure Area |

    @invalid
    Examples: Rejected
      | username | password | result                    |
      | wrong    | nope     | Your username is invalid! |
      | tomsmith | nope     | Your password is invalid! |
Why this beats a for-loop in code

Each row runs as a separate, isolated test with its own fresh World and page — so a failure on row 2 never stops row 3, and the report shows exactly which inputs passed and which failed. The data also stays readable to the business, right next to the behaviour it's testing.

09 — PROJECT LAYOUT

How a project is organised

A Cucumber-JS project has a predictable shape. Three folders do the work — one holds the what, one holds the how, one holds the plumbing — plus a config file that ties them together.

project structure
my-bdd-suite/
├─ features/
│  ├─ login.feature          # the scenarios — Gherkin (the WHAT)
│  ├─ cart.feature
│  ├─ steps/                 # step definitions — the glue (the HOW)
│  │  ├─ login.steps.ts
│  │  └─ cart.steps.ts
│  └─ support/               # World, hooks, params (the PLUMBING)
│     ├─ world.ts
│     ├─ hooks.ts
│     └─ parameters.ts
├─ cucumber.js               # Cucumber config + named profiles
├─ tsconfig.json
└─ package.json

features/

Your .feature files. Plain Gherkin. The part the whole team can read.

steps/

The step definitions that map each Gherkin line to Playwright actions.

support/

World class, hooks, custom parameter types — the wiring Cucumber loads before any test.

The config — cucumber.js

This file tells Cucumber where to find everything, how to compile TypeScript on the fly, and which reports to emit. Profiles let you save named run configurations — here a smoke profile that only runs @smoke scenarios.

cucumber.js
module.exports = {
  default: {
    requireModule: ['ts-node/register'],                 // run TypeScript directly
    require: ['features/support/**/*.ts', 'features/steps/**/*.ts'],
    paths: ['features/**/*.feature'],
    format: [
      'progress-bar',                                    // live console output
      'html:reports/cucumber.html',                      // shareable HTML report
      'json:reports/cucumber.json',                      // machine-readable results
    ],
    formatOptions: { snippetInterface: 'async-await' },
    publishQuiet: true,
  },
  smoke: {
    requireModule: ['ts-node/register'],
    require: ['features/support/**/*.ts', 'features/steps/**/*.ts'],
    paths: ['features/**/*.feature'],
    tags: '@smoke',                                       // only the smoke set
  },
};

The npm scripts — package.json

package.json
{
  "scripts": {
    "test": "cucumber-js",
    "test:smoke": "cucumber-js --profile smoke",
    "test:headed": "HEADLESS=false cucumber-js",
    "report": "open reports/cucumber.html"
  }
}
10 — RUN · TAG · REPORT

Running, filtering, and reporting

The same suite serves many jobs — a 30-second smoke check on every commit, a full nightly regression, a quick re-run of just the flaky cases. Tags and a handful of CLI flags are how you slice it.

The commands you'll actually use

terminal
# run everything
npx cucumber-js

# run one feature, or one scenario by line number
npx cucumber-js features/login.feature
npx cucumber-js features/login.feature:12

# filter by tags — boolean logic is supported
npx cucumber-js --tags '@smoke'
npx cucumber-js --tags '@regression and not @wip'

# use a saved profile from cucumber.js
npx cucumber-js --profile smoke

# check the glue WITHOUT launching the app — finds undefined/ambiguous steps
npx cucumber-js --dry-run

# stop on the first failure
npx cucumber-js --fail-fast

# auto-retry flaky scenarios up to 2 times
npx cucumber-js --retry 2 --retry-tag-filter '@flaky'

# run scenarios in parallel across 4 workers
npx cucumber-js --parallel 4

Tag expressions

Tags are just labels, but combined with and / or / not they become a precise filter language. This is the single most useful thing to master for CI.

ExpressionRuns
@smokeOnly scenarios tagged @smoke
not @wipEverything except work-in-progress
@smoke and @loginScenarios tagged with both
@smoke or @regressionScenarios tagged with either
@regression and not @slowRegression set, minus the slow ones

Reports

The format flags in your config decide the output. The three you saw earlier cover most needs: a live progress bar in the console, a shareable HTML report at reports/cucumber.html, and a machine-readable JSON file at reports/cucumber.json. Open the HTML to share results with the team; feed the JSON into a richer dashboard like cucumber-html-reporter or Allure when you want trends and history.

Two flags worth a CI habit

Put --dry-run as a fast early step in CI — it fails in seconds if any step is undefined, long before you spend minutes spinning up browsers. And because every scenario has its own isolated World, --parallel is safe to switch on the moment your suite gets slow.

11 — BEYOND THE UI

API testing with Cucumber

BDD isn't only for browsers. API behaviour is arguably cleaner to express in Gherkin — there's no UI noise, just inputs and expected responses. Playwright ships an APIRequestContext, so you can drive HTTP straight from your step definitions. We'll hit reqres.in.

First, give the World an API context instead of a browser page. A tagged hook sets it up only for @api scenarios — no browser launched, so these tests are fast.

features/support/api.hooks.ts
import { Before, After } from '@cucumber/cucumber';
import { request } from '@playwright/test';
// extend CustomWorld with:  api!: APIRequestContext;  response!: APIResponse;

Before({ tags: '@api' }, async function () {
  this.api = await request.newContext({ baseURL: 'https://reqres.in' });
});

After({ tags: '@api' }, async function () {
  await this.api?.dispose();
});

The feature — pure behaviour

features/api/users.feature
@api
Feature: Users API
  As a developer
  I want the users endpoint to behave correctly
  So that clients can rely on it

  Scenario: Fetch a single user
    When I GET "/api/users/2"
    Then the response status should be 200
    And the JSON field "data.id" should equal "2"

  Scenario: Create a user
    When I POST "/api/users" with body:
      """
      { "name": "neo", "job": "the one" }
      """
    Then the response status should be 201
    And the JSON field "name" should equal "neo"

The steps — HTTP + assertions

features/steps/users.steps.ts
import { When, Then } from '@cucumber/cucumber';
import { expect } from '@playwright/test';

When('I GET {string}', async function (path: string) {
  this.response = await this.api.get(path);
});

When('I POST {string} with body:', async function (path: string, body: string) {
  this.response = await this.api.post(path, { data: JSON.parse(body) });
});

Then('the response status should be {int}', async function (status: number) {
  expect(this.response.status()).toBe(status);
});

Then('the JSON field {string} should equal {string}',
  async function (path: string, expected: string) {
    const json = await this.response.json();
    const actual = path.split('.').reduce((o: any, k: string) => o?.[k], json);
    expect(String(actual)).toBe(expected);
  });
Same grammar, different target

Notice nothing about the Gherkin changed — it's still Given/When/Then. Only the glue swapped a browser for an HTTP client. That's the payoff of BDD: the language of behaviour stays constant whether you're testing a login button or a JSON endpoint, so the whole team reads UI and API specs the same way.

12 — DOING IT WELL

Best practice & anti-patterns

Cucumber rewards discipline and punishes shortcuts. Almost every "Cucumber is painful" story traces back to one root cause: scenarios written as click-by-click scripts instead of statements of behaviour. Fix that and most other problems disappear.

Declarative, not imperative

This is the whole game. An imperative scenario spells out every keystroke and selector. A declarative scenario states intent and lets the step definitions hide the mechanics.

❌ imperative — brittle
Scenario: Login
  Given I open "/login"
  When I type "tomsmith" into "#username"
  And I type "SecretPass!" into "#password"
  And I click "button[type=submit]"
  Then I should see ".flash.success"
✅ declarative — durable
Scenario: Login
  Given the user is on the login page
  When the user logs in with valid credentials
  Then the secure area is displayed
ImperativeDeclarative
Reads likea click-by-click scripta behaviour
Containsselectors, URLs, keystrokesintent only
Breaks whenthe markup changesthe behaviour changes
Audienceonly the script's authorthe whole team

The habits that keep a suite healthy

Anti-patterns to avoid

Conjunction overload — a scenario that's a wall of And I click… And I type… (that's a script, not a spec). Leaky selectors — CSS or XPath living in feature files. Shared state — scenarios that pass data to each other and must run in order. Mega-scenarios — one scenario trying to test an entire user journey. Testing everything through the UI — when an API check would be faster and steadier.

13 — THE HONEST COMPARISON

Cucumber BDD vs plain Playwright tests

Cucumber isn't automatically "better" than a plain Playwright test — it's a different trade. It adds a translation layer (Gherkin + glue) and buys you a shared language in return. Whether that trade is worth it depends entirely on who reads your tests.

Here's the exact same login test, both ways:

plain Playwright
import { test, expect } from '@playwright/test';

test('successful login', async ({ page }) => {
  await page.goto('.../login');
  await page.fill('#username', 'tomsmith');
  await page.fill('#password', 'SecretPass!');
  await page.click('button[type="submit"]');
  await expect(page).toHaveURL(/secure/);
});
Cucumber + Playwright
@smoke
Scenario: Successful login
  Given the user is on the login page
  When the user logs in with valid
    credentials
  Then the secure area is displayed
# + step definitions in a .ts file

The plain version is shorter and lives entirely in TypeScript. The Cucumber version is longer and split across files — but a product owner can read the right-hand side and a developer can't argue about what "valid credentials" means.

Plain Playwright TestCucumber + Playwright
Written inTypeScript onlyGherkin + TypeScript glue
Who can read itEngineersThe whole team
Setup overheadMinimalfeatures / steps / support layers
Living documentationNoYes — the specs are the docs
Data-drivenParametrised loops in codeScenario Outline + Examples
Reuse modelFixtures & helpersStep library + World + hooks
Best forEngineer-owned regression, raw speedCross-functional specs, collaboration
The deciding question

It isn't a technical one. Ask: "will anyone outside engineering ever read or write these tests?" If yes — product owners, BAs, manual QA who genuinely engage with the specs — Cucumber's Gherkin layer earns its keep as living documentation. If no, and tests are an engineering-only concern, that same layer is pure overhead, and a plain Playwright suite will be faster to build and maintain.

14 — QUICK REFERENCE

Cheat sheet

Everything in one place to pin next to your editor.

Gherkin keywords

Feature Rule Background Scenario Scenario Outline + Examples Given / When / Then And / But """ Doc String | Data Table @tag # comment

Parameter types

{string} → string {int} → number {float} → number {word} → string {} → anything (s) optional a/b alternative

Commands

cucumber-js essentials
npx cucumber-js                          # run all
npx cucumber-js features/x.feature:12    # one scenario by line
npx cucumber-js --tags '@smoke and not @wip'
npx cucumber-js --profile smoke          # saved config
npx cucumber-js --dry-run                # find undefined steps
npx cucumber-js --fail-fast              # stop on first failure
npx cucumber-js --retry 2                # retry flaky scenarios
npx cucumber-js --parallel 4             # parallel workers

Step definition skeleton

steps/*.steps.ts
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from '@playwright/test';

Given('a precondition', async function () { /* set state on this */ });
When('an action {string}', async function (arg: string) { /* do it */ });
Then('an outcome {int}', async function (n: number)  { /* assert */ });
Remember the one rule

Write scenarios as behaviour, not scripts. Given context · When one action · Then an observable outcome. Keep selectors in the glue, keep intent in the Gherkin — and Cucumber stays a joy instead of a chore.