The basic Playwright API testing setup takes fifteen minutes. What takes longer is building the patterns that make the test suite maintainable as the API surface grows and the team using it expands.
This guide covers the TypeScript-specific patterns that experienced Playwright API testing teams converge on: typed response clients, reusable fixture architecture, parallel execution with isolated contexts, and CI integration that keeps results meaningful.
Type-Safe API Client Abstraction
Raw request.get() calls scattered across test files are hard to maintain when API contracts change. A typed API client abstraction centralizes request logic and makes response types explicit.
// lib/api-client.ts
import { APIRequestContext } from '@playwright/test';
interface Order {
order_id: string;
status: 'pending' | 'confirmed' | 'cancelled';
product_id: string;
quantity: number;
created_at: string;
}
interface CreateOrderPayload {
product_id: string;
quantity: number;
payment_method: string;
}
interface APIError {
code: string;
message: string;
details?: Record<string, unknown>;
}
interface APIResponse<T> {
data: T | null;
error: APIError | null;
status: number;
}
export class OrderAPIClient {
constructor(
private request: APIRequestContext,
private baseURL: string,
private authToken?: string
) {}
private get headers(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.authToken) {
headers['Authorization'] = `Bearer ${this.authToken}`;
}
return headers;
}
async createOrder(
payload: CreateOrderPayload
): Promise<APIResponse<Order>> {
const response = await this.request.post(
`${this.baseURL}/orders`,
{ data: payload, headers: this.headers }
);
const status = response.status();
if (status === 201) {
return { data: await response.json() as Order, error: null, status };
}
return {
data: null,
error: await response.json() as APIError,
status
};
}
async getOrder(orderId: string): Promise<APIResponse<Order>> {
const response = await this.request.get(
`${this.baseURL}/orders/${orderId}`,
{ headers: this.headers }
);
const status = response.status();
if (status === 200) {
return { data: await response.json() as Order, error: null, status };
}
return {
data: null,
error: await response.json() as APIError,
status
};
}
async cancelOrder(orderId: string): Promise<APIResponse<Order>> {
const response = await this.request.patch(
`${this.baseURL}/orders/${orderId}/cancel`,
{ headers: this.headers }
);
const status = response.status();
return {
data: status === 200 ? await response.json() as Order : null,
error: status !== 200 ? await response.json() as APIError : null,
status
};
}
}
Tests using this client get type inference on response data and error objects:
// tests/order-api.spec.ts
import { test, expect } from '@playwright/test';
import { OrderAPIClient } from '../lib/api-client';
test('create order returns typed response', async ({ request }) => {
const client = new OrderAPIClient(
request,
process.env.API_BASE_URL!,
process.env.AUTH_TOKEN
);
const result = await client.createOrder({
product_id: 'prod-001',
quantity: 2,
payment_method: 'pm_test_valid'
});
expect(result.status).toBe(201);
expect(result.data).not.toBeNull();
// TypeScript knows result.data is Order here
if (result.data) {
expect(result.data.status).toBe('pending');
expect(result.data.product_id).toBe('prod-001');
expect(result.data.quantity).toBe(2);
}
});
Custom Fixture Architecture
Playwright's fixture system is where TypeScript developers can extract the most value. Custom fixtures handle authentication, API client instantiation, and test data setup in a composable way.
// fixtures/index.ts
import { test as base, APIRequestContext } from '@playwright/test';
import { OrderAPIClient } from '../lib/api-client';
interface AuthenticatedUser {
token: string;
userId: string;
email: string;
}
interface CustomFixtures {
authenticatedUser: AuthenticatedUser;
orderClient: OrderAPIClient;
adminClient: OrderAPIClient;
}
export const test = base.extend<CustomFixtures>({
authenticatedUser: async ({ request }, use) => {
const response = await request.post(
`${process.env.API_BASE_URL}/auth/login`,
{
data: {
email: process.env.TEST_USER_EMAIL!,
password: process.env.TEST_USER_PASSWORD!
}
}
);
expect(response.status()).toBe(200);
const body = await response.json();
await use({
token: body.access_token,
userId: body.user_id,
email: process.env.TEST_USER_EMAIL!
});
// Teardown: invalidate the session
await request.post(
`${process.env.API_BASE_URL}/auth/logout`,
{ headers: { 'Authorization': `Bearer ${body.access_token}` } }
);
},
orderClient: async ({ request, authenticatedUser }, use) => {
const client = new OrderAPIClient(
request,
process.env.API_BASE_URL!,
authenticatedUser.token
);
await use(client);
},
adminClient: async ({ request }, use) => {
// Separate authentication for admin operations
const response = await request.post(
`${process.env.API_BASE_URL}/auth/login`,
{
data: {
email: process.env.ADMIN_EMAIL!,
password: process.env.ADMIN_PASSWORD!
}
}
);
const body = await response.json();
const client = new OrderAPIClient(
request,
process.env.API_BASE_URL!,
body.access_token
);
await use(client);
}
});
export { expect } from '@playwright/test';
Tests using these fixtures are cleaner and fully typed:
// tests/order-lifecycle.spec.ts
import { test, expect } from '../fixtures';
test('complete order lifecycle', async ({ orderClient, adminClient }) => {
// Create order as regular user
const createResult = await orderClient.createOrder({
product_id: 'prod-001',
quantity: 1,
payment_method: 'pm_test_valid'
});
expect(createResult.status).toBe(201);
const orderId = createResult.data!.order_id;
// Verify order visible to admin
const adminViewResult = await adminClient.getOrder(orderId);
expect(adminViewResult.status).toBe(200);
expect(adminViewResult.data!.status).toBe('pending');
// Cancel order as user
const cancelResult = await orderClient.cancelOrder(orderId);
expect(cancelResult.status).toBe(200);
expect(cancelResult.data!.status).toBe('cancelled');
});
Response Schema Validation With Zod
TypeScript type assertions do not validate at runtime -- as Order tells the compiler the shape but does not verify that the API actually returned it. Zod adds runtime schema validation alongside TypeScript types.
// lib/schemas.ts
import { z } from 'zod';
export const OrderSchema = z.object({
order_id: z.string().uuid(),
status: z.enum(['pending', 'confirmed', 'cancelled']),
product_id: z.string(),
quantity: z.number().positive().int(),
amount_cents: z.number().positive(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
payment_method_details: z.object({
type: z.string(),
last4: z.string().length(4).optional()
}).optional()
});
export type Order = z.infer<typeof OrderSchema>;
export const APIErrorSchema = z.object({
code: z.string(),
message: z.string(),
details: z.record(z.unknown()).optional()
});
// tests/schema-validation.spec.ts
import { test, expect } from '../fixtures';
import { OrderSchema } from '../lib/schemas';
test('order response matches schema', async ({ orderClient }) => {
const result = await orderClient.createOrder({
product_id: 'prod-001',
quantity: 1,
payment_method: 'pm_test_valid'
});
expect(result.status).toBe(201);
// Runtime schema validation -- catches schema changes the TypeScript type missed
const parsed = OrderSchema.safeParse(result.data);
if (!parsed.success) {
// Zod error shows exactly which fields failed validation
console.error('Schema validation failed:', parsed.error.format());
}
expect(parsed.success).toBe(true);
if (parsed.success) {
expect(parsed.data.status).toBe('pending');
expect(parsed.data.quantity).toBe(1);
}
});
Zod's safeParse returns detailed error information when the API response does not match the expected schema. This catches API contract changes that TypeScript casting would silently miss.
Parallel Execution With Isolated Request Contexts
Playwright runs tests in parallel by default. API tests that share authentication state or test data can interfere when running concurrently. Isolated request contexts per worker prevent this.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
workers: 4,
use: {
baseURL: process.env.API_BASE_URL,
},
projects: [
{
name: 'api-tests',
testDir: './tests',
use: {
// Each worker gets its own storage state
storageState: undefined
}
}
]
});
// fixtures/isolated.ts
import { test as base } from '@playwright/test';
// Each test gets a unique user to prevent parallel test interference
export const test = base.extend({
isolatedUser: async ({ request }, use) => {
// Create a unique test user for this test
const timestamp = Date.now();
const email = `test-${timestamp}@example.com`;
const createResponse = await request.post('/users', {
data: { email, password: 'TestPass123!' }
});
const { user_id } = await createResponse.json();
const loginResponse = await request.post('/auth/login', {
data: { email, password: 'TestPass123!' }
});
const { access_token } = await loginResponse.json();
await use({ userId: user_id, token: access_token, email });
// Teardown: delete the test user
await request.delete(`/users/${user_id}`, {
headers: { 'Authorization': `Bearer ${access_token}` }
});
}
});
Where TypeScript Patterns Help Most and Where They Stop Helping
The patterns above -- typed clients, Zod schema validation, custom fixtures -- make the test suite significantly more maintainable as the API surface grows. TypeScript catches interface mismatches at compile time. Zod catches schema drift at runtime. Custom fixtures eliminate authentication boilerplate.
What these patterns cannot address is the accuracy of the behavioral assumptions encoded in the tests themselves. A Zod schema for the Order response is only as accurate as when it was written. If the order service adds a required field or changes the payment_method_details structure after a deployment, the Zod schema does not update automatically. The safeParse call passes until someone updates the schema to reflect the new structure.
For teams whose upstream services deploy frequently on independent schedules, this schema currency problem surfaces regardless of how well-typed the test suite is. The TypeScript types and Zod schemas reflect the API as it existed when they were written. Real APIs keep changing.
Keploy addresses the currency problem at a different layer. Rather than maintaining schemas from documentation, it records real HTTP interactions between services during sessions against staging environments after each deployment. The captured interactions generate test assertions from observed current behavior rather than from authored specifications. When the order service adds payment_method_details to its response after a deployment, the next recording session captures the new field automatically and the comparison between old and new captures surfaces the change explicitly. For teams combining Playwright's TypeScript patterns for full-stack and browser-adjacent API testing with observation-based tools for service contract accuracy, both layers stay current without manual schema maintenance.
CI Integration
// .github/workflows/api-tests.yml
name: API Tests
on:
push:
branches: [main, develop]
pull_request:
jobs:
api-tests:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps chromium
- name: Run API tests (shard ${{ matrix.shard }}/4)
run: npx playwright test --shard=${{ matrix.shard }}/4
env:
API_BASE_URL: ${{ secrets.STAGING_API_URL }}
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
ADMIN_EMAIL: ${{ secrets.ADMIN_EMAIL }}
ADMIN_PASSWORD: ${{ secrets.ADMIN_PASSWORD }}
AUTH_TOKEN: ${{ secrets.AUTH_TOKEN }}
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report-${{ matrix.shard }}
path: playwright-report/
Sharding distributes the test suite across parallel runners. Each shard runs a subset of tests independently, reducing total pipeline time proportionally to the number of shards.
The if: always() on the artifact upload ensures test reports are available even when tests fail -- essential for diagnosing CI failures without re-running locally.
Summary: When These Patterns Apply
The TypeScript patterns in this guide -- typed API clients, Zod schema validation, custom fixtures, parallel isolation, sharded CI -- apply to any Playwright API testing suite that needs to scale beyond a handful of tests.
They are most valuable for:
- Full-stack tests combining browser interactions with API assertions
- Authentication flows requiring persistent session state across requests
- API surfaces with complex response schemas where runtime validation adds safety
- Teams running large test suites in CI where parallel execution reduces pipeline time
For service-level playwright api testing use cases where the primary concern is maintaining accuracy against upstream service behavior across independent deployments rather than browser integration, complement these patterns with observation-based tooling that keeps behavioral assumptions current from recorded real traffic rather than from maintained TypeScript schemas.