Quick answer
Use the built-in request fixture: await request.post('/api/users', { data }). It runs outside any browser, supports the same auth as your UI, and returns a typed APIResponse you can assert on directly.
1. Your first API test
import { test, expect } from '@playwright/test';
test('GET /api/users returns 200 with a list', async ({ request }) => {
const res = await request.get('https://api.example.com/users');
expect(res.status()).toBe(200);
const body = await res.json();
expect(Array.isArray(body)).toBe(true);
expect(body[0]).toHaveProperty('id');
});
test('POST creates and DELETE removes', async ({ request }) => {
const post = await request.post('/api/users', {
data: { name: 'Ada', email: 'ada@acme.co' },
});
expect(post.status()).toBe(201);
const { id } = await post.json();
const del = await request.delete(`/api/users/${id}`);
expect(del.status()).toBe(204);
});2. Authentication that actually scales
export default defineConfig({
use: {
baseURL: 'https://api.example.com',
extraHTTPHeaders: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
Accept: 'application/json',
},
},
});import { request } from '@playwright/test';
export default async () => {
const ctx = await request.newContext();
const res = await ctx.post('/oauth/token', {
form: { grant_type: 'client_credentials', client_id: '...', client_secret: '...' },
});
process.env.API_TOKEN = (await res.json()).access_token;
};3. JSON schema / contract validation
import { z } from 'zod';
const User = z.object({
id: z.string().uuid(),
email: z.string().email(),
createdAt: z.string().datetime(),
});
test('user contract holds', async ({ request }) => {
const res = await request.get('/api/users/me');
User.parse(await res.json()); // throws if the shape drifted
});4. Mocking a backend from a UI test
test('shows empty state when API returns []', async ({ page }) => {
await page.route('**/api/orders', route => route.fulfill({
status: 200, contentType: 'application/json', body: '[]',
}));
await page.goto('/orders');
await expect(page.getByText('No orders yet')).toBeVisible();
});
test('shows error banner on 500', async ({ page }) => {
await page.route('**/api/orders', route => route.fulfill({ status: 500 }));
await page.goto('/orders');
await expect(page.getByRole('alert')).toContainText(/couldn't load/i);
});5. Blend API setup with UI assertions
Create data via API (fast), assert via UI (real user perspective). This alone can cut a suite runtime in half.
test('user sees their new invoice', async ({ page, request }) => {
// 1. seed via API
const res = await request.post('/api/invoices', { data: { amount: 42 } });
const { id } = await res.json();
// 2. verify in UI
await page.goto(`/invoices/${id}`);
await expect(page.getByRole('heading', { name: /invoice #/i })).toBeVisible();
await expect(page.getByTestId('total')).toHaveText('$42.00');
});6. Common mistakes
Log in once via API, save the token / cookies, reuse for the rest of the run.
200 with the wrong body is worse than a 500. Always assert shape and key values.
Create the data your test needs — never depend on a magic ID that vanishes when someone cleans the DB.
7. Hands-on task (30 minutes)
- 1
GET / POST / DELETE
Against
https://reqres.in, write 3 tests covering GET list, POST create, DELETE. - 2
Add zod schema
Validate the GET list response with a
z.array(User)schema. - 3
Mock a 500
In a UI test, stub the same endpoint to return 500 and assert an error banner appears.
Now zoom out: Framework Architecture.