Skip to content

Your test failed. But why? — How I built BDR to actually answer that question

Your test failed. But why? — How I built BDR to actually answer that question CONCEPT

Section titled “Your test failed. But why? — How I built BDR to actually answer that question ”

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.


A developer once left a comment on one of my articles about test automation. He described something painfully familiar:

“You can see the button is disabled, so the click doesn’t work. But now the question is — why? And where is the developer supposed to find the answer? You try to reproduce it manually… and suddenly it works fine. So what happened? Nobody knows. You need logs. You need video. You need something.”

He was right. And that comment stuck with me.

Because that’s not a rare edge case. That’s Tuesday in QA.

Test fails in CI. You open the report. You see: Error: element not clickable. That’s it. No context. No screenshot at the right moment. No API logs. No idea what the app state was. You spend an hour trying to reproduce it locally — and it doesn’t reproduce. The ticket gets closed as “flaky”. The bug stays in production.

This is the real problem with most test automation: tests tell you that something broke, but not why.

Of course, you can enable Playwright Trace Viewer, videos, and screenshots. It’s the standard advice. But here’s the reality:

  • Trace Viewer is a firehose of data. If you have 300 tests running in parallel, opening a 50MB trace file for every single flaky test is a full-time job. It shows you what happened, but it doesn’t tell you why the business logic failed.
  • Videos are useless for high-speed flaky bugs. You spend minutes watching a 30-second video at 0.5x speed, trying to catch that one flicker of an error message.
  • The core problem remains: These tools tell you how it failed, but they don’t explain what the application state was from a business perspective.

My goal with BDR wasn’t just to see the crash — it was to make the crash self-explanatory.


I looked at BDD. Then I looked at Cucumber. Then I had a problem.

Section titled “I looked at BDD. Then I looked at Cucumber. Then I had a problem.”

BDD made sense to me. Given/When/Then is a great way to write tests that humans can actually read. Business-readable scenarios. Living documentation. Tests that explain intent, not just implementation.

The promise of BDD is powerful:

  • Business sees exactly what the product does — in plain language
  • Engineers write tests that serve as living requirements
  • When a test fails, it’s a signal that a business requirement is broken

So I looked at Cucumber. And I saw the idea was right — but the implementation was painful.

Here’s what you actually get with Cucumber in practice:

  • .feature files that live separately from your code
  • Step definitions that need to be wired up manually
  • A developer renames a button → you spend an afternoon hunting which .feature file broke
  • A test fails → you read the Gherkin, then find the step definition, then find the actual code, then maybe understand what happened
  • Every new scenario requires writing in two places: the .feature file AND the TypeScript

You’re not writing tests anymore. You’re maintaining a translation layer between English and code. That’s the Gherkin tax — and it compounds as your suite grows.

And here’s the painful irony: business still doesn’t read those .feature files. They’re buried in a repository nobody outside engineering opens. You paid the Gherkin tax and got nothing for it.


Cucumber + GherkinBDR
Where scenarios liveSeparate .feature filesDirectly in code
IDE supportLimited — steps are stringsFull — TypeScript, autocomplete, refactoring
Renaming a methodHunt across .feature filesIDE updates everything instantly
Error caughtAt runtimeAt compile time
Report richnessBasic pass/fail + stepsSteps + tables + screenshots + API logs
Business reads it?Rarely (it’s in a repo)Yes — via Allure report, no repo access needed
Maintenance costHigh — two places to updateLow — one place

What if Given/When/Then lived directly in code?

Section titled “What if Given/When/Then lived directly in code?”

That’s the question that led me to build BDR — Behavior-Driven Living Requirements.

BDR is not a framework. It’s a methodology. The core idea is simple:

Keep everything that’s good about BDD. Remove the part that slows you down.

  • Given/When/Then structure — kept
  • Business-readable scenarios — kept
  • Living documentation — kept, and made richer
  • .feature files — gone
  • Step definition wiring — gone
  • Gherkin maintenance — gone

The result: a happy engineer makes a transparent product for the business.


BDR separates concerns into 4 layers. Each layer has one job and doesn’t bleed into others:

LayerWhat it doesExample
SpecificationBusiness intent. Reads like a user story.test('User can log in with valid credentials')
ScenarioGiven/When/Then stepstest.step('When user enters credentials')
ActionBusiness logic. Reusable flows.loginPage.login(username, password)
TechnicalRaw selectors and Playwright interactionspage.getByLabel('Username').fill(value)

This separation means: if you switch from Playwright to Selenium tomorrow, only the Technical layer changes. Your business scenarios stay untouched.


Technical Layer — Page Objects with robust locators

Section titled “Technical Layer — Page Objects with robust locators”
pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
constructor(private page: Page) {}
get usernameInput(): Locator {
return this.page.getByLabel('Username');
}
get passwordInput(): Locator {
return this.page.getByLabel('Password');
}
get loginButton(): Locator {
return this.page.getByRole('button', { name: 'Log In' });
}
async goto() {
await this.page.goto('/login');
}
async login(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
}

No magic strings. No CSS selectors that break on every UI change. Full IDE support.

This is the glue of the whole architecture. Fixtures inject Page Objects into your tests automatically — no manual instantiation, no boilerplate:

baseFixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { ProductsPage } from './pages/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';

Now every test gets a fresh, properly initialized Page Object — just by declaring it as an argument.

Specification Layer — Given/When/Then in code

Section titled “Specification Layer — Given/When/Then in code”
tests/ui/login.spec.ts
import { test, expect } from '../baseFixtures';
test('User can log in with valid credentials', async ({ loginPage, page }) => {
await test.step('Given the user is on the login page', async () => {
await loginPage.goto();
});
await test.step('When the user enters valid credentials', async () => {
await loginPage.login('testuser', 'password123');
});
await test.step('Then the user should be redirected to the dashboard', async () => {
await expect(page).toHaveURL('/dashboard');
});
});

This reads exactly like a BDD scenario. But it’s real TypeScript. Your IDE catches errors at compile time, not when CI runs at 2am.


This is where BDR goes beyond what Gherkin can do. Every step can carry structured data — tables, payloads, state snapshots — directly in the report.

tests/ui/products.spec.ts
import { test, expect } from '../baseFixtures';
import { attachTable } from '@bdr/core';
test('Product search filters correctly', async ({ productsPage }) => {
await test.step('Given products are available', async () => {
await attachTable('Available Products', [
['ID', 'Name', 'Category', 'Price'],
['101', 'Laptop Pro', 'Electronics', '1200'],
['102', 'Mouse X', 'Electronics', '25'],
]);
await productsPage.goto();
});
await test.step('When the user filters by "Electronics"', async () => {
await productsPage.filterByCategory('Electronics');
});
await test.step('Then only Electronics products are displayed', async () => {
const displayed = await productsPage.getDisplayedProductNames();
expect(displayed).toEqual(['Laptop Pro', 'Mouse X']);
await attachTable('Filtered Results', [
['Name', 'Category'],
['Laptop Pro', 'Electronics'],
['Mouse X', 'Electronics'],
]);
});
});

Here’s what this looks like in the Allure report:


Allure report showing a passed test.


Business opens this report and sees exactly what happened — without touching the codebase. That’s living documentation.


Remember the developer’s comment from the beginning? Here’s what debugging looks like with and without BDR.

Without BDR:

Error: Timeout 30000ms exceeded

That’s it. Good luck.

With BDR:

The report shows:

  • The scenario stopped at step: "When: user submits the login form"
  • Attached table: Form state before click — username filled, password filled, button status: disabled
  • Attached: API request logPOST /auth returned 403 Forbidden
  • Screenshot: captured automatically at the moment of failure

Allure report showing a failed test with a detailed comparison table.


Now you know exactly what happened. No reproduction needed. The report IS the reproduction.


tests/api/users.spec.ts
import { test, expect } from '@playwright/test';
import { attachTable } from '@bdr/core';
test('Create a new user via API', async ({ request }) => {
const newUser = {
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@example.com',
role: 'customer',
};
await test.step('When a POST request is sent to /users', async () => {
await attachTable('Request Payload', Object.entries(newUser));
const response = await request.post('/users', { data: newUser });
expect(response.status()).toBe(201);
});
await test.step('Then the user is created successfully', async () => {
const verify = await request.get(`/users?email=${newUser.email}`);
const users = await verify.json();
const created = users.find((u: any) => u.email === newUser.email);
expect(created).toMatchObject({ email: 'john.doe@example.com' });
await attachTable(
'Response',
Object.entries(created).filter(([k]) => ['id', 'email'].includes(k)),
);
});
});

Every request payload, every response — attached to the report. When something breaks in CI, you open the report and see exactly what was sent and what came back.


For engineers:

  • Full IDE support — autocomplete, compile-time errors, instant refactoring
  • One place to update when things change
  • Reports that answer “why?” without manual reproduction

For business:

  • Allure reports readable without engineering knowledge
  • Living documentation that’s always current — if the test runs, the doc is up to date
  • Clear signal when a business requirement is broken

The result: a happy engineer makes a transparent product for the business.



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