Related articles: Also see reusable fixtures that pair perfectly with BDD step definitions, design patterns that make test suites maintainable at scale, and shifting quality left with earlier and faster feedback loops.
BDD with Playwright: Write Tests That Non-Engineers Can Read, Review, and Trust
Your product manager asks, "Can we test the user registration flow?"
Your developer writes a test:
test('user registration', async () => {
await page.fill('#email', 'test@example.com');
await page.fill('#password', 'password123');
await page.click('button[type="submit"]');
expect(page.url()).toContain('/dashboard');
});
Your PM looks confused. "Where's the confirmation email? What about validation? Can I understand what this is actually testing?"
This is the communication gap that BDD solves.
Behavior-Driven Development (BDD) is a methodology that bridges technical and non-technical stakeholders by expressing test scenarios in plain language that everyone can understand, validate, and maintain together.
This comprehensive guide teaches you how to implement BDD with Playwright, use Cucumber and Gherkin syntax to write acceptance tests, and create living documentation that serves both your team and your codebase.
What is BDD and Why Does It Matter?
The Three Amigos
BDD emerged from the realization that the best software comes from collaboration between three perspectives:
| Role | Perspective | Questions They Ask |
|---|---|---|
| Business (PM/PO) | What should the system do? | What value does this deliver? What's the business rule? |
| Development | How will we build it? | What technical constraints exist? How do we implement this? |
| Testing (QA) | How do we know it works? | What could go wrong? What are edge cases? |
BDD brings these three together to define behavior before writing code.
Given-When-Then: The Language of BDD
BDD uses a simple, structured language called Gherkin:
Feature: User Registration
As a new user
I want to create an account
So that I can access the platform
Scenario: Successful registration with valid email
Given I am on the registration page
When I enter a valid email "user@example.com"
And I enter a password "SecureP@ss123"
And I click the "Sign Up" button
Then I should see a confirmation message
And I should receive a welcome email
And I should be redirected to the onboarding page
Everyone can read this. Your PM knows the requirements are being tested. Your developer knows the acceptance criteria. Your QA knows what to verify.
Setting Up BDD with Playwright and Cucumber
Installation and Configuration
npm install --save-dev @cucumber/cucumber playwright
npm install --save-dev @types/cucumber
Create a Cucumber configuration file:
// cucumber.config.js
const config = {
requireModule: ['ts-node/register'],
require: ['tests/bdd/step-definitions/**/*.ts'],
format: [
'progress-bar',
'html:reports/cucumber-report.html',
'json:reports/cucumber-report.json',
'@cucumber/pretty-formatter',
],
formatOptions: { snippetInterface: 'async-await' },
publishQuiet: true,
dryRun: false,
failFast: false,
paths: ['tests/bdd/features/**/*.feature'],
parallel: 2,
};
module.exports = { default: config };
Project Structure
tests/
+-- bdd/
� +-- features/
� � +-- authentication/
� � � +-- login.feature
� � � +-- registration.feature
� � +-- checkout/
� � � +-- purchase-flow.feature
� � +-- dashboard/
� � +-- user-profile.feature
� +-- step-definitions/
� � +-- authentication-steps.ts
� � +-- checkout-steps.ts
� � +-- common-steps.ts
� +-- support/
� � +-- world.ts
� � +-- hooks.ts
� � +-- page-objects/
� +-- fixtures/
� +-- test-data.json
Writing Effective Gherkin Scenarios
Feature: The High-Level Capability
Feature: Shopping Cart Management
As an online shopper
I want to manage items in my shopping cart
So that I can purchase multiple products efficiently
Background:
Given I am logged in as "test.user@example.com"
And I am on the products page
Scenario: Add single item to empty cart
When I click "Add to Cart" for product "Laptop"
Then the cart icon should show "1" item
And the cart total should be "$999.99"
Scenario: Remove item from cart
Given I have added "Laptop" to my cart
When I go to the cart page
And I click "Remove" for "Laptop"
Then my cart should be empty
And the cart total should be "$0.00"
Scenario: Update item quantity
Given I have added "Laptop" to my cart
When I go to the cart page
And I change the quantity to "3"
Then the cart should show "3" items of "Laptop"
And the cart total should be "$2,999.97"
Scenario Outline: Apply discount codes
Given I have added items worth "<subtotal>" to my cart
When I apply discount code "<code>"
Then the discount should be "<discount>"
And the final total should be "<total>"
Examples:
| subtotal | code | discount | total |
| $100.00 | SAVE10 | $10.00 | $90.00 |
| $100.00 | SAVE20 | $20.00 | $80.00 |
| $50.00 | SAVE10 | $5.00 | $45.00 |
| $100.00 | INVALID | $0.00 | $100.00 |
Best Practices for Gherkin
DO:
- Use business language, not technical implementation details
- Focus on behavior, not UI elements
- Keep scenarios independent (can run in any order)
- Use
Backgroundfor common setup steps - Use
Scenario Outlinefor testing multiple data combinations
DON'T:
- Include CSS selectors or XPath in feature files
- Write implementation details (how) instead of behavior (what)
- Create dependencies between scenarios
- Make scenarios too long (max 5-7 steps)
Implementing Step Definitions with Playwright
Setting Up the World Context
// support/world.ts
import { World, IWorldOptions, setWorldConstructor } from '@cucumber/cucumber';
import { Browser, BrowserContext, Page, chromium } from 'playwright';
export interface CustomWorld extends World {
browser?: Browser;
context?: BrowserContext;
page?: Page;
testData?: any;
}
export class CustomWorldImpl extends World implements CustomWorld {
browser?: Browser;
context?: BrowserContext;
page?: Page;
testData?: any;
constructor(options: IWorldOptions) {
super(options);
}
}
setWorldConstructor(CustomWorldImpl);
Hooks for Setup and Teardown
// support/hooks.ts
import { Before, After, BeforeAll, AfterAll, Status } from '@cucumber/cucumber';
import { chromium } from 'playwright';
import { CustomWorld } from './world';
BeforeAll(async function () {
console.log('Starting BDD test suite');
});
Before(async function (this: CustomWorld, { pickle }) {
// Launch browser for each scenario
this.browser = await chromium.launch({
headless: process.env.HEADLESS !== 'false',
slowMo: process.env.SLOW_MO ? parseInt(process.env.SLOW_MO) : 0,
});
this.context = await this.browser.newContext({
viewport: { width: 1920, height: 1080 },
recordVideo: process.env.RECORD_VIDEO
? {
dir: 'test-results/videos',
}
: undefined,
});
this.page = await this.context.newPage();
console.log(`Starting scenario: ${pickle.name}`);
});
After(async function (this: CustomWorld, { pickle, result }) {
// Take screenshot on failure
if (result?.status === Status.FAILED && this.page) {
const screenshot = await this.page.screenshot({
path: `test-results/screenshots/${pickle.name}.png`,
fullPage: true,
});
this.attach(screenshot, 'image/png');
}
// Cleanup
await this.context?.close();
await this.browser?.close();
console.log(`Finished scenario: ${pickle.name} - ${result?.status}`);
});
AfterAll(async function () {
console.log('BDD test suite completed');
});
Writing Step Definitions
// step-definitions/authentication-steps.ts
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from '@playwright/test';
import { CustomWorld } from '../support/world';
Given('I am on the registration page', async function (this: CustomWorld) {
await this.page!.goto('https://example.com/register');
await expect(this.page!.locator('h1')).toHaveText('Create Account');
});
When('I enter a valid email {string}', async function (this: CustomWorld, email: string) {
await this.page!.fill('[data-testid="email-input"]', email);
// Store for later steps
this.testData = { ...this.testData, email };
});
When('I enter a password {string}', async function (this: CustomWorld, password: string) {
await this.page!.fill('[data-testid="password-input"]', password);
this.testData = { ...this.testData, password };
});
When('I click the {string} button', async function (this: CustomWorld, buttonText: string) {
await this.page!.click(`button:has-text("${buttonText}")`);
});
Then('I should see a confirmation message', async function (this: CustomWorld) {
const message = this.page!.locator('[data-testid="success-message"]');
await expect(message).toBeVisible();
await expect(message).toContainText('Welcome');
});
Then('I should receive a welcome email', async function (this: CustomWorld) {
// This would typically check an email service or database
// For demo purposes, we'll check an API endpoint
const response = await this.page!.request.get(`https://api.example.com/emails/check?to=${this.testData.email}`);
expect(response.ok()).toBeTruthy();
const emails = await response.json();
const welcomeEmail = emails.find((e: any) => e.subject.includes('Welcome') && e.to === this.testData.email);
expect(welcomeEmail).toBeDefined();
});
Then('I should be redirected to the onboarding page', async function (this: CustomWorld) {
await expect(this.page!).toHaveURL(/.*\/onboarding/);
});
Reusable Step Definitions
// step-definitions/common-steps.ts
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from '@playwright/test';
import { CustomWorld } from '../support/world';
// Navigation
Given('I am on the {string} page', async function (this: CustomWorld, pageName: string) {
const urls: Record<string, string> = {
home: '/',
products: '/products',
cart: '/cart',
checkout: '/checkout',
login: '/login',
registration: '/register',
};
const url = urls[pageName.toLowerCase()];
if (!url) throw new Error(`Unknown page: ${pageName}`);
await this.page!.goto(`https://example.com${url}`);
});
// Authentication
Given('I am logged in as {string}', async function (this: CustomWorld, email: string) {
await this.page!.goto('https://example.com/login');
await this.page!.fill('[data-testid="email"]', email);
await this.page!.fill('[data-testid="password"]', 'TestPassword123!');
await this.page!.click('[data-testid="login-button"]');
await this.page!.waitForURL('**/dashboard');
});
// Form interactions
When('I fill in {string} with {string}', async function (this: CustomWorld, field: string, value: string) {
const selector = `[data-testid="${field}"], [name="${field}"], [placeholder*="${field}" i]`;
await this.page!.fill(selector, value);
});
// Assertions
Then('I should see {string}', async function (this: CustomWorld, text: string) {
await expect(this.page!.locator(`text=${text}`)).toBeVisible();
});
Then('the {string} should be {string}', async function (this: CustomWorld, element: string, value: string) {
const locator = this.page!.locator(`[data-testid="${element}"]`);
await expect(locator).toContainText(value);
});
// Wait conditions
When('I wait for {int} seconds', async function (this: CustomWorld, seconds: number) {
await this.page!.waitForTimeout(seconds * 1000);
});
Then('the page should contain {string}', async function (this: CustomWorld, text: string) {
const content = await this.page!.content();
expect(content).toContain(text);
});
Advanced BDD Patterns
Data Tables
Scenario: Bulk add products to cart
Given I am on the products page
When I add the following products to my cart:
| Product Name | Quantity | Price |
| Laptop | 1 | $999.99 |
| Mouse | 2 | $29.99 |
| Keyboard | 1 | $79.99 |
Then my cart should contain 3 types of items
And the cart total should be "$1,139.96"
When('I add the following products to my cart:', async function (this: CustomWorld, dataTable) {
const products = dataTable.hashes(); // Convert to array of objects
for (const product of products) {
await this.page!.locator(`[data-product="${product['Product Name']}"]`).click();
const quantityInput = this.page!.locator('[data-testid="quantity"]');
await quantityInput.fill(product.Quantity);
await this.page!.click('[data-testid="add-to-cart"]');
}
// Store for verification
this.testData = { ...this.testData, addedProducts: products };
});
Doc Strings
Scenario: Submit contact form with message
Given I am on the contact page
When I fill in the contact form with:
"""
Name: John Doe
Email: john@example.com
Subject: Product Inquiry
Hi, I'm interested in your enterprise plan.
Can you provide more details about pricing
and implementation timeline?
"""
And I click "Send Message"
Then I should see "Message sent successfully"
When('I fill in the contact form with:', async function (this: CustomWorld, docString: string) {
const lines = docString.split('\n').filter((line) => line.trim());
for (const line of lines) {
if (line.includes(':')) {
const [field, value] = line.split(':').map((s) => s.trim());
const selector = field.toLowerCase().replace(/\s+/g, '-');
await this.page!.fill(`[data-testid="${selector}"]`, value);
}
}
});
BDD Testing Strategy Decision Matrix
graph TD
A[New Feature Request] --> B{Who needs to understand it?}
B -->|Tech team only| C[Unit/Integration Tests]
B -->|Cross-functional| D[BDD Scenarios]
D --> E{Complexity?}
E -->|Simple CRUD| F[Basic Scenarios]
E -->|Business Logic| G[Scenario Outlines]
E -->|Complex Flow| H[Multiple Features]
F --> I[Write Gherkin]
G --> I
H --> I
I --> J[Define Step Definitions]
J --> K[Implement with Playwright]
K --> L[CI/CD Integration]
L --> M{Passing?}
M -->|No| N[Debug & Fix]
M -->|Yes| O[Living Documentation]
N --> J
Integrating BDD into CI/CD
GitHub Actions Workflow
# .github/workflows/bdd-tests.yml
name: BDD Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
bdd-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run BDD tests
run: npm run test:bdd
env:
BASE_URL: ${{ secrets.TEST_BASE_URL }}
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
- name: Upload Cucumber Report
if: always()
uses: actions/upload-artifact@v3
with:
name: cucumber-report
path: reports/cucumber-report.html
- name: Upload Screenshots
if: failure()
uses: actions/upload-artifact@v3
with:
name: failure-screenshots
path: test-results/screenshots/
- name: Comment PR with Results
if: github.event_name == 'pull_request'
uses: actions/github-script@v6
with:
script: |
const fs = require('fs');
const report = JSON.parse(fs.readFileSync('reports/cucumber-report.json'));
let passed = 0, failed = 0, skipped = 0;
report.forEach(feature => {
feature.elements.forEach(scenario => {
scenario.steps.forEach(step => {
if (step.result.status === 'passed') passed++;
else if (step.result.status === 'failed') failed++;
else if (step.result.status === 'skipped') skipped++;
});
});
});
const body = `## BDD Test Results\n\n? Passed: ${passed}\n? Failed: ${failed}\n?? Skipped: ${skipped}`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body
});
Best Practices for BDD Success
1. Write Scenarios from the User's Perspective
Bad (implementation-focused):
When I click the button with id "submit-btn"
And I wait for the AJAX request to complete
Then the DOM should contain element with class "success-msg"
Good (behavior-focused):
When I submit the registration form
Then I should see a success message
And I should receive a confirmation email
2. Keep Scenarios Independent
Each scenario should be able to run in isolation, in any order.
# ? BAD - Depends on previous scenario
Scenario: View cart
Then I should see "Laptop" in my cart
Scenario: Checkout
When I proceed to checkout
Then I should see the payment form
# ? GOOD - Self-contained
Scenario: View cart with items
Given I have added "Laptop" to my cart
When I view my cart
Then I should see "Laptop" in my cart
Scenario: Checkout with items
Given I have added "Laptop" to my cart
When I proceed to checkout
Then I should see the payment form
3. Use Background for Common Setup
Feature: User Profile Management
Background:
Given I am logged in as "john.doe@example.com"
And I am on the profile page
Scenario: Update email address
When I change my email to "john.new@example.com"
And I click "Save Changes"
Then I should see "Email updated successfully"
Scenario: Update password
When I change my password to "NewSecure123!"
And I click "Save Changes"
Then I should see "Password updated successfully"
4. Create a Ubiquitous Language
Build a shared vocabulary between business and technical teams:
# Domain terms everyone understands
Given a customer has an active subscription
When their payment fails
Then they enter a grace period of 7 days
And they receive a payment reminder email
# NOT technical jargon
Given a user record exists in the database with status_id = 2
When the Stripe webhook returns error code 402
Then update the subscription_status column to 'past_due'
Measuring BDD Effectiveness
Key Metrics to Track
| Metric | What It Measures | Target |
|---|---|---|
| Scenario Pass Rate | % of scenarios passing | > 95% |
| Spec by Example Coverage | % of requirements with scenarios | 100% critical paths |
| Ambiguity Index | Steps requiring clarification | < 5% |
| Living Documentation Usage | Teams referencing features | All stakeholders |
| Time to Scenario Creation | Speed of writing new scenarios | < 30 min/scenario |
| False Positive Rate | Failing tests with no bug | < 2% |
Continuous Improvement
// scenario-metrics.ts
interface ScenarioMetrics {
totalScenarios: number;
avgExecutionTime: number;
mostFailedScenarios: Array<{ name: string; failures: number }>;
flakyScenarios: string[];
coverage: {
features: number;
scenarios: number;
steps: number;
};
}
export function generateMetricsReport(cucumberJson: any): ScenarioMetrics {
const metrics: ScenarioMetrics = {
totalScenarios: 0,
avgExecutionTime: 0,
mostFailedScenarios: [],
flakyScenarios: [],
coverage: {
features: cucumberJson.length,
scenarios: 0,
steps: 0,
},
};
let totalDuration = 0;
const failureMap = new Map<string, number>();
cucumberJson.forEach((feature: any) => {
feature.elements.forEach((scenario: any) => {
metrics.totalScenarios++;
metrics.coverage.scenarios++;
let scenarioDuration = 0;
let hasFailure = false;
scenario.steps.forEach((step: any) => {
metrics.coverage.steps++;
scenarioDuration += step.result.duration || 0;
if (step.result.status === 'failed') {
hasFailure = true;
const currentCount = failureMap.get(scenario.name) || 0;
failureMap.set(scenario.name, currentCount + 1);
}
});
totalDuration += scenarioDuration;
// Detect flaky scenarios (sometimes pass, sometimes fail)
if (scenario.tags?.some((t: any) => t.name === '@flaky')) {
metrics.flakyScenarios.push(scenario.name);
}
});
});
metrics.avgExecutionTime = totalDuration / metrics.totalScenarios;
// Sort and get top 5 most failed scenarios
metrics.mostFailedScenarios = Array.from(failureMap.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([name, failures]) => ({ name, failures }));
return metrics;
}
Real-World BDD Success Story
The Challenge
An e-commerce company had communication breakdowns:
- 40% of "completed" features didn't match requirements
- QA found 60% of bugs after development
- Average 3 rounds of clarification per feature
The BDD Solution
-
Three Amigos Meetings
- PM, Dev, QA write scenarios together
- Before any code is written
- Agreement on acceptance criteria
-
Living Documentation
- All features documented in Gherkin
- Automatically generated reports from test runs
- Single source of truth
-
Shift-Left Testing
- Scenarios define requirements
- Tests written alongside code
- Immediate feedback
The Results
| Metric | Before BDD | After BDD | Improvement |
|---|---|---|---|
| Requirements Mismatch | 40% | 5% | 88% reduction |
| Bugs Found in QA | 60% | 15% | 75% reduction |
| Clarification Rounds | 3 avg | 0.5 avg | 83% reduction |
| Time to Market | 6 weeks | 3 weeks | 50% faster |
| Stakeholder Confidence | 60% | 95% | 58% increase |
ROI: $480,000/year in reduced rework and faster delivery.
Conclusion: BDD as a Communication Tool
BDD isn't just a testing framework�it's a communication protocol between business and technology.
Key takeaways:
- Write scenarios in plain language that everyone can understand
- Use Cucumber and Gherkin to create executable specifications
- Integrate with Playwright for powerful browser automation
- Make scenarios independent and repeatable
- Treat feature files as living documentation that evolves with your product
- Involve Three Amigos in scenario creation
- Measure effectiveness and iterate
When done right, BDD transforms "Can you test this?" into "Here's exactly what we're building, and here's proof it works."
The best part? Everyone on your team�from CEO to developer�can read your test suite and understand what your application does.
Ready to bridge the gap between business and technology? Start implementing BDD with ScanlyApp's comprehensive testing platform today.
