Skip to content

Why flat test architectures fail: Moving beyond POM to a 3-layer BDR approach

Why flat test architectures fail: Moving beyond POM to a 3-layer BDR approach PRO IMPLEMENTATION

Section titled “Why flat test architectures fail: Moving beyond POM to a 3-layer BDR approach ”

This is a technical deep dive into BDR’s layered architecture. For an introduction to why BDR exists and how the @Step decorator works internally, see Beyond Cucumber: A Type-Safe 4-Layer BDD Architecture with Playwright.

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.


Most Playwright projects start with two layers: Page Objects and tests. It works fine at twenty tests. At two hundred, it collapses.

Here’s a typical flat architecture failure:

// The test knows too much
test('User can complete purchase', async ({ page }) => {
// Setup — copy-pasted from 40 other tests
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Log In' }).click();
// The actual test
await page.getByTestId('add-to-cart').click();
await page.getByTestId('checkout-submit').click();
await page.getByLabel('Card Number').fill('4242424242424242');
await page.getByRole('button', { name: 'Pay' }).click();
await expect(page.getByText('Order confirmed')).toBeVisible();
});

When this test fails, your report shows:

✗ Test: User can complete purchase
- goto
- fill
- fill
- click
- click
- click
- fill
- click

Which click failed? What was the state? What was being tested — login, cart, or payment? Nobody knows without reading the entire test.


The standard advice is “add a Flow layer”. But most teams add it for the wrong reason — DRY. They think “I keep copy-pasting the cart setup, let me extract it into a Flow.”

DRY is a nice side effect. It’s not the point.

The real reason for three layers is separation of abstraction levels. Each layer speaks a different language:

  • POM speaks the language of markup: “click this button”, “fill this field”, “find this element”
  • Flow speaks the language of business: “add product to cart”, “place order”, “process payment” — these are self-contained business entities, not just reusable helpers
  • Spec speaks the language of scenarios: assembles business entities like Lego to express intent

Here’s what that looks like in practice with an e-commerce app:

// Three separate business entities — each its own Flow
class CartFlow { async addProduct(product: Product) {...} }
class CheckoutFlow { async placeOrder(address: Address) {...} }
class PaymentFlow { async pay(card: Card) {...} }
// Spec assembles them for different scenarios
test('Full purchase flow', async ({ cart, checkout, payment }) => {
await cart.addProduct(laptop);
await checkout.placeOrder(address);
await payment.pay(card);
});
test('Cart total updates correctly', async ({ cart }) => {
await cart.addProduct(laptop);
await cart.addProduct(mouse);
await cart.verifyTotal(1225);
});

Same building blocks, different scenarios. CartFlow exists not because you’ll reuse it (though you will), but because “managing the cart” is a real business concept with its own rules and boundaries.

This distinction matters because it changes how you design Flows. A DRY-driven Flow is shaped by what’s convenient to reuse. A business-entity Flow is shaped by what the business actually does. The second one is stable. The first one drifts.

Here’s the precise responsibility of each layer:

Job: Encapsulate raw Playwright interactions. Know about selectors. Know nothing else.

pom/CartPage.ts
export class CartPage {
constructor(private page: Page) {}
// Exposes WHAT can be done, not HOW the business uses it
get checkoutButton(): Locator {
return this.page.getByTestId('checkout-submit');
}
async clickCheckout() {
await this.checkoutButton.click();
}
}

What it must NOT do:

// WRONG: POM containing business logic
async proceedToCheckoutAndVerify() {
await this.checkoutButton.click();
// This is business logic — it doesn't belong here
await expect(this.page).toHaveURL('/payment');
}

Why? Because the URL /payment is a business rule, not a UI detail. If the business decides to show a modal instead of navigating — your POM shouldn’t need to change.


Job: Orchestrate business processes using Page Objects. Know about business rules. Know nothing about selectors.

flows/CheckoutFlow.ts
export class CheckoutFlow {
// Dependency Injection: receives ready Page Object instances
constructor(
private cartPage: CartPage,
private paymentPage: PaymentPage,
) {}
async completePurchase(orderData: OrderData) {
await test.step('WHEN: User proceeds to checkout', async () => {
await this.cartPage.clickCheckout();
// Business rule: payment form must appear
await expect(this.paymentPage.form).toBeVisible();
});
await test.step('WHEN: User fills payment details', async () => {
// Data comes from outside — no hardcoded values in Flows
await this.paymentPage.fillDetails(orderData.card);
await this.paymentPage.submit();
});
}
}

What it must NOT do:

// WRONG: Flow reaching into selectors
async completePurchase(orderData: OrderData) {
// This bypasses the POM entirely — now Flow is coupled to selectors
await this.page.getByTestId('checkout-submit').click();
}

Why does this matter? If checkout-submit becomes checkout-btn, you now have to find and fix this in every Flow that touches it — instead of fixing it once in CartPage.


Job: Express business intent. Read like a user story. Know nothing about implementation.

tests/checkout.spec.ts
test('User can complete a purchase', async ({ checkoutFlow }) => {
await BDR.Given('the user has items in their cart', async () => {
await checkoutFlow.addProductToCart(testProduct);
});
await BDR.When('the user completes the purchase', async () => {
await checkoutFlow.completePurchase(testOrderData);
});
await BDR.Then('the order is confirmed', async () => {
await checkoutFlow.verifyOrderConfirmation();
});
});

A non-engineer can read this and understand exactly what’s being tested. That’s the goal.

What it must NOT do:

// WRONG: Test reaching into POM directly
test('User can complete a purchase', async ({ page }) => {
// Test now knows about selectors — living documentation is broken
await page.getByTestId('checkout-submit').click();
});

Here’s what actually happens when teams blur the boundaries:

Month 1: “It’s just one selector in the Flow, it’s fine.”

Month 2: The selector changes. You fix it in the POM — but the Flow breaks too. Two places to fix instead of one.

Month 3: A new developer adds business logic to the POM because “that’s where the page stuff is”. Now the POM has assertions.

Month 6: Every layer knows about every other layer. Changing anything breaks everything. Nobody knows where to look when a test fails.

The three-layer rule isn’t aesthetic. It’s the thing that keeps your test suite maintainable at scale.


What the report looks like with proper layering

Section titled “What the report looks like with proper layering”

With this architecture, your Allure report becomes a business document:

✓ User can complete a purchase
✓ GIVEN: The user has items in their cart
📊 Cart Contents: [Laptop Pro x1, $1200]
✓ WHEN: User proceeds to checkout
✓ WHEN: User fills payment details
📊 Payment Data: [Card: **** 4242, Amount: $1200]
✓ THEN: Order is confirmed
📊 Order Summary: [ID: #12345, Status: confirmed]

When a test fails:

✗ User can complete a purchase
✓ GIVEN: The user has items in their cart
✗ WHEN: User proceeds to checkout
📊 Cart State before click: [button status: disabled, reason: stock_unavailable]
❌ Expected payment form to be visible

Thirty seconds from opening the report to understanding the failure. No code diving required.


Fixtures: the dependency injection container

Section titled “Fixtures: the dependency injection container”

The glue that makes all this work without boilerplate is Playwright’s fixture system:

fixtures/index.ts
import { test as base } from '@playwright/test';
import { CartPage } from '../pom/CartPage';
import { PaymentPage } from '../pom/PaymentPage';
import { CheckoutFlow } from '../flows/CheckoutFlow';
type Fixtures = {
cartPage: CartPage;
paymentPage: PaymentPage;
checkoutFlow: CheckoutFlow;
};
export const test = base.extend<Fixtures>({
cartPage: async ({ page }, use) => {
await use(new CartPage(page));
},
paymentPage: async ({ page }, use) => {
await use(new PaymentPage(page));
},
// Flow receives its Page Objects automatically via DI
checkoutFlow: async ({ cartPage, paymentPage }, use) => {
await use(new CheckoutFlow(cartPage, paymentPage));
},
});

Your test declares what it needs — Playwright provides it. Fresh instance per test, no shared state, no manual wiring.


Anti-pattern 1: The God Test The test does everything: setup, interaction, assertion, cleanup — all with raw Playwright calls. Sign: test file is 100+ lines.

Anti-pattern 2: The Smart POM Page Object contains assertions, navigation logic, or business rules. Sign: expect() calls inside a POM method.

Anti-pattern 3: The Leaky Flow Flow accesses page directly or imports locators. Sign: this.page.getBy... inside a Flow class.

Anti-pattern 4: The Copy-Paste Chain Same setup code (login, navigate, seed data) repeated across test files. Sign: changing one thing requires a grep-and-replace.


Each layer talks only to the layer directly below it. Spec → Flow → POM. Never skip a level. Never reach up.

Follow this and your test suite stays maintainable. Violate it and you’ll be rewriting everything in six months.


This architecture is implemented in the BDR Playwright template — ready to clone and use:


I’m open to QA Automation roles — remote, contract, or full-time. dmitryAQA@outlook.com | @DmitryMeAQA