Skip to main content
Back to Blog
BDD
2026-05-06

Playwright Cucumber BDD Integration Guide 2026

Complete guide to combining Playwright with Cucumber.js for BDD testing. Setup, World context, fixtures, parallel execution, tags, hooks, reporting, and CI integration for 2026.

Playwright Cucumber BDD Integration Guide 2026

Playwright has become the dominant cross-browser automation library for JavaScript and TypeScript teams in 2026, displacing Selenium and WebDriverIO across new adoption. When teams want to combine Playwright's modern browser automation with Behavior-Driven Development, the typical approach is to pair Cucumber.js with Playwright via a custom World context. This combination gives you the best of both worlds: Playwright's powerful selectors, automatic waiting, and tracing -- alongside Cucumber's readable Gherkin scenarios and stakeholder-friendly reports.

This guide is a complete walkthrough for setting up Playwright + Cucumber.js from scratch in 2026. We cover project structure, World context patterns, hooks, parameterized scenarios, parallel execution, custom reporters, CI integration, and the common pitfalls that derail integration efforts. Every code sample is production-tested against Playwright 1.50+ and Cucumber.js 11+.

By the end you will have a fully working BDD + Playwright project with parallel execution, Allure reporting, and GitHub Actions integration -- ready to scale to hundreds of scenarios.

Key Takeaways

  • Custom World class holds the Playwright browser, context, and page references.
  • Hooks open and close browsers per scenario for isolation.
  • Cucumber profile config declares formatters, parallel workers, and require paths.
  • Tags drive which scenarios run in CI vs nightly.
  • Allure or multiple-cucumber-html-reporter produces stakeholder-ready HTML reports.

1. Project Setup

Start with a clean directory:

npm init -y
npm install --save-dev @cucumber/cucumber @playwright/test typescript ts-node @types/node
npx playwright install --with-deps

Add a tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "outDir": "./dist"
  },
  "include": ["src/**/*", "features/**/*"]
}

Add the cucumber profile config at the project root:

// cucumber.cjs
module.exports = {
  default: {
    paths: ["features/**/*.feature"],
    require: ["features/support/**/*.ts", "features/step_definitions/**/*.ts"],
    requireModule: ["ts-node/register"],
    format: [
      "summary",
      "progress-bar",
      "json:reports/cucumber.json",
      "html:reports/cucumber.html",
    ],
    parallel: 4,
    publishQuiet: true,
  },
};

2. Custom World Class

Cucumber's World is the per-scenario state container. For Playwright we extend it with browser, context, and page handles:

// features/support/world.ts
import { setWorldConstructor, IWorldOptions, World } from "@cucumber/cucumber";
import { Browser, BrowserContext, Page, chromium } from "@playwright/test";

export interface ICustomWorld extends World {
  browser?: Browser;
  context?: BrowserContext;
  page?: Page;
  baseUrl: string;
  testData: Record<string, unknown>;
}

export class CustomWorld extends World implements ICustomWorld {
  browser?: Browser;
  context?: BrowserContext;
  page?: Page;
  baseUrl: string;
  testData: Record<string, unknown> = {};

  constructor(options: IWorldOptions) {
    super(options);
    this.baseUrl = process.env.BASE_URL || "http://localhost:3000";
  }
}

setWorldConstructor(CustomWorld);

3. Hooks

Open a fresh browser context per scenario for clean state:

// features/support/hooks.ts
import { Before, After, BeforeAll, AfterAll, Status } from "@cucumber/cucumber";
import { chromium, Browser } from "@playwright/test";
import { ICustomWorld } from "./world";
import * as fs from "fs/promises";

let browser: Browser;

BeforeAll(async function () {
  browser = await chromium.launch({
    headless: process.env.HEADLESS !== "false",
    slowMo: process.env.SLOWMO ? Number(process.env.SLOWMO) : 0,
  });
});

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

Before(async function (this: ICustomWorld) {
  this.browser = browser;
  this.context = await browser.newContext({
    viewport: { width: 1280, height: 720 },
    recordVideo: process.env.VIDEO === "true" ? { dir: "reports/videos/" } : undefined,
  });
  this.page = await this.context.newPage();
});

After(async function (this: ICustomWorld, scenario) {
  if (scenario.result?.status === Status.FAILED && this.page) {
    const screenshot = await this.page.screenshot({ fullPage: true });
    this.attach(screenshot, "image/png");
    const trace = await this.context!.tracing.stop({
      path: `reports/traces/${Date.now()}.zip`,
    }).catch(() => null);
  }
  await this.page?.close();
  await this.context?.close();
});

4. Sample Feature File

# features/checkout.feature
Feature: Checkout flow
  As a customer
  I want to checkout my cart
  So that I can purchase the items

  Background:
    Given the customer is logged in as "alice@example.com"
    And the cart contains:
      | Item    | Quantity |
      | Widget  | 2        |
      | Gadget  | 1        |

  @smoke @checkout
  Scenario: Successful checkout
    When the customer proceeds to checkout
    And the customer enters payment details
    And the customer confirms the order
    Then the order confirmation page should appear
    And the order total should be "89.97"

  @validation
  Scenario Outline: Checkout fails for invalid card
    When the customer proceeds to checkout
    And the customer enters card "<card>"
    And the customer confirms the order
    Then the error message should be "<error>"

    Examples:
      | card                | error                |
      | 4000-0000-0000-0002 | Card declined        |
      | 4000-0000-0000-9995 | Insufficient funds   |

5. Step Definitions

Organize step definitions by domain:

// features/step_definitions/checkout.steps.ts
import { Given, When, Then, DataTable } from "@cucumber/cucumber";
import { expect } from "@playwright/test";
import { ICustomWorld } from "../support/world";

Given("the customer is logged in as {string}", async function (this: ICustomWorld, email: string) {
  await this.page!.goto(`${this.baseUrl}/login`);
  await this.page!.getByLabel("Email").fill(email);
  await this.page!.getByLabel("Password").fill("Sup3rS3cret!");
  await this.page!.getByRole("button", { name: "Sign in" }).click();
  await expect(this.page!).toHaveURL(/dashboard/);
});

Given("the cart contains:", async function (this: ICustomWorld, table: DataTable) {
  const rows = table.hashes();
  for (const row of rows) {
    await this.page!.goto(`${this.baseUrl}/catalog?q=${row.Item}`);
    await this.page!.getByRole("button", { name: "Add to cart" }).click();
    if (Number(row.Quantity) > 1) {
      const qty = this.page!.getByLabel("Quantity");
      await qty.fill(row.Quantity);
    }
  }
});

When("the customer proceeds to checkout", async function (this: ICustomWorld) {
  await this.page!.goto(`${this.baseUrl}/cart`);
  await this.page!.getByRole("button", { name: "Proceed to checkout" }).click();
});

When("the customer enters payment details", async function (this: ICustomWorld) {
  await this.page!.getByLabel("Card number").fill("4242-4242-4242-4242");
  await this.page!.getByLabel("Expiry").fill("12/30");
  await this.page!.getByLabel("CVV").fill("123");
});

When("the customer enters card {string}", async function (this: ICustomWorld, card: string) {
  await this.page!.getByLabel("Card number").fill(card);
  await this.page!.getByLabel("Expiry").fill("12/30");
  await this.page!.getByLabel("CVV").fill("123");
});

When("the customer confirms the order", async function (this: ICustomWorld) {
  await this.page!.getByRole("button", { name: "Confirm order" }).click();
});

Then("the order confirmation page should appear", async function (this: ICustomWorld) {
  await expect(this.page!).toHaveURL(/order\/confirmation/);
  await expect(this.page!.getByRole("heading", { name: /thank you/i })).toBeVisible();
});

Then("the order total should be {string}", async function (this: ICustomWorld, total: string) {
  await expect(this.page!.getByTestId("order-total")).toHaveText(`$${total}`);
});

Then("the error message should be {string}", async function (this: ICustomWorld, error: string) {
  await expect(this.page!.getByRole("alert")).toContainText(error);
});

6. Parallel Execution

Cucumber.js supports parallel execution out of the box. Set parallel in the profile config:

// cucumber.cjs
module.exports = {
  default: {
    parallel: 4,
    // ...
  },
};

Or use the CLI:

npx cucumber-js --parallel 4 --tags "@smoke"

Each parallel worker runs scenarios independently with its own World instance, so the per-scenario browser context isolation continues to work correctly.

7. Reporting

Generate a polished HTML report with multiple-cucumber-html-reporter:

npm install --save-dev multiple-cucumber-html-reporter
// reporter.js
const reporter = require("multiple-cucumber-html-reporter");
reporter.generate({
  jsonDir: "reports/",
  reportPath: "reports/html",
  metadata: {
    browser: { name: "chrome", version: "131" },
    device: "CI",
    platform: { name: "linux", version: "22.04" },
  },
});

Run after cucumber:

npx cucumber-js && node reporter.js

8. CI Integration

GitHub Actions:

name: BDD Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-22.04
    strategy:
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx cucumber-js --tags "@smoke" --parallel 2
        env:
          BASE_URL: http://localhost:3000
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: bdd-report-${{ matrix.shard }}
          path: reports/

9. Common Gotchas

GotchaSolution
this.page is undefined in stepsEnsure Before hook ran before each scenario
World methods missing TypeScript typesCast this: ICustomWorld on every step
Parallel scenarios sharing stateMove state into World, never globals
Browser not closing in CIAlways close in After + AfterAll
Slow CI runsReuse a single browser, fresh context per scenario

10. AI-Assisted BDD Authoring

Pair this setup with the playwright-cucumber skill from the QASkills directory. It teaches Claude or Cursor to generate Gherkin scenarios plus matching Playwright step definitions in your project's style. See cursor-playwright-skill-setup-guide for installation.

11. Advanced Patterns

Tag-Driven Browser Configuration

Configure which browser to use per scenario based on tags:

Before(async function (this: ICustomWorld, { pickle }) {
  const tags = pickle.tags.map(t => t.name)
  const browserName = tags.includes('@firefox') ? 'firefox' : tags.includes('@webkit') ? 'webkit' : 'chromium'
  this.browser = await { chromium, firefox, webkit }[browserName].launch({ headless: true })
  this.context = await this.browser.newContext()
  this.page = await this.context.newPage()
})

Tracing on Failure

Enable Playwright's trace viewer for richer failure diagnostics:

Before(async function (this: ICustomWorld) {
  await this.context!.tracing.start({ screenshots: true, snapshots: true, sources: true })
})

After(async function (this: ICustomWorld, scenario) {
  if (scenario.result?.status === Status.FAILED) {
    const path = `reports/traces/${Date.now()}.zip`
    await this.context!.tracing.stop({ path })
    this.attach(`Trace: ${path}`, 'text/plain')
  } else {
    await this.context!.tracing.stop()
  }
})

Open failed traces with: npx playwright show-trace reports/traces/<id>.zip

API Setup via Playwright Request Context

Use Playwright's APIRequestContext to set up state without going through the UI:

Given('the user has 5 unread notifications', async function (this: ICustomWorld) {
  const api = await this.context!.request
  for (let i = 0; i < 5; i++) {
    await api.post(`${this.baseUrl}/api/notifications`, {
      data: { userId: this.testData.userId, message: `Test #${i}` },
    })
  }
})

Storage State Reuse

For tests that share login state, save and reuse storage state:

BeforeAll(async function () {
  const browser = await chromium.launch()
  const context = await browser.newContext()
  const page = await context.newPage()
  await page.goto('http://localhost:3000/signin')
  await page.fill('[name=email]', 'alice@example.com')
  await page.fill('[name=password]', 'Sup3rS3cret!')
  await page.click('button[type=submit]')
  await context.storageState({ path: 'storage/alice.json' })
  await browser.close()
})

Then in Before:

Before({ tags: '@auth-as-alice' }, async function (this: ICustomWorld) {
  this.context = await this.browser!.newContext({ storageState: 'storage/alice.json' })
  this.page = await this.context.newPage()
})

12. Performance Optimization

For large BDD + Playwright suites, optimize CI runtime by:

  • Reusing the browser across scenarios (open once in BeforeAll).
  • Creating fresh contexts per scenario for isolation.
  • Sharding scenarios across CI workers.
  • Using --tags to skip slow scenarios in PR checks.
  • Pre-seeding test data via API instead of clicking through forms.

13. CI/CD Recipes

GitHub Actions with Sharding

jobs:
  bdd:
    runs-on: ubuntu-22.04
    strategy:
      matrix: { shard: [1, 2, 3, 4] }
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: |
          FEATURES=$(find features -name "*.feature" | sort | awk 'NR%4==${{ matrix.shard }}-1' | tr '\n' ' ')
          npx cucumber-js --parallel 2 $FEATURES
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: report-${{ matrix.shard }}, path: reports/ }

Allure Reporting

Add allure-cucumberjs:

npm install --save-dev allure-cucumberjs

In cucumber.cjs:

format: ['allure-cucumberjs/reporter']

Generate:

npx allure generate reports/allure-results -o reports/allure --clean
npx allure open reports/allure

14. Comparison with Playwright Test Native

Some teams skip BDD and use Playwright's native test runner with descriptive test names. Tradeoffs:

AspectPlaywright + Cucumber.jsPlaywright Test
SetupMore complexSimple
Readability for stakeholdersHigh (Gherkin)Medium (test descriptions)
Parallel executionCucumber profileNative
ReportingCucumber HTML/AllureNative HTML
RefactoringStep definition complexitySimpler
Best forCross-functional collaborationEngineering-only suites

If stakeholders don't read scenarios, the BDD overhead is hard to justify. If they do, Cucumber's readability pays for itself.

15. AI-Assisted Authoring

The playwright-cucumber SKILL.md pack on QASkills teaches Claude or Cursor to generate scenario + step definition pairs in your house style. Install:

npx @qaskills/cli add playwright-cucumber

Then prompt:

Generate a Cucumber feature for the password reset flow with 4 scenarios. Then generate matching Playwright step definitions using our existing page objects.

See claude-code-qa-testing-workflows-2026 for concrete workflows.

16. Frequently Asked Questions

Q: Can I use Playwright's expect inside step definitions? A: Yes -- import expect from @playwright/test and use it normally inside steps.

Q: How do I share state between steps in the same scenario? A: Use the World instance via this.. Each scenario gets a fresh World, so state stays scoped.

Q: Can I run Cucumber tests in headed mode for debugging? A: Yes -- set HEADLESS=false. The Before hook in this guide already respects that env var.

Q: Does Playwright support iframes in BDD? A: Yes -- use frameLocator() in step definitions for iframe interactions.

Q: Multi-tab scenarios? A: Use context.newPage() in steps to open additional tabs.

Conclusion

Playwright + Cucumber.js is a powerful combination in 2026: the readability of Gherkin with the robustness of Playwright's modern selectors, automatic waits, and tracing. Set up the World class once, write disciplined hooks, and the rest is just authoring scenarios. For broader BDD strategy see comparing-popular-bdd-frameworks-2026-complete-guide.

Playwright Cucumber BDD Integration Guide 2026 | QASkills.sh