BDD in Playwright - 2 Ways to Do It (What I Found)
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:
- @cucumber/cucumber - Traditional Cucumber that uses Playwright as a library
- playwright-bdd - Compiles Gherkin into native Playwright tests
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:
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
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:
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.
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:
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('[email protected]', 'password123');
});
Then('I should see the dashboard', async function(this: CustomWorld) {
await expect(this.page.locator('[data-testid="dashboard"]')).toBeVisible();
});
4. Config (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
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:
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('[email protected]', 'password123');
});
Then('I should see the dashboard', async ({ page }) => {
await expect(page.locator('[data-testid="dashboard"]')).toBeVisible();
});
2. Config (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
# 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/cucumber | playwright-bdd |
|---|---|---|
| Pattern | World object (mutable this) | Direct parameters |
| Type safety | Manual | Automatic |
| Setup | Manual in hooks | Automatic |
| Scope | Scenario only | Test 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/cucumber | playwright-bdd |
|---|---|---|
| Test runner | Cucumber CLI | Playwright Test |
| Parallel tests | Via --parallel flag | Native workers |
| Sharding | Manual | Built-in --shard |
| Retries | Scenario level | Per test/project |
Debugging
| Feature | @cucumber/cucumber | playwright-bdd |
|---|---|---|
| Trace Viewer | Manual (complex) | Native, automatic |
| HTML Report | Cucumber HTML | Cucumbe & Playwright HTML |
| Screenshots/Videos | Manual hooks | Config-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
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!