BDD September 2, 2026 18 min read

Playwright + Cucumber BDD Tutorial 2026 (TypeScript)

Cucumber BDD bridges the gap between business stakeholders and technical tests. This tutorial walks you through integrating Playwright with Cucumber's Gherkin syntax in TypeScript — from project setup to a full framework with Page Object Model, tags, parallel execution, and CI/CD.

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:

Gherkin syntax
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

Terminal
# 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

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "moduleResolution": "node",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src/**/*", "features/**/*"]
}

Project structure

Directory 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.

playwright.config.ts
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:

package.json (scripts section)
{
  "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.

.gitignore
# 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

features/login.feature
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.

features/checkout.feature
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

Do this
Write declarative steps that describe intent:

When I add the first product to my cart
Then the cart count should be 1
Not this
Write imperative steps that describe actions:

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

src/steps/login.steps.ts
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:

src/steps/common.steps.ts
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

src/pages/LoginPage.ts
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:

src/fixtures/fixtures.ts
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:

src/steps/login.steps.ts (with fixtures)
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

Tag system
@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

Terminal
# 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:

features/cart.feature
@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.

src/steps/hooks.ts
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:

Terminal
# 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:

Terminal
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.

playwright.config.ts (parallel settings)
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 storageState with 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.

.github/workflows/playwright-bdd.yml
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

1
One scenario per behavior, not per step

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.

2
Keep step definitions under 5 lines

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.

3
Use Scenario Outline for data variations, not for edge cases

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.

4
Tag ruthlessly and run @smoke on every PR

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.

5
Never let feature files become stale

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.

Asim Noaman
Asim Noaman
Senior QA Automation Engineer & AI Testing Specialist
Connect on LinkedIn →

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
Enroll on Udemy →