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
| Layer | Contains | Never contains |
|---|---|---|
| Tests | test() blocks, assertions, business language | locators, URLs, waits |
| Flows | multi-page journeys (purchaseFlow(page, item)) | assertions about specific tests |
| Pages | locators, page actions, navigation | expect() on business outcomes, test data |
| Fixtures | object construction, setup/teardown, DI | business logic |
| Utils | pure 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();
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.