Skip to content

Beyond Cucumber: A Type-Safe 4-Layer BDD Architecture with Playwright

Beyond Cucumber: A Type-Safe 4-Layer BDD Architecture with Playwright PRO IMPLEMENTATION

Section titled “Beyond Cucumber: A Type-Safe 4-Layer BDD Architecture with Playwright ”

If you want the story behind why BDR exists — I wrote about it this Article. This article is the technical deep dive: architecture, real code, and implementation details.

Note: BDR (Behavior-Driven Living Requirements) is my own architectural approach to organizing Playwright tests — a Cucumber-free alternative to BDD that I designed and documented at bdr-methodology.dev.


You write your scenario in a .feature file, then wire it to TypeScript in a step definition file, and your IDE has no idea they’re connected. Rename a method — nothing breaks at compile time. Run your tests — everything breaks at runtime.

BDR solves this by keeping Given/When/Then directly in TypeScript. Same BDD philosophy, zero translation layer.


BDR enforces strict separation of concerns across 4 layers. Each layer has one job:

LayerResponsibilityExample
SpecificationBusiness intent. Reads like a user story.test('User can log in')
ScenarioGiven/When/Then stepsBDR.When('User enters credentials', ...)
Action (Flow)Reusable business logicloginFlow.submitCredentials(user)
Technical (POM)Raw selectors and Playwright interactionspage.getByLabel('Username').fill(value)

The rule: no layer reaches down more than one level. Your Specification layer never touches selectors. Your POM layer never knows about business logic.

This means if you switch from Playwright to Selenium tomorrow — only the Technical layer changes. Business scenarios stay untouched.


Instead of Gherkin strings wired to step definitions, BDR gives you a fluent API:

bdr/bdr.ts
const createStep = (prefix: string) => {
return async (name: string, ...args: any[]): Promise<any> => {
const body = args.pop();
if (typeof body !== 'function') {
throw new Error(`BDR.${prefix}: Last argument must be a function`);
}
const stepName = `${prefix.toUpperCase()}: ${formatTitle(name, args)}`;
const executionFn = async () => (body.length > 0 ? body(...args) : body());
return test.step(stepName, executionFn);
};
};
export const BDR = {
Given: createStep('Given'),
When: createStep('When'),
Then: createStep('Then'),
And: createStep('And'),
};

Usage in a test:

test('User can log in with valid credentials', async ({ loginPage, page }) => {
await BDR.Given('the user is on the login page', async () => {
await loginPage.goto();
});
await BDR.When('the user enters valid credentials', async () => {
await loginPage.login('testuser', 'password123');
});
await BDR.Then('the user is redirected to the dashboard', async () => {
await expect(page).toHaveURL('/dashboard');
});
});

Your IDE fully understands this. loginPage.login is a real TypeScript method — rename it and the IDE updates every reference instantly.


Smart title interpolation with formatTitle

Section titled “Smart title interpolation with formatTitle”

Step titles support argument interpolation — so your reports are always meaningful:

bdr/utils.ts
export function formatTitle(template: string, args: any[]): string {
let argIndex = 0;
return template.replace(/{(\d+|[\w.]*)}/g, (match, key) => {
if (key === '') {
return argIndex < args.length ? String(args[argIndex++]) : match;
}
const parts = key.split('.');
const index = parseInt(parts[0], 10);
if (!isNaN(index) && index >= 0 && index < args.length) {
let value = args[index];
for (let i = 1; i < parts.length; i++) {
if (value && typeof value === 'object') {
value = value[parts[i]];
} else return match;
}
return value !== undefined ? String(value) : match;
}
return match;
});
}

This supports three interpolation modes:

// Index-based
formatTitle('Login as {0}', ['admin']);
// → "Login as admin"
// Sequential
formatTitle('Filter by {} and {}', ['Electronics', 'price']);
// → "Filter by Electronics and price"
// Nested property access
formatTitle('Welcome {0.user.name}', [{ user: { name: 'John' } }]);
// → "Welcome John"

Your Allure report shows WHEN: Filter by Electronics and price — not a generic string, but a meaningful description of what actually happened.


For reusable business flows, BDR provides a @Step decorator that wraps class methods automatically:

bdr/decorators.ts
export function Step(title: string, options: StepOptions = {}) {
return function (...args: any[]) {
const wrapMethodInStep = (originalMethod: Function) => {
return async function (this: any, ...methodArgs: any[]) {
const stepName = formatTitle(title, methodArgs);
return test.step(stepName, async () => originalMethod.apply(this, methodArgs));
};
};
// Supports both Legacy and Stage 3 decorators
if (typeof args[1] === 'object' && 'kind' in args[1]) {
return wrapMethodInStep(args[0]); // Stage 3
}
if (typeof args[1] === 'string') {
const descriptor = args[2];
descriptor.value = wrapMethodInStep(descriptor.value);
return descriptor; // Legacy
}
};
}

Usage in a Flow class:

flows/ProductFlow.ts
export class ProductFlow {
constructor(private products: Product[]) {}
@Step('GIVEN: I have a product catalog with {0} items')
async logProducts(count: number) {
await attachTable('Source Product Catalog', this.products);
}
@Step('WHEN: I filter products by category "{0}"')
async filterByCategory(category: string) {
const filtered = this.products.filter((p) => p.category === category);
await attachTable(`Filtered Products: ${category}`, filtered);
return filtered;
}
@Step('THEN: The total price should be calculated')
async calculateTotalPrice() {
const total = this.products.reduce((sum, p) => sum + p.price, 0);
await attachTable('Price Summary', [
{ 'Total Items': this.products.length, 'Total Price': `$${total.toFixed(2)}` },
]);
return total;
}
}

Every public method is automatically wrapped in a named test.step. The report shows exactly which business action was running when something failed.


Fixtures inject Page Objects and Flows into tests automatically. No manual instantiation, no shared state between tests:

fixtures/index.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pom/LoginPage';
import { ProductsPage } from '../pom/ProductsPage';
type MyFixtures = {
loginPage: LoginPage;
productsPage: ProductsPage;
};
export const test = base.extend<MyFixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
productsPage: async ({ page }, use) => {
await use(new ProductsPage(page));
},
});
export { expect } from '@playwright/test';

Each test gets a fresh instance. No state leaking between tests. And because it’s TypeScript — if you remove a fixture, every test that depends on it fails at compile time, not at runtime.


This is where BDR goes beyond standard Playwright reporting. attachTable generates a styled HTML table and attaches it directly to the Allure report step:

bdr/tables.ts
export async function attachTable(name: string, data: any[]) {
if (!data || data.length === 0) return;
const html = generateHtmlTable(data);
await test.info().attach(name, {
body: Buffer.from(html),
contentType: 'text/html',
});
}
function generateHtmlTable(data: any[]): string {
const headers = Object.keys(data[0]);
const ths = headers.map((h) => `<th>${h}</th>`).join('');
const trs = data
.map((row) => {
const tds = headers
.map((h) => {
const val = row[h];
return `<td>${val === undefined || val === null ? '' : val}</td>`;
})
.join('');
return `<tr>${tds}</tr>`;
})
.join('');
return `
<html><head><style>
table { border-collapse: collapse; width: 100%; box-shadow: 0 2px 15px rgba(0,0,0,0.1); }
th { background-color: #2c3e50; color: #fff; padding: 12px 15px; text-transform: uppercase; }
td { padding: 12px 15px; border-bottom: 1px solid #ddd; }
tr:nth-child(even) { background-color: #f8f9fa; }
tr:hover { background-color: #f1f4f6; }
</style></head>
<body>
<table>
<thead><tr>${ths}</tr></thead>
<tbody>${trs}</tbody>
</table>
</body></html>`;
}

Here’s what this looks like in the report:


Allure report — test step with attachTable showing a styled HTML table inside the step


This is the diagnostic killer feature. When a test fails on a data mismatch, attachCompareTable shows you exactly which fields don’t match:

export async function attachCompareTable(name: string, expected: any, actual: any) {
const allKeys = Array.from(new Set([...Object.keys(expected), ...Object.keys(actual)]));
const comparisonData = allKeys.map((key) => {
const exp = expected[key];
const act = actual[key];
const isMatch = JSON.stringify(exp) === JSON.stringify(act);
return {
Field: key,
Expected: exp === undefined ? '<undefined>' : JSON.stringify(exp),
Actual: act === undefined ? '<undefined>' : JSON.stringify(act),
Result: isMatch ? '✅ MATCH' : '❌ MISMATCH',
};
});
await attachTable(name, comparisonData);
}

Instead of:

AssertionError: expected { role: 'admin' } to equal { role: 'user' }

You get a table in the report:

FieldExpectedActualResult
id”123""123”MATCH
emailjohn@example.com""john@example.comMATCH
role”user""admin”MISMATCH

Allure report — attachCompareTable showing Expected vs Actual with MATCH/MISMATCH status per field


A complete hybrid scenario: API setup + UI verification

Section titled “A complete hybrid scenario: API setup + UI verification”

Here’s a real-world scenario that uses all the layers together:

test('User created via API can log in through UI', async ({ loginPage, page, request }) => {
const newUser = {
email: 'john.doe@example.com',
password: 'SecurePass123',
role: 'customer',
};
await BDR.Given('a user exists in the system', async () => {
await attachTable('New User Payload', [newUser]);
const response = await request.post('/users', { data: newUser });
expect(response.status()).toBe(201);
const created = await response.json();
await attachTable('Created User Response', [created]);
});
await BDR.When('the user logs in through the UI', async () => {
await loginPage.goto();
await loginPage.login(newUser.email, newUser.password);
});
await BDR.Then('the user sees their dashboard', async () => {
await expect(page).toHaveURL('/dashboard');
});
});

When this test fails, your report shows: the exact payload sent to the API, the response received, and a screenshot at the moment of failure. No reproduction needed.


Cucumber vs BDR — the technical comparison

Section titled “Cucumber vs BDR — the technical comparison”
Cucumber + GherkinBDR
Where scenarios liveSeparate .feature filesDirectly in TypeScript
IDE supportSteps are strings — no autocompleteFull TypeScript — autocomplete, go-to-definition
Compile-time safetyNone — errors at runtimeFull — broken references caught immediately
Renaming a methodHunt across .feature files manuallyIDE updates every reference instantly
Report richnessBasic pass/fail + step namesSteps + styled HTML tables + screenshots + API logs
Decorator supportN/A@Step with title interpolation and nested property access
Maintenance costTwo places to updateOne place


I’m open to QA Automation roles — remote, contract, or full-time. If you’re building a team and care about test architecture, reach out. _dmitryAQA@outlook.com | @DmitryMeAQA_