API Automation Framework Design with Playwright and TypeScript

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.

Quick answerA solid Playwright API automation framework has clear layers: configuration (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.
Advertisement

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

LayerResponsibilityWithout it…
ConfigBase URL, headers, timeouts, projects, reportersURLs hardcoded in 300 places; one environment only
Service objectsEndpoints, methods, headers, request shapesAn endpoint change means editing every test
Models / schemasTypes and runtime contractsTypos in field names; structural bugs slip through
FixturesReady clients, auth per role, cleanup, shared setupCopy-pasted login and teardown code everywhere
Test dataBuilders, files, generatorsGiant literal objects repeated in every test
TestsIntent: action + assertions–
Real-life example: the endpoint rename An API team moved /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.
Advertisement

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 .env file, so no extra dependency is needed. In CI the variables come from the pipeline’s secret store.
  • forbidOnly fails CI if someone commits test.only.
  • retries: 1 in 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 ...OrThrow helpers).
  • Accept typed inputs (NewObject, UserQuery) so TypeScript catches mistakes.
  • Take the APIRequestContext in 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.
Advertisement

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.

Advertisement

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-Id with the test title) via extraHTTPHeaders if the backend logs it.
Advertisement

Scaling the Framework

Growth problemSolution
Many environmentsOne project per environment, or API_BASE_URL per CI job
Many rolesGeneric apiAs(role) fixture backed by a token map
Slow suiteMore workers, sharding in CI (--shard=1/4), smoke vs full tags
Several servicesFolder per service in src/api/; monorepo with shared fixtures package
API + UISame fixtures; UI tests use service objects for fast setup (Project 5)
Contract driftSchemas generated from OpenAPI; contract tests in CI

Code Quality for Test Code

  • TypeScript strict mode and tsc --noEmit in 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.
Real gotcha: Playwright doesn’t type-check Playwright transpiles TypeScript but doesn’t report type errors. While building this course, a custom matcher ran perfectly yet failed tsc because res.json() returns any. Only the separate npx tsc --noEmit step caught it. Add that step to CI.
Advertisement

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.

Advertisement
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.

Advertisement
View the full API testing syllabus