Skip to main content

BDD in Playwright - 2 Ways to Do It (What I Found)

8 min read

I've used Katalon for a while. BDD setup there is simple. When I switched to Playwright, I wanted to keep doing BDD.

Turns out there are two ways:

  1. @cucumber/cucumber - Traditional Cucumber that uses Playwright as a library
  2. playwright-bdd - Compiles Gherkin into native Playwright tests
info

Before I dive into comparing these two approaches, let me be clear: I'm not endorsing either package. This post is simply about sharing what I learned during my exploration of BDD implementation in Playwright. Your choice will depend on your team's specific needs, existing infrastructure, and preferences.

What's BDD anyway?

Behavior-Driven Development (BDD) is a collaborative approach that bridges the gap between business stakeholders and technical teams. At its core, BDD uses natural language (Gherkin syntax) to define how software should behave from a user's perspective. It's basically a way for business people and tech people to work together.

Looks something like this:

login.feature
Feature: User Login
Scenario: Successful login with valid credentials
Given I am on the login page
When I enter valid username and password
And I click the login button
Then I should see the dashboard

The idea is simple - Business Analysts, Developers, and Testers work together on these scenarios. They become both documentation and tests.

Why two approaches exist

Both packages exist because they solve the same problem in fundamentally different ways:

  • @cucumber/cucumber - Cucumber runs everything, Playwright is just a tool it calls
  • playwright-bdd - Playwright runs everything, Gherkin is just the input language

Like building a house: one makes Cucumber the boss and hires Playwright. The other makes Playwright the boss and uses Gherkin as the blueprint.

Approach A: @cucumber/cucumber

How it works

In this approach, Cucumber is your test runner. It reads .feature files, matches steps to your functions, runs tests. Playwright is a library you call - similar to how you might use axios for HTTP or fs for files.

Example Project structure

Project Structure
my-project/
├── cucumber.js # Cucumber config
├── package.json
├── src/
│ ├── pages/ # Page objects
│ │ └── LoginPage.ts
│ └── support/ # Cucumber stuff
│ ├── custom-world.ts # State container
│ └── hooks.ts # Browser setup
├── features/
│ └── login.feature # Gherkin
└── features/
└── step_definitions/
└── login.steps.ts # Step code

How to set it up

1. Custom World (src/support/custom-world.ts)

"World" is how Cucumber manages state. You extend a base class to hold your Playwright stuff:

src/support/custom-world.ts
import { World, IWorldOptions, setWorldConstructor } from '@cucumber/cucumber';
import { Page, BrowserContext } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

export class CustomWorld extends World {
page: Page;
context: BrowserContext;
loginPage: LoginPage;

constructor(options: IWorldOptions) {
super(options);
}
}

setWorldConstructor(CustomWorld);

2. Hooks (src/support/hooks.ts)

You must manually manage the browser yourself, start it, create pages, and clean up.

src/support/hooks.ts
import { Before, After, BeforeAll, AfterAll } from '@cucumber/cucumber';
import { chromium, Browser } from 'playwright';
import { LoginPage } from '../pages/LoginPage';

let browser: Browser;

BeforeAll(async function() {
browser = await chromium.launch({ headless: true });
});

Before(async function(this: CustomWorld) {
this.context = await browser.newContext();
this.page = await this.context.newPage();
this.loginPage = new LoginPage(this.page);
});

After(async function(this: CustomWorld) {
await this.page?.close();
await this.context?.close();
});

AfterAll(async function() {
await browser?.close();
});

3. Step Definitions (features/step_definitions/login.steps.ts)

Steps access World through the this keyword:

features/step_definitions/login.steps.ts
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from '@playwright/test';
import { CustomWorld } from '../../src/support/custom-world';

Given('I am on the login page', async function(this: CustomWorld) {
await this.loginPage.goto();
});

When('I enter valid username and password', async function(this: CustomWorld) {
await this.loginPage.login('user@example.com', 'password123');
});

Then('I should see the dashboard', async function(this: CustomWorld) {
await expect(this.page.locator('[data-testid="dashboard"]')).toBeVisible();
});

4. Config (cucumber.js)

cucumber.js
module.exports = {
default: {
require: ['src/support/**/*.ts', 'features/step_definitions/**/*.ts'],
requireModule: ['ts-node/register'],
format: ['progress', 'html:reports/cucumber-report.html'],
formatOptions: { snippetInterface: 'async-await' }
}
};

When to use this

Good for:

  • Teams already using Cucumber in multiple languages (Java, Ruby, etc.)
  • Need tool-independent test setup
  • Compliance needs specific Cucumber report formats
  • Non-technical people write and run feature files

What you get:

  • Full control over Cucumber
  • Works same way with Playwright, Selenium, or anything else
  • Familiar if you've used Cucumber before
  • Lots of Cucumber plugins available

Approach B: playwright-bdd

How it works

This approach is the complete opposite. Instead of Cucumber running things, Playwright Test is your runner. The playwright-bdd package acts as a compiler: it reads your .feature files and generates native Playwright test files (.spec.ts). When you run npx playwright test, you're executing these generated tests and Playwright doesn't even know they came from Gherkin.

Project structure

Project Structure
my-project/
├── playwright.config.ts # config file
├── package.json
├── .features-gen/ # Generated tests (gitignored)
├── src/
│ ├── pages/ # POM Design Pattern
│ │ └── LoginPage.ts
│ └── test/
│ ├── features/ # Gherkin files
│ │ └── login.feature
│ └── steps/ # Step code
│ └── login.steps.ts

How to set it up

1. Step Definitions (src/test/steps/login.steps.ts)

Steps use destructuring to access Playwright's built-in fixtures like page:

src/test/steps/login.steps.ts
import { expect } from '@playwright/test';
import { createBdd } from 'playwright-bdd';
import { LoginPage } from '../../pages/LoginPage';

const { Given, When, Then } = createBdd();

Given('I am on the login page', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
});

When('I enter valid username and password', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login('user@example.com', 'password123');
});

Then('I should see the dashboard', async ({ page }) => {
await expect(page.locator('[data-testid="dashboard"]')).toBeVisible();
});

2. Config (playwright.config.ts)

playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import { defineBddConfig, cucumberReporter } from 'playwright-bdd';

const testDir = defineBddConfig({
features: 'src/test/features/*.feature',
steps: 'src/test/steps/*.ts',
});

export default defineConfig({
testDir,
reporter: [
cucumberReporter('html', {
outputFile: 'cucumber-report/index.html',
externalAttachments: true,
}),
['html', { open: 'never' }],
],
use: {
screenshot: 'on',
trace: 'on',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

3. Running tests

Terminal
# Generate tests from Gherkin
npx bddgen

# Run tests (normal Playwright command)
npx playwright test

When to use this

Good for:

  • Teams where devs and QA work together closely
  • Already using Playwright for other tests

What you get:

  • Type-safe fixtures instead of mutable state
  • BDD and non-BDD tests run together

Quick comparison

State management

Thing@cucumber/cucumberplaywright-bdd
PatternWorld object (mutable this)Direct parameters
Type safetyManualAutomatic
SetupManual in hooksAutomatic
ScopeScenario onlyTest level

Accessing the page:

With @cucumber/cucumber:

Given('I create a user', async function(this: CustomWorld) {
this.userId = await createUser();
});

When('I update the user', async function(this: CustomWorld) {
await updateUser(this.userId); // Access via this
});

With playwright-bdd:

import { createBdd } from 'playwright-bdd';
const { Given, When } = createBdd();

Given('I am on the login page', async ({ page }) => {
await page.goto('/login');
});

When('I click login', async ({ page }) => {
await page.click('[data-testid="login-btn"]');
});

Running tests

Feature@cucumber/cucumberplaywright-bdd
Test runnerCucumber CLIPlaywright Test
Parallel testsVia --parallel flagNative workers
ShardingManualBuilt-in --shard
RetriesScenario levelPer test/project

Debugging

Feature@cucumber/cucumberplaywright-bdd
Trace ViewerManual (complex)Native, automatic
HTML ReportCucumber HTMLCucumbe & Playwright HTML
Screenshots/VideosManual hooksConfig-based

Developer experience

@cucumber/cucumber:

  • Manual browser management
  • Remember cleanup in hooks
  • Harder to learn if new to Playwright

playwright-bdd:

  • Automatic resource management
  • Familiar if you know Playwright
  • Direct access to Playwright features

Which one to pick

Pick @cucumber/cucumber if:

  • Your company already uses Cucumber everywhere
  • Need tool-independent architecture
  • Business analysts run tests independently
  • Want full control over test pipeline

Pick playwright-bdd if:

  • Starting faster
  • Better Debugging
  • Need fast parallel tests for large suites
  • Want BDD and non-BDD tests in one runner
  • Developer experience matters

My take

tip

For fast-paced or modern projects, playwright-bdd has real advantages.

For teams already deep in Cucumber across multiple languages or with strict requirements, @cucumber/cucumber is the safe choice that keeps your existing setup.

Both let you do BDD with Playwright, just different philosophies. @cucumber/cucumber keeps Cucumber in charge, using Playwright as a library. playwright-bdd makes Playwright the foundation, using Gherkin as input.

Neither is "better", it all depends on your team, priorities, constraints. The important part is both let you connect business requirements and technical code through clear specs.

Hope this helps, happy testing!