Your QA lead wants tests that the product owner can read. Your developers want tests that are maintainable and fast. Your business analyst wants to define acceptance criteria in plain English. That's the promise of BDD — and in 2026, Playwright is the best browser automation engine to back it up.
This tutorial covers the full integration path: setting up the playwright-bdd library, writing Gherkin feature files, wiring them to TypeScript step definitions, applying the Page Object Model, and running everything in parallel in CI. By the end, you'll have a framework your whole team can contribute to — not just the engineers.
What You'll Build
- Playwright + Cucumber BDD project setup
- Gherkin feature files with Scenario Outlines
- TypeScript step definitions
- Page Object Model integration
- Tags for @smoke / @regression filtering
- Cucumber HTML + Playwright HTML reporter
- Parallel execution with workers
- GitHub Actions CI pipeline
1. What Is BDD and Why Use It with Playwright?
Behavior-Driven Development (BDD) is a testing approach where test scenarios are written in natural language — specifically in a format called Gherkin — before the automation code. Each scenario describes a behavior from the user's perspective:
Feature: User login Scenario: Successful login with valid credentials Given I am on the login page When I enter valid username and password Then I should be redirected to the dashboard And I should see a welcome message
A product owner can read that scenario without knowing TypeScript. That's the core value of BDD — it makes tests a shared artefact between business and engineering, not just a developer concern.
Why Playwright specifically? Playwright's auto-waiting, multi-browser support, and built-in API testing capabilities make it the ideal engine for BDD tests that cover realistic end-to-end flows. Its TypeScript-first design also makes the step definition layer clean and type-safe. For a broader look at Playwright's strengths, see our what is Playwright testing guide.
When BDD makes sense — and when it doesn't
BDD is the right call when:
- Non-technical stakeholders need to read, review, or write test scenarios
- Acceptance criteria from tickets can map directly to Gherkin scenarios
- Your team practices three amigos (BA + developer + tester) refinement sessions
- You need a living documentation layer that reflects actual test coverage
BDD adds overhead when your team is entirely technical and stakeholders never look at the feature files. In that case, Playwright's native test runner with descriptive test.describe blocks gives you readability without the extra maintenance layer. Be deliberate about the trade-off.
Key insight: The most common reason BDD frameworks fail is that the feature files get written after the code, by engineers, and are never read by anyone outside QA. If that's your team's pattern, you're getting the cost of BDD without the benefit. Solve the process problem before solving the tooling problem.
2. Two Ways to Run Playwright with Cucumber BDD
Before diving into setup, you need to choose your integration approach. There are two production-ready options:
Option A: playwright-bdd (recommended for 2026)
playwright-bdd is a community library that wraps Playwright's test runner with Gherkin support. It uses Playwright's native test runner underneath, which means you get:
- Full access to Playwright fixtures (browser, context, page, custom fixtures)
- Playwright's parallel sharding and worker model
- Playwright's HTML reporter alongside Cucumber's HTML report
- Playwright trace viewer for debugging failed scenarios
Option B: @cucumber/cucumber directly
Using Cucumber's own runner with Playwright's browser APIs injected. Better if you have an existing Cucumber setup (hooks, custom formatters, shared world) you need to preserve. More setup work for greenfield projects, and you lose Playwright's native retry and trace integration.
This tutorial uses Option A (playwright-bdd) — it's the faster path to a production-grade BDD framework and is what most 2026 Udemy and enterprise courses recommend.
3. Project Setup
Prerequisites
- Node.js 20+ (LTS)
- npm or yarn
- Basic TypeScript familiarity
Initialize the project
# Create project directory mkdir playwright-bdd-demo && cd playwright-bdd-demo # Initialize package.json npm init -y # Install Playwright npm install -D @playwright/test # Install Playwright browsers npx playwright install chromium # Install playwright-bdd and Cucumber npm install -D playwright-bdd @cucumber/cucumber # Install TypeScript npm install -D typescript ts-node @types/node
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*", "features/**/*"]
}
Project structure
playwright-bdd-demo/ ├── features/ │ ├── login.feature │ ├── checkout.feature │ └── search.feature ├── src/ │ ├── steps/ │ │ ├── login.steps.ts │ │ ├── checkout.steps.ts │ │ └── common.steps.ts │ ├── pages/ │ │ ├── LoginPage.ts │ │ ├── CheckoutPage.ts │ │ └── SearchPage.ts │ └── fixtures/ │ └── fixtures.ts ├── playwright.config.ts ├── tsconfig.json └── package.json
Naming convention: Keep feature files in features/ at the root and step definitions in src/steps/. Mirror the feature file names in step file names — login.feature maps to login.steps.ts. This makes navigation instant in any IDE.
4. Playwright Config for BDD
The playwright-bdd library uses a custom preprocessor that generates test files from your feature files before Playwright's runner picks them up. Your config needs to declare where the generated tests live.
import { defineConfig, devices } from '@playwright/test'; import { defineBddConfig } from 'playwright-bdd'; const testDir = defineBddConfig({ // Where your .feature files live features: 'features/**/*.feature', // Where your step definitions live steps: 'src/steps/**/*.ts', }); export default defineConfig({ testDir, // Run scenarios in parallel fullyParallel: true, // Fail the build on CI if you accidentally left test.only forbidOnly: !!process.env.CI, // Retry on CI only retries: process.env.CI ? 2 : 0, // Parallel workers — set to half CPU count for local dev workers: process.env.CI ? 4 : undefined, reporter: [ ['html', { open: 'never' }], ['@cucumber/cucumber/formatters', { format: 'html:cucumber-report.html' }], ], use: { baseURL: process.env.BASE_URL || 'https://demo.playwright.dev/todomvc', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, ], });
Add a generate script to package.json that pre-processes feature files before running:
{
"scripts": {
"test": "bddgen && playwright test",
"test:smoke": "bddgen && playwright test --grep @smoke",
"test:headed": "bddgen && playwright test --headed",
"report": "playwright show-report"
}
}
bddgen scans your feature files and generates Playwright test files from them. This happens every run — it's fast (under a second) and idempotent. The generated files appear in .features-gen/ and should be git-ignored.
# playwright-bdd generated files .features-gen/ # Playwright outputs test-results/ playwright-report/ cucumber-report.html
5. Writing Feature Files
Feature files are written in Gherkin — a structured natural language syntax. Each file covers one feature of your application. Scenarios within the file describe individual behaviors.
Basic scenario
Feature: User Authentication As a registered user I want to log into my account So that I can access my dashboard Background: Given I am on the login page @smoke Scenario: Successful login with valid credentials When I enter username "standard_user" and password "secret_sauce" And I click the login button Then I should be on the inventory page And I should see the product list @regression Scenario: Failed login with invalid credentials When I enter username "invalid_user" and password "wrong_pass" And I click the login button Then I should see error message "Username and password do not match" @regression Scenario: Login with empty credentials shows validation When I click the login button Then I should see error message "Username is required"
Scenario Outline (data-driven testing)
When the same flow needs to run with multiple data sets, use Scenario Outline with an Examples table. This is the BDD equivalent of Playwright's data-driven tests.
Feature: Product Checkout @smoke Scenario Outline: Complete checkout for <product> Given I am logged in as a standard user When I add "<product>" to the cart And I proceed to checkout And I fill in shipping details for "<first_name>" "<last_name>" "<zip>" And I confirm the order Then I should see order confirmation Examples: | product | first_name | last_name | zip | | Sauce Labs Backpack | Alice | Smith | 12345 | | Sauce Labs Bike Light | Bob | Jones | 67890 | | Sauce Labs Fleece Jacket | Carol | White | 11111 |
Gherkin best practices
When I add the first product to my cartThen the cart count should be 1
When I click the button with id "add-to-cart-btn"Then I see "1" in element ".cart-count"
Declarative steps are resilient to UI changes. If the button's ID changes, your feature file still makes sense — only the step definition needs updating. Imperative steps break at both the feature and step level when the UI shifts.
6. Step Definitions in TypeScript
Step definitions are the bridge between your Gherkin scenarios and Playwright's automation code. Each step in a feature file maps to a function decorated with Given, When, or Then.
Basic step definitions
import { Given, When, Then } from '@cucumber/cucumber'; import { createBdd } from 'playwright-bdd'; import { LoginPage } from '../pages/LoginPage'; import { expect } from '@playwright/test'; const { Given: bddGiven, When: bddWhen, Then: bddThen } = createBdd(); // Access Playwright's page via fixture bddGiven('I am on the login page', async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.goto(); }); bddWhen( 'I enter username {string} and password {string}', async ({ page }, username: string, password: string) => { const loginPage = new LoginPage(page); await loginPage.fillCredentials(username, password); } ); bddWhen('I click the login button', async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.submit(); }); bddThen('I should be on the inventory page', async ({ page }) => { await expect(page).toHaveURL(/inventory/); }); bddThen('I should see error message {string}', async ({ page }, message: string) => { const loginPage = new LoginPage(page); await expect(loginPage.errorMessage).toContainText(message); });
Regex vs Cucumber expressions: The {string} in step patterns is a Cucumber expression — it captures a quoted string parameter. This is cleaner than raw regex (/"([^"]+)"/). Use Cucumber expressions for simple types ({string}, {int}, {float}) and raw regex only when you need complex capture groups.
Background steps
The Background block in your feature file runs before every scenario. Map it to a step definition just like any other step — it gets no special treatment in the step definition layer:
import { createBdd } from 'playwright-bdd'; import { LoginPage } from '../pages/LoginPage'; const { Given } = createBdd(); // Used by Background blocks in multiple feature files Given('I am logged in as a standard user', async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.loginAs('standard_user', 'secret_sauce'); });
Shared steps (like login) belong in common.steps.ts. Feature-specific steps go in their own files. This keeps the step library maintainable as it grows.
7. Page Object Model Integration
Step definitions should be thin — they describe what happens, not how. All the "how" belongs in Page Objects. This is the single most important architectural decision in a Playwright BDD framework.
LoginPage class
import { Page, Locator } from '@playwright/test'; export class LoginPage { readonly page: Page; readonly usernameInput: Locator; readonly passwordInput: Locator; readonly loginButton: Locator; readonly errorMessage: Locator; constructor(page: Page) { this.page = page; this.usernameInput = page.locator('[data-test="username"]'); this.passwordInput = page.locator('[data-test="password"]'); this.loginButton = page.locator('[data-test="login-button"]'); this.errorMessage = page.locator('[data-test="error"]'); } async goto() { await this.page.goto('https://www.saucedemo.com'); } async fillCredentials(username: string, password: string) { await this.usernameInput.fill(username); await this.passwordInput.fill(password); } async submit() { await this.loginButton.click(); } async loginAs(username: string, password: string) { await this.fillCredentials(username, password); await this.submit(); } }
Why this matters
Imagine the login button's data-test attribute changes from "login-button" to "submit-btn". Without a POM, you'd scan through every step definition file. With a POM, you change one line in LoginPage.ts — done. In a 200-scenario BDD suite, this difference is huge.
Locator strategy in POM: Prefer data-test attributes over CSS selectors or XPath. They survive design changes and are explicitly for testing. Second choice: ARIA roles with page.getByRole(). Avoid class names and positional selectors. See our full Playwright locators guide for the complete hierarchy.
Custom fixtures for cleaner step definitions
Instead of instantiating page objects in every step definition, create a Playwright fixture that provides them automatically:
import { test as base } from 'playwright-bdd'; import { LoginPage } from '../pages/LoginPage'; import { CheckoutPage } from '../pages/CheckoutPage'; type MyFixtures = { loginPage: LoginPage; checkoutPage: CheckoutPage; }; export const test = base.extend<MyFixtures>({ loginPage: async ({ page }, use) => { await use(new LoginPage(page)); }, checkoutPage: async ({ page }, use) => { await use(new CheckoutPage(page)); }, });
Now step definitions become even cleaner — destructure the page object directly from the fixture:
import { createBdd } from 'playwright-bdd'; import { test } from '../fixtures/fixtures'; import { expect } from '@playwright/test'; const { Given, When, Then } = createBdd(test); Given('I am on the login page', async ({ loginPage }) => { await loginPage.goto(); }); When( 'I enter username {string} and password {string}', async ({ loginPage }, username: string, password: string) => { await loginPage.fillCredentials(username, password); } ); Then('I should be on the inventory page', async ({ page }) => { await expect(page).toHaveURL(/inventory/); });
8. Tags and Test Filtering
Tags are one of the most powerful features of Cucumber BDD. They let you label scenarios and run targeted subsets — essential for fast feedback in CI.
Common tag conventions
@smoke # Critical path — runs on every PR (fast, <5 min) @regression # Full suite — runs on merge to main @api # API-only tests — no browser needed @visual # Visual regression scenarios @mobile # Mobile device scenarios @wip # Work in progress — excluded from CI @skip # Known broken — excluded from all runs
Running tagged scenarios
# Run only @smoke scenarios npm run test -- --grep @smoke # Run @regression but exclude @wip npm run test -- --grep @regression --grep-invert @wip # Run scenarios tagged @login OR @checkout npm run test -- --grep "@login|@checkout"
Tagging at Feature level
Tags on a Feature block apply to every scenario inside it. Tags on individual scenarios override or extend feature-level tags:
@regression Feature: Shopping Cart # All scenarios in this feature inherit @regression @smoke Scenario: Add item to cart # This scenario is tagged @smoke AND @regression ... @wip Scenario: Apply promo code # This scenario is tagged @regression AND @wip ...
9. Hooks: Before and After
Hooks run setup and teardown logic around scenarios. In playwright-bdd, you define them in step definition files using Playwright's fixture model — not Cucumber's world hooks.
import { createBdd } from 'playwright-bdd'; import { test } from '../fixtures/fixtures'; const { Before, After, BeforeAll, AfterAll } = createBdd(test); // Runs before each scenario Before(async ({ page }) => { // Clear cookies/storage between scenarios await page.context().clearCookies(); }); // Runs before scenarios tagged @smoke only Before({ tags: '@smoke' }, async ({ page }) => { console.log('Starting smoke test...'); }); // Runs after each scenario — capture screenshot on failure After(async ({ page }, scenario) => { if (scenario.result?.status === 'FAILED') { await page.screenshot({ path: `test-results/${scenario.pickle.name}.png`, fullPage: true, }); } }); // Runs once before the entire suite BeforeAll(async () => { console.log('Suite starting — seeding test data...'); });
Test isolation: Each scenario should start with a clean state. Use Before hooks to reset cookies, localStorage, and any shared state. Never rely on execution order between scenarios — Playwright may run them in any order with parallel workers. For login-heavy suites, consider using Playwright's API calls to authenticate rather than navigating the UI on every scenario.
10. Reporting
A BDD framework without good reporting defeats the purpose — stakeholders need to see results in a readable format. Use both Playwright's HTML reporter (great for debugging) and Cucumber's HTML reporter (great for business stakeholders).
Playwright HTML reporter
Playwright's built-in HTML reporter shows every test, its status, screenshots, traces, and video. View it after a run:
# Run tests and open the Playwright report npm test npx playwright show-report
Cucumber HTML reporter
The @cucumber/cucumber HTML formatter produces a report showing features, scenarios, steps, and tags — exactly how a product owner would want to see test coverage. Install it:
npm install -D @cucumber/html-formatter
Your playwright.config.ts reporter array already includes both. After a run, open cucumber-report.html in any browser for the business-facing view and playwright-report/index.html for the engineering debug view.
CI artifact tip: In GitHub Actions, upload both reports as artifacts. Link to the Playwright report in your PR description for engineers and the Cucumber report in your sprint review for stakeholders. Two audiences, two reports — each perfectly suited to its reader.
11. Parallel Execution
Playwright's worker model gives you free parallel execution. Each worker gets its own browser instance, so scenarios can't interfere with each other — provided you've followed the isolation rules from the hooks section.
export default defineConfig({ // Run all scenarios in parallel across workers fullyParallel: true, // Local: auto-detect CPU cores (half by default) // CI: set explicitly for predictability workers: process.env.CI ? 4 : undefined, // Retry failed scenarios on CI before marking them failed retries: process.env.CI ? 2 : 0, });
Common parallel isolation mistakes
- Shared test accounts — two scenarios logging into the same account simultaneously will conflict. Use separate accounts per worker, or use Playwright's
storageStatewith API authentication to skip UI login - Shared database records — if one scenario creates a record that another deletes, race conditions occur. Use unique identifiers (e.g., timestamps) in test data names
- Hardcoded ports — if your test server starts on port 3000, multiple workers will conflict. Let the test server pick a random port
12. GitHub Actions CI Pipeline
The full BDD pipeline in CI: generate test files from feature files, install dependencies, run tests with sharding, and upload both reports as artifacts.
name: Playwright BDD Tests on: push: branches: [ main, develop ] pull_request: branches: [ main ] jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: shard: ["1/4", "2/4", "3/4", "4/4"] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: 'npm' - name: Install dependencies run: npm ci - name: Install Playwright browsers run: npx playwright install --with-deps chromium - name: Generate BDD test files from feature files run: npx bddgen - name: Run BDD tests (shard ${{ matrix.shard }}) run: npx playwright test --shard=${{ matrix.shard }} env: BASE_URL: ${{ secrets.BASE_URL }} CI: true - name: Upload Playwright report uses: actions/upload-artifact@v4 if: always() with: name: playwright-report-shard-${{ strategy.job-index }} path: playwright-report/ - name: Upload Cucumber report uses: actions/upload-artifact@v4 if: always() with: name: cucumber-report-shard-${{ strategy.job-index }} path: cucumber-report.html smoke-pr: # Fast smoke run on PRs only — no sharding if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 22, cache: 'npm' } - run: npm ci - run: npx playwright install --with-deps chromium - run: npx bddgen && npx playwright test --grep @smoke env: { CI: true, BASE_URL: ${{ secrets.BASE_URL }} }
The workflow has two jobs: test runs the full suite with 4-shard parallelism on pushes to main/develop, and smoke-pr runs only @smoke scenarios on pull requests for fast feedback (typically under 3 minutes). For a deeper dive on pipeline optimization, see our GitHub Actions CI/CD guide.
13. BDD Best Practices
A scenario should verify one specific business behavior. Resist the urge to chain multiple unrelated behaviors in one scenario to "save time." Short, focused scenarios fail faster, are easier to debug, and run better in parallel.
If a step definition is longer than 5 lines, the logic belongs in a Page Object or a helper. Step definitions are glue code — they translate Gherkin into method calls. Automation logic lives in the page layer.
Scenario Outlines work well for "happy path with different products" — not for "invalid email format A, B, C, D." Edge case coverage belongs in unit tests. BDD tests are integration tests: keep them focused on user journeys.
Your @smoke tag should cover the 3-5 flows that, if broken, would immediately affect real users: login, core purchase flow, critical data display. These run on every PR. If they pass, the PR can be reviewed. Full @regression runs only on merge.
The whole point of BDD is that feature files are living documentation. If a product behavior changes and the feature file isn't updated, stakeholders will read incorrect specs. Treat feature file updates as part of the definition of done for every ticket that changes behavior.
FAQ
Can I use Playwright with Cucumber BDD?
Yes. The most popular approach is playwright-bdd, which integrates Cucumber's Gherkin syntax natively into Playwright's test runner. It gives you full access to Playwright fixtures, parallel execution, and the Playwright HTML reporter alongside Cucumber's reporting format.
Should I use playwright-bdd or @cucumber/cucumber with Playwright?
Use playwright-bdd for greenfield projects — it has less boilerplate and gives you Playwright's native runner. Use @cucumber/cucumber directly if you have existing Cucumber infrastructure (shared hooks, world objects, custom formatters) you need to preserve across a migration.
How do I run Playwright Cucumber BDD tests in parallel?
Set fullyParallel: true and workers: 4 (or more) in playwright.config.ts. Each worker gets its own isolated browser context. The key requirement is test isolation — no shared state between scenarios. Use unique test data and API-based authentication to avoid cross-scenario interference.
Is BDD a good fit for Playwright automation?
Yes — when your team has non-technical stakeholders who review or write scenarios. If your entire team is technical and stakeholders never read the feature files, plain Playwright with descriptive test.describe blocks gives you the same structure with less maintenance overhead. Evaluate the process need first, then choose the tooling.
Can I use Page Object Model with Playwright BDD?
Yes — and you should. POM + BDD is the recommended architecture: Gherkin scenarios describe intent, step definitions call POM methods, POM classes encapsulate Playwright locators and actions. When a UI element changes, you update one POM class rather than hunting through step definitions.
How do I debug a failing BDD scenario?
Set trace: 'on-first-retry' in your config. On failure, Playwright captures a full trace. Run npx playwright show-trace test-results/your-test/trace.zip to open the Trace Viewer and step through every action with screenshots, network requests, and console logs. For local debugging, run with --headed --timeout 0 to see the browser in real time.
Playwright + Claude AI Course
Go Beyond BDD — Build a Complete AI-Powered Test Framework
This guide gives you the BDD layer. The full course adds AI test generation with Claude, MCP Server integration, API testing, self-healing locators, and a real e-commerce project you can show in interviews. Everything in TypeScript, from scratch to production-ready.
- Playwright BDD + TypeScript E2E framework from scratch
- Claude AI generates tests 3–5× faster than writing by hand
- Web + API + Device testing in one framework
- Real e-commerce project — portfolio-ready from day one