Write tests that read like the requirements your product manager just emailed you. This track wires
Cucumber-JS on top of Playwright with the Page Object Model, so the
Given / When / Then story stays close to business intent and the step implementations
stay close to clean Playwright code. Three lectures, one ready-to-clone framework, every example
wired against our TTACart sandbox.
Behaviour-Driven Development is the practice of writing tests as conversations
between a tester, a developer, and a business analyst. The artefact those three people
agree on is a .feature file written in Gherkin:
Given (the state before the action), When (the action), and
Then (the expected outcome). It is not a clever way to write JavaScript - it is
a clever way to make a single document that is readable by everyone in the room.
The BDD pyramid below shows the three audiences. The top says "this is what the system should
do" in plain English. The middle says "this is how each English sentence wires to runnable code".
The base says "this is the Page Object that touches the browser".
The BDD pyramid - one feature file at the top fans out into many step definitions and page objects below.
BDD is not the same as "writing tests". You can write traditional Playwright tests and call them
behaviour-driven if the test names match user stories, but the moment a product manager wants to
edit a test without help, you need the Gherkin syntax. The whole point of Cucumber is that the
highest-value-readers can change the test, not just the engineers.
What you get when you combine BDD + Playwright
Business-readable specs. A non-engineer can open login.feature and tell
whether the rule matches the requirement.
Single source of truth. The same file is the requirement, the test, and the
acceptance criterion.
Playwright's power underneath. Auto-waiting, network interception, traces, web-first
assertions - none of it is lost. Cucumber is a thin orchestration layer; Playwright still
drives the browser.
Living documentation. The HTML report Cucumber emits doubles as the regression report.
If a step is red, the story is red.
02Cucumber-JS + Playwright in one sentence
Cucumber-JS reads .feature files, matches each English line to a JavaScript
function (a step definition), and runs them. Those JavaScript functions drive
Playwright. The Page Object Model wraps Playwright into per-page classes
(LoginPage, InventoryPage) so the step definitions stay one-liners.
flowchart LR
Feature[login.feature
Given I am on the login page
When I sign in as standard_user
Then I should see the products page]
Steps[loginSteps.ts
Given/When/Then arrow functions]
POM[LoginPage.ts
page.getByTestId('username').fill(...)]
PW[Playwright runtime
Browser - DOM]
Feature -->|Cucumber matches text| Steps
Steps -->|Step calls method| POM
POM -->|POM calls Playwright API| PW
PW -->|Result + screenshot| POM
POM -->|Returns to step| Steps
Steps -->|Pass / fail| Feature
Gherkin -> Step definition -> Page Object -> Playwright - the four hops every BDD scenario takes.
Why not just plain Playwright Test?
Cucumber adds overhead. You have an extra layer to maintain. You have to keep the Gherkin in
sync with the steps. You pay that cost when your team includes non-engineers who need to read
and edit the spec, or when the test suite is the acceptance criterion the business signs off on.
For pure engineer-only teams, plain Playwright Test is usually a better fit. For mixed teams or
SDET interviews where a "real-world BDD framework" question is expected, Cucumber + Playwright +
POM is the canonical answer.
Each lecture is a self-contained page with a live walkthrough, ~5 mermaid diagrams, ~3 hand-styled
SVGs and 8 drill exercises. Plan to spend 60-90 minutes per lecture, including the drills.
01
Setup + POM walkthrough
Project scaffold, folder structure, your first .feature file, step
definitions, custom World, hooks, and a Page Object Model for the TTACart login page.
First green run on npx cucumber-js.
Scenario Outlines, Examples tables, type coercion in step parameters,
@tags, tag expressions, multi-feature parallel runs, and a
products-sort data-driven scenario.
dotenv for .env.dev / .env.staging / .env.prod, npm scripts per tag
and per environment, failure screenshots, HTML reports, and a GitHub Actions matrix that
fans out across environments.
Every code sample in this track lines up with the cucumber-bdd-pom branch of the
Advance Playwright Framework. Clone it once, keep it open in a second editor window as you read
the lectures.
A reference project that pairs Cucumber-JS, Playwright, TypeScript, dotenv, and an HTML
reporter. Step definitions live next to the features; pages live in pages/.
The Cucumber-JS runtime owns the lifecycle. It reads features, finds matching step definitions,
calls hooks around each scenario, and writes the report at the end. Your
World object carries shared state between steps - usually the Playwright
Page, the Browser, and a BrowserContext.
flowchart TB
subgraph "Cucumber-JS lifecycle"
A[Read cucumber.cjs] --> B[Load features]
B --> C[For each Scenario]
C --> D[Before hooks]
D --> E[Run each step]
E --> F[After hooks - screenshot on fail]
F --> G[Next scenario]
end
E -->|step body| W[World instance]
W -->|page, context| POM[Page Object class]
POM -->|locator + action| PW[Playwright]
Lifecycle - Cucumber drives, World carries state, Page Object talks to Playwright.The five layers of a BDD + Playwright + POM framework. Each row uses only the row below it.
Mental model. The feature file is a contract. The step file is the translator.
The POM is the receptionist who knows the building. Playwright is the courier who actually
walks through the door.
06When to reach for BDD
Mixed team. Your PM, BA, or QA lead wants to read or edit the spec without
JavaScript knowledge.
Acceptance criteria as tests. The story's "Definition of Done" is the green
scenario.
Living documentation needed. Regulated industry, audit, or onboarding doc all
read from the same source.
Interview / portfolio piece. A clean Cucumber + Playwright + POM repo is the
fastest way to demonstrate framework design at an SDET interview.
When not to reach for it: a small engineering team that only owns the spec, a project
where the spec is more easily expressed in test code than in English, or anywhere the extra
Gherkin layer is pure overhead.
PTry this before the lectures
Two quick warm-ups to make sure the rest of the track lands.
Read a feature file aloud. Open
features/login.feature from the cloned repo and read each scenario out
loud. Tag the words your PM would understand vs. the words only an engineer would. There
should be zero engineer-only words above the Given line.
Match steps to code. Open step-definitions/login.steps.ts next to
the feature. For every Gherkin line, find the regex / Cucumber expression that matches it.
Note which steps share the same English wording - those are step reuses.
Spot the World. Find support/world.ts. List every property it holds
(Page, Context, env, screenshots). Then guess which step puts each property there.
Predict a failure. Comment out the this.page = await this.browser.newPage()
line in the World. Predict what error Cucumber will print on the next run. Then run it.
Add a step. In login.feature, add
And I see the cart icon as a new step. Run Cucumber - it should print a
snippet for the missing step. Paste that snippet into the step file and finish it.
Toggle the tag. Add @smoke above one scenario and run
npx cucumber-js --tags @smoke. Verify only that scenario runs.
Force a fail. Change the expected products-page heading text in the assertion.
Run the suite, then read the HTML report - find the step, the line, and the screenshot.
Sketch the loop. On paper, draw the perceive-step-act loop Cucumber takes when
running one scenario. Mark which arrows are sync, which are async (Playwright auto-waits).
SReference snippets for this hub
The full multi-file Page Object Model + step definitions live in the cloned repo. Below is the
skeleton of a single login scenario in three flavours. Each snippet is fewer than 30 lines.
features/login.feature
Feature: TTACart login
@smoke
Scenario: Standard user signs in
Given I am on the TTACart login page
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, 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');
});
src/test/resources/features/login.feature
Feature: TTACart login
@smoke
Scenario: Standard user signs in
Given I am on the TTACart login page
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
import io.cucumber.java.en.*;
import com.microsoft.playwright.*;
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 user, String pw) { world.loginPage.signIn(user, pw); }
@Then("I should land on the products page")
public void assertProducts() {
assertThat(world.page.getByTestId("title")).hasText("Products");
}
}
features/login.feature
Feature: TTACart login
@smoke
Scenario: Standard user signs in
Given I am on the TTACart login page
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 open_login(context):
context.login_page.goto()
@when('I sign in as "{user}" with password "{pw}"')
def sign_in(context, user, pw):
context.login_page.sign_in(user, pw)
@then('I should land on the products page')
def assert_products(context):
expect(context.page.get_by_test_id('title')).to_have_text('Products')