Playwright BDD: how to use Gherkin with Playwright

Playwright Test does not read .feature files and there is no official Gherkin plugin. Here are the three arrangements that do work, which to pick, and the cucumber-js setup end to end.

Generate a Playwright .feature file

Get valid Gherkin plus edge cases, ready to add to your Playwright project.

Feature generation is free. JavaScript/TypeScript step definitions are included with Pro.

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.

ApproachWhat you keepWhat you give upChoose 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-bddThe 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.
The three ways to run Gherkin against Playwright, and what each one costs.

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.js
cucumber-js resolves step definitions from its config rather than by convention, so this layout is a default rather than a requirement.

The 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,  },};
parallel spawns worker processes, not threads. Each gets its own World, which is why per-scenario browser setup is not optional.

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 function syntax, Playwright locators, no explicit waits.
  • A runnable project export: filtering-the-product-list.feature in features/, its steps in features/step_definitions/, and a README naming npx cucumber-js as 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.

Questions

Can I use Cucumber with Playwright?
Yes. Run cucumber-js as the test runner and use Playwright as the browser automation library — Playwright locators and expect() work normally, including auto-waiting and assertion retries. The Playwright runner is what you give up: no projects matrix, no sharding, and traces are not attached to a report unless you wire that up yourself.
Can Playwright Test run .feature files directly?
No. Playwright Test discovers .spec files and owns its own runner, and there is no official Gherkin plugin. Running Gherkin against Playwright means either cucumber-js as the runner with Playwright as the browser library, or a tool like playwright-bdd that compiles feature files into Playwright specs first.
Should I use cucumber-js or playwright-bdd?
cucumber-js if you want the fewest moving parts and can live without the Playwright runner. playwright-bdd if the projects matrix, sharding or trace viewer are why you chose Playwright — the cost is a compile step before every run and stack traces that point at generated code rather than at the step you wrote.
Why is this.page undefined in my Playwright step definition?
Because the step is an arrow function. Cucumber binds the World to `this`, and an arrow function captures the enclosing `this` instead. Use `async function (this: CustomWorld)` — the type annotation costs nothing at runtime and turns the mistake into a compile error rather than a null reference at run time.
Why do my Cucumber steps time out after five seconds?
That is cucumber-js's default step timeout, not Playwright's. Playwright's navigation timeout is 30 seconds, so a slow page load fails as a Cucumber timeout with an error that says nothing about the browser. Call setDefaultTimeout(30_000) once in your support files.
Do I still need explicit waits with Playwright and Cucumber?
Rarely. Locators auto-wait and expect() retries until its timeout, so a sleep carried over from a Selenium suite mostly slows the run down. Worse, it hides the cause of a failure: the step waits out the sleep and then fails on a stale assumption instead of failing immediately on the element that was never there.

Other frameworks