34Test Automation Architecture
What you will master here
- Test pyramid + trophy — pick the right shape for your project
- Layered framework architecture: tests / pages / services / clients / utils / config / data
- Page Object Model (POM), Screenplay, App Actions — when to use each
- Test data strategy: factories, fixtures, snapshots, builders
- Configuration management across environments
- Logging, reporting, observability of test runs
- Folder structure that scales
- Anti-patterns to avoid
34.1 The two shapes of a healthy suite
Pyramid (Mike Cohn)
- Wide base of unit tests (fast, isolated)
- Smaller integration layer (real DB, real HTTP)
- Thin top of E2E (slow, full browser)
Classic — works for most backends.
Trophy (Kent C. Dodds)
- Static (types, lint) — broadest
- Unit — narrow
- Integration — widest middle
- E2E — thin top
Modern frontend / SPA workflows lean here — integration tests give best ROI.
Practical SDET split5–10% E2E (Playwright), 20–30% API/integration (Playwright request, REST Assured), 60–70% unit (the dev's responsibility). If the pyramid inverts and your team relies on hundreds of slow E2Es to find bugs, the suite is in trouble.
34.2 Layered framework architecture
Figure 13 — Layers depend downward only. Tests never know about Playwright directly; pages never construct browsers.
34.3 Folder structure that scales
/tests /e2e # full user journeys (Playwright) /api # pure HTTP chains /integration # API + DB or API + UI hybrids /visual # screenshot tests /a11y # axe-core scans /src /pages # POM classes /components # component-level page objects (shared modal, header) /services # ApiClient, UserService, OrderService /clients # thin: PlaywrightClient, DbClient /fixtures # test.extend definitions /factories # buildUser(), buildOrder() — fake data /utils # helpers, custom matchers, date helpers /config # env.ts, urls.ts, timeouts.ts /data # static JSON, CSV fixtures /playwright.config.ts /tsconfig.json /.env.example /README.md
34.4 Patterns — which to use when
(a) Page Object Model (POM) — default
// pages/LoginPage.ts
export class LoginPage {
constructor(private page: Page) {}
email = () => this.page.getByLabel('Email');
password = () => this.page.getByLabel('Password');
signIn = () => this.page.getByRole('button', { name: 'Sign in' });
async goto() { await this.page.goto('/login'); }
async submit(email: string, password: string) {
await this.email().fill(email);
await this.password().fill(password);
await this.signIn().click();
}
}
(b) Screenplay pattern — for very large suites
Tests express what an Actor does using Abilities and Tasks. Heavy abstraction; pays off only in 100+ engineer / 1000+ test orgs. Overkill for most teams.
(c) App Actions — bypass UI for setup
Instead of going through the UI to set up state ("click sign-up, fill, submit, verify email…"), expose a test-only API that creates state directly. Drastically faster. Used heavily in frontend frameworks; SDETs apply it via API setup + UI verify (covered in Module 5).
34.5 Service classes — keep test code thin
// services/UserService.ts
export class UserService {
constructor(private api: APIRequestContext) {}
async create(data: Partial<User> = {}) {
const payload = { ...userFactory(), ...data };
const r = await this.api.post('/users', { data: payload });
if (!r.ok()) throw new Error(`create failed: ${r.status()}`);
return r.json() as Promise<User>;
}
async delete(id: string) {
await this.api.delete(`/users/${id}`);
}
}
// In a test
const user = await new UserService(request).create({ role: 'admin' });
34.6 Test data strategy
| Pattern | What it is | When to use |
|---|---|---|
| Factory | Function that returns a default valid object, overridable | Most cases — flexible, readable |
| Builder | Fluent API: UserBuilder().withRole('admin').build() | Complex objects with many optional fields |
| Fixture file | Static JSON in /data | Reference data (currency lists, country codes) |
| Seeded DB | Migration / SQL script populates known rows | Long-running E2E envs with shared catalog |
| Faker | Random data generator | Stress tests, edge cases, avoid duplicate-key collisions |
// Factory + Faker
import { faker } from '@faker-js/faker';
export function userFactory(over: Partial<User> = {}): User {
return {
id: faker.string.uuid(),
name: faker.person.fullName(),
email: faker.internet.email(),
role: 'user',
...over,
};
}
const admin = userFactory({ role: 'admin' });
const ten = Array.from({ length: 10 }, () => userFactory());
34.7 Configuration across environments
// config/env.ts
import 'dotenv/config';
import { z } from 'zod';
const Env = z.object({
BASE_URL: z.string().url(),
API_URL: z.string().url(),
ADMIN_EMAIL: z.string().email(),
ADMIN_PASS: z.string().min(8),
DB_URL: z.string(),
CI: z.string().optional(),
});
export const env = Env.parse(process.env);
/* Fails loudly at startup if any var is missing or malformed. */
34.8 Logging and observability
- Trace + screenshot + video on retry/failure — Playwright built-in
- Structured logs from helpers — JSON lines so they're parseable by Datadog/Splunk
- JUnit XML for CI test dashboards (Bamboo, CircleCI, Jenkins)
- Allure for stakeholder-friendly reports
- Custom metrics — test runtime trend, retry rate per test, flakiness score in a time-series DB (Grafana)
34.9 Anti-patterns — what to avoid
- God Page Object — one class with 200 methods. Split by component / feature.
- Selectors in tests — never. Page objects own selectors.
- Tests depend on order — fight isolation. Each test self-sufficient.
- Shared mutable globals — last-known-state surprises. Use fixtures.
- Hardcoded URLs / users / dates — env breaks them. Factor into config + factories.
- Massive monolithic spec file — split by feature.
- Treating tests as second-class code — no review, no refactor, no rename. They rot.
- Catching errors silently — wraps masks bugs. Let them throw.
34.10 Maintenance plan (sustainable suite)
- Weekly — review CI flake rate. Top 3 flakiest go to quarantine with a JIRA.
- Sprintly — drop 1 quarantined test (fix or delete). Review test pyramid mix.
- Per release — refresh storageState. Run full visual baseline regen if branding changed.
- Quarterly — audit RTM. Drop dead tests. Re-evaluate framework upgrade.
Module 20 — Architecture Q&A bank
What's the test pyramid?
A model for distributing tests across layers: many unit tests (cheap, fast, isolated), fewer integration tests, very few E2E tests. Keeps the suite fast and reliable. The opposite — a pyramid inverted to mostly E2E — is the most common sign of a struggling test org.
What's the test trophy?
An alternative shape (Kent C. Dodds) that adds a static base (types, lint) and emphasises integration tests as the widest middle. Suits modern frontend / SPAs where integration tests give the best bug-catching ROI.
What layers does a Playwright suite usually have?
tests (intent), pages + services (abstractions), clients (thin wrappers over Playwright + HTTP + DB), utils, fixtures (setup), config (env), data (factories + static). Dependencies flow downward only.
Why keep selectors out of tests?
When markup changes, you'd update every test using that selector. With selectors confined to page objects, you update one class, all tests still pass. Single source of truth.
What's the Screenplay pattern and is it worth it?
A heavier abstraction where tests describe what an Actor does via Tasks and Abilities. Worth it for very large suites (1000+ tests, 50+ contributors). Overkill for most teams — start with POM, escalate only if needed.
What's an App Action?
A test-only mechanism to put the app into a state without going through the UI — e.g. seeding a logged-in user, an order, or a feature flag. Faster and more deterministic than UI navigation; only the test that exercises a flow uses the UI.
How should test data be generated?
Factories with sensible defaults + override params:
userFactory({ role: 'admin' }). Combine with Faker for unique values to avoid collisions in parallel runs. Static fixtures only for reference data that doesn't change.How do you handle config across dev/staging/prod?
Centralise in a config module. Read from env vars (12-factor). Validate at startup with Zod/Joi so missing/malformed values fail loud, not via cryptic test failures later. Per-env files (.env.staging, .env.prod) for non-secret defaults.
What's the danger of tests sharing state?
Order-dependence — tests pass only when run in a specific sequence. Breaks under parallelism, shards differently across CI, and one failing test cascades. Isolate every test; use fixtures for shared setup that recreates state cheaply.
How big should a single test be?
One business intent — "user can sign in", "checkout converts to order". Avoid mega-tests that chain seven flows. If your test name needs "and" multiple times, split.
How do you keep tests as production code?
Same review rigour as app code, same refactors, same naming, same lint/format. Tests live in a real folder structure, get PR reviews, have CI gating. They're not a sandbox; they're the safety net.
What's your folder structure for a Playwright SDET project?
tests/ split by type (e2e, api, integration, visual, a11y). src/ split by concern (pages, components, services, clients, fixtures, factories, utils, config, data). playwright.config + tsconfig at root. .env.example checked in; real secrets via CI.
How do you keep the suite fast as it grows?
Increase parallelism (workers + sharding), move UI-only assertions to API where possible, use storageState for auth, mock external services, profile slowest tests and refactor (or split into smoke vs full).
What's a sustainable maintenance cadence?
Weekly: flake-rate review, quarantine top 3. Sprintly: clear 1 quarantined test. Per release: regen storageState, visual baselines if branding changed. Quarterly: RTM audit, framework upgrade evaluation.
One sentence: what makes a test suite "sustainable"?
It's fast enough that engineers run it before pushing, isolated enough that flakes don't hide real bugs, structured enough that new tests are easy to write, and trusted enough that nobody bypasses it.