Test Automation Architecture

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

tests/ — business intent only (Given / When / Then) pages/ + services/ — Page Objects, API service classes clients/ — thin wrappers: Playwright page, APIRequestContext, DB pool utils/ helpers / matchers fixtures/ shared setup config/ per-env settings data/ seeds, factories, JSON
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

PatternWhat it isWhen to use
FactoryFunction that returns a default valid object, overridableMost cases — flexible, readable
BuilderFluent API: UserBuilder().withRole('admin').build()Complex objects with many optional fields
Fixture fileStatic JSON in /dataReference data (currency lists, country codes)
Seeded DBMigration / SQL script populates known rowsLong-running E2E envs with shared catalog
FakerRandom data generatorStress 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

34.9 Anti-patterns — what to avoid

34.10 Maintenance plan (sustainable suite)

  1. Weekly — review CI flake rate. Top 3 flakiest go to quarantine with a JIRA.
  2. Sprintly — drop 1 quarantined test (fix or delete). Review test pyramid mix.
  3. Per release — refresh storageState. Run full visual baseline regen if branding changed.
  4. 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.