Playwright API Testing & Network Mocking
Playwright isn't only a browser tool. It has a built-in HTTP client for API testing, and it can intercept the browser's own network calls — to mock responses, simulate failures, block noise and assert what the page sent. This guide covers both with complete code. The API tests were run against a local API and passed; the mocking examples type-check against Playwright 1.63.
Part 1 — API Testing With the request Fixture
Every test can ask for a request fixture — an APIRequestContext that sends HTTP requests directly, with no browser. It uses baseURL from the config, returns an APIResponse, and works with the same expect() as UI tests.
test('get a booking', async ({ request }) => {
const res = await request.get('/bookings/1001');
await expect(res).toBeOK(); // status 200–299
expect(await res.json()).toMatchObject({ firstname: 'Asha' });
});
An authenticated client and a full CRUD flow ⭐
import { test, expect, request, APIRequestContext } from '@playwright/test';
let api: APIRequestContext;
test.beforeAll(async ({ playwright }) => {
const login = await request.newContext({ baseURL: 'http://localhost:8767' });
const res = await login.post('/auth', {
data: {
username: 'admin',
password: process.env.API_PASSWORD
}
});
expect(res.ok()).toBeTruthy();
const { token } = await res.json();
await login.dispose();
api = await playwright.request.newContext({
baseURL: 'http://localhost:8767',
extraHTTPHeaders: {
Authorization: `Bearer ${token}`
},
});
});
test.afterAll(async () => {
await api.dispose();
});
test('create, read, update and delete a booking', async () => {
const created = await api.post('/bookings', {
data: {
firstname: 'Asha',
totalprice: 150
}
});
expect(created.status()).toBe(201);
const { bookingid } = await created.json();
const read = await api.get(`/bookings/${bookingid}`);
await expect(read).toBeOK();
expect(await read.json()).toMatchObject({
firstname: 'Asha',
totalprice: 150
});
const updated = await api.put(`/bookings/${bookingid}`, {
data: {
firstname: 'Asha',
totalprice: 200
}
});
expect((await updated.json()).totalprice).toBe(200);
expect(
(await api.delete(`/bookings/${bookingid}`)).status()
).toBe(204);
expect(
(await api.get(`/bookings/${bookingid}`)).status()
).toBe(404); // really gone
});
test('negative cases', async ({ request }) => {
expect(
(await request.post('/bookings', {
data: {
firstname: 'X',
totalprice: 1
}
})).status()
).toBe(401); // no token
expect(
(await api.post('/bookings', {
data: {
totalprice: 10
}
})).status()
).toBe(400); // missing field
expect(
(await api.post('/bookings', {
data: {
firstname: 'X',
totalprice: -5
}
})).status()
).toBe(422); // business rule
const wrongType = await api.post('/bookings', {
data: 'firstname=X',
headers: {
'Content-Type': 'text/plain'
}
});
expect(wrongType.status()).toBe(415); // wrong content type
});
Result against the local API: 2 passed.
Notes: data with an object is sent as JSON automatically; use form or multipart for form posts and uploads; params adds query parameters; dispose() frees contexts you create yourself.
Where API calls help UI tests
- Fast setup — create the user, cart or order through the API, then test only the UI step that matters.
- Log in once — call the login API, save the session as storage state, reuse it across tests: Storage State & Session Reuse.
- Verify the backend — after a UI action, fetch the record through the API to confirm it was saved correctly.
- Clean up — delete test data in
afterEach.
Part 2 — Intercepting the Browser's Network Calls
page.route(url, handler) intercepts matching requests from the page. The handler decides what happens:
| Method | Effect |
|---|---|
route.fulfill() |
Answer with your own status, headers and body — the server is never called |
route.fetch() + fulfill({ response }) |
Call the real server, then modify its response |
route.continue() |
Let it through, optionally changing URL, headers or body |
route.abort() |
Block the request (simulates a network failure) |
Register routes before the page makes the call — usually before page.goto().
Mock a response
await page.route('**/api/products', route =>
route.fulfill({
status: 200,
json: [
{
id: 1,
title: 'Mock Backpack',
price: 10
}
]
})
);
await page.goto('/shop');
await expect(
page.getByText('Mock Backpack')
).toBeVisible();
Simulate errors, empty data and slowness
await page.route('**/api/orders', route =>
route.fulfill({
status: 500,
json: {
error: 'Server error'
}
})
);
await page.route('**/api/products', route =>
route.fulfill({
json: []
})
); // empty state
await page.route('**/api/search**', async route => {
await new Promise(r => setTimeout(r, 3000)); // slow backend
await route.continue();
});
These states are hard to produce on a real server — and they're exactly where UIs break: missing error messages, spinners that never stop, blank pages instead of "no results".
Modify a real response
await page.route('**/api/products', async route => {
const response = await route.fetch(); // real call
const json = await response.json();
json.push({
id: 999,
title: 'Injected item',
price: 0
}); // add an edge case
await route.fulfill({
response,
json
}); // keep real status and headers
});
Block noise
await page.route(
/\.(png|jpg|jpeg|gif|woff2?)$/,
route => route.abort()
); // images and fonts
await page.route(
'**/*google-analytics*/**',
route => route.abort()
); // analytics
Blocking third-party scripts makes tests faster and more stable — but keep at least one unblocked run so you still test the page as users see it.
Assert what the page sent and received
const reqPromise = page.waitForRequest(
r =>
r.url().endsWith('/api/orders') &&
r.method() === 'POST'
);
const resPromise = page.waitForResponse(
r =>
r.url().includes('/api/orders') &&
r.status() === 201
);
await page
.getByRole('button', { name: 'Place order' })
.click();
expect(
(await reqPromise).postDataJSON()
).toMatchObject({
items: [
{
sku: 'BAG-1'
}
]
}); // right payload
const orderId =
(await (await resPromise).json()).id;
await expect(
await request.get(`/api/orders/${orderId}`)
).toBeOK(); // saved on the backend
Start waiting before the click, then await after it — the same pattern as popups and downloads.
Record and replay with HAR
await page.routeFromHAR(
'hars/products.har',
{
url: '**/api/**',
update: false
}
);
Run once with update: true to record real responses into a HAR file, then replay them — stable, realistic data without hand-writing mocks. Re-record when the API changes.
When to Mock — and When Not To
- Mock for UI states (errors, empty, slow), unfinished backends, third-party services (payments, maps) and flaky dependencies.
- Don't mock in the end-to-end tests that prove the real system works together — a suite that mocks everything can pass while production is broken.
- Keep mocks close to the real contract; validate API responses against a schema in separate API tests. More: Mock Servers & Advanced API Testing.
Coming From Selenium or Cypress
| Task | Selenium | Cypress | Playwright |
|---|---|---|---|
| API call in a test | A separate client (REST Assured) | cy.request() |
request.get() |
| Mock a response | Not built in (proxy or CDP) | cy.intercept(url, body) |
page.route() + fulfill() |
| Wait for a call | Not built in | cy.wait('@alias') |
page.waitForResponse() |
Convert more code between the three in the Selenium ⇄ Playwright ⇄ Cypress Translator.
From Real Projects
My own automation experience is with Selenium WebDriver, Java and TestNG in a POM-based hybrid framework, running in batch, parallel and cross-browser mode. The concepts on this page carry straight over to Playwright — page objects, independent tests, parallel execution and reliable synchronization — which makes it a natural next tool for Selenium testers. The biggest difference you'll notice is how much waiting Playwright handles for you. Testing Testsigma — a platform where users write automated tests in plain English — meant thinking like its users, who are testers themselves. Register routes before navigating to the page.
📚 Official documentation: Playwright docs: Mock APIs · Playwright docs: API testing
FAQs
Can Playwright do API testing?
Yes — the request fixture (an APIRequestContext) sends GET, POST, PUT, PATCH and DELETE requests without a browser and works with Playwright's expect().
How do you send an auth token with every request?
Create a context with playwright.request.newContext({ baseURL, extraHTTPHeaders: { Authorization: 'Bearer …' } }).
How do you mock an API response?
Register page.route(url, route => route.fulfill({ status, json })) before the page makes the request.
How do you change part of a real response?
In the route handler, call route.fetch(), edit the JSON, and route.fulfill({ response, json }).
How do you block images or analytics?
page.route(pattern, route => route.abort()).
How do you check what the page sent to the server?
page.waitForRequest() before the action, then inspect postDataJSON(), headers or URL.