Lesson 6 of 9 · API Testing
API Automation with Playwright
Test REST APIs with Playwright's request fixture and APIRequestContext: configuration, assertions, auth fixtures, and using the API to set up UI tests.
intermediate 7 min read Updated
You will learn to
- Configure baseURL and default headers for API tests in playwright.config.ts
- Send GET, POST, PATCH and DELETE requests and assert on status, headers and JSON bodies
- Share an authenticated APIRequestContext through a worker-scoped fixture
- Use API calls to set up and verify state in UI tests
Prerequisites
- Playwright: Getting Started
- API Authentication
Playwright is best known as a browser automation tool, but it ships a complete HTTP client built for testing. If your UI tests are already in Playwright, writing API tests in the same project means one language, one runner, one report and one CI job. It also unlocks the most valuable pattern in modern UI automation: set up state through the API, verify behaviour through the UI.
The examples target the SDETRole Practice API at https://sdetrole.com/api/mock and work with Playwright 1.5x and TypeScript.
The request fixture and APIRequestContext
Every Playwright test can ask for a request fixture. It is an APIRequestContext: an HTTP client with methods get, post, put, patch, delete, head and fetch. It honours the baseURL, extraHTTPHeaders, proxy and ignoreHTTPSErrors settings from your config, and it does not need a browser, so API-only tests are fast.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
projects: [
{
name: 'api',
testMatch: /api\/.*\.spec\.ts/,
use: {
baseURL: 'https://sdetrole.com/api/mock/',
extraHTTPHeaders: { Accept: 'application/json' },
},
},
],
});
Your first API tests
// tests/api/users.spec.ts
import { test, expect } from '@playwright/test';
test('GET a single user', async ({ request }) => {
const response = await request.get('users/1');
await expect(response).toBeOK(); // any 2xx
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toContain('application/json');
expect(response.headers()['etag']).toBe('"user-1-v1"');
const user = await response.json();
expect(user).toMatchObject({
id: 1,
name: 'Priya Shah',
email: 'priya.shah@example.com',
role: 'tester',
active: true,
});
expect(Date.parse(user.createdAt)).not.toBeNaN();
});
test('list users is paginated', async ({ request }) => {
const response = await request.get('users', { params: { page: 1, limit: 10 } });
await expect(response).toBeOK();
const headers = response.headers();
expect(headers['x-total-count']).toBe('25');
expect(headers['link']).toContain('rel="next"');
const body = await response.json();
expect(body.data).toHaveLength(10);
expect(body).toMatchObject({ page: 1, limit: 10, total: 25, totalPages: 3 });
});
Things worth knowing:
await expect(response).toBeOK()passes for any 2xx and, on failure, prints the response body, which saves a debugging round-trip. Useresponse.status()when the exact code matters.response.headers()returns an object with lower-cased header names.paramsbuilds the query string for you and handles encoding.toMatchObjectchecks only the properties you list, so the test survives new fields being added to the response. AvoidtoEqualon whole bodies that contain timestamps or generated IDs.
Sending data
Pass a plain object as data and Playwright serialises it to JSON and sets Content-Type: application/json:
test('POST creates a user and returns its location', async ({ request }) => {
const response = await request.post('users', {
data: { name: 'Test User', email: `test.${Date.now()}@example.com`, role: 'tester' },
});
expect(response.status()).toBe(201);
expect(response.headers()['location']).toMatch(/^\/api\/mock\/users\/\d+$/);
const created = await response.json();
expect(created).toMatchObject({ name: 'Test User', role: 'tester', active: true });
expect(created.id).toEqual(expect.any(Number));
});
test('POST with an existing email is a conflict', async ({ request }) => {
const response = await request.post('users', {
data: { name: 'Priya Again', email: 'priya.shah@example.com' },
});
expect(response.status()).toBe(409);
expect(response.headers()['content-type']).toContain('application/problem+json');
const problem = await response.json();
expect(problem.title).toBe('Conflict');
expect(problem.errors).toContainEqual({ field: 'email', message: 'is already registered' });
});
test('PATCH and DELETE', async ({ request }) => {
const patched = await request.patch('users/2', { data: { active: false } });
await expect(patched).toBeOK();
expect(await patched.json()).toMatchObject({ id: 2, active: false });
const deleted = await request.delete('users/3');
expect(deleted.status()).toBe(204);
expect(await deleted.text()).toBe('');
});
The Practice API is stateless: it validates and answers writes realistically but does not store them, so a later GET /users/3 still returns user 3. On a real system you would follow the DELETE with a GET expecting 404, which is the assertion that actually proves deletion.
Playwright does not throw on 4xx or 5xx responses by default; you get a normal response object to assert on. If you want an unexpected error status to throw, pass failOnStatusCode: true on a request or in a new context’s options.
Authentication with a worker-scoped fixture
Most real APIs need a token. Logging in inside every test is wasteful, and repeating headers everywhere is noise. A custom fixture can log in once per worker and hand tests a pre-authenticated client:
// tests/api/fixtures.ts
import { test as base, expect, type APIRequestContext } from '@playwright/test';
const BASE_URL = process.env.API_BASE_URL ?? 'https://sdetrole.com/api/mock/';
type WorkerFixtures = { authedApi: APIRequestContext };
export const test = base.extend<{}, WorkerFixtures>({
authedApi: [
async ({ playwright }, use) => {
// 1. Log in with a short-lived context
const loginContext = await playwright.request.newContext({ baseURL: BASE_URL });
const login = await loginContext.post('auth/login', {
data: {
username: process.env.API_USER ?? 'tester',
password: process.env.API_PASSWORD ?? 'practice123',
},
});
expect(login.status(), 'login should succeed').toBe(200);
const { accessToken } = await login.json();
await loginContext.dispose();
// 2. Create the authenticated context every test in this worker shares
const api = await playwright.request.newContext({
baseURL: BASE_URL,
extraHTTPHeaders: { Authorization: `Bearer ${accessToken}` },
});
await use(api);
await api.dispose();
},
{ scope: 'worker' },
],
});
export { expect };
// tests/api/me.spec.ts
import { test, expect } from './fixtures';
test('authenticated user profile', async ({ authedApi }) => {
const response = await authedApi.get('auth/me');
await expect(response).toBeOK();
const me = await response.json();
expect(me).toMatchObject({ username: 'tester', role: 'tester' });
expect(me.scopes).toContain('users:read');
});
test('missing token is rejected', async ({ request }) => {
// the default request fixture has no Authorization header
const response = await request.get('auth/me');
expect(response.status()).toBe(401);
expect(response.headers()['www-authenticate']).toContain('Bearer');
expect((await response.json()).detail).toBe('Missing Authorization header.');
});
{ scope: 'worker' } (the second type parameter of test.extend holds worker fixtures) means one login per worker process rather than per test. Always dispose() contexts you create yourself; the built-in request fixture is disposed for you.
Retries and timing
Real systems have transient failures. The Practice API’s /flaky endpoint returns 503 with Retry-After: 1 about half the time, which is good for practising assertion-level retries without retrying the whole test. Playwright’s expect.poll re-runs a function until its result passes:
test('flaky endpoint eventually succeeds', async ({ request }) => {
await expect
.poll(async () => (await request.get('flaky')).status(), { timeout: 10_000 })
.toBe(200);
});
Use this only where the system under test is designed to be eventually consistent or retried by clients. Polling around a genuine bug hides it.
For performance smoke checks, measure around the call yourself (Date.now() before and after) and use generous thresholds; a functional test suite is not a load test.
Combining API and UI
The page fixture’s browser context has its own API client, page.request, which shares cookies with the browser context. That gives two powerful patterns:
- Arrange via API: create the user, order or feature flag the test needs with
request.post, then open the UI directly at the relevant page. This is far faster and more reliable than clicking through setup screens. - Assert via API: after a UI action, query the API to confirm the data was really saved, not just displayed.
test('new user appears in the admin list', async ({ page, request }) => {
const res = await request.post('https://staging.example.com/api/users', {
data: { name: 'Ana Silva', email: `ana.${Date.now()}@example.com` },
});
expect(res.status()).toBe(201);
const { id } = await res.json();
await page.goto(`https://staging.example.com/admin/users/${id}`);
await expect(page.getByRole('heading', { name: 'Ana Silva' })).toBeVisible();
});
Absolute URLs override baseURL, which is handy when one project talks to both a web app and an API on different hosts.
Practice exercise
Write a Playwright spec against the Practice API that covers POST /users validation. Include:
- A valid create that asserts
201, theLocationheader and the defaultrolewhen none is sent. - A request with
name: "A"andemail: "not-an-email"that asserts422and both field errors. - A request with an unknown field
nicknamethat asserts422. - A request sent with a plain-text body and no JSON content type that asserts
415.
Explore the responses first in the API Playground.
Show a model answer
import { test, expect } from '@playwright/test';
test.describe('POST /users validation', () => {
test('valid user defaults to the viewer role', async ({ request }) => {
const res = await request.post('users', {
data: { name: 'Default Role', email: `default.${Date.now()}@example.com` },
});
expect(res.status()).toBe(201);
expect(res.headers()['location']).toMatch(/\/api\/mock\/users\/\d+$/);
expect(await res.json()).toMatchObject({ role: 'viewer', active: true });
});
test('reports every invalid field', async ({ request }) => {
const res = await request.post('users', { data: { name: 'A', email: 'not-an-email' } });
expect(res.status()).toBe(422);
const { errors } = await res.json();
expect(errors.map((e: { field: string }) => e.field)).toEqual(
expect.arrayContaining(['name', 'email']),
);
});
test('rejects unknown fields', async ({ request }) => {
const res = await request.post('users', {
data: { name: 'Valid Name', email: `nick.${Date.now()}@example.com`, nickname: 'vn' },
});
expect(res.status()).toBe(422);
expect((await res.json()).errors).toContainEqual({
field: 'nickname',
message: 'is not a recognised field',
});
});
test('rejects non-JSON bodies', async ({ request }) => {
const res = await request.post('users', {
headers: { 'Content-Type': 'text/plain' },
data: 'name=Valid',
});
expect(res.status()).toBe(415);
});
});Asserting on the list of fields with arrayContaining keeps the test stable if the API adds a third message, while still proving both problems are reported in a single response.
Key takeaways
- The
requestfixture is anAPIRequestContextthat honoursbaseURLandextraHTTPHeaders; use a trailing slash onbaseURLand relative paths. await expect(response).toBeOK()checks for 2xx; usestatus(),headers()(lower-case keys) andjson()for precise assertions.- Playwright does not throw on 4xx/5xx by default, which makes negative tests straightforward.
- Create authenticated clients with
playwright.request.newContextin a worker-scoped fixture and dispose them afterwards. - Arrange state through the API and verify through the UI (or the reverse) to make end-to-end tests faster and more trustworthy.
Key terms
Finished this lesson?
Progress is saved in this browser. No account needed.