There’s a gap that keeps causing problems on every team I’ve worked with: QC testers know what to test, but they can’t always express it in code. Product owners know what the feature should do, but they can’t read TypeScript. Developers know how to code, but they don’t always know the edge cases.

BDD — Behavior-Driven Development — bridges that gap. You write tests in plain English using a format called Gherkin. Anyone can read them. Anyone can contribute. And Cucumber connects those English descriptions to real Playwright automation behind the scenes.

What BDD Actually Means

BDD isn’t about tools. It’s about shared understanding. Before anyone writes code, the team agrees on how a feature should behave, written in a format everyone can read.

Here’s a traditional test case:

Test Case: TC-042
Precondition: User has an account
Steps:
1. Navigate to login page
2. Enter valid email
3. Enter valid password
4. Click Sign In button
Expected: User is redirected to dashboard

Here’s the same test in Gherkin (BDD format):

Feature: User Authentication
  As a registered user
  I want to log into the application
  So that I can access my dashboard

  Scenario: Successful login with valid credentials
    Given I am on the login page
    When I enter "user@example.com" as my email
    And I enter "correctPassword" as my password
    And I click the Sign In button
    Then I should be redirected to the dashboard
    And I should see a welcome message

Notice the difference. The Gherkin version:

  • Is readable by anyone — even your project manager
  • Describes behavior from the user’s perspective
  • Acts as both a test and a specification
  • Can be used in meetings to discuss requirements

Setting Up Cucumber with Playwright

Let’s build this from scratch. You need two additional packages:

npm install --save-dev @cucumber/cucumber @playwright/test ts-node

Create a Cucumber configuration file:

// cucumber.js
module.exports = {
  default: {
    require: ['tests/steps/**/*.ts'],
    requireModule: ['ts-node/register'],
    format: [
      'progress',
      'html:reports/cucumber-report.html',
    ],
    paths: ['tests/features/**/*.feature'],
    publishQuiet: true,
  },
};

Add a script to your package.json:

{
  "scripts": {
    "test:bdd": "cucumber-js",
    "test:e2e": "playwright test",
    "test": "npm run test:e2e && npm run test:bdd"
  }
}

Your project structure now looks like:

tests/
├── features/              ← Gherkin feature files (plain English)
│   ├── login.feature
│   ├── blog.feature
│   └── search.feature
├── steps/                 ← Step definitions (code that runs)
│   ├── login.steps.ts
│   ├── blog.steps.ts
│   └── common.steps.ts
├── pages/                 ← Page Objects (same as before)
│   ├── LoginPage.ts
│   └── BlogPage.ts
├── e2e/                   ← Regular Playwright tests
│   └── ...
└── support/               ← Shared hooks and utilities
    └── world.ts

Writing Feature Files

Feature files are the heart of BDD. They live in tests/features/ and use the .feature extension.

The Basics: Feature, Scenario, Steps

# tests/features/login.feature
Feature: User Login
  As a registered user
  I want to log into the application
  So that I can access my personalized dashboard

  Scenario: Successful login
    Given I am on the login page
    When I enter "user@example.com" as the email
    And I enter "password123" as the password
    And I click the Sign In button
    Then I should be redirected to the dashboard
    And I should see "Welcome back" on the page

  Scenario: Failed login with wrong password
    Given I am on the login page
    When I enter "user@example.com" as the email
    And I enter "wrongpassword" as the password
    And I click the Sign In button
    Then I should see an error message "Invalid email or password"
    And I should remain on the login page

  Scenario: Empty form submission
    Given I am on the login page
    When I click the Sign In button
    Then I should see an error message "Email is required"

Scenario Outline: Data-Driven BDD

When you want to test the same scenario with different data, use a Scenario Outline:

# tests/features/login.feature (continued)

  Scenario Outline: Login validation
    Given I am on the login page
    When I enter "<email>" as the email
    And I enter "<password>" as the password
    And I click the Sign In button
    Then I should see "<result>"

    Examples:
      | email              | password     | result                      |
      | user@example.com   | correct123   | Welcome back                |
      | user@example.com   | wrong        | Invalid email or password   |
      |                    | password     | Email is required           |
      | not-an-email       | password     | Please enter a valid email  |
      | user@example.com   |              | Password is required        |

This generates five separate tests from one template — each row in the Examples table becomes a test run with those values substituted in.

# tests/features/blog.feature
Feature: Blog Search and Filtering
  As a blog reader
  I want to search and filter blog posts
  So that I can find content relevant to my interests

  Background:
    Given I am on the blog page

  Scenario: Search by keyword
    When I search for "playwright"
    Then I should see blog posts containing "playwright"
    And the result count should be greater than 0

  Scenario: Filter by tag
    When I click the "ai" tag filter
    Then all visible posts should have the "ai" tag
    And the "ai" tag should appear selected

  Scenario: Clear search
    Given I have searched for "testing"
    When I clear the search field
    Then I should see all blog posts

  Scenario: No results found
    When I search for "xyznonexistent123"
    Then I should see a "No posts found" message
    And the search field should still contain "xyznonexistent123"

Notice the Background section — it runs before every scenario in this feature, like beforeEach in Playwright.

Building Step Definitions

Step definitions connect the plain English steps to actual Playwright code. This is where QC testers who know Playwright shine.

The World: Shared Context

First, create a “World” that holds the browser instance and page objects:

// tests/support/world.ts
import { setWorldConstructor, World } from '@cucumber/cucumber';
import { Browser, BrowserContext, Page, chromium } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { BlogPage } from '../pages/BlogPage';

export class CustomWorld extends World {
  browser!: Browser;
  context!: BrowserContext;
  page!: Page;
  loginPage!: LoginPage;
  blogPage!: BlogPage;

  async openBrowser() {
    this.browser = await chromium.launch({ headless: true });
    this.context = await this.browser.newContext();
    this.page = await this.context.newPage();
    this.loginPage = new LoginPage(this.page);
    this.blogPage = new BlogPage(this.page);
  }

  async closeBrowser() {
    await this.context?.close();
    await this.browser?.close();
  }
}

setWorldConstructor(CustomWorld);

Hooks: Setup and Teardown

// tests/support/hooks.ts
import { Before, After } from '@cucumber/cucumber';
import { CustomWorld } from './world';

Before(async function (this: CustomWorld) {
  await this.openBrowser();
});

After(async function (this: CustomWorld) {
  await this.closeBrowser();
});

Login Step Definitions

// tests/steps/login.steps.ts
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from '@playwright/test';
import { CustomWorld } from '../support/world';

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

When('I enter {string} as the email', async function (this: CustomWorld, email: string) {
  await this.loginPage.emailInput.fill(email);
});

When('I enter {string} as the password', async function (this: CustomWorld, password: string) {
  await this.loginPage.passwordInput.fill(password);
});

When('I click the Sign In button', async function (this: CustomWorld) {
  await this.loginPage.signInButton.click();
});

Then('I should be redirected to the dashboard', async function (this: CustomWorld) {
  await expect(this.page).toHaveURL(/\/dashboard/);
});

Then('I should see {string} on the page', async function (this: CustomWorld, text: string) {
  await expect(this.page.getByText(text)).toBeVisible();
});

Then('I should see an error message {string}', async function (this: CustomWorld, errorText: string) {
  await expect(this.loginPage.errorMessage).toContainText(errorText);
});

Then('I should remain on the login page', async function (this: CustomWorld) {
  await expect(this.page).toHaveURL(/\/login/);
});

Then('I should see {string}', async function (this: CustomWorld, text: string) {
  await expect(this.page.getByText(text)).toBeVisible();
});

Blog Step Definitions

// tests/steps/blog.steps.ts
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from '@playwright/test';
import { CustomWorld } from '../support/world';

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

Given('I have searched for {string}', async function (this: CustomWorld, query: string) {
  await this.blogPage.searchFor(query);
});

When('I search for {string}', async function (this: CustomWorld, query: string) {
  await this.blogPage.searchFor(query);
});

When('I click the {string} tag filter', async function (this: CustomWorld, tagName: string) {
  await this.blogPage.filterByTag(tagName);
});

When('I clear the search field', async function (this: CustomWorld) {
  await this.blogPage.searchInput.clear();
});

Then('I should see blog posts containing {string}', async function (this: CustomWorld, keyword: string) {
  const count = await this.blogPage.getVisiblePostCount();
  expect(count).toBeGreaterThan(0);
});

Then('the result count should be greater than {int}', async function (this: CustomWorld, minCount: number) {
  const count = await this.blogPage.getVisiblePostCount();
  expect(count).toBeGreaterThan(minCount);
});

Then('I should see a {string} message', async function (this: CustomWorld, messageText: string) {
  await expect(this.page.getByText(messageText)).toBeVisible();
});

Then('I should see all blog posts', async function (this: CustomWorld) {
  const count = await this.blogPage.getVisiblePostCount();
  expect(count).toBeGreaterThan(5); // Assuming you have at least 5 posts
});

Running BDD Tests

# Run all BDD tests
npm run test:bdd

# Run a specific feature
npx cucumber-js tests/features/login.feature

# Run with tags
npx cucumber-js --tags "@smoke"

Tagging Scenarios

You can tag scenarios for selective execution:

@smoke
Scenario: Successful login
  Given I am on the login page
  ...

@regression
Scenario: Login with expired session
  Given I had a previous session
  ...

@wip
Scenario: Social media login
  Given I am on the login page
  When I click "Login with Google"
  ...
npx cucumber-js --tags "@smoke"           # Only smoke tests
npx cucumber-js --tags "not @wip"         # Everything except work-in-progress
npx cucumber-js --tags "@smoke and @login" # Smoke tests for login feature

Living Documentation

One of BDD’s greatest strengths is that your feature files serve as documentation. They describe exactly what the system does in plain English.

Generating HTML Reports

With the html format configured, Cucumber generates a beautiful HTML report:

// cucumber.js
format: [
  'progress',
  'html:reports/cucumber-report.html',
],

Share this report with stakeholders. They can read every scenario and understand exactly what’s been tested — without opening a single code file.

Feature Files as Requirements

During sprint planning, use feature files to define acceptance criteria:

Product Owner: “Users should be able to reset their password.”

QC Team writes:

Feature: Password Reset
  As a user who forgot their password
  I want to reset it via email
  So that I can regain access to my account

  Scenario: Request password reset
    Given I am on the login page
    When I click "Forgot password?"
    And I enter my registered email "user@example.com"
    And I click "Send reset link"
    Then I should see "Check your email for a reset link"

  Scenario: Reset link expires after 24 hours
    Given I requested a password reset 25 hours ago
    When I click the reset link from my email
    Then I should see "This reset link has expired"
    And I should see a "Request new link" button

  Scenario: Password must meet requirements
    Given I am on the password reset page
    When I enter "short" as my new password
    Then I should see "Password must be at least 8 characters"

The PO reads it, adds a scenario they forgot (“What about when the email isn’t registered?”), and the developer implements knowing exactly what to build. Everyone speaks the same language.

When BDD Adds Value vs. When It’s Overkill

BDD isn’t always the right choice. Here’s when to use it and when to skip it:

Use BDD When:

  • Multiple stakeholders need to understand the tests — POs, BAs, and clients
  • Acceptance criteria need formal definition — Feature files become the contract
  • Your QC team is stronger in domain knowledge than code — They write feature files, developers write step definitions
  • You want living documentation — Feature files auto-generate readable reports
  • Cross-team collaboration — Feature files are a shared language

Skip BDD When:

  • The team is small and technical — Developers + QC all read TypeScript fine
  • Speed matters more than documentation — BDD adds overhead (feature files + step definitions)
  • Tests are highly technical — API contract tests, performance tests, database tests
  • You’re just starting automation — Learn Playwright first, add Cucumber later

The Hybrid Approach (What I Recommend)

Use BDD for user-facing acceptance tests — the scenarios a product owner cares about. Use regular Playwright tests for everything else:

tests/
├── features/         ← BDD for acceptance tests (login, checkout, core flows)
├── steps/
├── e2e/              ← Regular Playwright for regression, edge cases, visual tests
├── api/              ← API tests (no BDD needed)
└── visual/           ← Visual regression (no BDD needed)

This gives you the best of both worlds: stakeholder-readable tests for critical flows, and fast-to-write Playwright tests for everything else.

Series Navigation

In Part 6, we’ll explore the AI tools that can supercharge your test writing — Claude, GitHub Copilot, and Antigravity. You’ll see how each tool approaches the same test scenario and learn when to use which one.

Export for reading

Comments