API Automation Framework Design with Playwright and TypeScript
Fifty API tests in one file work fine. Fifteen hundred tests, five environments, eight roles and six people committing every day don’t, unless the framework underneath is designed for it. “Explain your framework” is also the question every automation interview spends the most time on. In this lesson we assemble everything from Module 6 into a clean, layered Playwright TypeScript API framework, explain each design decision, and finish with the conventions that keep it maintainable for years.
playwright.config.ts + environment variables), API clients / service objects (one class per resource), models and schemas (TypeScript types + Zod/Ajv), fixtures (pre-built clients, auth per role, cleanup), test data (builders, JSON/CSV files) and tests grouped by feature. Add a setup project for login, tags for smoke/regression, reporters (HTML, JUnit, Allure) and CI. Tests stay short and readable because the layers below hide the plumbing.The Folder Structure
This is the structure of the framework built in this module. Every file shown in lessons 31 to 35 lives here, and the whole suite passes against the practice API:
sqa-api-tests/
├── playwright.config.ts # projects, baseURL, reporters, retries
├── tsconfig.json
├── package.json
├── .env.example # documented variables, no secrets
├── .gitignore # .env, .auth/, test-results/, playwright-report/
├── src/
│ ├── config.ts # credentials + paths from environment variables
│ ├── fixtures.ts # test.extend: clients, role contexts, cleanup
│ ├── schemas.ts # Zod schemas + Ajv helper
│ ├── builders.ts # test data builders
│ └── api/ # service objects (one per resource)
│ ├── UsersApi.ts
│ ├── ObjectsApi.ts
│ └── AuthApi.ts
├── data/ # data-driven inputs
│ ├── age-boundaries.json
│ └── object-ttl.csv
└── tests/
├── auth.setup.ts # logs in once per run (setup project)
├── request-basics.spec.ts
├── crud.spec.ts
├── assertions.spec.ts
├── auth.spec.ts
├── data-driven.spec.ts
└── builders.spec.ts
The Layers and Why They Exist
| Layer | Responsibility | Without it… |
|---|---|---|
| Config | Base URL, headers, timeouts, projects, reporters | URLs hardcoded in 300 places; one environment only |
| Service objects | Endpoints, methods, headers, request shapes | An endpoint change means editing every test |
| Models / schemas | Types and runtime contracts | Typos in field names; structural bugs slip through |
| Fixtures | Ready clients, auth per role, cleanup, shared setup | Copy-pasted login and teardown code everywhere |
| Test data | Builders, files, generators | Giant literal objects repeated in every test |
| Tests | Intent: action + assertions | – |
/v1/objects to /v2/items. In a framework with service objects, the change was one line in ObjectsApi (the prefix), and 240 tests kept working. In another team’s suite with URLs inside tests, the same change took two days and three broken builds.Layer 1: Configuration and Environments
The full playwright.config.ts:
import { defineConfig } from '@playwright/test';
try { process.loadEnvFile(); } catch { /* no .env file: rely on real environment variables (CI) */ }
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: [
['list'],
['html', { open: 'never' }],
['junit', { outputFile: 'results/junit.xml' }],
],
use: {
// Trailing slash matters: request paths are resolved relative to this URL.
baseURL: process.env.API_BASE_URL ?? 'https://sqatools.in/wp-json/sqa-api/v1/',
extraHTTPHeaders: { Accept: 'application/json' },
},
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{ name: 'api', testIgnore: /.*\.setup\.ts/, dependencies: ['setup'] },
],
});
process.loadEnvFile()(built into Node.js 20.12+) reads a local.envfile, so no extra dependency is needed. In CI the variables come from the pipeline’s secret store.forbidOnlyfails CI if someone commitstest.only.retries: 1in CI absorbs rare network blips; a test that needs retries regularly is a bug to fix, not to hide.
Commit a .env.example that documents every variable without real values:
# .env.example - copy to .env and fill in
API_BASE_URL=https://sqatools.in/wp-json/sqa-api/v1/
ADMIN_USER=admin
ADMIN_PASS=
VIEWER_USER=viewer
VIEWER_PASS=
Layer 2: Service Objects
One class per resource; methods return raw APIResponses; a few ...OrThrow helpers for setup. You built ObjectsApi in the CRUD lesson, UsersApi in the data-driven lesson and AuthApi in the auth lesson. Guidelines:
- No assertions inside service objects (except guards in
...OrThrowhelpers). - Accept typed inputs (
NewObject,UserQuery) so TypeScript catches mistakes. - Take the
APIRequestContextin the constructor, so the same class works for anonymous, admin and viewer contexts. - Keep them thin. Business flows that span services (place an order = cart + payment) go in a separate “flows” or “steps” layer if you need them.
Layer 3: Models and Schemas
src/schemas.ts (from the assertions lesson) holds Zod schemas that double as TypeScript types, and an Ajv helper for official JSON Schemas. Rule of thumb: every response type the suite touches has a schema, and every positive test validates against it.
Layer 4: Fixtures
src/fixtures.ts is the heart of the framework: usersApi, objectsApi, authApi, role contexts adminApi/viewerApi and cleanup. Tests import test and expect from here, never directly from @playwright/test, so new shared capabilities (a custom matcher, a logger) reach every test through one file.
Layer 5: Test Data Builders
A builder returns a valid object with sensible defaults; tests override only the field they’re testing. This keeps tests short and makes the intent obvious. src/builders.ts:
import { NewObject } from './api/ObjectsApi';
let counter = 0;
/** Test data builder: valid defaults, override only what a test cares about. */
export function anObject(overrides: Partial<NewObject> = {}): NewObject {
counter += 1;
return {
name: `auto-${process.pid}-${Date.now()}-${counter}`,
data: { source: 'playwright' },
ttl: 60,
...overrides,
};
}
export function aUserPayload(overrides: Record<string, unknown> = {}) {
return { name: 'Asha Verma', email: 'asha@example.com', age: 30, ...overrides };
}
import { test, expect } from '../src/fixtures';
import { anObject, aUserPayload } from '../src/builders';
test('builder: only the field under test is specified', async ({ objectsApi, cleanup }) => {
const res = await objectsApi.create(anObject({ ttl: 1800 }));
expect(res.status()).toBe(201);
const obj = await res.json();
cleanup(() => objectsApi.delete(obj.id));
expect(obj.ttl_seconds).toBe(1800);
});
test('builder: one invalid field, everything else valid', async ({ request }) => {
const res = await request.post('validate/user', { data: aUserPayload({ age: 101 }) });
expect(res.status()).toBe(422);
expect((await res.json()).error.details).toEqual([expect.objectContaining({ field: 'age' })]);
});
The second test proves a subtle point: because every other field is valid, the API reports exactly one error, for age. Negative tests built from a valid baseline isolate the rule under test.
Layer 6: Tests and Conventions
- Files by feature (
orders.spec.ts,refunds.spec.ts), not by test type. - Titles that state behaviour:
"PATCH merges and null removes a key". - Tags for selection:
test('...', { tag: '@smoke' }, ...), run with--grep @smoke. - Arrange-Act-Assert shape; one behaviour per test.
- No sleeps; use
expect.poll. - Independent: own data, cleanup, parallel-safe.
Debuggability: Make Failures Explain Themselves
- Use
await expect(res).toBeOK()and custom messages, so reports show status and body. - Attach request/response details on failure with
testInfo.attach()(see the bug reporting lesson). - Name generated data so it’s traceable (
auto-<pid>-<timestamp>) and searchable in server logs. - Send a correlation header (e.g.
X-Request-Idwith the test title) viaextraHTTPHeadersif the backend logs it.
Scaling the Framework
| Growth problem | Solution |
|---|---|
| Many environments | One project per environment, or API_BASE_URL per CI job |
| Many roles | Generic apiAs(role) fixture backed by a token map |
| Slow suite | More workers, sharding in CI (--shard=1/4), smoke vs full tags |
| Several services | Folder per service in src/api/; monorepo with shared fixtures package |
| API + UI | Same fixtures; UI tests use service objects for fast setup (Project 5) |
| Contract drift | Schemas generated from OpenAPI; contract tests in CI |
Code Quality for Test Code
- TypeScript
strictmode andtsc --noEmitin CI (Playwright runs tests without type-checking, so run tsc separately). - ESLint with
eslint-plugin-playwright(catches missing awaits, focused tests). - Pull-request reviews for tests, like production code.
- Delete or fix flaky tests quickly; quarantine with
test.fixme()and a ticket.
tsc because res.json() returns any. Only the separate npx tsc --noEmit step caught it. Add that step to CI.Interview Questions: API Framework Design
Explain your API automation framework.
Describe the layers: Playwright with TypeScript; config with baseURL and environments via variables; service objects per resource; Zod/Ajv schemas; fixtures for clients, role-based auth from a setup project and cleanup; builders and JSON/CSV data; tests by feature with tags; HTML, JUnit and Allure reports; GitHub Actions with secrets and sharding. Mention a concrete benefit, such as an endpoint rename fixed in one line.
What is the service object pattern?
A class per API resource that encapsulates endpoints and request details behind methods, like the Page Object Model for UIs. Tests call methods such as objectsApi.create() instead of building requests.
Why use fixtures instead of beforeEach hooks?
Fixtures are composable, lazy (created only when used), can be test- or worker-scoped, include teardown after use, and are injected by name, making setup reusable and explicit. Hooks run for every test in scope whether needed or not.
How do you manage environments and secrets?
Use environment variables for base URLs and credentials, a local .env file (git-ignored) loaded with process.loadEnvFile or dotenv, an .env.example for documentation, and CI secret stores in pipelines. Projects can target environments too.
What is a test data builder?
A function or class that creates valid test objects with sensible defaults and lets tests override only relevant fields, keeping tests short and making negative tests change one field from a valid baseline.
How do you keep API tests independent?
Each test creates its own data with unique values, uses read-only reference data where shared, registers cleanup, avoids shared mutable state and doesn’t depend on execution order.
How do you organise tests for smoke and regression runs?
Tag tests (for example @smoke, @regression, @slow) and select them with –grep; keep smoke small and critical; run full regression nightly or per merge, possibly sharded.
Does Playwright check TypeScript types?
No. It strips types and runs tests. Run tsc –noEmit separately (locally and in CI) to catch type errors.
How would you add a new API service to the framework?
Create a service object in src/api, add schemas for its responses, expose it through a fixture, add builders for its payloads, then write feature tests and tag critical ones as smoke.
What design principles apply to test frameworks?
Single responsibility per layer, DRY through fixtures and builders, readable tests over clever ones, fast feedback, deterministic and independent tests, and treating test code with the same quality standards as product code.