Lesson 8 of 12 · Automation Frameworks
Framework Architecture
Design a test automation framework that stays maintainable: layers, folder structure, configuration, fixtures as dependency injection, API clients, and when to stop abstracting.
intermediate 8 min read Updated
You will learn to
- Describe the layers of a maintainable automation framework and what belongs in each
- Structure configuration so the same tests run against any environment
- Use fixtures and API clients to keep tests short, independent and fast
- Recognise over-engineering and framework anti-patterns
Prerequisites
- The Page Object Model
- TypeScript for Testers
“Build a framework” is one of the most common SDET job requirements and one of the most misunderstood. A framework is not a pile of utilities or a custom wrapper around every Playwright method. It is the set of conventions and shared code that lets a team write tests quickly, read them easily, run them anywhere, and diagnose failures without guesswork.
This lesson uses Playwright with TypeScript for examples, but the architecture applies equally to Selenium with Java or any other stack. Interviewers often ask you to “walk through your framework”; the structure below gives you a clear way to answer.
What a framework must do
Before choosing folders, agree on the outcomes. A good framework makes it easy to:
- Write a new test in a few lines, using shared building blocks.
- Read a test as a specification of behaviour, without implementation noise.
- Run the same tests locally and in CI, against any environment, in parallel.
- Diagnose failures from the report alone: logs, screenshots, traces, request and response bodies.
- Change the application without editing dozens of tests.
Every architectural choice should serve one of these. If a layer does not, it is overhead.
The layers
┌─────────────────────────────────────────────┐
│ Tests specs: behaviour + asserts │
├─────────────────────────────────────────────┤
│ Domain layer page objects, components, │
│ API clients, flows │
├─────────────────────────────────────────────┤
│ Test data builders, factories, seeds │
├─────────────────────────────────────────────┤
│ Core config, fixtures, logging, │
│ reporting hooks │
├─────────────────────────────────────────────┤
│ Tool Playwright / Selenium / │
│ REST Assured │
└─────────────────────────────────────────────┘
- Tests describe behaviour and contain the assertions. No raw locators, no URLs, no credentials.
- Domain layer knows how the application works: page objects and components for the UI, typed API clients for services, and occasionally flows that combine several steps a lot of tests need (for example “create a customer with an active subscription”).
- Test data creates the data tests need, uniquely and on demand. See Test Data Management.
- Core is the plumbing: configuration, fixtures, logging and reporting hooks. Tests rarely import from here directly.
- Tool: the underlying library. Use it directly from the domain layer; do not hide it behind your own wrapper API.
Dependencies point downwards only. A page object may use config; config must never import a page object. Tests may use everything above core.
A folder structure that scales
e2e/
├── playwright.config.ts
├── src/
│ ├── config/
│ │ └── env.ts # typed, validated environment config
│ ├── api/
│ │ ├── api-client.ts # base client: auth, errors, logging
│ │ └── users-api.ts # one client per resource or service
│ ├── pages/
│ │ ├── login-page.ts
│ │ └── users-page.ts
│ ├── components/
│ │ └── data-table.ts
│ ├── data/
│ │ └── user-builder.ts
│ └── fixtures/
│ └── index.ts # test.extend: wires everything together
└── tests/
├── api/
│ └── users.spec.ts
└── ui/
└── admin-users.spec.ts
Organise tests by feature or behaviour, not by test type alone, once the suite grows; tests/billing/, tests/onboarding/ are easier to own than one giant ui/ folder. Keep the framework code in the same repository as the tests that use it unless several repositories genuinely share it.
Configuration: one suite, many environments
Hard-coded URLs and credentials are the first thing that stops a suite running anywhere except on its author’s laptop. Centralise config, read it from environment variables, and fail fast when something is missing:
// src/config/env.ts
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing required environment variable ${name}`);
return value;
}
export const env = {
baseUrl: process.env.BASE_URL ?? 'http://localhost:3000',
apiUrl: process.env.API_URL ?? 'https://sdetrole.com/api/mock/',
apiUser: process.env.API_USER ?? 'tester',
apiPassword: required('API_PASSWORD'),
isCI: !!process.env.CI,
} as const;
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import { env } from './src/config/env';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: env.isCI ? 2 : 0,
reporter: env.isCI ? [['html', { open: 'never' }], ['junit', { outputFile: 'results/junit.xml' }]] : 'list',
use: {
baseURL: env.baseUrl,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'api', testDir: './tests/api', use: { baseURL: env.apiUrl } },
{ name: 'chromium', testDir: './tests/ui', use: { ...devices['Desktop Chrome'] } },
],
});
Locally, values come from a .env file that is listed in .gitignore (load it with Node’s --env-file flag or the dotenv package); in CI they come from the pipeline’s secret store. The tests never know which.
API clients in the domain layer
UI tests should create their preconditions through the API, not by clicking. A thin, typed client makes that one line per test:
// src/api/users-api.ts
import { type APIRequestContext, expect } from '@playwright/test';
export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'tester' | 'developer' | 'viewer';
active: boolean;
}
export class UsersApi {
constructor(private readonly request: APIRequestContext) {}
async get(id: number): Promise<User> {
const res = await this.request.get(`users/${id}`);
await expect(res, `GET users/${id}`).toBeOK();
return res.json();
}
async create(input: Pick<User, 'name' | 'email'> & Partial<User>): Promise<User> {
const res = await this.request.post('users', { data: input });
expect(res.status(), await res.text()).toBe(201);
return res.json();
}
}
The client checks that setup succeeded, with a helpful message if not. It deliberately does not assert behaviour; API tests that verify the users endpoint call request directly so they can check every status and header.
Fixtures: dependency injection for tests
Fixtures wire the layers together, so tests declare what they need and receive it ready to use:
// src/fixtures/index.ts
import { test as base } from '@playwright/test';
import { env } from '../config/env';
import { UsersApi } from '../api/users-api';
import { LoginPage } from '../pages/login-page';
type TestFixtures = { loginPage: LoginPage };
type WorkerFixtures = { usersApi: UsersApi };
export const test = base.extend<TestFixtures, WorkerFixtures>({
usersApi: [
async ({ playwright }, use) => {
const ctx = await playwright.request.newContext({ baseURL: env.apiUrl });
await use(new UsersApi(ctx));
await ctx.dispose();
},
{ scope: 'worker' },
],
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
});
export { expect } from '@playwright/test';
// tests/api/users.spec.ts
import { test, expect } from '../../src/fixtures';
test('seed user 1 is an active tester', async ({ usersApi }) => {
const user = await usersApi.get(1);
expect(user).toMatchObject({ name: 'Priya Shah', role: 'tester', active: true });
});
In Java the same role is played by JUnit 5 extensions, TestNG listeners and base classes, or a dependency injection library. Keep base classes thin and prefer composition.
Principles that matter more than structure
- Independent tests: each test creates what it needs and can run alone, in any order, in parallel. Shared mutable state between tests is the root of most “passes alone, fails in the suite” bugs.
- No fixed sleeps: rely on the tool’s auto-waiting or explicit waits.
- Fast feedback: arrange through APIs, keep end-to-end UI tests for journeys that genuinely need a browser.
- Diagnosable failures: assertion messages, traces and attached request/response bodies. See Test Reporting.
- Code quality: the framework is production code for your team. Lint it (for example
eslint-plugin-playwright), type-check it, and review it like application code.
Anti-patterns
Add abstraction when duplication or pain appears, not in advance. A framework you can explain on one whiteboard is usually a better framework.
Practice exercise
A team’s suite has these problems: tests fail when run in a different order; the staging URL and an admin password appear in 30 spec files; each UI test logs in through the login page; and failures in CI show only “Timeout 30000ms exceeded”. Propose changes, naming the layer each change belongs to and showing the key code for one of them. The Playwright Lab is a good place to practise fixture-based structure.
Show a model answer
| Problem | Change | Layer |
|---|---|---|
| Order dependence | Each test creates its own data via an API client and a builder with unique values; remove shared users and test.describe.serial |
Test data + domain |
| Hard-coded URL and password | src/config/env.ts reads BASE_URL and ADMIN_PASSWORD from the environment, fails fast if missing; baseURL in config; rotate the leaked password |
Core |
| UI login in every test | A setup project logs in once and saves storageState; or a worker fixture logs in via the API |
Core (fixtures) |
| Unhelpful failures | trace: 'on-first-retry', screenshots on failure, assertion messages, and API clients that include the response body in setup failures |
Core + domain |
Key code for the login change, using a Playwright setup project (with adminEmail and adminPassword added to env):
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import { env } from '../src/config/env';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(env.adminEmail);
await page.getByLabel('Password').fill(env.adminPassword);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: '.auth/admin.json' });
});The UI project then declares dependencies: ['setup'] and use: { storageState: '.auth/admin.json' }, so every test starts already logged in, while the login page is still exercised once per run.
Key takeaways
- A framework exists to make tests easy to write, read, run, diagnose and change; judge every layer by that.
- Layer it: tests, domain (pages, components, API clients), test data, core (config, fixtures, reporting), with dependencies pointing downwards.
- Centralise configuration from environment variables and fail fast; never commit secrets.
- Use fixtures as dependency injection and API clients to arrange state quickly.
- Avoid wrapping the tool, god base classes and speculative abstraction. Add structure when real duplication appears.
Key terms
Finished this lesson?
Progress is saved in this browser. No account needed.