Can I use Gherkin with Playwright?
Yes — but not with Playwright Test on its own.
Playwright Test is a full test runner. It owns parallelism, fixtures, retries, the HTML reporter and the trace viewer, and it discovers .spec.ts files. It does not read .feature files, and there is no official Gherkin plugin for it.
So every Playwright BDD setup is one of three arrangements. They take about the same amount of work to stand up. What separates them is which half you give up: the Cucumber tooling, or the Playwright runner.
| Approach | What you keep | What you give up | Choose it when |
|---|---|---|---|
| cucumber-js + Playwright API | Gherkin, tags, Cucumber reporters, and Playwright locators and expect(). | The Playwright runner: no projects matrix, no sharding, no automatic traces in a report. | You want the fewest moving parts, and Playwright was chosen for its browser API rather than its runner. |
| playwright-bdd | The entire Playwright toolchain — traces, projects, sharding, the HTML report. | A generated directory in your tree, a compile step before every run, and stack traces pointing at generated code. | The Playwright runner’s features are the reason you picked Playwright. |
| Gherkin as spec + specs by hand | Everything on both sides. Nothing to install, nothing to break. | The link between the sentence and the test. Nothing fails when they drift apart. | The value of BDD to your team is the conversation, not executable documentation. |
Which Playwright BDD approach should you choose?
Start with cucumber-js unless you can name the Playwright runner feature you would be giving up. Most teams cannot, and the ones who can already know it.
1. cucumber-js driving the Playwright API
You run @cucumber/cucumber as the runner and use Playwright as a browser automation library. Playwright's expect comes along: it is importable standalone from @playwright/test and keeps its auto-retry behaviour, which is the part you would most miss.
Traces are not wired up for you. You can still call context.tracing.start() yourself, but nothing attaches the result to a report. This is the arrangement Gherkinizer generates for Playwright, and the rest of this page sets it up.
2. playwright-bdd compiling features into specs
A build step reads your .feature files and emits Playwright test files, which the Playwright runner then executes normally. Everything the runner does keeps working.
The cost lands on debugging. A failure points at a generated file rather than at the step definition you wrote, and there is a compile step between editing a feature and running it. Worth it if projects, sharding or the trace viewer are why Playwright is in your stack.
3. Gherkin as the specification, specs written by hand
The feature file is the agreed description of behaviour and lives with the ticket. The Playwright spec is written to match it. No tooling, no binding, nothing to break.
It is unfashionable, and for a lot of teams it is the right trade. It is also the one option where nothing tells you the two have drifted apart, so it works best where someone reviews both in the same pull request. The discovery conversation is where most of BDD's value comes from either way.
How to set up cucumber-js with Playwright
Two files decide whether this works: a config that tells cucumber-js where your code is, and a support file that gives every scenario its own browser. Here is the layout the default configuration expects.
features
├── filtering-the-product-list.feature
├── step_definitions
│ └── filtering-the-product-list.steps.ts
└── support
└── world.ts
cucumber.jsThe config below registers ts-node so cucumber-js can load TypeScript step definitions directly, and turns on four parallel workers. Both lines matter for the section that follows.
module.exports = { default: { requireModule: ['ts-node/register'], require: ['features/**/*.ts'], format: ['progress-bar', 'html:reports/cucumber.html'], parallel: 4, },};That file is CommonJS. If your package.json sets "type": "module", name it cucumber.mjs and use export default — and drop requireModule, which is the CommonJS mechanism. ESM projects load TypeScript through a Node loader instead.
The Cucumber World: where the Playwright page object lives
The World is Cucumber's per-scenario object. Cucumber builds a fresh one before each scenario, binds it to this inside every step function, and throws it away afterwards. It is the only state container in a Cucumber suite that is guaranteed not to leak between scenarios.
That makes it the right home for the Playwright browser, context and page. The alternative — a module-level page variable — is shared by every scenario running in the same worker process. Scenarios then inherit each other's cookies, storage and open dialogs, and with parallel above 1 the resulting failures move around between runs.
The file below does two things: it declares the shape of the World so TypeScript knows what this.page is, and it uses Cucumber's Before and After hooks to open and close a browser context around each scenario.
import { After, Before, setWorldConstructor, World}from '@cucumber/cucumber';import { chromium, type Browser, type BrowserContext, type Page}from '@playwright/test'; export class CustomWorld extends World { browser!: Browser; context!: BrowserContext; page!: Page;} setWorldConstructor(CustomWorld); // One browser context per scenario. Cucumber constructs a fresh World for each// scenario, so nothing here leaks between them -- which is what makes it safe// to raise the parallel worker count.Before(async function (this: CustomWorld) { this.browser = await chromium.launch(); this.context = await this.browser.newContext({ baseURL: 'http://localhost:3000' }); this.page = await this.context.newPage();}); After(async function (this: CustomWorld) { await this.context?.close(); await this.browser?.close();});One consequence worth stating before you write a step: because the World arrives on this, step definitions have to be written with function rather than arrow syntax. See the errors section below.
A Playwright BDD example: feature file and step definitions
A product-filtering requirement, because it is the case where Gherkin's data tables earn their keep — the combination rules are the behaviour, and a table states them more clearly than prose does.
Start with the requirement as someone would actually write it in a ticket: prose, with the rules mixed into the description.
Shoppers can filter the product list by category and price range. Filters combine,so choosing "Outdoor" and £20-£50 shows only outdoor products in that range. Theresult count updates as filters change. Clearing all filters restores the fulllist. If a combination matches nothing, show an empty state rather than a blankgrid.The feature file below pulls three separate behaviours out of that paragraph — filters combining, clearing, and matching nothing — and gives each a scenario with concrete values. The Background table sets up the catalogue once, so no scenario has to restate it. Every keyword in it is explained here.
Feature: Filtering the product list Background: Given the catalogue contains these products | name | category | price | | Camping stove | Outdoor | 34.00 | | Head torch | Outdoor | 18.50 | | Desk lamp | Home | 42.00 | Scenario: Filters combine rather than replace each other Given the shopper is on the product list When they filter by category "Outdoor" And they filter by price between 20 and 50 Then the results should be | name | | Camping stove | And the result count should read "1 product" Scenario: Clearing filters restores the full list Given the shopper has filtered by category "Outdoor" When they clear all filters Then the result count should read "3 products" Scenario: A combination that matches nothing shows an empty state Given the shopper is on the product list When they filter by category "Home" And they filter by price between 0 and 10 Then they should see the empty state "No products match these filters"The step definitions are what to read closely. Note what is not in them: no waits, no retries, no assertions about intermediate state. Playwright's locators auto-wait and expect retries, so each step is a single statement about what the shopper does or sees. Steps also take their values as parameters rather than hard-coding them, which is what makes one step definition serve every scenario that needs it.
import { Given, When, Then, setDefaultTimeout}from '@cucumber/cucumber';import { expect}from '@playwright/test';import type { CustomWorld}from './world'; // cucumber-js defaults to a 5000ms step timeout, which is shorter than// Playwright's own navigation timeout. Without this line the first slow page// load fails as a Cucumber timeout and tells you nothing about why.setDefaultTimeout(30_000); Given('the shopper is on the product list', async function (this: CustomWorld) { await this.page.goto('/products');}); When('they filter by category {string}', async function (this: CustomWorld, category: string) { await this.page.getByRole('checkbox', { name: category }).check();}); When('they filter by price between {int} and {int}',async function (this: CustomWorld, min: number, max: number) { await this.page.getByLabel('Minimum price').fill(String(min)); await this.page.getByLabel('Maximum price').fill(String(max));}); Then('the result count should read {string}', async function (this: CustomWorld, count: string) { // Web-first assertion: retries until the text matches or the timeout expires, // so no explicit wait is needed after the filter change. await expect(this.page.getByTestId('result-count')).toHaveText(count);}); Then('they should see the empty state {string}', async function (this: CustomWorld, message: string) { await expect(this.page.getByRole('status')).toHaveText(message);});Common Playwright and Cucumber errors, and how to fix them
Generating Playwright feature files and step definitions
Writing the feature file is the slow half. Turning a paragraph of requirement into scenarios that name concrete values, cover the empty case and stay readable to a product owner takes longer than wiring up the steps that run them — and it is the half you cannot skip, because the step definitions are written against whatever the feature file says.
Gherkinizer does that half first, then the other:
- The feature file, parsed with the official Cucumber grammar before it is shown to you. A file that would not load is never displayed as if it would.
- Edge cases suggested against the scenarios you have, which is where the empty-state scenario in the example above came from.
- Step definitions in the cucumber-js arrangement described on this page — World-bound
functionsyntax, Playwright locators, no explicit waits. - A runnable project export:
filtering-the-product-list.featureinfeatures/, its steps infeatures/step_definitions/, and a README namingnpx cucumber-jsas the command that runs them.
A note on .ts and .js
The examples on this page are TypeScript, because most Playwright projects are. The export names its step file filtering-the-product-list.steps.js and the generated code is plain JavaScript, so it runs without ts-node in the config above.
For a TypeScript project, rename it to .ts and add the this: CustomWorld annotations shown above — the runtime behaviour is identical, and the annotations are what make the arrow-function mistake a compile error rather than a five-second timeout.
Generating feature files and edge cases is free. JavaScript/TypeScript step definitions are included with Pro. If you are new to any of this, the BDD guide covers the practice the feature file comes out of.