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.
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:
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.
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.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
Keyword
Meaning
Convention
Given
State / context the scenario starts in
Past or present tense
When
Action the user takes
Active verb
Then
Observable outcome
"I should see..." or "I should be..."
And
Continuation of the previous step kind
Only use after the same keyword
But
Continuation in a negative direction
Used sparingly
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.
Hook execution order across two scenarios. BeforeAll / AfterAll run once, Before / After run per scenario.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.
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:
$ 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.
PDrill exercises - lecture 1
Eight drills. Do them in order; each builds on the last. Open the Solution tab only after you
have a green or you have stared at the failure for at least 5 minutes.
1Scaffold from zero
Create a new folder, run npm init -y, install Cucumber-JS, ts-node and
Playwright. Verify npx cucumber-js --help prints help text.
Hint
Order: npm i -D @cucumber/cucumber ts-node typescript @types/node then
npx playwright install chromium.
2Author your first feature
Write features/smoke.feature with one scenario: open the TTACart login page
and assert the page title reads TTACart - Login.
Hint
Use a single Given I am on the TTACart login page and a single
Then the title should be "TTACart - Login". Wire the step to
await expect(this.page).toHaveTitle('TTACart - Login').
3Wire the World
Create support/world.ts with browser, context,
page properties. Add a method attachPageObjects(). Don't
forget setWorldConstructor.
Hint
Use definite-assignment ! on each field. The Before hook will set them.
4Add Before + After hooks
Launch Chromium in BeforeAll, open a new context + page in Before,
close them in After. Run the smoke scenario - it should pass.
Hint
Keep sharedBrowser at module scope. One browser, many contexts.
5Build the LoginPage POM
Create pages/LoginPage.ts with a goto() and a
signIn(user, pw). Use data-test attributes from TTACart.
Hint
Configure testIdAttribute: 'data-test' in
playwright.config.ts so getByTestId('username') picks up
data-test="username".
6Negative scenario
Add a scenario for locked_out_user that asserts the error banner contains
"Sorry, this user has been locked". Don't peek - write the step yourself.
Hint
Reuse the I sign in as {string} with password {string} step you already
wrote. New Then targets data-test="error".
7Attach a screenshot on failure
In the After hook, screenshot the page and attach it when
scenario.result?.status === 'FAILED'. Force a failure (change expected text)
and look at the HTML report.
Hint
await this.page.screenshot() returns a Buffer. Pass it to
this.attach(buffer, 'image/png').
8Switch to a real-browser headed mode
Add a npm script test:headed that runs Cucumber with
HEADLESS=false. Watch the browser open while you run the scenario.
Hint
cross-env on Windows, or just HEADLESS=false cucumber-js on
macOS/Linux.
SSolution snippets - TS / Java / Python
The complete TypeScript framework lives on the cucumber-bdd-pom branch of the
Advance Playwright Framework. Below is the full skeleton in three languages. Each language
tab carries the feature file, the step definitions, the POM, and the World.
features/login.feature
Feature: TTACart login
Background:
Given I am on the TTACart login page
@smoke
Scenario: Standard user signs in
When I sign in as "standard_user" with password "tta_secret"
Then I should land on the products page
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, u: string, p: string) {
await this.loginPage.signIn(u, p);
});
Then('I should land on the products page', async function (this: CustomWorld) {
await expect(this.page.getByTestId('title')).toHaveText('Products');
});
Feature: TTACart login
Background:
Given I am on the TTACart login page
@smoke
Scenario: Standard user signs in
When I sign in as "standard_user" with password "tta_secret"
Then I should land on the products page
src/test/java/steps/LoginSteps.java
package steps;
import io.cucumber.java.en.*;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
public class LoginSteps {
private final World world;
public LoginSteps(World world) { this.world = world; }
@Given("I am on the TTACart login page")
public void openLogin() { world.loginPage.goto(); }
@When("I sign in as {string} with password {string}")
public void signIn(String u, String p) { world.loginPage.signIn(u, p); }
@Then("I should land on the products page")
public void onProducts() {
assertThat(world.page.getByTestId("title")).hasText("Products");
}
}
src/test/java/pages/LoginPage.java
package pages;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
public class LoginPage {
private final Page page;
private final String url;
private final Locator username, password, loginButton;
public LoginPage(Page page, String url) {
this.page = page; this.url = url;
this.username = page.getByTestId("username");
this.password = page.getByTestId("password");
this.loginButton = page.getByTestId("login-button");
}
public void goto_() { page.navigate(url); }
public void signIn(String u, String p) {
username.fill(u);
password.fill(p);
loginButton.click();
}
}
src/test/java/hooks/World.java + Hooks.java
// World.java - Cucumber-DI scoped picocontainer
package hooks;
import com.microsoft.playwright.*;
import pages.LoginPage;
public class World {
public Playwright playwright;
public Browser browser;
public BrowserContext context;
public Page page;
public LoginPage loginPage;
}
// Hooks.java
package hooks;
import io.cucumber.java.*;
import com.microsoft.playwright.*;
import pages.LoginPage;
public class Hooks {
private final World w;
public Hooks(World w) { this.w = w; }
@Before
public void before() {
w.playwright = Playwright.create();
w.browser = w.playwright.chromium().launch();
w.context = w.browser.newContext();
w.page = w.context.newPage();
w.loginPage = new LoginPage(w.page, System.getenv().getOrDefault("BASE_URL",
"http://localhost:5173/playwright/ttacart/index.html"));
}
@After
public void after(Scenario sc) {
if (sc.isFailed()) sc.attach(w.page.screenshot(), "image/png", "fail");
w.context.close();
w.browser.close();
w.playwright.close();
}
}
features/login.feature
Feature: TTACart login
Background:
Given I am on the TTACart login page
@smoke
Scenario: Standard user signs in
When I sign in as "standard_user" with password "tta_secret"
Then I should land on the products page
features/steps/login_steps.py
from behave import given, when, then
from playwright.sync_api import expect
@given('I am on the TTACart login page')
def step_open_login(context):
context.login_page.goto()
@when('I sign in as "{user}" with password "{pw}"')
def step_sign_in(context, user, pw):
context.login_page.sign_in(user, pw)
@then('I should land on the products page')
def step_on_products(context):
expect(context.page.get_by_test_id('title')).to_have_text('Products')