Lesson 8 of 9 · Advanced Quality Engineering
Test Architecture
Design test architecture that scales: layers of a test codebase, testing at service boundaries with contracts, choosing test doubles, environment strategy and clear ownership.
advanced 8 min read Updated
You will learn to
- Structure a test codebase into layers so tests stay readable and changes stay local
- Use contracts to test service boundaries without full end-to-end environments
- Choose the right test double (stub, mock, fake, spy) for a given dependency
- Define an environment and ownership model for tests across teams
Prerequisites
- Page Object Model
- REST APIs
- Designing CI/CD Quality Gates
Test architecture is the set of decisions that determine how a team’s tests are structured, where they run, what they depend on and who looks after them. A small project can get away without deliberate decisions. Once several teams, services and hundreds of tests are involved, the absence of architecture shows up as slow pipelines, tests nobody dares delete, environments that are always broken and failures nobody owns.
This lesson covers five areas: code layers, service boundaries and contracts, test doubles, environments and ownership.
1. Layers inside a test codebase
A test should read like a description of behaviour. Everything that is not about the behaviour (how to click, how to call the API, how to create a user) belongs in lower layers.
tests/
specs/ # Layer 1: test intent only
checkout.spec.ts
flows/ # Layer 2: business actions spanning screens or calls
checkout-flow.ts
pages/ # Layer 3a: UI adapters (page objects, components)
cart-page.ts
api/ # Layer 3b: API clients
orders-client.ts
data/ # Builders and factories for test data
user-builder.ts
fixtures/ # Wiring: creates clients, pages, users per test
index.ts
config/ # Environment URLs, timeouts, feature flags
Rules that keep the layers healthy:
- Dependencies point down. Specs use flows, pages, API clients and builders; pages never import specs.
- Assertions live in specs, or in clearly named assertion helpers, not hidden inside page objects. A page object that both clicks and asserts makes failures confusing and reuse hard.
- One reason to change per layer. A redesigned button changes one page object; a new API version changes one client; a new business rule changes specs.
- Data is created through builders and APIs, not by clicking through the UI in every test.
A spec written against these layers:
import { test, expect } from './fixtures';
test('a returning customer can reorder a previous order', async ({ api, flows, pages }) => {
const customer = await api.customers.create({ withOrders: 1 });
await flows.signInAs(customer);
await pages.orders.open();
await pages.orders.reorder(customer.orders[0].id);
await expect(pages.cart.lineItems).toHaveCount(customer.orders[0].items.length);
});
Setup goes through the API (fast, reliable); only the behaviour under test goes through the UI. Fixtures (Playwright’s dependency injection, or JUnit extensions and TestNG listeners in Java) create these objects per test, so tests stay independent and can run in parallel.
2. Service boundaries and contracts
In a system of services, the riskiest places are the boundaries: one team changes a response field, and another team’s service breaks in production. Full end-to-end environments with every service deployed can catch this, but they are slow, shared and fragile. Contracts catch most of it earlier.
A contract is an agreed description of the interactions between a consumer (the caller) and a provider (the service being called): requests, responses and their shapes.
Two common approaches:
- Schema-based contracts: the provider publishes an OpenAPI (or GraphQL, Protobuf, AsyncAPI) specification. Providers test that responses conform to it; consumers generate clients or mocks from it. This checks shape, not whether consumers actually rely on a given field.
- Consumer-driven contract testing: each consumer records the interactions it actually depends on, in its own tests against a mock provider. The provider then verifies, in its own pipeline, that it satisfies every consumer’s recorded expectations. Pact is the best-known open-source tool for this approach.
The key benefit of consumer-driven contracts is that the provider learns, before deploying, that a change would break a specific consumer, without either side needing a shared environment.
A lightweight schema check you can add today in API tests, using ajv:
import Ajv from 'ajv';
import { test, expect } from '@playwright/test';
const orderSchema = {
type: 'object',
required: ['id', 'status', 'total', 'currency'],
properties: {
id: { type: 'string' },
status: { enum: ['pending', 'paid', 'shipped', 'cancelled'] },
total: { type: 'number', minimum: 0 },
currency: { type: 'string', minLength: 3, maxLength: 3 },
},
additionalProperties: true,
};
const validateOrder = new Ajv().compile(orderSchema);
test('GET /orders/:id matches the order contract', async ({ request }) => {
const res = await request.get('/orders/ord_1001');
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(validateOrder(body), JSON.stringify(validateOrder.errors)).toBe(true);
});
additionalProperties: true is deliberate for a consumer-side check: providers may add fields without breaking consumers, but removing or renaming a required field should fail.
3. Test doubles
A test double replaces a real dependency in a test. The terms are often used loosely, but the distinctions help you choose:
| Double | What it does | Use it when |
|---|---|---|
| Dummy | Fills a parameter; never used | A signature needs an argument the test does not care about |
| Stub | Returns canned answers | You need the dependency to respond in a certain way (for example, an error) |
| Spy | Records how it was called, often wrapping real behaviour | You need to check that a call happened, with what arguments |
| Mock | Pre-programmed with expectations and fails if they are not met | The interaction itself is the behaviour under test |
| Fake | A working, simplified implementation | You need realistic behaviour without the real infrastructure (in-memory database, local payment simulator) |
Guidelines:
- Double what you do not own or cannot control: third-party payment providers, email and SMS gateways, external rate-limited APIs. Their sandboxes are often slow or unreliable.
- Prefer fakes over deep mocks for complex dependencies. A fake payment gateway with realistic success, decline and timeout behaviour is reused across many tests; a chain of mocks is rewritten for each one.
- Do not mock what you are testing. A test that mocks the service under test proves only that the mock works.
- Keep doubles honest. Contract tests or periodic tests against the real dependency stop doubles drifting from reality.
At UI level, Playwright’s page.route stubs network calls in the browser, which is useful for error states that are hard to trigger:
await page.route('**/api/payments', (route) =>
route.fulfill({ status: 502, contentType: 'application/json', body: '{"error":"gateway_unavailable"}' }),
);
At service level, tools such as WireMock (HTTP stub server) or a containerised fake provide the same capability for API and integration tests.
4. Environment strategy
Decide deliberately which tests run against which environment:
- Local and CI, with doubles: unit, component and most API tests. No shared environment; fast and deterministic.
- Ephemeral environments: a short-lived deployment per pull request or branch (often a namespace in Kubernetes, or a Compose stack), with seeded data. Ideal for integration and focused end-to-end tests because nobody else changes the data.
- Shared staging: production-like integration with real dependencies or their sandboxes. Use it for the small end-to-end and release checks, not for everything.
- Production: smoke tests, synthetic monitoring and canary analysis with dedicated test accounts.
Problems shared environments cause, and their mitigations:
- Data collisions: every test creates its own data with unique identifiers, and never depends on records another test might change.
- Version drift: tests record which versions of each service they ran against, so a failure can be traced.
- Availability: a broken staging blocks everyone. Health-check the environment before the suite runs and fail fast with a clear “environment unhealthy” message rather than hundreds of test failures.
Configuration must be external to tests: base URLs, credentials and feature flag states come from environment variables or config files per environment, never hard-coded.
5. Ownership
Architecture fails without clear ownership.
- Tests are owned by the team that owns the behaviour. If the payments team changes the payments service, it owns the payments tests, including the end-to-end journeys that start in payments.
- Shared framework code has an owner, often a platform or quality engineering team, with versioning and change review like any other library.
- Every failing test routes to someone. Use code owners files, test tags or naming conventions so CI can notify the right team.
- Deleting tests is allowed. Tests that duplicate coverage, test removed features or never fail for real reasons cost time. An owner can remove them after review.
Practice exercise
A company has four services (web, orders, inventory, payments). All 900 tests are UI end-to-end tests in one repository owned by a central QA team, run against a shared staging environment. Runs take 90 minutes, fail often because of data collisions and staging outages, and failures sit uninvestigated. Propose a target test architecture and the first three steps.
Show a model answer
Target architecture
- Each service team owns unit, component and API tests in its own repository, run on every PR with doubles for other services.
- Consumer-driven contracts between web and orders, orders and inventory, orders and payments; providers verify contracts in their pipelines before deploying.
- A fake payment provider for most tests; a small set of tests against the provider’s sandbox nightly.
- Ephemeral environments per PR for integration tests and focused end-to-end checks, with seeded data.
- A small cross-service end-to-end suite (critical journeys only) on staging, owned jointly, with an environment health check before it runs.
- The central QA team becomes a quality engineering team owning shared framework code, test data builders and the contract tooling, and coaching service teams.
First three steps
- Tag the 900 tests by journey and owning service; route failures to owners; delete clear duplicates.
- Fix data collisions: every test creates its own data through APIs with unique IDs; add the staging health check.
- Pick the most painful boundary (likely orders and payments), introduce contract tests and a payment fake, and move the related UI tests down to API and contract level. Measure suite time and failure rate before and after to show the gain.
Key takeaways
- Layer test code: specs express intent; flows, page objects, API clients and builders hide mechanics; dependencies point down.
- Test service boundaries with contracts (schema-based or consumer-driven) so fewer slow end-to-end tests are needed.
- Choose doubles deliberately: stubs for canned answers, mocks when the interaction is the behaviour, fakes for realistic reusable behaviour.
- Match tests to environments: doubles locally, ephemeral environments per PR, a small suite on staging, smoke and synthetics in production.
- Every test and every failure needs an owner, and tests can be deleted.
Key terms
Finished this lesson?
Progress is saved in this browser. No account needed.