SoftwareTestPilot
Automation TestingPublished: 18 min read

Playwright API Testing Framework in TypeScript: Structure, Auth, Data and CI

Build a Playwright API testing framework in TypeScript: project structure, environment config, request contexts, authentication, reusable assertions, test data, cleanup, reporting and CI.

SoftwareTestPilot
QA Editorial Team
Share:XLinkedInWhatsApp
Playwright API testing framework in TypeScript with project structure, auth, assertions and CI
Playwright API testing framework in TypeScript with project structure, auth, assertions and CI

Last updated 2026-10-16 · 18 min read · By SoftwareTestPilot

Writing one Playwright API test is easy. Keeping two hundred of them fast, readable and trustworthy is the hard part. Without a plan, base URLs get copied into every file, each test logs in on its own, assertions say "expected true, received false", and leftover records slowly fill the test database until something unrelated breaks.

This guide shows how to build a Playwright API testing framework in TypeScript that avoids those problems. We use one fictional shop API throughout (products, carts, orders and a login endpoint) and grow the framework in layers: configuration, request contexts, authentication, assertions, test data, cleanup, reporting and CI. Every layer has one home in the project, so a new team member can guess where things live without asking.

If you are new to the request fixture itself, start with our Playwright API testing guide and come back here when you want structure. The code below was checked against the official Playwright documentation but not executed, because the shop is made up; URLs and field names are examples.

1. A consistent project structure

Decide the layout first. Every later section adds files to exactly one of these folders:

api-tests/
  playwright.config.ts
  .env.example            # documented variable names, no real values
  config/
    env.ts                # reads and validates environment variables
  clients/
    ProductsClient.ts     # one small typed client per resource
    OrdersClient.ts
  fixtures/
    index.ts              # extended test: api contexts, clients, data
  schemas/
    product.schema.ts     # response shapes (Zod)
  helpers/
    assertions.ts         # reusable expect helpers
    data.ts               # unique names and builders
  tests/
    auth.setup.ts         # gets tokens once
    products/
      create-product.spec.ts
      list-products.spec.ts
    orders/
      place-order.spec.ts
  .auth/                  # saved tokens (git-ignored)

The rules behind it are simple:

  • Spec files describe behaviour only. They never build URLs, read process.env or construct clients.
  • Clients know endpoints, not assertions. A client returns the raw response so each test decides what "correct" means, including negative cases.
  • Fixtures wire everything together. They create contexts, clients and data, and they clean up.
  • Tests are grouped by resource, which matches how API bugs are usually reported and makes it easy to run one area with npx playwright test tests/orders.

You can merge clients/ into fixtures for a tiny suite. Once you pass a handful of endpoints, the separation pays for itself because the same client is reused by setup, data fixtures and tests.

2. Environment configuration

An API suite usually runs against at least two environments, such as local and staging. Keep every environment-specific value in variables and read them in exactly one module. Validating them at start-up means a missing value produces one clear error rather than a wall of failed requests.

// config/env.ts
import { z } from 'zod';

const EnvSchema = z.object({
  API_BASE_URL: z.string().url(),
  API_USER_EMAIL: z.string().email(),
  API_USER_PASSWORD: z.string().min(1),
  API_ADMIN_EMAIL: z.string().email(),
  API_ADMIN_PASSWORD: z.string().min(1),
});

const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
  const missing = parsed.error.issues.map((i) => i.path.join('.')).join(', ');
  throw new Error('Invalid API test environment: ' + missing);
}

export const env = parsed.data;

The config file then reads from that module. Load a local .env file with a library such as dotenv before importing it; in CI, the variables come from the pipeline's secret store instead.

// playwright.config.ts
import 'dotenv/config';
import { defineConfig } from '@playwright/test';
import { env } from './config/env';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 1 : 0,
  reporter: process.env.CI
    ? [['list'], ['html', { open: 'never' }], ['junit', { outputFile: 'results/junit.xml' }]]
    : [['list'], ['html', { open: 'on-failure' }]],
  use: {
    baseURL: env.API_BASE_URL,
    extraHTTPHeaders: { Accept: 'application/json' },
  },
  projects: [
    { name: 'setup', testMatch: /auth\.setup\.ts/ },
    { name: 'api', dependencies: ['setup'], testIgnore: /auth\.setup\.ts/ },
  ],
});

Two habits keep this layer healthy. First, commit a .env.example with the variable names and add .env to .gitignore. Second, never add "default" passwords in code for convenience; a default that works locally will eventually be used against the wrong environment.

3. Request contexts: built-in fixture or your own

Playwright gives you two ways to send API requests. The built-in request fixture is an APIRequestContext that already uses baseURL and extraHTTPHeaders from your config, and Playwright disposes it for you. For a suite with one kind of user, that is often enough:

import { test, expect } from '@playwright/test';

test('lists products', async ({ request }) => {
  const response = await request.get('/api/products');
  expect(response.status()).toBe(200);
});

Real frameworks quickly need more than one identity: a shopper who places an order and an admin who refunds it, or an anonymous caller checking that a route rejects them. For that, create contexts yourself with request.newContext() (the request import from @playwright/test, or playwright.request inside fixtures). Each context has its own headers and cookies, so identities never leak into each other. Contexts you create are yours to close with dispose().

The cleanest place to create them is a fixture. Here a worker-scoped fixture builds one context per role per worker, because creating contexts is cheap but logging in is not:

// fixtures/index.ts (part 1)
import { test as base, expect, request, APIRequestContext } from '@playwright/test';
import { env } from '../config/env';
import { readToken } from '../helpers/tokens';

type Role = 'shopper' | 'admin';

type WorkerFixtures = {
  apiAs: (role: Role) => Promise<APIRequestContext>;
  anonymousApi: APIRequestContext;
};

export const test = base.extend<{}, WorkerFixtures>({
  apiAs: [async ({}, use) => {
    const created: APIRequestContext[] = [];
    await use(async (role) => {
      const ctx = await request.newContext({
        baseURL: env.API_BASE_URL,
        extraHTTPHeaders: {
          Accept: 'application/json',
          Authorization: 'Bearer ' + readToken(role),
        },
      });
      created.push(ctx);
      return ctx;
    });
    for (const ctx of created) await ctx.dispose();
  }, { scope: 'worker' }],

  anonymousApi: [async ({}, use) => {
    const ctx = await request.newContext({ baseURL: env.API_BASE_URL });
    await use(ctx);
    await ctx.dispose();
  }, { scope: 'worker' }],
});

export { expect };

A factory such as apiAs(role) keeps tests readable (const admin = await apiAs('admin')) and still guarantees every context is disposed when the worker finishes. If you prefer named fixtures, shopperApi and adminApi work just as well; pick one style and use it everywhere.

4. Authentication: log in once, reuse everywhere

Logging in inside every test is the most common reason API suites become slow and flaky. Get tokens once, store them for the run, and attach them as headers. A setup project is a good fit because it runs before every other test and shows up in the report like any test:

// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import fs from 'node:fs';
import { env } from '../config/env';

const users = {
  shopper: { email: env.API_USER_EMAIL, password: env.API_USER_PASSWORD },
  admin: { email: env.API_ADMIN_EMAIL, password: env.API_ADMIN_PASSWORD },
};

for (const [role, creds] of Object.entries(users)) {
  setup('get token for ' + role, async ({ request }) => {
    const response = await request.post('/api/auth/login', { data: creds });
    expect(response.status(), 'login failed for ' + role).toBe(200);
    const { token } = await response.json();
    expect(token).toBeTruthy();
    fs.mkdirSync('.auth', { recursive: true });
    fs.writeFileSync('.auth/' + role + '.json', JSON.stringify({ token }));
  });
}
// helpers/tokens.ts
import fs from 'node:fs';

export function readToken(role: string): string {
  const raw = fs.readFileSync('.auth/' + role + '.json', 'utf-8');
  return JSON.parse(raw).token;
}

Some points that matter in practice:

  • Token lifetime. If tokens expire faster than the suite runs, move the login into the worker fixture instead of the setup project, so each worker gets a fresh token when it starts.
  • Cookie-based APIs. If the API sets a session cookie rather than returning a token, save await request.storageState({ path }) in setup and pass storageState to request.newContext().
  • Secrets. Add .auth/ to .gitignore and never print the token. If a test needs to show which user acted, log the role, not the header.
  • Testing auth itself. The reused token is for testing everything else. Login, expiry, tampered tokens and missing headers deserve their own tests that build requests explicitly with anonymousApi.

For those dedicated authentication tests, including expired and tampered tokens, see our guide to testing JWT authentication in Playwright, Postman and REST Assured, and how to decode and verify JWT tokens when you need to inspect what a token actually contains.

5. Small typed clients per resource

Clients remove URL building and payload shapes from tests. Keep them thin: one method per endpoint, typed inputs, raw APIResponse out. Returning the response (rather than parsed JSON) is deliberate, because negative tests need the status code and error body.

// clients/ProductsClient.ts
import { APIRequestContext, APIResponse } from '@playwright/test';

export type NewProduct = { name: string; price: number; stock: number };

export class ProductsClient {
  constructor(private readonly api: APIRequestContext) {}

  create(product: NewProduct): Promise<APIResponse> {
    return this.api.post('/api/products', { data: product });
  }

  get(id: string): Promise<APIResponse> {
    return this.api.get('/api/products/' + id);
  }

  list(params: { page?: number; search?: string } = {}): Promise<APIResponse> {
    return this.api.get('/api/products', { params });
  }

  remove(id: string): Promise<APIResponse> {
    return this.api.delete('/api/products/' + id);
  }
}

Resist the urge to add expect calls inside clients. The moment a client asserts "status must be 201", you can no longer use it to check that a duplicate name returns 409.

6. Reusable assertions that explain failures

The default failure for expect(response.ok()).toBeTruthy() is "expected true, received false". That tells you nothing about what the server said. A few helpers fix this and keep tests short.

// helpers/assertions.ts
import { expect, APIResponse } from '@playwright/test';
import { z } from 'zod';

export async function expectStatus(response: APIResponse, expected: number) {
  if (response.status() !== expected) {
    const body = await response.text();
    throw new Error(
      'Expected ' + expected + ' but got ' + response.status() +
      ' for ' + response.url() + '\nBody: ' + body.slice(0, 500)
    );
  }
}

export async function expectJsonMatching<T>(response: APIResponse, schema: z.ZodType<T>): Promise<T> {
  const json = await response.json();
  const result = schema.safeParse(json);
  expect(result.success, result.success ? '' : JSON.stringify(result.error.issues, null, 2)).toBe(true);
  return result.data as T;
}

export async function expectApiError(response: APIResponse, status: number, code: string) {
  await expectStatus(response, status);
  const body = await response.json();
  expect(body).toMatchObject({ error: { code } });
}

Three helpers cover most suites: status with body on failure, shape against a schema, and error format. The schema lives in schemas/ and doubles as a TypeScript type, so the test gets autocomplete on the parsed body:

// schemas/product.schema.ts
import { z } from 'zod';

export const ProductSchema = z.object({
  id: z.string(),
  name: z.string().min(1),
  price: z.number().nonnegative(),
  stock: z.number().int(),
  createdAt: z.string(),
});

export type Product = z.infer<typeof ProductSchema>;

Zod is one option; JSON Schema validators such as Ajv work the same way and suit teams that already publish OpenAPI schemas. The key idea is the same in any tool: check the shape once per endpoint, then assert the business values the test actually cares about. Our guide on validating JSON API responses explains what a schema check catches and what it misses, and the contract testing with Pact guide covers the step beyond shape checks, where consumer and provider agree on the contract.

Two cautions. Do not snapshot entire responses that contain timestamps or IDs; they fail on every run. And do not replace value checks with schema checks: a price of 0 passes nonnegative() but may still be a bug.

7. Test data that never collides

Parallel API tests break when two tests touch the same record. The fix is boring and reliable: every test creates the data it needs, with a name nobody else will use, and reads only what it created.

// helpers/data.ts
import { test } from '@playwright/test';
import type { NewProduct } from '../clients/ProductsClient';

export function uniqueName(prefix: string): string {
  const info = test.info();
  return prefix + '-w' + info.workerIndex + '-' + Date.now().toString(36) + '-' +
    Math.random().toString(36).slice(2, 6);
}

export function buildProduct(overrides: Partial<NewProduct> = {}): NewProduct {
  return { name: uniqueName('qa-product'), price: 499, stock: 10, ...overrides };
}

Builders give every field a sensible default, and tests override only what matters to them, such as buildProduct({ stock: 0 }) for an out-of-stock case. That makes the purpose of each test obvious from one line.

Some data cannot be created per test: reference data like countries or tax rates, or a seeded catalogue the API reads from. Treat that as read-only fixtures of the environment, document it in the README, and never modify it from tests. If a test must change shared configuration, mark that file with test.describe.configure({ mode: 'serial' }) and restore the value at the end, or better, ask for an API that scopes the setting to a test tenant.

8. Cleanup that runs even when tests fail

Code after an assertion never runs when that assertion fails, so cleanup at the bottom of a test is unreliable. Put creation and deletion in a test-scoped fixture: everything after await use() runs whether the test passed or failed.

// fixtures/index.ts (part 2)
import { ProductsClient } from '../clients/ProductsClient';
import { buildProduct } from '../helpers/data';
import { expectStatus, expectJsonMatching } from '../helpers/assertions';
import { ProductSchema, Product } from '../schemas/product.schema';

type TestFixtures = {
  adminProducts: ProductsClient;
  createdProducts: string[];
  product: Product;
};

export const apiTest = test.extend<TestFixtures>({
  adminProducts: async ({ apiAs }, use) => {
    await use(new ProductsClient(await apiAs('admin')));
  },

  // Tests push IDs here when they create extra records themselves.
  createdProducts: async ({ adminProducts }, use) => {
    const ids: string[] = [];
    await use(ids);
    for (const id of ids) {
      const res = await adminProducts.remove(id);
      if (![200, 204, 404].includes(res.status())) {
        console.warn('Cleanup failed for product ' + id + ': ' + res.status());
      }
    }
  },

  product: async ({ adminProducts, createdProducts }, use) => {
    const res = await adminProducts.create(buildProduct());
    await expectStatus(res, 201);
    const product = await expectJsonMatching(res, ProductSchema);
    createdProducts.push(product.id);
    await use(product);
  },
});

A few details make this robust. Treat 404 as fine during cleanup, because the test may have deleted the record itself. Log cleanup failures rather than throwing, so a broken delete does not hide the real test failure; then review those warnings in CI. And because every record carries the qa-product- prefix, a scheduled job can sweep anything left behind by cancelled runs without touching real data.

With the fixtures in place, a spec reads like a description of behaviour:

// tests/products/create-product.spec.ts
import { apiTest as test, expect } from '../../fixtures';
import { buildProduct } from '../../helpers/data';
import { expectStatus, expectApiError } from '../../helpers/assertions';

test.describe('POST /api/products', () => {
  test('new product is readable by id', async ({ product, adminProducts }) => {
    const res = await adminProducts.get(product.id);
    await expectStatus(res, 200);
    expect(await res.json()).toMatchObject({ name: product.name, price: product.price });
  });

  test('duplicate name is rejected', async ({ product, adminProducts }) => {
    const res = await adminProducts.create(buildProduct({ name: product.name }));
    await expectApiError(res, 409, 'DUPLICATE_NAME');
  });

  test('shopper cannot create products', async ({ apiAs }) => {
    const shopper = await apiAs('shopper');
    const res = await shopper.post('/api/products', { data: buildProduct() });
    expect(res.status()).toBe(403);
  });
});

Practice this on a real API

Learn API automation with hands-on labs that call a real practice API from your own terminal.

9. Reporting that helps people fix things

A report is useful when someone who did not write the test can tell what broke. Playwright's built-in reporters cover most needs: list for the terminal, html for browsing failures, and junit for CI dashboards that read XML. The config in section 2 already switches between them based on CI.

API failures need context the default output lacks. Attach the request and response for failed calls with test.info().attach(), which shows them inside the HTML report:

// helpers/attach.ts
import { test, APIResponse } from '@playwright/test';

export async function attachResponse(name: string, response: APIResponse) {
  const body = await response.text();
  await test.info().attach(name, {
    body: JSON.stringify({
      url: response.url(),
      status: response.status(),
      body: body.slice(0, 5000),
    }, null, 2),
    contentType: 'application/json',
  });
}

Call it from expectStatus just before throwing, so every unexpected status is recorded once without extra code in tests. Leave headers out; that is where tokens live. Also use test.step() for multi-call flows such as "create cart, add item, place order", so the report shows which step failed instead of one long test.

Tags make reports easier to slice. Add { tag: '@smoke' } to the handful of tests that must pass on every deploy, and run them with --grep @smoke.

10. Running the framework in CI

API tests are ideal for CI: no browsers to install, short runs and clear signals. A GitHub Actions job looks like this:

# .github/workflows/api-tests.yml
name: API tests
on:
  pull_request:
  push:
    branches: [main]

jobs:
  api:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npx playwright test
        env:
          CI: true
          API_BASE_URL: ${{ vars.STAGING_API_URL }}
          API_USER_EMAIL: ${{ secrets.API_USER_EMAIL }}
          API_USER_PASSWORD: ${{ secrets.API_USER_PASSWORD }}
          API_ADMIN_EMAIL: ${{ secrets.API_ADMIN_EMAIL }}
          API_ADMIN_PASSWORD: ${{ secrets.API_ADMIN_PASSWORD }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

Notice what is missing: npx playwright install. A pure API suite does not launch a browser, so you can skip downloading one. If the same repository also has UI tests, put them in another project and job.

Some choices to make deliberately:

  • Retries. One retry in CI surfaces flaky tests as "flaky" in the report rather than hiding them. If a test needs retries to pass, fix the cause, usually shared data or timing on eventual consistency.
  • Workers. API tests are light on the runner but can be heavy on the server. Limit workers if the staging environment rate-limits or slows down under parallel load.
  • Which environment. Run against staging or a disposable environment. Never point a suite that creates and deletes records at production.
  • Gates. Run @smoke on every pull request and the full suite on merge or nightly, if the full run is long.

Growing the framework without losing the structure

The structure stays consistent as you add resources because each new endpoint follows the same path:

  1. Add a schema in schemas/.
  2. Add a thin client in clients/.
  3. Add a builder in helpers/data.ts and a data fixture with cleanup.
  4. Write specs in a new folder under tests/.

Review rules help too. In code review, reject spec files that read process.env, build full URLs, or create data without a fixture. Those three checks catch most structural drift.

When should you not build all of this? A suite of ten checks for a small service is fine with the built-in request fixture and a couple of helpers. Add layers when the pain appears: config validation when you get a second environment, role contexts when you get a second user type, data fixtures when parallel runs start colliding.

Framework checklist

  • Environment variables validated in one module; .env.example committed, .env ignored.
  • baseURL and default headers set in config, not in tests.
  • Tokens obtained once (setup project or worker fixture) and never logged.
  • Contexts you create are disposed; the built-in request is left to Playwright.
  • Thin typed clients that return raw responses.
  • Assertion helpers that show status, URL and body on failure.
  • Schemas checked once per endpoint, business values asserted separately.
  • Unique, prefixed test data created per test; cleanup after use().
  • HTML and JUnit reports in CI, artefacts uploaded even on failure.
  • Smoke tag for pull requests; never run against production.

Preparing for interviews as well? Our Playwright API testing interview questions walk through how to explain these decisions, and the custom fixtures example goes deeper on fixture scope and teardown.

Learn API automation with hands-on labs that call a real practice API from your own terminal.

Key takeaways

  1. 1Validate environment variables once at start-up in a typed config module, so a missing secret fails in one clear line instead of fifty confusing 401s.
  2. 2Use the built-in request fixture for simple suites; create your own request contexts when you need different users or base URLs in one test.
  3. 3Get a token once in a setup project or worker fixture and pass it through extraHTTPHeaders, never log in inside every test.
  4. 4Put repeated checks (status with body on failure, schema shape, error format) in small helper functions that report useful messages.
  5. 5Create data with unique names per test and delete it in the fixture after use(), so cleanup runs even when the test fails.

Common mistakes to avoid

  • Hardcoding base URLs and tokens in spec files, so switching from staging to local means editing dozens of files.
  • Asserting only response.ok(), which hides the real status code and body when something breaks.
  • Sharing one created record across many tests, then chasing failures that only appear in parallel runs.
  • Calling dispose() on contexts you never created, or forgetting to dispose the ones you did.
  • Printing tokens or full request headers in logs and reports that CI keeps for weeks.

References

  1. 1
    Playwright: API testing— playwright.dev
  2. 2
  3. 3
    Playwright: Fixtures— playwright.dev
  4. 4
  5. 5
    Playwright: Reporters— playwright.dev
  6. 6
  7. 7