Day 18: Cucumber BDD in the Advanced Playwright Framework
This is Day 18 of the 21-day JS to Playwright Framework series. One lesson a day. JavaScript first. TypeScript next. Playwright Test. Then the framework. Yesterday the layers. Today a second runner on those layers.
Days 1-7 were the language. Days 8-9 were the object. Days 10-16 opened the browser in the fundamentals repo. Day 17 left that repo for AdvancePlaywrightFramework1x on branch feat-cucumber — config, BasePage, fixtures, the custom TTA reporter. Today that same tree grows a BDD lane. Same LoginPage. Same TTACart. A different executable.
I am Pramod Dutta. I teach SDETs in India for a living. The week I open Cucumber, someone always copies the Gherkin into a Playwright test() and calls it BDD. A sentence in a .feature file is not a test. A step that calls this.loginPage.open() and forgets await is not a Given. A report that only Playwright CI uploads is not coverage of the BDD suite.
This is not the existing 21-Day Playwright with TypeScript Challenge. That series starts later in the stack. This series started at console.log. Day 18 is the day the Advanced Framework speaks Gherkin without growing a second automation stack.
All files in this post come from the public repo AdvancePlaywrightFramework1x on branch feat-cucumber. I fetched cucumber.js, the three level folders, and src/cucumber/support/. I quote those files. I will not invent a Level 03. I will not invent a Cucumber job in GitHub Actions. I will not invent an await that is not on disk.
Classroom names stay. Level 0 steps live in steps/smoke.spec.ts — a .spec.ts filename that Cucumber loads, not Playwright. Level 1 and Level 2 use step/ (singular). Level 1’s Given is missing await. I do not rename folders or patch the step to make this post prettier.
If you want the video plus project path after you finish these 21 posts, the course is here: Playwright Automation Mastery. The series hub for every day lives here: JavaScript to TypeScript to Playwright Advanced Framework 21-Day Guide.
*Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.*

Contents
What you will be able to do after Day 18
By the end of this post you can:
- Say why this framework has two runners —
playwright testandcucumber-js— and why that is not two POMs. - Read
cucumber.jsand point atrequireModule,require,paths, the three formatters, and thelevel0/level1/level2profiles. - Explain why
src/cucumber/tsconfig.jsonforces CommonJS while the rest of the repo is Node16. - Walk
CustomWorld:browser,context,page, six page objects,CREDS,scratch,initPages(). - Walk
hooks.ts: one Chromium inBeforeAll, a fresh context and page inBefore, a PNG on everyAfterStep, an extra PNG on fail. - Run Level 0 from
smoke.feature+smoke.spec.tsand see a three-line happy path that only talks tothis.loginPageandthis.inventoryPage. - Run Level 1 from
login.feature+login.steps.tsand call out the missingawaitonthis.loginPage.open(). - Read Level 2 three ways: Scenario Outline, a DataTable of product ids, and personas loaded from
customers.json. - See that Level 2 has one step file —
checkout.steps.ts— for all three features. - Trace
ttaFormatter.ts->renderExternalRun(...)->tta-report/, and say whyttaFormatter.cjsexists. - Admit that
.github/workflows/playwright.ymlrunsnpx playwright testand does not run Cucumber. - Draw the Day 18 diagram from memory: feature -> steps -> world -> page.
That is the skill. Not the Gherkin keyword. The skill is who owns the locator. The feature never does. The step should not. The page already does — Day 17 built it. Today we glue English to that page.
The files we are actually using
Clone the advanced framework and stay on feat-cucumber. That is the Cucumber-enabled tip, not a guess at main.
git clone https://github.com/PramodDutta/AdvancePlaywrightFramework1x.git
cd AdvancePlaywrightFramework1x
git checkout feat-cucumber
I fetched every path GitHub lists under src/cucumber/ plus the root runner. Those files, as GitHub serves them:
Root runner
cucumber.js—ts-node+tsconfig-paths, feature glob, three formatters, profilesdefault/level0/level1/level2
Cucumber TypeScript
src/cucumber/tsconfig.json— extends the repotsconfig.json, then overridesmoduletoCommonJS
Support (the glue that is not a scenario)
src/cucumber/support/world.ts—CustomWorld,BASE_URL,CREDS,initPages(),setWorldConstructorsrc/cucumber/support/hooks.ts—BeforeAll/AfterAll/Before/AfterStep/Aftersrc/cucumber/support/ttaFormatter.ts— CucumberFormatterthat rebuildsTestData[]and callsCustomTTAReporter.renderExternalRunsrc/cucumber/support/ttaFormatter.cjs— CommonJS shim. Cucumber loads formatters with native ESMimport()
Level 0 — installation wiring
src/cucumber/level-00-Installation/feature/smoke.featuresrc/cucumber/level-00-Installation/steps/smoke.spec.ts
Level 1 — basic login
src/cucumber/level-01-basic/feature/login.featuresrc/cucumber/level-01-basic/step/login.steps.ts— Given is missingawait
Level 2 — data-driven
src/cucumber/level-02-data-driven/feature/login-outline.featuresrc/cucumber/level-02-data-driven/feature/cart-datatable.featuresrc/cucumber/level-02-data-driven/feature/checkout-external-data.featuresrc/cucumber/level-02-data-driven/step/checkout.steps.tssrc/cucumber/level-02-data-driven/data/customers.json
Also fetched so I do not lie about CI or the page
.github/workflows/playwright.yml—npx playwright testonlypackage.json—test:bdd,test:bdd:smoke,test:bdd:tta,cucumber:level0/level1/level2src/pages/LoginPage.ts— the POM the World constructs (open(),loginAs())src/utils/DataGenerator.ts— only for theCheckoutCustomertype thatcheckout.steps.tsimports
There is no level-03. There is no src/cucumber/support/hooks.js. There is no Playwright config switch that starts Cucumber. src/fixtures/index.ts is empty on this branch — I am not teaching fixtures from it today. Day 17 already covered test.extend. Today the World is the fixture.
The app under test is still TTACart at https://app.thetestingacademy.com plus LoginPage.PATH /playwright/ttacart/index.html. SauceDemo-shaped. data-test locators. You have seen this cart since Day 16.
Why a second runner at all
Playwright Test is a runner. It owns test(), fixtures, projects, retries, the HTML report, the trace. Cucumber is also a runner. It owns .feature files, step glue, tags as Gherkin tags, formatters.
Two runners is a cost. You pay it only if someone in the room needs to read the scenario without opening TypeScript. Product. BA. A junior who can review “locked-out user is refused” before they can review loginAs. If that person does not exist on your team, stay on Playwright Test. Day 17’s e2e-checkout.spec.ts already tells the story in test.step titles.
This framework pays the cost thin. The README on feat-cucumber says it in one sentence I will not decorate: business-readable .feature files drive TypeScript step definitions that reuse the same Page Objects as the Playwright suite — no parallel automation stack.
That is the whole argument. If your Cucumber steps start calling page.locator for every field, you grew a second stack. Level 1’s error assertion already leans that way — I will show you the locator. Level 0 does not. Level 2’s checkout walk uses the pages. Hold that standard while you read.
cucumber.js — the file that is not TypeScript
Cucumber-js looks for cucumber.js at the repo root. This one is CommonJS. It does three jobs before any scenario runs.
Job 1 — point ts-node at the Cucumber tsconfig. Step defs, hooks, and the formatter are TypeScript. Cucumber is not. The file sets TS_NODE_PROJECT to src/cucumber/tsconfig.json unless you already exported one.
const path = require("path");
process.env.TS_NODE_PROJECT =
process.env.TS_NODE_PROJECT ||
path.resolve(__dirname, "src/cucumber/tsconfig.json");
Job 2 — one shared profile body. requireModule loads ts-node/register and tsconfig-paths/register. require globs src/cucumber/**/*.ts. paths globs src/cucumber/**/*.feature. Formatters are a list. Snippets, if Cucumber has to print a missing step, come out as async-await. publishQuiet stays true so a classroom run does not offer to publish results to the Cucumber Reports service.
const base = {
requireModule: ["ts-node/register", "tsconfig-paths/register"],
require: ["src/cucumber/**/*.ts"],
paths: ["src/cucumber/**/*.feature"],
format: [
"progress-bar",
"html:reports/cucumber/report.html",
"./src/cucumber/support/ttaFormatter.cjs:tta-report/.cucumber-tta.log",
],
formatOptions: { snippetInterface: "async-await" },
publishQuiet: true,
};
Read the third formatter carefully. The custom TTA formatter is given a file sink, not stdout. The comment in cucumber.js says why: so it never collides with the progress-bar formatter. The real HTML still lands in tta-report/ via renderExternalRun. The log path tta-report/.cucumber-tta.log is the sink Cucumber demanded, not the report you open.
Job 3 — named profiles. default is the shared body. level0, level1, level2 spread that body and add a tag filter.
module.exports = {
default: base,
level0: { ...base, tags: "@level0" },
level1: { ...base, tags: "@level1" },
level2: { ...base, tags: "@level2" },
};
Profiles filter features. They do not filter require. src/cucumber/**/*.ts still loads every step file, every hook, the World, the formatter TypeScript. That is why a Level 2 Scenario Outline can say When I log in as "<username>" with password "<password>" and hit the Level 1 step. The glue is global once Cucumber starts.
Here is the script matrix from the repo on this branch.
| Script | What it runs |
|---|---|
test:bdd | cucumber-js (default profile, every feature) |
test:bdd:smoke | cucumber-js --tags @smoke |
test:bdd:report | open reports/cucumber/report.html |
test:bdd:tta | cucumber-js && open tta-report/index.html |
cucumber | cucumber-js |
cucumber:level0 | HEADED=1 cucumber-js --profile level0 |
cucumber:level1 | HEADED=1 cucumber-js --profile level1 |
cucumber:level2 | HEADED=1 cucumber-js --profile level2 |
cucumber:level2:report | Level 2, then open the TTA HTML |
cucumber:headed | HEADED=1 cucumber-js |
HEADED=1 is not a Cucumber flag. hooks.ts reads process.env.HEADED when it launches Chromium. The level scripts open a window on purpose. CI, when it one day grows a Cucumber job, should not copy those scripts blindly.
Dependency on this branch: @cucumber/cucumber ^12.9.0, plus ts-node ^10.9.2 and tsconfig-paths ^4.2.0. Playwright stays @playwright/test ^1.60.0. Cucumber does not replace Playwright. It imports the same chromium and the same expect.
The CommonJS tsconfig
The Cucumber tsconfig extends the root file and then forces CommonJS so ts-node can load steps.
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"ignoreDeprecations": "6.0",
"types": [
"node"
],
"baseUrl": "../../",
"paths": {
"@api/*": [
"src/api/*"
],
"@config/*": [
"src/config/*"
],
"@fixtures/*": [
"src/fixtures/*"
],
"@pages/*": [
"src/pages/*"
],
"@testdata/*": [
"src/testdata/*"
],
"@tests/*": [
"src/tests/*"
],
"@utils/*": [
"src/utils/*"
]
}
},
"ts-node": {
"transpileOnly": true,
"files": true
}
}
transpileOnly is true. A missing await can survive a run. Do not assume the compiler saved you.
CustomWorld — this is the fixture
Playwright Test injects page or, after Day 17, a bundle of page objects from test.extend. Cucumber injects this. this is whatever you passed to setWorldConstructor. src/cucumber/support/world.ts is that constructor.
import { World, IWorldOptions, setWorldConstructor } from "@cucumber/cucumber";
import type { Browser, BrowserContext, Page } from "@playwright/test";
import { LoginPage } from "../../pages/LoginPage";
import { InventoryPage } from "../../pages/InventoryPage";
import { CartPage } from "../../pages/CartPage";
import { CheckoutStepOnePage } from "../../pages/CheckoutStepOnePage";
import { CheckoutStepTwoPage } from "../../pages/CheckoutStepTwoPage";
import { CheckoutCompletePage } from "../../pages/CheckoutCompletePage";
export const BASE_URL =
process.env.BASE_URL ?? "https://app.thetestingacademy.com";
export const CREDS = {
standardUser: process.env.STANDARD_USER ?? "standard_user",
password: process.env.TTA_SECRET ?? "tta_secret",
} as const;
export class CustomWorld extends World {
browser!: Browser;
context!: BrowserContext;
page!: Page;
loginPage!: LoginPage;
inventoryPage!: InventoryPage;
cartPage!: CartPage;
checkoutStepOnePage!: CheckoutStepOnePage;
checkoutStepTwoPage!: CheckoutStepTwoPage;
checkoutCompletePage!: CheckoutCompletePage;
scratch: Record<string, unknown> = {};
constructor(options: IWorldOptions) {
super(options);
}
initPages(): void {
this.loginPage = new LoginPage(this.page);
this.inventoryPage = new InventoryPage(this.page);
this.cartPage = new CartPage(this.page);
this.checkoutStepOnePage = new CheckoutStepOnePage(this.page);
this.checkoutStepTwoPage = new CheckoutStepTwoPage(this.page);
this.checkoutCompletePage = new CheckoutCompletePage(this.page);
}
}
setWorldConstructor(CustomWorld);
Four things to say out loud in class. First: browser, context, and page are definite assignment. They are empty until hooks.ts fills them. A step that runs without the Before hook will throw on this.page.
Second: every TTACart page the e2e checkout needs is pre-built. The step does not new LoginPage(this.page). The World does. That is the same ownership Day 17 taught with fixtures. Different injection. Same classes.
I fetched src/pages/LoginPage.ts so I can say what open() and loginAs() actually do. open() is goto(LoginPage.PATH) where PATH is /playwright/ttacart/index.html. loginAs fills username and password data-test fields and clicks login. The feature file never sees those selectors.
Third: CREDS is not src/config/credentials.ts. It lives in this file. STANDARD_USER and TTA_SECRET, with classroom fallbacks standard_user / tta_secret. Two stories. I am not merging them in a blog post.
Fourth: scratch is a bag. Record<string, unknown>. None of the step files I fetched write to it. I will not invent a scenario that does. BASE_URL becomes baseURL on the Playwright context in the hook.
hooks.ts — browser once, page every scenario
One Chromium for the process. A new context and a new page for every scenario. Pages are constructed after the page exists. That order is not optional.
import {
BeforeAll,
AfterAll,
Before,
After,
AfterStep,
Status,
setDefaultTimeout,
} from "@cucumber/cucumber";
import { chromium, Browser } from "@playwright/test";
import { CustomWorld, BASE_URL } from "./world";
setDefaultTimeout(60_000);
let browser: Browser;
BeforeAll(async function () {
browser = await chromium.launch({ headless: !process.env.HEADED });
});
AfterAll(async function () {
await browser?.close();
});
Before(async function (this: CustomWorld) {
this.browser = browser;
this.context = await browser.newContext({ baseURL: BASE_URL });
this.page = await this.context.newPage();
this.initPages();
});
// Attach a screenshot after every Gherkin step so the TTA report shows a shot
// per step (the custom formatter wires these into each StepData).
AfterStep(async function (this: CustomWorld) {
if (this.page) {
const png = await this.page.screenshot();
this.attach(png, "image/png");
}
});
After(async function (this: CustomWorld, { result }) {
if (result?.status === Status.FAILED && this.page) {
const png = await this.page.screenshot();
this.attach(png, "image/png");
}
await this.page?.close();
await this.context?.close();
});
Headless is the default. HEADED any truthy string flips it. The level scripts set HEADED=1. test:bdd does not.
AfterStep attaches a PNG after every Gherkin step so the TTA report can show a shot per step. After attaches another PNG if the scenario failed, then closes page and context.
Sixty seconds is the default step timeout. Cucumber will not retry from playwright.config.ts. Workers are not configured in cucumber.js on this branch. The TTA formatter meta hard-codes workers: 1.
Level 0 — prove the wiring, then stop
Three lines. No parameters. No outline. The feature description is the lesson: wiring. If this fails, do not debug Gherkin. Debug TS_NODE_PROJECT, the World, BASE_URL, and whether TTACart is up.
@level0 @smoke
Feature: TTACart login (Level 0)
Verifies the Cucumber + Playwright wiring end to end by driving the
Page Objects exposed on the CustomWorld.
Scenario: A standard user can log in and reach the inventory
Given I am on the TTACart login page
When I log in as the standard user
Then the inventory page is displayed
smoke.spec.ts is the filename. Level 0 uses steps/ (plural) and a Playwright-looking suffix. Cucumber does not care. The require glob is **/*.ts.
import { Given, When, Then } from "@cucumber/cucumber";
import { CustomWorld, CREDS } from "../../support/world";
Given("I am on the TTACart login page", async function (this: CustomWorld) {
await this.loginPage.open();
});
When("I log in as the standard user", async function (this: CustomWorld) {
await this.loginPage.loginAs(CREDS.standardUser, CREDS.password);
});
Then("the inventory page is displayed", async function (this: CustomWorld) {
await this.inventoryPage.assertLoaded();
});
This is the pattern I want every later step to copy. async function (this: CustomWorld). await on every page call. Credentials from CREDS, not from a quoted password in the feature. No page.locator in the step.
test:bdd:smoke is a different filter. It is –tags @smoke. Level 0 is tagged @level0 @smoke. Level 1 happy scenario is tagged @smoke @P0. So test:bdd:smoke is not Level 0 only.
Level 1 — parameters, negatives, and the missing await
Three classroom lessons in one file, before you open the steps. Background runs before every scenario. The happy scenario repeats the login-page Given after Background already did. Quoted parameters put the password in English so a reviewer can see wrong_password without opening TypeScript.
@level1 @login
Feature: TTA Cart Login
Background:
Given I am on the TTACart Login Page
@smoke @P0
Scenario: A standard user can log in
Given I am on the TTACart Login Page
When I log in as "standard_user" with password "tta_secret"
Then I should land on the products page
@negative
Scenario: A locked-out user is refused
When I log in as "locked_out_user" with password "tta_secret"
Then I should see a login error containing "locked out"
@negative
Scenario: Wrong password is rejected
When I log in as "standard_user" with password "wrong_password"
Then I should see a login error containing "do not match"
Now the step file. Folder name is step, singular. Filename is login.steps.ts. Call this out. Do not skip it.
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) {
this.loginPage.open();
});
When(
'I log in as {string} with password {string}',
async function (this: CustomWorld, username: string, password: string) {
await this.loginPage.loginAs(username, password);
},
);
Then('I should land on the products page', async function (this: CustomWorld) {
await this.inventoryPage.assertLoaded();
});
Then(
'I should see a login error containing {string}',
async function (this: CustomWorld, fragment: string) {
const error = this.page.locator('[data-test="error"]');
await expect(error).toBeVisible();
await expect(error).toContainText(fragment);
},
);
Line 6 is this.loginPage.open() with no await. LoginPage.open() is async. It returns a Promise. The Given is declared async and then throws that Promise away. Cucumber can move to When while goto is still in flight. That is a race. Students will call this flaky Cucumber. It is not Cucumber. It is a missing await.
Level 0 Given of the same idea is correct: it awaits open(). Same method. Same World. Different string. Different spelling of the step text, too. Level 0: I am on the TTACart login page. Level 1: I am on the TTACart Login Page. Capital L, capital P. Cucumber glue is exact. These are two step definitions.
The error Then uses this.page.locator of the error data-test. LoginPage on this branch already constructs errorBox as that same selector. The step does not use it. Level 0 passed the POM standard. This Then is the first leak.
expect here is Playwright expect, not a Cucumber assert library. toContainText locked out and do not match are fragments of TTACart real error copy. Fix you should make in the repo, not in this post: add await. I am not shipping a patch from a blog. I am teaching you to see it.
Level 2 — three data-driven shapes, one step file
Level 2 is not more login. It is how data enters a scenario. Three features. One step module: checkout.steps.ts. The outline feature reuses Level 0 and Level 1 glue for navigation and login. The new Then and the cart and checkout steps live in checkout.steps.ts.
Shape 1 — Scenario Outline
Five rows. Two Examples tables so the report groups valid versus rejected. The scenario title interpolates username and outcome. Background uses Level 0 sentence, the one that has await. The When is Level 1 parameterized step. The Then is new.
@level2 @outline
Feature: TTACart login outcomes (Level 2 — Scenario Outline)
Background:
Given I am on the TTACart login page
Scenario Outline: <username> logging in ends on the <outcome>
When I log in as "<username>" with password "<password>"
Then I should see the "<outcome>"
Examples: valid users
| username | password | outcome |
| standard_user | tta_secret | products |
| problem_user | tta_secret | products |
| performance_glitch_user | tta_secret | products |
Examples: rejected attempts
| username | password | outcome |
| locked_out_user | tta_secret | error |
| standard_user | wrong_password | error |
Shape 2 — DataTable
A DataTable is not an Examples table. Examples explode one scenario into many. A DataTable is an argument to one step. One scenario. Three adds. One assertion. table.hashes() turns the header row into keys. The Gherkin column must stay named productId.
@level2 @datatable
Feature: Adding products from a Data Table (Level 2)
Background:
Given I am logged in as a standard user
And I am on the products page
Scenario: Add three products to the cart in one step
When I add the following products to the cart:
| productId |
| tta-practice-backpack |
| tta-bike-light |
| test-allthethings-tshirt-red |
Then the cart should contain 3 products
Shape 3 — external JSON
The feature does not contain first names, last names, or postal codes. It contains keys: alice, bob, carol. The step loads the book. Faker DataGenerator.checkoutCustomer() is not called here. These personas are fixed. The import is the type CheckoutCustomer, not the factory.
@level2 @external @e2e
Feature: End-to-end checkout, data from an external JSON file (Level 2)
Background:
Given I am logged in as a standard user
Scenario Outline: <persona> completes a full checkout
When I add product "test-allthethings-tshirt-red" to the cart
And I check out as the "<persona>" customer
Then the order should be confirmed
Examples:
| persona |
| alice |
| bob |
| carol |
{
"alice": {
"firstName": "Alice",
"lastName": "Walker",
"postalCode": "560001"
},
"bob": {
"firstName": "Bob",
"lastName": "Singh",
"postalCode": "110011"
},
"carol": {
"firstName": "Carol",
"lastName": "Mendes",
"postalCode": "400001"
}
}
checkout.steps.ts is the only file under level-02-data-driven/step/. Shared Given, outline Then, DataTable When, single-product When, and the checkout walk all live here. If you add dave to Examples and forget the JSON key, the step throws No customer dave in customers.json. That is a better fail than filling empty strings.
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { Given, When, Then, DataTable } from '@cucumber/cucumber';
import { expect } from '@playwright/test';
import { CustomWorld, CREDS } from '../../support/world';
import type { CheckoutCustomer } from '../../../utils/DataGenerator';
type CustomerBook = Record<string, CheckoutCustomer>;
const customers: CustomerBook = JSON.parse(
readFileSync(join(__dirname, '../data/customers.json'), 'utf-8'),
);
// ---------- shared Given steps ----------
Given('I am logged in as a standard user', async function (this: CustomWorld) {
await this.loginPage.open();
await this.loginPage.loginAs(CREDS.standardUser, CREDS.password);
await this.inventoryPage.assertLoaded();
});
Given('I am on the products page', async function (this: CustomWorld) {
await this.inventoryPage.open();
});
// ---------- Scenario Outline outcome ----------
Then('I should see the {string}', async function (this: CustomWorld, outcome: string) {
if (outcome === 'products') {
await this.inventoryPage.assertLoaded();
} else {
await expect(this.page.locator('[data-test="error"]')).toBeVisible();
}
});
// ---------- Data Table ----------
When(
'I add the following products to the cart:',
async function (this: CustomWorld, table: DataTable) {
for (const { productId } of table.hashes()) {
await this.inventoryPage.addToCart(productId);
}
},
);
Then('the cart should contain {int} products', async function (this: CustomWorld, count: number) {
await this.cartPage.open();
expect(await this.cartPage.rowCount()).toBe(count);
});
// ---------- single product + external-data checkout ----------
When('I add product {string} to the cart', async function (this: CustomWorld, productId: string) {
await this.inventoryPage.addToCart(productId);
});
When('I check out as the {string} customer', async function (this: CustomWorld, persona: string) {
const customer = customers[persona];
if (!customer) throw new Error(`No customer "${persona}" in customers.json`);
await this.cartPage.open();
await this.cartPage.checkout();
await this.checkoutStepOnePage.assertLoaded();
await this.checkoutStepOnePage.fillGuest(customer);
await this.checkoutStepOnePage.continue();
await this.checkoutStepTwoPage.assertLoaded();
await this.checkoutStepTwoPage.finish();
});
Then('the order should be confirmed', async function (this: CustomWorld) {
await this.checkoutCompletePage.assertOrderComplete();
});
The checkout When is the same e2e spine as Day 17: cart, checkout step one, guest form, step two, finish, complete. The feature says none of those page names. The step does. That is the line. If the step grows another ten lines of locators, you lost.
The TTA formatter — same HTML, different driver
Playwright reporters implement Playwright Reporter. Cucumber reporters implement Formatter. They do not meet. This repo meets them on purpose. ttaFormatter.ts listens to the message stream, rebuilds TestData and SuiteStats, and calls renderExternalRun. That is the public door Day 17 reporter grew so a non-Playwright run can reuse HTML, RCA, and Flaky tabs.
/**
* TTA Cucumber Formatter
*
* Bridges a Cucumber run into the existing Playwright TTA HTML reporter.
*
* Cucumber drives Formatters (not Playwright's Reporter interface), so this
* formatter listens to the message stream, rebuilds the same TestData[] /
* SuiteStats model the reporter renders from, and hands it to
* `CustomTTAReporter.renderExternalRun(...)` — reusing the entire HTML + RCA +
* Flaky pipeline. Output lands in `tta-report/` exactly like a Playwright run.
*
* Wire it up via `format` in cucumber.js. The report opens with
* `npm run test:bdd:tta`.
*/
import { Formatter, IFormatterOptions, Status } from '@cucumber/cucumber';
import type * as messages from '@cucumber/messages';
import * as fs from 'fs';
import * as path from 'path';
import CustomTTAReporter, {
type StepData,
type TestData,
type SuiteStats,
} from '../../utils/CustomReporter';
const SCREENSHOT_DIR = path.join('tta-report', 'screenshots');
function tsToMs(t: messages.Timestamp): number {
return t.seconds * 1000 + Math.floor(t.nanos / 1_000_000);
}
function durationToMs(d?: messages.Duration): number {
if (!d) return 0;
return d.seconds * 1000 + Math.floor(d.nanos / 1_000_000);
}
function mapStatus(s: messages.TestStepResultStatus): StepData['status'] {
if (s === Status.PASSED) return 'passed';
if (s === Status.FAILED || s === Status.AMBIGUOUS || s === Status.UNDEFINED) return 'failed';
return 'skipped';
}
function runIdFrom(date: Date): string {
const p = (n: number): string => String(n).padStart(2, '0');
return (
`${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}` +
`_${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`
);
}
export default class TtaCucumberFormatter extends Formatter {
private startTime = new Date();
private endTime = new Date();
private renderPromise: Promise<void> = Promise.resolve();
constructor(options: IFormatterOptions) {
super(options);
options.eventBroadcaster.on('envelope', (envelope: messages.Envelope) => {
if (envelope.testRunStarted?.timestamp) {
this.startTime = new Date(tsToMs(envelope.testRunStarted.timestamp));
}
if (envelope.testRunFinished?.timestamp) {
this.endTime = new Date(tsToMs(envelope.testRunFinished.timestamp));
this.renderPromise = this.render();
}
});
}
// Block Cucumber's shutdown until the (async) report has been written.
async finished(): Promise<void> {
await this.renderPromise;
await super.finished();
}
private writeScreenshot(att: messages.Attachment, testIndex: number, shotIndex: number): string {
if (!fs.existsSync(SCREENSHOT_DIR)) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
}
const fileName = `bdd_${testIndex}_${shotIndex}.png`;
const buffer = Buffer.from(att.body, att.contentEncoding === 'BASE64' ? 'base64' : 'utf-8');
fs.writeFileSync(path.join(SCREENSHOT_DIR, fileName), buffer);
return `screenshots/${fileName}`;
}
private buildTests(): { tests: TestData[]; stats: SuiteStats } {
const stats: SuiteStats = { total: 0, passed: 0, failed: 0, skipped: 0, flaky: 0 };
const tests: TestData[] = [];
const attempts = this.eventDataCollector.getTestCaseAttempts();
let testIndex = 0;
for (const attempt of attempts) {
testIndex++;
const pickle = attempt.pickle;
const featureName = attempt.gherkinDocument.feature?.name ?? 'Feature';
const pickleStepById = new Map(pickle.steps.map((s) => [s.id, s]));
const steps: StepData[] = [];
const screenshots: { name: string; path: string }[] = [];
let durationMs = 0;
let error: string | undefined;
for (const testStep of attempt.testCase.testSteps) {
const result = attempt.stepResults[testStep.id];
if (!result) continue;
durationMs += durationToMs(result.duration);
// Skip Before/After hooks — only Gherkin steps map to a pickle step.
if (!testStep.pickleStepId) continue;
const pickleStep = pickleStepById.get(testStep.pickleStepId);
const status = mapStatus(result.status);
const stepData: StepData = {
title: pickleStep?.text ?? '(step)',
category: 'test.step',
duration: durationToMs(result.duration),
status,
startTime: '',
error: result.message,
stackTrace: result.message,
consoleLogs: [],
stepIndex: steps.length,
};
for (const att of attempt.stepAttachments[testStep.id] ?? []) {
if (att.mediaType === 'image/png') {
const rel = this.writeScreenshot(att, testIndex, screenshots.length + 1);
stepData.screenshot = rel;
screenshots.push({ name: pickleStep?.text ?? `Step ${steps.length + 1}`, path: rel });
}
}
if (status === 'failed' && !error) error = result.message;
steps.push(stepData);
}
const status = mapStatus(attempt.worstTestStepResult.status);
stats.total++;
if (status === 'passed') stats.passed++;
else if (status === 'failed') stats.failed++;
else stats.skipped++;
tests.push({
id: `test-bdd-${testIndex}`,
title: pickle.name,
fullTitle: `${featureName} › ${pickle.name}`,
file: pickle.uri,
describePath: [featureName],
location: pickle.uri.split('/').pop() ?? pickle.uri,
duration: durationMs,
status,
retry: attempt.attempt,
screenshots,
steps,
logs: [],
error,
errorStack: error,
tags: pickle.tags.map((t) => t.name),
});
}
return { tests, stats };
}
private async render(): Promise<void> {
const { tests, stats } = this.buildTests();
const reporter = new CustomTTAReporter();
const file = await reporter.renderExternalRun({
runId: runIdFrom(this.endTime),
startTime: this.startTime,
endTime: this.endTime,
tests,
stats,
meta: { browser: 'chromium (cucumber)', workers: 1 },
});
this.log(`\n📊 TTA report written: ${file} (open tta-report/index.html)\n`);
}
}
Why ttaFormatter.cjs exists: Cucumber resolves custom formatters with a native ESM import(), which cannot load a .ts module. cucumber.js references the cjs shim. The shim requires the TypeScript file through ts-node.
/**
* CommonJS loader shim for the TTA Cucumber formatter.
*
* Cucumber resolves custom formatters with a native ESM `import()`, which can't
* load a `.ts` module (nor its extensionless imports). This CJS shim is what
* cucumber.js references; it requires the TypeScript implementation through the
* already-registered ts-node hook and re-exports the formatter class.
*/
require("ts-node/register");
require("tsconfig-paths/register");
module.exports = require("./ttaFormatter.ts").default;
An undefined step is a fail in this report, not a skip. finished() awaits the render Promise so Cucumber does not exit before the HTML hits disk. Two HTML outputs: reports/cucumber/report.html and tta-report/index.html. Students will mix them. Say the path out loud.
CI does not run Cucumber
I fetched the workflow file on feat-cucumber. The job runs playwright test. Not cucumber-js. Not test:bdd.
name: Playwright Tests
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
Triggers are main and master. This branch is feat-cucumber. The artifact is playwright-report/. A green check on this workflow is not a green Level 1. Day 20 returns to this file.
If you add that job later, do not copy cucumber:level0. That script sets HEADED=1. Use a headless cucumber-js profile instead.
How the four boxes connect
Draw it once. Feature to steps to world to page. A Gherkin line is a string. Cucumber looks up that string in every file require loaded. The function it finds is a step. The step this is CustomWorld. The World got a page in Before and ran initPages. this.loginPage is a LoginPage that already owns the username data-test. The feature never imported Playwright. The page never imported Cucumber. The step is the only file that knows both.
That is why Level 0 is twelve lines and enough. That is why Level 1 missing await is a World-level race, not a Cucumber is unstable story. That is why Level 2 checkout When is allowed to be long: it is still a page-object walk, not a locator dump.
Classroom landmines I will not polish away
- Missing await in level-01-basic/step/login.steps.ts Given. Fix it in git. Talk about it in every batch until the file changes.
- Two login-page sentences. TTACart login page versus TTACart Login Page. Glue is case-sensitive. Level 2 outline uses the first. Level 1 uses the second.
- Folder names. steps/ plus smoke.spec.ts at Level 0. step/ plus steps.ts at Level 1 and 2. The glob hides the mess. A human ls does not.
- require is not filtered by profile. All TypeScript under src/cucumber/ loads. Duplicate step text across files is an AMBIGUOUS fail, and the TTA formatter maps that to failed.
- Level 1 happy path opens the login page twice. Background plus Given.
- Error locators leak in Level 1 Then and the outline Then. LoginPage already has errorBox. The steps do not use it.
- CI is Playwright-only. Say it in the PR template until a workflow step exists.
- CREDS versus any other credentials helper. Env names STANDARD_USER and TTA_SECRET. Fallbacks are hard-coded in world.ts.
- test:bdd:smoke is not Level 0. It is the @smoke tag. Two features carry it.
- HEADED=1 on the level scripts. Demo in class. Not for the runner image.
What you should run today
From the repo root after install: run level0, then level1 and watch the first Given, then level2, then test:bdd:tta for the TTA HTML.
You need TTACart reachable at BASE_URL. You do not need an LLM key. You do not need the API project. Day 19 is the API. If Level 0 fails, stop. If Level 1 flakes on navigation, add the await.
FAQ
Is Cucumber replacing Playwright in this framework?
No. Playwright test still runs src/tests. Cucumber runs the feature files. Both construct the same page objects. Day 18 is a second runner, not a second POM.
Which branch has the BDD layer?
feat-cucumber on AdvancePlaywrightFramework1x. I fetched that branch. I did not invent a merge to main.
Where is the Cucumber config file?
cucumber.js at the repo root. It is CommonJS. It sets TS_NODE_PROJECT, requires src/cucumber TypeScript, globs feature files, and exports profiles default, level0, level1, level2.
Why does Level 0 use smoke.spec.ts instead of smoke.steps.ts?
That is the filename on disk. Cucumber require glob is **/*.ts. Playwright testMatch is not involved. I do not rename it in a blog post.
What is wrong with the Level 1 Given?
login.steps.ts calls this.loginPage.open() without await. open() is async. The step can return before navigation finishes. Level 0 smoke.spec.ts awaits the same call. Level 2 outline Background uses the Level 0 sentence, so it does not hit this line.
Why are there two I am on the TTACart login page steps?
Because the text is not the same. Level 0 is login page lowercase. Level 1 is Login Page capitals. Cucumber matches exact strings.
Does Level 2 have its own login steps?
No. login-outline.feature reuses Level 0 Given and Level 1 When. The new Then, the DataTable When, the checkout walk, and the logged-in Given all live in checkout.steps.ts. That is the only file under level-02-data-driven/step/.
Where do alice, bob, and carol come from?
customers.json. Keys alice, bob, carol. Each value is a CheckoutCustomer: firstName, lastName, postalCode. checkout.steps.ts reads the file at load and throws if the persona is missing.
Is that JSON generated by Faker?
No. DataGenerator.checkoutCustomer() exists and is not called from the Cucumber steps I fetched. The personas are static. The import is the type CheckoutCustomer, not the factory.
How do I open the TTA report after a BDD run?
test:bdd:tta runs cucumber-js and then opens tta-report/index.html. The formatter writes that folder through renderExternalRun. Cucumber own built-in HTML is reports/cucumber/report.html.
Why is there a ttaFormatter.cjs and a ttaFormatter.ts?
The ts file is the Formatter class. The cjs file is a CommonJS shim because Cucumber loads formatters with ESM import and cannot import the ts file directly. cucumber.js references the cjs.
Does GitHub Actions run the Cucumber suite?
No. The workflow on feat-cucumber runs playwright test and uploads playwright-report. There is no cucumber-js step. A green workflow is not a green BDD run. Day 20 returns to this file.
Tomorrow — Day 19: the API layer
Gherkin is English on top of a page. A booking is JSON on top of no page at all. Day 19 stays in AdvancePlaywrightFramework1x on feat-cucumber. It leaves src/cucumber/ for src/tests/apiTests/ and src/api/. Restful Booker. APIRequestContext, not Chromium. ApiHelper. JSONPath. AJV Draft-07. The dedicated api project in playwright.config.ts. I will quote those files. I will not quote a Cucumber API feature — there is not one in the tree I fetched.
Do not start Day 19 until Level 0 is green on your machine. If the World cannot open TTACart, you will not debug a booking token with a clear head.
If you only remember one sentence from Day 18: a feature names the story, a step calls the World, the World already holds the page. And Level 1 Given is still missing await.
Master Playwright end to end
If you want these labs as a live classroom — CustomWorld versus test.extend, the missing await I will circle in red, Scenario Outline versus DataTable versus customers.json, and the API layers we open on Day 19 — join Playwright Automation Mastery at The Testing Academy. Lifetime access. Real projects. A job-ready suite, not a green Playwright workflow you treated as a green BDD run.
Series hub (bookmark this): JavaScript to TypeScript to Playwright Advanced Framework 21-Day Guide.
*This is Day 18 of 21. Draft only. Not published.*
