Lecture 1
BDD Lecture 1 . Setup + POM walkthrough

Cucumber + Playwright setup and first green run

We will scaffold the project, walk every folder, write a real login.feature, wire Given / When / Then step definitions, build a tiny CustomWorld, add Before / After hooks, and finish with a LoginPage POM. The whole thing targets TTACart at http://localhost:5173/playwright/ttacart/index.html.

01Why Cucumber + Playwright

Plain Playwright Test is fantastic for engineer-owned suites. The moment a non-engineer wants to read or edit the spec - or the acceptance criteria sign-off should map one-to-one to a passing run - you want the natural-language layer that Cucumber gives you. The trade-off is one extra layer; the benefit is that the spec, the test, and the requirement become the same file.

Playwright still does all the heavy lifting underneath: auto-waiting, web-first assertions, traces, network interception, parallel workers. Cucumber is the orchestrator. The Gherkin file is the contract; the step definitions are the translator; Playwright is the engine.

flowchart LR
  PM[Product manager writes story] --> FEAT[login.feature]
  FEAT -->|Cucumber-JS| STEP[Step definitions]
  STEP --> POM[Page Objects]
  POM -->|Playwright| BROWSER[Chromium - Firefox - WebKit]
  BROWSER --> RES[HTML report]
From PM story to HTML report - every box owned by a different role.
FeaturePlain Playwright TestCucumber + Playwright
Spec languageTypeScript / JavaScriptGherkin (English)
PM-editableNoYes
Setup overheadLowMedium
Reuse of stepsVia helper functionsNative via regex / Cucumber expressions
Living documentationJSDoc + reportThe feature file itself

02Install Playwright and Cucumber

Start clean. Make a folder, run npm init -y, then add the two big dependencies. We use TypeScript so the step definitions are typed.

Terminal
mkdir tta-cucumber-pw && cd tta-cucumber-pw
npm init -y

# Playwright
npm init playwright@latest -- --quiet --lang=ts --browser=chromium

# Cucumber + TypeScript runtime
npm i -D @cucumber/cucumber ts-node typescript @types/node
npm i -D cucumber-html-reporter dotenv

The Playwright init drops playwright.config.ts, a tests/ folder, and the browsers. We will not use the tests/ folder - Cucumber-JS owns the runner. We add a parallel features/ tree instead.

tsconfig + cucumber config

Cucumber runs off a config file - in CommonJS form so older versions of Node load it without friction. Drop the file in the repo root:

cucumber.cjs
module.exports = {
  default: {
    requireModule: ['ts-node/register'],
    require: ['support/**/*.ts', 'step-definitions/**/*.ts'],
    paths: ['features/**/*.feature'],
    format: [
      'progress-bar',
      'json:reports/cucumber.json',
      'html:reports/cucumber.html'
    ],
    formatOptions: { snippetInterface: 'async-await' },
    publishQuiet: true,
    parallel: 0
  }
};
Why CJS? Cucumber-JS reads cucumber.cjs reliably even when the rest of the project is ESM. Avoids the type: "module" + __dirname ping-pong.

03Folder structure that scales

Four top-level folders. Each one owns one job. Resist the urge to mix them - the moment your steps know about Playwright locators, the POM stops earning its keep.

tta-cucumber-pw/ +- features/ # Gherkin .feature files | +- login.feature | +- inventory.feature +- step-definitions/ # Cucumber-JS step glue | +- login.steps.ts | +- inventory.steps.ts +- pages/ # Page Object Model classes | +- LoginPage.ts | +- InventoryPage.ts +- support/ # World + hooks + env loader | +- world.ts | +- hooks.ts | +- env.ts +- cucumber.cjs +- playwright.config.ts +- package.json +- tsconfig.json
graph TB
  R[Project root] --> F[features/]
  R --> S[step-definitions/]
  R --> P[pages/]
  R --> U[support/]
  F -.matches.-> S
  S -.calls.-> P
  P -.wraps.-> PW[Playwright APIs]
  U -.wires.-> S
  U -.wires.-> P
The four folders and how they depend on each other - features at the top, Playwright at the bottom.
tta-cucumber-pw/ features/ PM + BA + QA write Gherkin here step-definitions/ Glue functions that match the Gherkin pages/ POM classes - LoginPage, InventoryPage support/ CustomWorld + hooks + env Four folders, four jobs. No mixing.
Lecture-board sketch of the framework folders.

04Feature file syntax

A .feature file has one Feature: at the top and one or more Scenario: blocks. Each scenario is a chain of Given / When / Then (optionally And / But) clauses. Indentation is two spaces, no braces, no semicolons.

features/login.feature
Feature: TTACart login
  As a customer
  I want to sign in to TTACart
  So that I can see the products page

  Background:
    Given I am on the TTACart login page

  @smoke
  Scenario: Standard user can sign in
    When I sign in as "standard_user" with password "tta_secret"
    Then I should land on the products page
    And I should see "Products" as the page title

  @regression
  Scenario: Locked-out user is rejected
    When I sign in as "locked_out_user" with password "tta_secret"
    Then I should see a login error containing "Sorry, this user has been locked"
    And I should still be on the login page

The five keywords you need today

KeywordMeaningConvention
GivenState / context the scenario starts inPast or present tense
WhenAction the user takesActive verb
ThenObservable outcome"I should see..." or "I should be..."
AndContinuation of the previous step kindOnly use after the same keyword
ButContinuation in a negative directionUsed sparingly
Gherkin in one picture Feature: - one line describing the system feature Scenario: - one user journey through the feature Given - starting state When - the action Then - the observable outcome (assert!) And / But continue the previous keyword
Hand-styled Gherkin cheat card. Print this and pin it to the team board.
Background. A Background: block runs before every scenario in the file. Use it for the "Given I am on the login page" line you would otherwise repeat. Don't put assertions in a Background - that masks failures.

05Step definitions

Each English line in the feature is matched by a function in a .ts file inside step-definitions/. Cucumber-JS uses Cucumber expressions ({string}, {int}, {float}, {word}) - friendlier than regex.

step-definitions/login.steps.ts
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from '@playwright/test';
import { CustomWorld } from '../support/world';

Given('I am on the TTACart login page', async function (this: CustomWorld) {
  await this.loginPage.goto();
});

When('I sign in as {string} with password {string}',
  async function (this: CustomWorld, user: string, pw: string) {
    await this.loginPage.signIn(user, pw);
  }
);

Then('I should land on the products page', async function (this: CustomWorld) {
  await expect(this.page.getByTestId('title')).toHaveText('Products');
});

Then('I should see {string} as the page title', async function (this: CustomWorld, text: string) {
  await expect(this.page.getByTestId('title')).toContainText(text);
});

Then('I should see a login error containing {string}',
  async function (this: CustomWorld, fragment: string) {
    await expect(this.page.getByTestId('error')).toContainText(fragment);
  }
);

Then('I should still be on the login page', async function (this: CustomWorld) {
  await expect(this.page).toHaveURL(/index\.html$/);
});

Step matching - how Cucumber finds the right function

sequenceDiagram
  participant F as Feature file
  participant C as Cucumber-JS
  participant R as Step registry
  participant S as Step function
  F->>C: When I sign in as "standard_user" with password "tta_secret"
  C->>R: Find pattern matching this text
  R-->>C: Match: When('I sign in as {string} with password {string}', fn)
  C->>S: Invoke fn with args ['standard_user', 'tta_secret']
  S-->>C: Promise resolved
  C-->>F: Step pass
Step matching trip - text in, function out, args extracted.

Cucumber expressions you will use this week

  • {string} - any single- or double-quoted string
  • {int} - integer (typed as number in the handler)
  • {float} - decimal
  • {word} - bare word, no spaces
  • {} - anything (avoid; lose type info)
The "this:" type. Step bodies use the function keyword (not arrow functions) so this binds to the World instance. Always type it: function (this: CustomWorld, ...).

06Custom World - the shared state object

Each scenario gets its own World instance. Cucumber instantiates one before the scenario starts and discards it after. The World is where you put the Playwright Page, the BrowserContext, any test data, screenshot helpers, etc.

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

export interface CustomWorldOptions extends IWorldOptions {}

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

  loginPage!: LoginPage;
  inventoryPage!: InventoryPage;

  baseUrl: string = process.env.BASE_URL || 'http://localhost:5173/playwright/ttacart/index.html';

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

  attachPageObjects() {
    this.loginPage = new LoginPage(this.page, this.baseUrl);
    this.inventoryPage = new InventoryPage(this.page);
  }
}

setWorldConstructor(CustomWorld);
stateDiagram-v2
  [*] --> New: Cucumber starts scenario
  New --> Configured: Before hook runs
  Configured --> Active: First step executes
  Active --> Active: Subsequent steps reuse World
  Active --> Drained: After hook runs (close page)
  Drained --> [*]: World discarded
World lifecycle - one instance per scenario, recycled by the next.
Watch the !. The TypeScript definite-assignment ! on browser!, context!, and page! says "these will be set in the Before hook, trust me". Without it the compiler errors. If you forget the hook, you get a runtime undefined.goto() instead - and that one is harder to debug.

07Hooks - Before, After, BeforeAll, AfterAll

Hooks are where you launch and close the browser. Cucumber-JS runs BeforeAll once, then Before + steps + After per scenario, then AfterAll at the end. The order is non-negotiable.

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

let sharedBrowser: Browser;

BeforeAll(async function () {
  sharedBrowser = await chromium.launch({
    headless: process.env.HEADLESS !== 'false'
  });
});

Before(async function (this: CustomWorld, scenario: ITestCaseHookParameter) {
  this.browser = sharedBrowser;
  this.context = await sharedBrowser.newContext({ viewport: { width: 1280, height: 720 } });
  this.page = await this.context.newPage();
  this.attachPageObjects();
});

After(async function (this: CustomWorld, scenario: ITestCaseHookParameter) {
  if (scenario.result?.status === 'FAILED') {
    const buffer = await this.page.screenshot({ fullPage: true });
    this.attach(buffer, 'image/png');
  }
  await this.page.close();
  await this.context.close();
});

AfterAll(async function () {
  await sharedBrowser.close();
});
flowchart TB
  BA[BeforeAll - launch chromium once] --> SC1[Scenario 1]
  SC1 --> B1[Before - new context + page]
  B1 --> S1[Given]
  S1 --> S2[When]
  S2 --> S3[Then]
  S3 --> A1[After - screenshot if failed, close page]
  A1 --> SC2[Scenario 2]
  SC2 --> B2[Before]
  B2 --> dots[...]
  dots --> AA[AfterAll - close browser]
Hook execution order across two scenarios. BeforeAll / AfterAll run once, Before / After run per scenario.
BeforeAll Before Given / When / Then After Before/After loop runs once per scenario AfterAll closes the browser once
Hook order - hand-drawn for the lecture board.

08The LoginPage POM

Page Objects hide the locators. Steps know verbs (signIn, goto); pages know nouns (data-test="username", data-test="login-button"). The LoginPage below maps the TTACart login form one-to-one.

pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly url: string;
  readonly username: Locator;
  readonly password: Locator;
  readonly loginButton: Locator;
  readonly errorBanner: Locator;

  constructor(page: Page, baseUrl: string) {
    this.page = page;
    this.url = baseUrl;
    this.username = page.getByTestId('username');
    this.password = page.getByTestId('password');
    this.loginButton = page.getByTestId('login-button');
    this.errorBanner = page.getByTestId('error');
  }

  async goto() {
    await this.page.goto(this.url);
    await expect(this.loginButton).toBeVisible();
  }

  async signIn(user: string, pw: string) {
    await this.username.fill(user);
    await this.password.fill(pw);
    await this.loginButton.click();
  }

  async expectError(fragment: string) {
    await expect(this.errorBanner).toContainText(fragment);
  }
}
classDiagram
  class BasePage {
    +Page page
    +goto(url) void
  }
  class LoginPage {
    +Locator username
    +Locator password
    +Locator loginButton
    +Locator errorBanner
    +goto() Promise
    +signIn(user, pw) Promise
    +expectError(text) Promise
  }
  class InventoryPage {
    +Locator title
    +Locator sortSelect
    +Locator inventoryGrid
    +goto() Promise
    +addToCart(name) Promise
  }
  BasePage <|-- LoginPage
  BasePage <|-- InventoryPage
POM hierarchy - shared base, one class per page, locators stored as fields.

TTACart locators reference. The login form ships with data-test="username", data-test="password", data-test="login-button", and data-test="error". We use getByTestId(...) because Playwright maps data-testid by default; if your app uses data-test, set the option in playwright.config.ts:

playwright.config.ts (excerpt)
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    testIdAttribute: 'data-test',
    baseURL: process.env.BASE_URL,
    trace: 'retain-on-failure'
  }
});

09First green run

Wire an npm script and let it rip. Cucumber prints a snippet for any undefined step - that snippet is the starting code for the missing function.

package.json (excerpt)
{
  "scripts": {
    "test": "cucumber-js",
    "test:smoke": "cucumber-js --tags @smoke",
    "test:regression": "cucumber-js --tags @regression",
    "report": "node scripts/report.js"
  }
}
Terminal output (snippet)
$ npm test

........

2 scenarios (2 passed)
6 steps (6 passed)
0m04.812s (executing steps: 0m04.730s)
HTML report: reports/cucumber.html
Open the HTML report. open reports/cucumber.html on macOS, start reports/cucumber.html on Windows. The report includes the screenshot you attached in the After hook on any failed scenario.

10Common failures and how to read them

"Undefined step"

Cucumber-JS could not match the Gherkin line to any registered step. Look for typos, smart quotes, or a mismatch between {string} and a literal value.

"Pending"

The step function called return 'pending'. Useful while you scaffold, dangerous if left in. Cucumber colours pending steps yellow and lets the suite "pass" - configure strict: true in cucumber.cjs to fail on pending.

"Target closed"

The step body called page.goto after the After hook closed the page. Move the page-closing logic to the After hook and never call page.close() from a step.

"Cannot find module 'ts-node/register'"

You either forgot npm i -D ts-node or the requireModule array in cucumber.cjs is misspelled. Re-install and re-check.

Smart quotes. Pasted Gherkin from Word or Slack often ships with curly quotes that look identical but don't match {string}. Configure your editor to disable auto-smart-quote.