API Testing with Playwright¶
Where this fits: you already know the Playwright project anatomy, the framework structure, and UI testing. This guide adds the API testing capability — automating REST endpoints with the same tool, the same runner, and the same CI as your UI suite.
For deep protocol theory (WebSocket handshakes, multipart/BLOB integrity, rate-limit algorithms, AI/non-deterministic APIs) see Advanced API Testing. This page is the hands-on "how do I automate it in Playwright" companion.
1. Why Test APIs With Playwright?¶
Most teams reach for Postman or REST Assured for APIs and Playwright for UI. But Playwright ships a first-class HTTP client — so you can do both in one framework:
| Benefit | Why it matters |
|---|---|
| One tool, one runner | Same test, expect, reporters, CI, and trace viewer for API and UI |
| Shared auth | Log in once via API, reuse the token/session in UI tests |
| Hybrid tests | Set up state via fast API calls, verify via UI (or vice-versa) |
| No extra dependencies | request is built in — no axios/supertest needed |
| Real browser context | API calls can ride the same cookies/origin as the browser |
flowchart LR
UI["🖥️ UI testing<br/>page.click / fill / expect"] --- PW["🎭 Playwright<br/>one framework"]
API["🔌 API testing<br/>request.get / post / expect"] --- PW
PW --> RUN["Shared runner · fixtures · reporters · CI · trace viewer"]
2. The request Fixture & APIRequestContext¶
Playwright exposes HTTP through APIRequestContext. There are two ways to get one:
import { test, expect } from '@playwright/test';
// (a) the built-in `request` fixture — isolated per test
test('get user', async ({ request }) => {
const res = await request.get('/api/users/1');
expect(res.status()).toBe(200);
});
// (b) a standalone context — for setup outside a test (global setup, helpers)
import { request as apiRequest } from '@playwright/test';
const ctx = await apiRequest.newContext({ baseURL: 'https://api.example.com' });
Set a baseURL and default headers once in playwright.config.ts so every call stays clean:
// playwright.config.ts
export default defineConfig({
use: {
baseURL: process.env.API_URL ?? 'https://api.example.com',
extraHTTPHeaders: { Accept: 'application/json' },
},
});
3. CRUD + Assertions¶
expect(response) has API-aware matchers (toBeOK()), and you read the body with .json(), .text(), or .body().
test.describe('Users API', () => {
let createdId: number;
test('POST creates a user', async ({ request }) => {
const res = await request.post('/api/users', {
data: { name: 'Manoj', role: 'qa-lead' }, // auto-serialised to JSON
});
expect(res.status()).toBe(201);
const body = await res.json();
expect(body).toMatchObject({ name: 'Manoj', role: 'qa-lead' });
expect(body.id).toBeTruthy();
createdId = body.id;
});
test('GET returns the user', async ({ request }) => {
const res = await request.get(`/api/users/${createdId}`);
await expect(res).toBeOK(); // any 2xx
expect((await res.json()).name).toBe('Manoj');
});
test('PUT is idempotent', async ({ request }) => {
const payload = { name: 'Manoj K', role: 'qa-lead' };
const first = await request.put(`/api/users/${createdId}`, { data: payload });
const second = await request.put(`/api/users/${createdId}`, { data: payload });
expect(await first.json()).toEqual(await second.json()); // same result
});
test('DELETE removes the user', async ({ request }) => {
expect((await request.delete(`/api/users/${createdId}`)).status()).toBe(204);
expect((await request.get(`/api/users/${createdId}`)).status()).toBe(404);
});
});
Common request options: params (query string), data (JSON body), form (urlencoded), multipart (file uploads), headers, timeout.
// query params + per-request header
await request.get('/api/search', {
params: { q: 'playwright', page: 2 },
headers: { 'X-Trace-Id': 'abc-123' },
});
// multipart file upload
await request.post('/api/documents', {
multipart: {
metadata: JSON.stringify({ title: 'Q3' }),
file: { name: 'report.pdf', mimeType: 'application/pdf',
buffer: fs.readFileSync('fixtures/report.pdf') },
},
});
4. Response Validation (Schema)¶
Status + a few fields isn't enough for a real contract. Validate the shape with a JSON-schema library (Ajv) or Zod:
import Ajv from 'ajv';
const ajv = new Ajv();
const userSchema = {
type: 'object',
required: ['id', 'name', 'role'],
properties: {
id: { type: 'number' },
name: { type: 'string' },
role: { type: 'string', enum: ['qa', 'qa-lead', 'admin'] },
},
additionalProperties: false,
};
test('user response matches contract', async ({ request }) => {
const body = await (await request.get('/api/users/1')).json();
const valid = ajv.validate(userSchema, body);
expect(valid, JSON.stringify(ajv.errors)).toBe(true);
});
5. Authentication — Log In Once, Reuse Everywhere¶
The highest-value pattern: authenticate via API in a setup project, save the storage state, and reuse it across all tests (UI and API) — no logging in per test.
// auth.setup.ts — runs once, saves the authenticated state
import { test as setup, expect } from '@playwright/test';
setup('authenticate', async ({ request }) => {
const res = await request.post('/api/login', {
data: { username: process.env.USER, password: process.env.PASS },
});
expect(res.ok()).toBeTruthy();
// persist cookies/tokens so every later test starts logged in
await request.storageState({ path: '.auth/state.json' });
});
// playwright.config.ts — wire the setup as a dependency
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{ name: 'api', dependencies: ['setup'],
use: { storageState: '.auth/state.json' } },
],
For pure token-bearer APIs, just inject the header:
const ctx = await apiRequest.newContext({
baseURL: process.env.API_URL,
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
});
6. The Killer Pattern — Hybrid API + UI Tests¶
UI setup is slow and flaky. Use the API to arrange state, then the UI to verify behaviour. This is where one-tool-for-both pays off.
test('new order appears in the UI dashboard', async ({ request, page }) => {
// ARRANGE — create the order via API (fast, reliable)
const res = await request.post('/api/orders', {
data: { item: 'Widget', qty: 3 },
});
const orderId = (await res.json()).id;
// ACT + ASSERT — verify it surfaces in the UI
await page.goto('/dashboard/orders');
await expect(page.getByTestId(`order-${orderId}`)).toBeVisible();
await expect(page.getByText('Widget × 3')).toBeVisible();
});
The reverse is also powerful — act in the UI, assert the side-effect via API:
test('submitting the form persists via the API', async ({ page, request }) => {
await page.goto('/profile');
await page.getByLabel('Display name').fill('Manoj K');
await page.getByRole('button', { name: 'Save' }).click();
const me = await (await request.get('/api/me')).json();
expect(me.displayName).toBe('Manoj K'); // verify it actually saved
});
7. Organising API Tests in the Framework¶
Mirror the Page Object idea with an API client / service object — endpoints and payload-shaping in one place, tests stay readable.
// services/UserService.ts
import { APIRequestContext } from '@playwright/test';
export class UserService {
constructor(private request: APIRequestContext) {}
create(user: { name: string; role: string }) {
return this.request.post('/api/users', { data: user });
}
get(id: number) { return this.request.get(`/api/users/${id}`); }
delete(id: number) { return this.request.delete(`/api/users/${id}`); }
}
// fixtures/services.ts — inject the service like a Page Object
import { test as base } from '@playwright/test';
import { UserService } from '../services/UserService';
export const test = base.extend<{ users: UserService }>({
users: async ({ request }, use) => { await use(new UserService(request)); },
});
export { expect } from '@playwright/test';
// tests/users.spec.ts — clean, intent-revealing
test('creates a user', async ({ users }) => {
const res = await users.create({ name: 'Manoj', role: 'qa-lead' });
expect(res.status()).toBe(201);
});
Suggested layout:
tests/api/for specs,services/for API clients,data/for payloads, reusing the samefixtures/,config/, and.envfrom the framework tutorial.
8. WebSockets & Streaming (What Playwright Can / Can't Do)¶
Playwright's request context is REST/HTTP only. For real-time:
- Observe WebSocket traffic driven by the page — fully supported via
page.on('websocket')(see the WebSocket section of Advanced API Testing). - Act as a standalone WS client (connect, send, assert frames headlessly) — not Playwright's job; use a
ws/websocketsclient in the same test project.
// observe frames the app sends/receives — great for UI-driven real-time checks
test('dashboard receives live ticks', async ({ page }) => {
const frames: string[] = [];
page.on('websocket', ws =>
ws.on('framereceived', f => frames.push(f.payload as string)));
await page.goto('/dashboard');
await expect.poll(() => frames.length).toBeGreaterThan(0);
});
9. Running in CI¶
API specs run in the same workflow as UI — they're just faster and need no browser for pure-API projects:
# .github/workflows/ci.yml (excerpt)
- run: npx playwright test --project=api # pure API, no browser install needed
- run: npx playwright test --project=chromium # UI + hybrid
Split projects so API smoke tests gate quickly while the heavier UI suite runs in parallel. Traces, the HTML report, and retries all work identically for API tests.
10. Quick Reference¶
| Task | Playwright |
|---|---|
| GET / POST / PUT / DELETE | request.get/post/put/delete(url, opts) |
| Query params | { params: { q: 'x' } } |
| JSON body | { data: {...} } (auto-serialised) |
| Form / file upload | { form: {...} } / { multipart: {...} } |
| Read body | await res.json() / .text() / .body() |
| Status assertions | expect(res).toBeOK(), res.status() |
| Default URL/headers | use.baseURL, use.extraHTTPHeaders |
| Reuse login | auth.setup.ts + storageState |
| Organise | API service object + fixture injection |
Where to Go Next¶
- Advanced API Testing — the deep protocol theory (WebSockets, BLOBs, rate limits, AI APIs)
- Playwright Framework Tutorial — fixtures, services, and folder structure these tests live in
- TypeScript Cheat Sheet — the language reference
- Official docs: playwright.dev/docs/api-testing