level 04 / architecture-layers

Folder Structure & Layered Architecture

How production Playwright frameworks are organised — the layers, what belongs in each, and the dependency rule that keeps 5,000 tests maintainable.

The layers

A production framework separates concerns into layers. Tests express business intent; everything mechanical lives below them.

The dependency rule: each layer depends only on layers below it. A test imports a page object; a page object never imports a test. Break this rule and changes ripple upward unpredictably.

A production folder structure

playwright-framework/
├── tests/                      # Test layer — specs only
│   ├── auth/
│   │   └── login.spec.ts
│   ├── checkout/
│   │   └── checkout.spec.ts
│   └── api/
│       └── orders.api.spec.ts
├── src/
│   ├── pages/                  # Business layer — page objects
│   │   ├── login.page.ts
│   │   └── checkout.page.ts
│   ├── flows/                  # Business layer — multi-page journeys
│   │   └── purchase.flow.ts
│   ├── api/                    # Business layer — API clients
│   │   └── orders.client.ts
│   ├── fixtures/               # Core layer — DI wiring
│   │   └── test-fixtures.ts
│   └── utils/                  # Core layer — pure helpers
│       └── date.utils.ts
├── test-data/                  # Foundation — builders + static data
│   └── users.builder.ts
├── playwright/.auth/           # Foundation — session state (gitignored)
├── playwright.config.ts
└── package.json

What belongs where

LayerContainsNever contains
Teststest() blocks, assertions, business languagelocators, URLs, waits
Flowsmulti-page journeys (purchaseFlow(page, item))assertions about specific tests
Pageslocators, page actions, navigationexpect() on business outcomes, test data
Fixturesobject construction, setup/teardown, DIbusiness logic
Utilspure functions (dates, strings, random)anything Playwright-specific

The most contested rule: assertions live in tests, not page objects. A page object that asserts couples every test to one expected outcome. Return state; let the test decide what it means.

// ❌ Page object asserting — every caller now expects success
async signIn(user: string, pass: string) {
  // ...fill and click...
  await expect(this.page.getByText('Products')).toBeVisible();
}

// ✅ Page object returns, test asserts
async signIn(user: string, pass: string): Promise<void> {
  await this.page.getByPlaceholder('Username').fill(user);
  await this.page.getByPlaceholder('Password').fill(pass);
  await this.page.getByRole('button', { name: 'Login' }).click();
}

// In the test — intent is explicit, failure tests can reuse signIn
await loginPage.signIn('locked_out_user', 'secret_sauce');
await expect(page.getByText('locked out')).toBeVisible();
Key insight: One pragmatic exception: a page object may assert it has loaded (its own readiness), because that’s a fact about the page, not a test outcome.

Naming conventions

login.page.ts        → LoginPage class
purchase.flow.ts     → purchaseFlow function or PurchaseFlow class
orders.client.ts     → OrdersClient class
login.spec.ts        → test file
users.builder.ts     → buildUser() factory

Suffixes make the layer visible in every import statement — a reviewer can spot a layering violation (import ... from '../tests/...' inside a page object) without opening the file.

Scaling signals

When these appear, the structure needs work:

  • A locator change touches ten test files → locators leaked above the page layer.
  • Two teams editing the same page object constantly conflict → split it into component objects.
  • utils/ is 40 files → it became a junk drawer; promote cohesive clusters into named modules.
  • Test names don’t match folder names → reorganise by user journey, not by page.

Interview questions