Playwright: Network Mocking, Clock & API Testing
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
A browser test is only as repeatable as the world it runs in. The server may be slow, down or returning different data
tomorrow; the clock keeps moving; the device may have no GPS. Playwright gives a test control over each of these: it can
sit between the page and the network and answer, change or refuse requests (route), replay whole recordings (HAR), stand
in for a WebSocket server, replace browser APIs before the page runs, freeze and wind the clock, and talk to the
application’s REST API directly, without a page, to arrange and verify state.
This page was written against Playwright 1.64.1 for Node.js (the Python, Java and .NET APIs have the same shape and
are covered in
Playwright with Python, Java & .NET) and checked on
25 October 2026. Every TypeScript example below was run against Bookshelf, the demo application described in
Example Application & Test Layout, in Chromium and in
WebKit, except where a sentence says otherwise. The Firefox engine was not exercised for this page (the local Firefox build does not launch on the machine used), and
Playwright limits service-worker support to Chromium. The routes, test ids and REST API used here (/api/books, /api/reviews, /api/session, the reset and
seed endpoints, data-testid) are those documented on that page, and the locator and page-object style follows
Test Design & Page Objects.
How interception works
A route is a handler that Playwright runs for every request whose URL matches a pattern. The handler runs before the
request reaches the network stack and receives a Route object. It must settle the request in exactly one of four ways:
| Call | What happens | Typical use |
|---|---|---|
|
Answers the request with a response you build (status, headers, body, JSON, a file, or an existing response with overrides). The server is never contacted. |
Canned data, error pages, empty states. |
|
Sends the request on to the network, optionally with a new URL, method, headers or body. |
Adding or removing headers, rewriting a body, delaying and then proceeding. |
|
Fails the request as if the network had failed, with an optional error code. |
Blocking images, fonts or trackers; simulating an offline server. |
|
Passes the request to the next matching handler, optionally with overrides, instead of settling it. |
Layered handlers; handling only some methods of a URL. |
A fifth call, route.fetch(), performs the request without settling it and returns an APIResponse. It is how a
handler modifies a real response: fetch, change, then route.fulfill({ response, ... }).
Things worth knowing before writing the first route:
-
Register routes before the navigation or action that triggers the request, normally in the test body or in a
beforeEach. A route does not affect requests that already completed. -
Routing disables the HTTP cache for the page or context, so a test with routes behaves as if every request were a cache miss.
-
Handlers run in reverse order of registration: the most recently registered matching handler runs first. When it calls
route.fallback()the next older matching handler runs; when it callsfulfill(),continue()orabort()the chain stops.route.continue()does not fall through to other handlers; usefallback()for that. -
Redirects are a single unit. The handler runs once, for the original request, and the browser follows redirects by itself. Headers given to
route.continue()apply to every hop of the chain. -
Some headers are owned by the network stack (
Cookie,Host,Accept-Encoding,Content-Length,Sec-Fetch-*). They are not reliably present inrequest.headers()inside a handler and cannot be overridden; aCookieheader passed toroute.continue()is ignored in favour of the browser’s cookie store. Usecontext.addCookies()to set cookies. -
Every handler must settle the request. A handler that returns without calling one of the four methods leaves the request hanging until the test times out.
Page routes versus context routes
page.route() applies to one page. context.route() applies to every page of the browser context, including pages
opened later and popups, so it is the right place for rules that belong to the whole scenario. Both accept the same URL
matchers and handlers, and both return a disposable that removes the route.
test('context.route applies to every page, including popups', async ({ context }) => {
await context.route('**/covers/*.svg', (route) => route.fulfill({ status: 200, contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg"/>' }));
const first = await context.newPage();
const second = await context.newPage();
for (const page of [first, second]) {
await page.goto('/');
const text = await page.evaluate(() => fetch('/covers/7.svg').then((r) => r.text()));
expect(text).toBe('<svg xmlns="http://www.w3.org/2000/svg"/>');
}
});
Source: Playwright: Network
Setting up the examples
All specs on this page share a small helper and reset Bookshelf before each test, as the foundation pages recommend. The
helper signs in through the REST API; the cookie goes into the jar of whichever APIRequestContext makes the call, and
page.request shares its jar with the browser context, so the page is signed in too.
// helpers.ts
import type { APIRequestContext } from '@playwright/test';
export const readerPassword = process.env.BOOKSHELF_READER_PASSWORD ?? 'reader-local-only';
// Sign in through the REST API. The cookie lands in the cookie jar of whatever
// APIRequestContext is used (page.request shares it with the browser context).
export async function signInAsReader(request: APIRequestContext) {
const response = await request.post('/api/session', {
data: { username: 'reader', password: readerPassword },
});
if (!response.ok()) throw new Error(`sign-in failed: ${response.status()}`);
}
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: '.',
outputDir: './test-results',
workers: 1, // every test shares one Bookshelf server and resets it first
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
testIdAttribute: 'data-testid',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
Source: Playwright: Test configuration
The configuration sets workers: 1 for a reason that applies to any suite sharing a single stateful server: the
reset in beforeEach clears every session, so two workers running tests at the same time reset each other’s state and
produce failures such as a review rollback caused by a 401. Real suites avoid this by giving each worker its own data
(see Parallelism, Sharding & Retries) or by
mocking the API as shown next.
Mocking responses with route.fulfill
The simplest mock answers a request with data you control. route.fulfill() accepts:
| Option | Meaning |
|---|---|
|
HTTP status code, 200 by default. |
|
Response headers as an object. |
|
Shortcut for the |
|
A string or a |
|
A serializable value sent as the body with |
|
A file whose contents become the body; its extension chooses the content type. |
|
An existing |
Bookshelf’s catalogue pages are rendered by the server, so the JSON API is what a script, a mobile client or a widget
calls. The test below loads a page, then lets the page itself call the API; the route answers instead of the server. Note
that the glob uses * after books so that it also matches the query string (see
URL matching).
test('fulfills /api/books with a canned response', async ({ page }) => {
await page.route('**/api/books?*', async (route) => {
await route.fulfill({
json: {
items: [{ id: 99, title: 'Mocked Book', author: 'Nobody', genre: 'Test', price: 1, year: 2026 }],
total: 1, page: 1, pageSize: 6, pages: 1,
},
});
});
await page.goto('/books');
const body = await page.evaluate(() => fetch('/api/books?q=anything').then((r) => r.json()));
expect(body.total).toBe(1);
expect(body.items[0].title).toBe('Mocked Book');
});
Source: Playwright: Mock APIs
Modifying a real response
Sometimes the call must really happen, but the data needs patching. route.fetch() performs the request (using the
same cookies and settings as the page) and returns the response; you change it and pass it back with
route.fulfill({ response, json }). Everything you do not override, such as the status and headers, is kept. This is
the right tool for adding one more row to a live list, forcing a flag on in a feature-toggle response, or truncating a
page of results.
test('modifies a real response with route.fetch and route.fulfill', async ({ page }) => {
// `**/api/books*` matches /api/books and /api/books?q=... (a `*` never crosses a `/`).
await page.route('**/api/books*', async (route) => {
const response = await route.fetch();
const json = await response.json();
json.items.push({ id: 100, title: 'Injected Book', author: 'Playwright', genre: 'Test', price: 0, year: 2026 });
json.total += 1;
await route.fulfill({ response, json });
});
await page.goto('/books');
const body = await page.evaluate(() => fetch('/api/books').then((r) => r.json()));
expect(body.total).toBe(25);
expect(body.items.at(-1).title).toBe('Injected Book');
});
Source: Playwright: Mock APIs
route.fetch() accepts url, method, headers, postData, maxRedirects, maxRetries (retries
on a connection reset) and timeout. The same pattern rewrites HTML: fetch the page, replace() a string in
response.text(), and fulfill with the new body and the original headers.
Errors and edge cases the real server will not produce on demand
Bookshelf returns a 500 from POST /api/reviews only when the review text contains the word FAIL, which is how the
foundation pages test the optimistic rollback. A route makes the same scenario available without special text and
without touching the server. The handler below answers POST requests with a 500 and hands every other method (the
GET that lists reviews) to the real server through route.fallback().
test('a 500 from /api/reviews rolls the optimistic review back', async ({ page }) => {
await signInAsReader(page.request);
await page.route('**/api/reviews', async (route) => {
if (route.request().method() !== 'POST') return route.fallback();
await route.fulfill({ status: 500, json: { error: 'Could not save the review' } });
});
await page.goto('/books/3');
const reviews = page.getByTestId('review');
const before = await reviews.count();
await page.getByLabel('Rating').selectOption('4');
await page.getByLabel('Your review').fill('A thorough and well written guide.');
await page.getByTestId('review-submit').click();
await expect(page.getByTestId('review-error')).toHaveText('Could not save the review');
await expect(reviews).toHaveCount(before);
});
Source: Playwright: Network
The page adds an optimistic row immediately, the 500 arrives and the page removes the row and shows the message. The
test asserts both the message and that the list is back to its original length. The same shape works for a 401 (session
expired), a 422 with an errors object, an empty list or a payload with a field set to null, all of which are
tedious to produce with the real backend.
Slow responses
Wrapping route.continue() in a delay makes a loading state observable without changing the server. Bookshelf marks
its optimistic row with data-state="pending" until the response arrives; the test holds the response for
1.5 seconds and asserts the pending state, then the saved state. Web-first assertions retry, so there is no sleep in the
test, only a delay inside the handler that simulates the slow network.
test('a slow response keeps the optimistic row pending', async ({ page }) => {
await signInAsReader(page.request);
await page.route('**/api/reviews', async (route) => {
if (route.request().method() !== 'POST') return route.fallback();
await new Promise((resolve) => setTimeout(resolve, 1500));
await route.continue();
});
await page.goto('/books/3');
await page.getByLabel('Rating').selectOption('5');
await page.getByLabel('Your review').fill('Worth every one of its pages.');
await page.getByTestId('review-submit').click();
const mine = page.locator('[data-testid=review][data-state]');
await expect(mine).toHaveAttribute('data-state', 'pending');
await expect(mine).toHaveAttribute('data-state', 'saved');
});
Source: Playwright: Network
Modifying and aborting requests
route.continue() takes url, method, headers and postData (a string, a Buffer or a serializable
object). The example below rewrites the body of the outgoing review so that the real server rejects it with its FAIL
rule, which proves that the page handles a server-side 500 coming from a real round trip.
test('modifies the outgoing request: the server now rejects the review', async ({ page }) => {
await signInAsReader(page.request);
await page.route('**/api/reviews', async (route) => {
const request = route.request();
if (request.method() !== 'POST') return route.fallback();
const body = request.postDataJSON();
await route.continue({ postData: { ...body, text: `${body.text} FAIL` } });
});
await page.goto('/books/3');
await page.getByLabel('Rating').selectOption('3');
await page.getByLabel('Your review').fill('This text is long enough.');
await page.getByTestId('review-submit').click();
await expect(page.getByTestId('review-error')).toHaveText('Could not save the review');
});
Source: Playwright: Network
To remove a header, delete it from a copy of request.headers() and pass the copy; to set one, add it. A header whose
value is undefined is removed.
Aborting
route.abort() fails a request. It is the quickest way to make a test faster and calmer: block images, fonts,
analytics and third-party widgets that are irrelevant to the behaviour under test. Decide by URL pattern or by
request.resourceType() (document, stylesheet, image, media, font, script, xhr, fetch, websocket
and others). The page sees a network failure, and Playwright emits requestfailed on the page.
test('aborts images by resource type', async ({ page }) => {
const failed: string[] = [];
page.on('requestfailed', (request) => failed.push(request.url()));
await page.route('**/*', (route) =>
route.request().resourceType() === 'image' ? route.abort() : route.continue(),
);
await page.goto('/books');
await expect(page.getByTestId('book-card')).toHaveCount(6);
await expect.poll(() => failed.length).toBeGreaterThan(0);
expect(failed[0]).toContain('/covers/');
});
Source: Playwright: Network
route.abort() accepts an error code that decides how the failure looks to the page:
'aborted', 'accessdenied', 'addressunreachable', 'blockedbyclient', 'blockedbyresponse',
'connectionaborted', 'connectionclosed', 'connectionfailed', 'connectionrefused',
'connectionreset', 'internetdisconnected', 'namenotresolved', 'timedout' and 'failed' (the
default). Use the more specific codes to test offline and DNS-failure messages.
Limiting how often a route applies
The optional third argument of page.route() and context.route() is { times: n }: the handler runs for the
first n matching requests and is then removed. It is how to model a flaky dependency: fail once, succeed on the retry.
test('abort with an error code, and times: 1', async ({ page }) => {
await page.route('**/covers/7.svg', (route) => route.abort('connectionrefused'), { times: 1 });
const results = await page.goto('/').then(() =>
page.evaluate(async () => {
const out: string[] = [];
for (let i = 0; i < 2; i++) {
try { out.push(String((await fetch('/covers/7.svg')).status)); } catch { out.push('failed'); }
}
return out;
}),
);
expect(results).toEqual(['failed', '200']);
});
Source: Playwright API: page.route
The cover is requested once by the <img> on the home page and then by the explicit fetch() calls, so the test
uses a cover that the home page does not display (7), otherwise the page’s own image request would consume the single
abort.
URL matching
The first argument of route(), routeFromHAR(), waitForResponse() and friends is a matcher. Four kinds are
accepted, and one test exercises all of them against Bookshelf’s API.
| Matcher | Example | Notes |
|---|---|---|
Glob string |
|
|
Regular expression |
|
Matched against the full URL. Use it for anything a glob cannot express. |
Predicate |
|
Receives a |
|
|
The web-standard URL pattern, for components such as pathname and search. |
test('URL matching: glob, regular expression, predicate, URLPattern', async ({ page }) => {
const hits: string[] = [];
await page.route('**/api/books/*', (route) => { hits.push('glob'); return route.fallback(); });
await page.route(/\/api\/books\?.*q=ocean/, (route) => { hits.push('regex'); return route.fallback(); });
await page.route((url) => url.pathname === '/api/reviews' && url.searchParams.has('bookId'),
(route) => { hits.push('predicate'); return route.fallback(); });
await page.route(new URLPattern({ pathname: '/api/session' }), (route) => { hits.push('urlpattern'); return route.fallback(); });
await page.goto('/');
await page.evaluate(async () => {
await fetch('/api/books/2');
await fetch('/api/books?q=ocean');
await fetch('/api/reviews?bookId=2');
await fetch('/api/reviews');
await fetch('/api/session');
});
expect(hits).toEqual(['glob', 'regex', 'predicate', 'urlpattern']);
});
Details that cause most matching mistakes:
-
A
*never crosses a/.**/api/books*matches/api/booksand/api/books?q=x, but not/api/books/2;**/api/books/*matches/api/books/2and not/api/books. -
In a glob,
?matches only a literal question mark and[]is not special (this changed in Playwright 1.52). To match a query string use*or a regular expression. -
When
baseURLis configured and the glob does not start with*, a relative pattern such as'/api/session'is resolved against it. A pattern starting with**/matches on any origin. -
Only requests that go through the browser’s network stack can be routed. A request that a service worker answers is the usual exception (see Service workers).
Handler order and fallback
Because the newest handler runs first, a common pattern is to register a broad default early and narrower overrides
later. route.fallback() is the explicit way to say "not mine": the request continues down the chain, optionally with
changed headers, URL, method or body that the next handler sees.
test('handlers run last-registered first, fallback passes the request down', async ({ page }) => {
const order: string[] = [];
await page.route('**/api/session', async (route) => {
order.push('first-registered');
await route.fulfill({ json: { authenticated: true, name: 'ghost', role: 'reader' } });
});
await page.route('**/api/session', async (route) => {
order.push('last-registered');
await route.fallback();
});
await page.goto('/');
const session = await page.evaluate(() => fetch('/api/session').then((r) => r.json()));
expect(order).toEqual(['last-registered', 'first-registered']);
expect(session.name).toBe('ghost');
});
Source: Playwright API: route.fallback
Removing routes
page.unroute(url, handler) removes one handler; page.unrouteAll({ behavior }) and
context.unrouteAll({ behavior }) remove all of them, including the ones from routeFromHAR(). behavior is
'default' (do not wait for running handlers), 'wait' (wait for handlers that are mid-flight) or
'ignoreErrors' (do not wait and swallow their errors). Since Playwright 1.59 the value returned by route() is
a disposable, so a route can be scoped to a block with await using.
test('unroute and unrouteAll', async ({ page }) => {
const handler = (route: import('@playwright/test').Route) => route.fulfill({ json: { authenticated: false } });
await page.route('**/api/session', handler);
await page.goto('/');
expect(await page.evaluate(() => fetch('/api/session').then((r) => r.json()))).toEqual({ authenticated: false });
await page.unroute('**/api/session', handler);
await page.route('**/api/books', handler);
await page.unrouteAll({ behavior: 'wait' });
const books = await page.evaluate(() => fetch('/api/books').then((r) => r.json()));
expect(books.total).toBe(24);
});
test('await using removes a route at the end of the block', async ({ page }) => {
await page.goto('/');
{
await using _route = await page.route('**/api/session', (route) => route.fulfill({ json: { authenticated: true, name: 'temp', role: 'reader' } }));
expect((await page.evaluate(() => fetch('/api/session').then((r) => r.json()))).name).toBe('temp');
}
expect((await page.evaluate(() => fetch('/api/session').then((r) => r.json()))).authenticated).toBe(false);
});
Source: Playwright API: page.unrouteAll
Playwright Test cleans up routes and contexts at the end of each test, so explicit unrouting is needed only when a test must change its mocking part-way through.
Network events and waiting for responses
Routing changes traffic; events observe it. A page emits request, response, requestfinished and
requestfailed for each request, and the browser context emits the same events for all its pages. The listeners below
log only Bookshelf’s API calls. page.waitForResponse() (and waitForRequest()) takes the same matchers as
route(), plus a predicate over the Response. The important rule is to start waiting before the action that
triggers the request, and await afterwards, otherwise a fast response can arrive before the listener exists.
test('network events and waitForResponse', async ({ page }) => {
const log: string[] = [];
page.on('request', (r) => { if (r.url().includes('/api/')) log.push(`>> ${r.method()} ${new URL(r.url()).pathname}`); });
page.on('response', (r) => { if (r.url().includes('/api/')) log.push(`<< ${r.status()} ${new URL(r.url()).pathname}`); });
await signInAsReader(page.request);
await page.goto('/books/3');
await page.getByLabel('Rating').selectOption('4');
await page.getByLabel('Your review').fill('Clear, careful and quietly funny.');
const responsePromise = page.waitForResponse(
(r) => r.url().endsWith('/api/reviews') && r.request().method() === 'POST',
);
await page.getByTestId('review-submit').click();
const response = await responsePromise;
expect(response.status()).toBe(201);
expect((await response.json()).text).toBe('Clear, careful and quietly funny.');
expect(log).toEqual(['>> POST /api/reviews', '<< 201 /api/reviews']);
});
Useful properties of the objects involved: request.method(), request.url(), request.postDataJSON(),
request.headers() and request.allHeaders(), request.resourceType(), request.redirectedFrom(),
request.failure(); response.status(), response.json(), response.fromServiceWorker() and
response.headerValue(). request.allHeaders() and response.allHeaders() include headers added by the network
stack, which headers() may not. For a request that is observed without routes in place, allHeaders() shows
the headers exactly as they went over the wire.
For asserting on traffic after the fact, prefer collecting events into an array inside the test and asserting with
expect.poll() or toPass() over sleeping; the trace viewer’s Network tab shows the same data for debugging (see
Debugging, Tracing & Codegen).
Recording and replaying HAR files
A HAR (HTTP Archive) file is a JSON record of requests and responses. Playwright can record one and later serve matching requests from it, giving tests realistic data without a backend. The workflow is: record once, commit the file next to the tests (edit the payload if you want to), and replay in every run.
Recording with routeFromHAR and update
page.routeFromHAR(har, options) and context.routeFromHAR(har, options) take the HAR path and these options:
| Option | Meaning |
|---|---|
|
Glob, regular expression or predicate. Only matching requests are served from (or recorded to) the HAR; others are unaffected. Without it, every request is. |
|
When |
|
|
|
|
|
What to do with a request that matches |
The first test below does the whole cycle: it records the catalogue API calls to a HAR file, changes the live server’s data to something very different so that the origin of an answer is unambiguous, then replays. The recorded query is served from the HAR (the 24 seeded books), while a query that was never recorded falls back to the live server and sees the single seeded book.
test('record with routeFromHAR(update), then replay without the network', async ({ browser, request }) => {
const harPath = path.join(harDir, 'books.har');
// 1. Record: with update: true the requests go to the real server and are written to the HAR
// when the context closes.
const recording = await browser.newContext();
const recPage = await recording.newPage();
await recPage.routeFromHAR(harPath, { url: '**/api/books*', update: true });
await recPage.goto('/books');
await recPage.evaluate(() => fetch('/api/books?q=fiction').then((r) => r.json()));
await recording.close();
expect(fs.existsSync(harPath)).toBe(true);
// 2. Change the live data so we can tell where a response came from.
await request.post('/api/test/seed', { data: { books: [{ title: 'Only Book', author: 'Test Author' }], reviews: [] } });
expect((await (await request.get('/api/books')).json()).total).toBe(1);
// 3. Replay: matching requests are answered from the HAR file, others abort or fall back.
const replay = await browser.newContext();
const page = await replay.newPage();
await page.routeFromHAR(harPath, { url: '**/api/books*', notFound: 'fallback' });
await page.goto('/');
const recorded = await page.evaluate(() => fetch('/api/books?q=fiction').then((r) => r.json()));
const unrecorded = await page.evaluate(() => fetch('/api/books?q=only').then((r) => r.json()));
await replay.close();
expect(recorded.items.length).toBeGreaterThan(0); // served from the HAR (the 24 seeded books)
expect(recorded.total).toBeGreaterThan(1);
expect(unrecorded.total).toBe(1); // not in the HAR: fell back to the live server
});
With the default notFound: 'abort' the same request would have failed instead, which is what you want when a test
must never reach the network:
test('notFound: abort (the default) fails requests missing from the HAR', async ({ browser }) => {
const harPath = path.join(harDir, 'empty.har');
const recording = await browser.newContext();
const recPage = await recording.newPage();
await recPage.routeFromHAR(harPath, { url: '**/api/books*', update: true });
await recPage.goto('/');
await recording.close();
const context = await browser.newContext();
const page = await context.newPage();
await page.routeFromHAR(harPath, { url: '**/api/books*' });
await page.goto('/');
const outcome = await page.evaluate(() => fetch('/api/books').then(() => 'served', () => 'aborted'));
await context.close();
expect(outcome).toBe('aborted');
});
Replay matches the URL and the HTTP method strictly, and for POST requests it matches the payload strictly too. When
several entries match, the one with the most matching headers wins. An entry that is a redirect is followed
automatically. A HAR path ending in .zip is an archive with the HAR plus its payloads as separate entries; you can
unzip it, edit a payload or the HAR itself, and point the test at the extracted file.
Because a recorded HAR is data, keep two habits. First, record with update: true only on purpose (a flag or an
environment variable such as UPDATE_HAR=1 is common), never in the default run, or the suite quietly re-records
against whatever the server returns that day. Second, treat HAR files as fixtures that can contain cookies and tokens:
use url to record only API calls, review the file before committing it, and strip authorization headers.
Recording a whole context with recordHar
browser.newContext({ recordHar }) records every request of the context until it closes, with no routing involved.
Options: path, mode ('full' by default or 'minimal'), content ('omit', 'embed' or
'attach'; 'attach' is the default for .zip paths and 'embed' otherwise) and urlFilter (glob or regular
expression). In the Playwright Test runner the same options are available under use: { contextOptions: { recordHar } }.
The file is written when the context is closed, so always close it.
test('recordHar on the context records everything until the context closes', async ({ browser, baseURL }) => {
const harPath = path.join(harDir, 'session.har');
const context = await browser.newContext({
baseURL,
recordHar: { path: harPath, urlFilter: '**/api/**', mode: 'minimal', content: 'embed' },
});
const page = await context.newPage();
await page.goto('/');
await page.evaluate(() => fetch('/api/books/2').then((r) => r.json()));
await page.evaluate(() => fetch('/covers/2.svg').then((r) => r.text()));
await context.close();
const har = JSON.parse(fs.readFileSync(harPath, 'utf8'));
const urls: string[] = har.log.entries.map((e: { request: { url: string } }) => new URL(e.request.url).pathname);
expect(urls).toEqual(['/api/books/2']);
expect(har.log.entries[0].response.content.text).toContain('"id":2');
});
In the runs for this page, a response body was stored only when the page read it: the test above reads the JSON with
.then((r) => r.json()), whereas a fetch() whose body is never consumed produced an entry without content in
Chromium. Read the response in any test that records content.
Zip archives and the CLI
A .zip HAR keeps each payload as its own entry, which keeps the JSON small and makes the files editable.
test('a zip HAR stores payloads as separate entries', async ({ browser }) => {
const harPath = path.join(harDir, 'books.zip');
const context = await browser.newContext();
const page = await context.newPage();
await page.routeFromHAR(harPath, { url: '**/api/books*', update: true, updateContent: 'attach', updateMode: 'minimal' });
await page.goto('/');
await page.evaluate(() => fetch('/api/books').then((r) => r.json()));
await context.close();
expect(fs.statSync(harPath).size).toBeGreaterThan(0);
});
The command line can also record while you browse by hand. --save-har-glob restricts what is stored:
npx playwright open --save-har=example.har --save-har-glob="**/api/**" http://localhost:3000
This command opens a headed browser, so it was not run for this page; the programmatic recordings above were.
Choosing between a HAR, a handwritten mock and the real server
| Approach | Strengths | Weaknesses |
|---|---|---|
Handwritten |
Small, readable, deterministic; the test states exactly the data it needs. Easy to produce errors and edge cases. |
Drifts from the real API if nobody checks the contract. |
HAR replay |
Realistic payloads for many calls with one line of test code; good for large, read-mostly pages. |
Opaque fixtures, brittle when the request shape changes (strict matching), must be re-recorded deliberately. |
Real server with API-seeded state |
Exercises the true integration; catches contract drift. |
Slower, needs an environment, state must be isolated. |
A healthy suite uses all three: mock the dependencies you do not own, seed the ones you do through the API, and keep a few end-to-end tests that touch everything.
Since Playwright 1.60 a HAR can also be recorded through the tracing API
(context.tracing.startHar(path) and stopHar()), which returns a disposable so that the recording can be scoped
to a block with await using. That API is not exercised here.
WebSocket routing
Playwright can intercept WebSocket connections the page opens. page.routeWebSocket(url, handler) and
context.routeWebSocket(url, handler) take the same matchers as route() and call the handler with a
WebSocketRoute. What you do with it decides the behaviour:
-
Mock the server entirely. Do not call
connectToServer(). The route is the server:ws.onMessage()receives what the page sends,ws.send()sends to the page,ws.close()closes the connection. The real server is never contacted, so nothing needs to listen. -
Sit in the middle.
ws.connectToServer()opens the real connection and returns a secondWebSocketRouterepresenting the server side. Messages are forwarded only if you forward them, so you can rewrite, drop or add messages in either direction. -
Refuse.
ws.close({ code, reason })closes the connection.
Bookshelf has no WebSocket endpoint, so these examples open sockets from a Bookshelf page to a made-up address and to a
small echo server that the test itself starts with the ws package (npm install --save-dev ws). The first test
needs no server at all: nothing listens on the address it connects to.
test('mocks the whole conversation: no server is contacted', async ({ page }) => {
await page.routeWebSocket('**/chat', (ws) => {
ws.onMessage((message) => {
if (message === 'request') {
ws.send('response');
ws.send('done');
}
});
});
await page.goto('/');
// Nothing listens on this address; the route answers instead of a server.
expect(await converse(page, 'ws://localhost:3916/chat', ['request'], 2)).toEqual(['response', 'done']);
});
The helper converse() used by these tests lives in the same spec: it runs in the page, opens the socket, sends the
given messages and resolves with the first n messages received.
// websocket.spec.ts (helper)
// Bookshelf has no WebSocket endpoint, so these tests open sockets from a Bookshelf page
// (any page works) to a made-up address or to a small server started by the test.
async function converse(page: Page, url: string, send: string[], expected: number) {
return page.evaluate(
({ url, send, expected }) =>
new Promise<string[]>((resolve) => {
const received: string[] = [];
const socket = new WebSocket(url);
socket.onopen = () => send.forEach((message) => socket.send(message));
socket.onmessage = (event) => {
received.push(String(event.data));
if (received.length === expected) resolve(received);
};
}),
{ url, send, expected },
);
}
Source: Playwright API: page.evaluate
Intercepting and changing traffic to a real server (here, an echo server started in beforeAll), and the passive
websocket event with its frame events, which only observes:
test.describe('with a real server', () => {
let server: WebSocketServer;
let url: string;
test.beforeAll(async () => {
server = new WebSocketServer({ port: 0, host: '127.0.0.1' });
server.on('connection', (socket) => socket.on('message', (data) => socket.send(`echo:${data}`)));
await new Promise((resolve) => server.once('listening', resolve));
url = `ws://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
test.afterAll(() => server.close());
test('modifies messages between the page and the real server', async ({ page }) => {
await page.routeWebSocket(/127\.0\.0\.1/, (ws) => {
const upstream = ws.connectToServer();
ws.onMessage((message) => upstream.send(message === 'request' ? 'request2' : message));
upstream.onMessage((message) => ws.send(`[patched] ${message}`));
});
await page.goto('/');
expect(await converse(page, url, ['request', 'other'], 2)).toEqual(['[patched] echo:request2', '[patched] echo:other']);
});
test('inspects frames with the websocket event', async ({ page }) => {
const frames: string[] = [];
page.on('websocket', (ws) => {
ws.on('framesent', (event) => frames.push(`sent ${event.payload}`));
ws.on('framereceived', (event) => frames.push(`received ${event.payload}`));
});
await page.goto('/');
await converse(page, url, ['hello'], 1);
await expect.poll(() => frames).toEqual(['sent hello', 'received echo:hello']);
});
test('close the connection from the route', async ({ page }) => {
await page.routeWebSocket(/127\.0\.0\.1/, (ws) => ws.close({ code: 1008, reason: 'blocked by test' }));
await page.goto('/');
const closed = await page.evaluate(
(url) => new Promise<string>((resolve) => {
const socket = new WebSocket(url);
socket.onclose = (event) => resolve(`${event.code} ${event.reason}`);
}),
url,
);
expect(closed).toBe('1008 blocked by test');
});
});
Source: Playwright: Network (WebSockets)
A few more facts: webSocketRoute.protocols() (since 1.60) returns the sub-protocols the page requested;
ws.onClose() lets the route react to the page closing the connection; and HAR and trace recordings include
WebSocket requests since 1.61. WebSocket routes apply to connections opened after they are registered, so register them
before navigating.
Service workers
A service worker is a script that the browser runs in the background and that can answer a page’s fetch() calls
from a cache or by itself. It is useful in production and a nuisance in tests, because a request that a worker with a
fetch handler answers never reaches page.route(). Playwright reports service workers on Chromium only; the
tests below are skipped in WebKit and were not run in Firefox.
Bookshelf registers no service worker, so the spec starts its own tiny server on 127.0.0.1 (a secure context, which
service workers require) serving a page that registers /transparent-service-worker.js, a worker that forwards every
request, and a data.json file:
// Service workers need a page and a script on the same origin, and Bookshelf registers none, so the
// test serves its own tiny site on localhost (a secure context, which service workers require).
const files: Record<string, { type: string; body: string }> = {
'/index.html': {
type: 'text/html',
body: `<!doctype html><title>SW demo</title><body>
<script>
navigator.serviceWorker.register('/transparent-service-worker.js');
</script>`,
},
'/transparent-service-worker.js': {
type: 'text/javascript',
body: `self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});
self.addEventListener('activate', (event) => {
event.waitUntil(clients.claim());
});`,
},
'/data.json': { type: 'application/json', body: '{"from":"network"}' },
};
let server: http.Server;
let origin: string;
test.beforeAll(async () => {
server = http.createServer((req, res) => {
const file = files[req.url ?? ''] ?? files['/index.html'];
res.writeHead(files[req.url ?? ''] ? 200 : 404, { 'content-type': file.type, 'service-worker-allowed': '/' });
res.end(file.body);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
test.afterAll(() => server.close());
async function waitForActivation(page: import('@playwright/test').Page) {
await page.evaluate(async () => {
await navigator.serviceWorker.ready;
if (navigator.serviceWorker.controller) return;
await new Promise((resolve) => navigator.serviceWorker.addEventListener('controllerchange', resolve));
});
}
The simplest fix: block service workers
If you are testing the page and not the worker, set serviceWorkers: 'block'. It is a context option (and a Playwright
Test use option); no worker is registered, so every request is visible to page.route(), and tests become more
predictable and a little faster. The cost is that the page no longer runs the way it does in production, so keep at
least one test with workers enabled if the offline behaviour matters.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
serviceWorkers: 'block',
},
});
The next two tests show the problem and the fix on the demo site: with the worker active, a page.route() for
data.json is bypassed and the network answer wins; in a context that blocks workers the route works.
test('page.route is bypassed once a fetch handler controls the page', async ({ page }) => {
await page.route('**/data.json', (route) => route.fulfill({ json: { from: 'page.route' } }));
await page.goto(`${origin}/index.html`);
await waitForActivation(page);
expect(await page.evaluate(() => fetch('/data.json').then((r) => r.json()))).toEqual({ from: 'network' });
});
test("serviceWorkers: 'block' lets page.route see every request", async ({ browser }) => {
const blocked = await browser.newContext({ serviceWorkers: 'block' });
const page = await blocked.newPage();
await page.route('**/data.json', (route) => route.fulfill({ json: { from: 'page.route' } }));
await page.goto(`${origin}/index.html`);
expect(await page.evaluate(() => navigator.serviceWorker.getRegistration().then((r) => r ?? null))).toBeNull();
expect(await page.evaluate(() => fetch('/data.json').then((r) => r.json()))).toEqual({ from: 'page.route' });
await blocked.close();
});
Observing and routing the worker’s own requests
With workers enabled, context.serviceWorkers() lists them and context.waitForEvent('serviceworker') waits for a
registration. The event fires before the worker controls the page, so wait for activation (the helper above waits for
controllerchange) before evaluating in the worker or expecting it to handle requests.
The requests a worker makes are reported through the BrowserContext, not the page: context.on('request'),
'response', 'requestfinished' and 'requestfailed' fire, context.route() can route them, and
request.serviceWorker() returns the worker (calling request.frame() on such a request throws). On the page side,
response.fromServiceWorker() tells you the worker handled it. With a transparent worker, the one fetch('/data.json')
produces two context request events: the frame’s, which is not routable because the worker has a fetch handler, and the
worker’s own, which is.
test('the context reports the worker and the requests it makes', async ({ page, context }) => {
const workerPromise = context.waitForEvent('serviceworker');
await page.goto(`${origin}/index.html`);
const worker = await workerPromise;
expect(worker.url()).toBe(`${origin}/transparent-service-worker.js`);
await waitForActivation(page);
const seen: string[] = [];
context.on('request', (request) => seen.push(`${request.serviceWorker() ? 'worker' : 'frame '} ${new URL(request.url()).pathname}`));
const responsePromise = page.waitForResponse('**/data.json');
await page.evaluate(() => fetch('/data.json'));
const response = await responsePromise;
expect(response.fromServiceWorker()).toBe(true);
expect(seen).toEqual(['frame /data.json', 'worker /data.json']);
});
test('route only the requests made by the service worker', async ({ page, context }) => {
await page.goto(`${origin}/index.html`);
await waitForActivation(page);
await context.route('**', async (route) => {
if (route.request().serviceWorker()) {
// Calling route.request().frame() here would throw.
await route.fulfill({ contentType: 'text/plain', status: 200, body: 'from sw' });
} else {
await route.continue();
}
});
expect(await page.evaluate(() => fetch('/data.json').then((r) => r.text()))).toBe('from sw');
});
Known limitation: requests for an updated service-worker script cannot be routed (an open Playwright issue). Since 1.57
the worker network and console events are on by default in Chromium; the environment variables
PLAYWRIGHT_DISABLE_SERVICE_WORKER_NETWORK and PLAYWRIGHT_DISABLE_SERVICE_WORKER_CONSOLE turn them off. If a
tool such as Mock Service Worker (MSW) owns the mocking in your application, its worker takes over the requests and
they become invisible to page.route(); choose one mocking mechanism per test.
Mocking browser APIs
Playwright automates what browsers agree on. For features that are experimental, not available everywhere or tied to
hardware, such as the Battery API, bluetooth, or a payment sheet, you replace the API in the page. The tool is
page.addInitScript() (and context.addInitScript()): the script runs in every frame before any of the page’s
scripts, on every navigation, so the page never sees the real API. Typical pattern:
-
Define the mock in an init script and, if a test needs to drive it later, save it on
window. -
Navigate; assert on what the page does with the mock.
-
Use
page.evaluate()to change the mock and watch the UI follow. -
Use
page.exposeFunction()to send calls from the page back to the test, to assert on what the page called.
The following spec mocks navigator.getBattery with a call log (the pattern from the official guide, here against a
Bookshelf page that has no battery widget, so the assertion is made on the mock itself) and a read-only property:
test('a mocked navigator API with a call log', async ({ page }) => {
const calls: string[] = [];
await page.exposeFunction('logCall', (message: string) => calls.push(message));
await page.addInitScript(() => {
const battery = { level: 0.75, charging: true, chargingTime: 1800, dischargingTime: Infinity, addEventListener: (name: string) => (window as any).logCall(`addEventListener:${name}`) };
(navigator as any).getBattery = async () => { (window as any).logCall('getBattery'); return battery; };
});
await page.goto('/');
const level = await page.evaluate(async () => (await (navigator as any).getBattery()).level);
expect(level).toBe(0.75);
expect(calls).toEqual(['getBattery']);
});
test('read-only navigator properties need defineProperty', async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(Object.getPrototypeOf(navigator), 'cookieEnabled', { value: false });
});
await page.goto('/');
expect(await page.evaluate(() => navigator.cookieEnabled)).toBe(false);
});
Source: Playwright: Mock browser APIs
An assignment to a read-only property such as navigator.cookieEnabled silently does nothing; if the property is
configurable on the prototype, Object.defineProperty(Object.getPrototypeOf(navigator), name, { value }) replaces it,
as the second test shows. When the mock must also fire events (a battery level change, for example), make it a class that
keeps its listeners and expose a _setLevel() style method for page.evaluate() to call, as the official guide
does.
Built-in emulation first
Playwright already emulates several of these without a mock, so reach for these before writing an init script:
| API | What it does |
|---|---|
|
Sets the position and the permission state that |
|
Sets |
|
Changes |
|
Makes every request fail as if the network were gone. |
|
Headers, user agent and HTTP basic authentication for every request of the context. |
|
Fake time; see the Clock API. |
A geolocation example: a context created with a position and the permission, then moved and revoked at run time. It ran
in Chromium and WebKit; the final check that the permission was revoked is made only in Chromium, because WebKit keeps
reporting granted from the Permissions API after clearPermissions().
test('geolocation and permissions from context options', async ({ browser, baseURL, browserName }) => {
const context = await browser.newContext({
baseURL,
geolocation: { latitude: 41.3874, longitude: 2.1686 },
permissions: ['geolocation'],
});
const page = await context.newPage();
await page.goto('/');
const position = await page.evaluate(
() => new Promise<[number, number]>((resolve, reject) =>
navigator.geolocation.getCurrentPosition((p) => resolve([p.coords.latitude, p.coords.longitude]), reject)),
);
expect(position).toEqual([41.3874, 2.1686]);
// Move the "device" and revoke the permission at run time.
await context.setGeolocation({ latitude: 48.8566, longitude: 2.3522 });
const moved = await page.evaluate(
() => new Promise<number>((resolve, reject) =>
navigator.geolocation.getCurrentPosition((p) => resolve(p.coords.latitude), reject)),
);
expect(moved).toBe(48.8566);
await context.clearPermissions();
const state = await page.evaluate(async () => (await navigator.permissions.query({ name: 'geolocation' })).state);
// WebKit keeps reporting 'granted' for the Permissions API after clearPermissions().
if (browserName === 'chromium') expect(state).not.toBe('granted');
await context.close();
});
Two further examples from Bookshelf: the theme toggle reads matchMedia('(prefers-color-scheme: dark)') when the user
has not chosen a theme. The first test uses the built-in emulateMedia(); the second replaces matchMedia in an
init script to produce the same effect, which is what you do for media queries Playwright cannot emulate.
test('emulated colour scheme drives the theme toggle', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto('/');
expect(await page.evaluate(() => matchMedia('(prefers-color-scheme: dark)').matches)).toBe(true);
await page.getByTestId('theme-toggle').click();
// The page treated the OS preference as dark, so the first click switches to light.
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
});
test('addInitScript replaces matchMedia before the page runs', async ({ page }) => {
await page.addInitScript(() => {
const original = window.matchMedia.bind(window);
window.matchMedia = (query: string) =>
query.includes('prefers-color-scheme: dark')
? ({ ...original(query), matches: true, media: query } as MediaQueryList)
: original(query);
});
await page.goto('/');
await page.getByTestId('theme-toggle').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
});
A date can be faked with an init script that replaces the Date class, as in the last test of that spec, but the Clock
API below does this properly, and also fakes timers, so prefer it.
test('a fixed Date through an init script (see the Clock API for the full tool)', async ({ page }) => {
await page.addInitScript(() => {
const fixed = new Date('2030-01-02T03:04:05Z').getTime();
const RealDate = Date;
// @ts-expect-error replace the global
window.Date = class extends RealDate {
constructor(...args: ConstructorParameters<typeof Date>) { super(...(args.length ? args : [fixed] as never)); }
static now() { return fixed; }
};
});
await page.goto('/');
expect(await page.evaluate(() => new Date().toISOString())).toBe('2030-01-02T03:04:05.000Z');
});
The Clock API
Code that depends on time is hard to test: a five-minute inactivity logout, a clock widget, a debounce, a "posted 3 hours
ago" label. page.clock (since Playwright 1.45) controls time in the page. It overrides these native globals so that the
test can drive them: Date, Temporal.Now, setTimeout, clearTimeout, setInterval, clearInterval,
requestAnimationFrame, cancelAnimationFrame, requestIdleCallback, cancelIdleCallback, performance and
Event.timeStamp.
| Method | What it does |
|---|---|
|
|
|
Installs the fake clock, optionally at a given start time. After it, time flows normally until you pause it, and the other methods become available. |
|
Jumps to |
|
Jumps forward, firing each due timer at most once, like closing a laptop lid and reopening it later. |
|
Advances time, firing every timer and animation frame on the way, as if the time had really passed. |
|
Resumes the flow of time after |
|
Sets the current |
ticks is a number of milliseconds or a string: '30:00' is 30 minutes, '05:00' 5 minutes and '01:30:00'
an hour and a half. time is a Date, a timestamp or a string.
Rules that avoid surprises:
-
Call
install()before any other clock-related call and before the page’s own timers are created. Calling it aftersetInterval()and beforeclearInterval()gives undefined behaviour, becauseinstall()replaces the native functions. -
setFixedTime()works withoutinstall()and does not touch timers.install()pluspauseAt()gives complete control. -
After
pauseAt(), nothing advances until you callfastForward(),runFor()orresume(); assertions about a paused page are deterministic. -
The clock is per page (
page.clock) and also available on the context ascontext.clock, so every page of the context shares one time. -
Pick a
timezoneIdandlocalefor tests that print dates, becausetoLocaleString()output depends on them.
Bookshelf has no screens that depend on time, so the demo pages for this section are served by a route on the Bookshelf
origin (page.route() with route.fulfill() and an HTML body); in a real project the page under test takes their
place. The setup is:
import { test, expect } from '@playwright/test';
// Bookshelf has no time-dependent screens, so the demo pages are served from a route on the
// Bookshelf origin. In your own project the page under test replaces this.
const clockPage = `<!doctype html><title>Clock demo</title>
<div id="current-time" data-testid="current-time"></div>
<script>
const renderTime = () => {
document.getElementById('current-time').textContent = new Date().toLocaleString('en-US', { timeZone: 'UTC' });
};
renderTime();
setInterval(renderTime, 1000);
</script>`;
const inactivityPage = `<!doctype html><title>Inactivity demo</title>
<div id="remaining" data-testid="remaining-time"></div>
<button type="button">Interaction</button>
<script>
const endTime = Date.now() + 5 * 60_000;
const render = () => {
const seconds = Math.round((endTime - Date.now()) / 1000);
document.getElementById('remaining').textContent = seconds <= 0
? 'You have been logged out due to inactivity.'
: 'You will be logged out in ' + seconds + ' seconds.';
setTimeout(render, 1000);
};
render();
</script>`;
test.beforeEach(async ({ page }) => {
await page.route('**/clock-demo', (route) => route.fulfill({ contentType: 'text/html', body: clockPage }));
await page.route('**/inactivity-demo', (route) => route.fulfill({ contentType: 'text/html', body: inactivityPage }));
});
Source: Playwright: Clock
The tests:
test('setFixedTime: Date is fixed while timers keep running', async ({ page }) => {
await page.clock.setFixedTime(new Date('2024-02-02T10:00:00Z'));
await page.goto('/clock-demo');
await expect(page.getByTestId('current-time')).toHaveText('2/2/2024, 10:00:00 AM');
await page.clock.setFixedTime(new Date('2024-02-02T10:30:00Z'));
// The page re-renders every real second, so the next tick picks up the new fixed time.
await expect(page.getByTestId('current-time')).toHaveText('2/2/2024, 10:30:00 AM');
});
test('install, pauseAt and fastForward', async ({ page }) => {
await page.clock.install({ time: new Date('2024-02-02T08:00:00Z') });
await page.goto('/clock-demo');
// Jump to 10:00 and pause there.
await page.clock.pauseAt(new Date('2024-02-02T10:00:00Z'));
await expect(page.getByTestId('current-time')).toHaveText('2/2/2024, 10:00:00 AM');
// Fast forward 30 minutes ("close the laptop lid and open it again").
await page.clock.fastForward('30:00');
await expect(page.getByTestId('current-time')).toHaveText('2/2/2024, 10:30:00 AM');
});
test('runFor ticks through time, firing every timer', async ({ page }) => {
await page.clock.install({ time: new Date('2024-02-02T08:00:00Z') });
await page.goto('/clock-demo');
await page.clock.pauseAt(new Date('2024-02-02T10:00:00Z'));
await expect(page.getByTestId('current-time')).toHaveText('2/2/2024, 10:00:00 AM');
await page.clock.runFor(2000);
await expect(page.getByTestId('current-time')).toHaveText('2/2/2024, 10:00:02 AM');
// After pauseAt, time does not move until you move it...
await page.waitForTimeout(1500);
await expect(page.getByTestId('current-time')).toHaveText('2/2/2024, 10:00:02 AM');
// ...or call resume() to let it flow again.
await page.clock.resume();
await expect(page.getByTestId('current-time')).not.toHaveText('2/2/2024, 10:00:02 AM');
});
test('inactivity timeout in milliseconds instead of five minutes', async ({ page }) => {
await page.clock.install();
await page.goto('/inactivity-demo');
await expect(page.getByTestId('remaining-time')).toContainText('You will be logged out in');
await page.getByRole('button').click();
await page.clock.fastForward('05:00');
await expect(page.getByText('You have been logged out due to inactivity.')).toBeVisible();
});
setSystemTime() and the other faked globals:
test('setSystemTime moves Date without firing timers', async ({ page }) => {
await page.clock.install({ time: new Date('2024-02-02T08:00:00Z') });
await page.goto('/clock-demo');
await page.clock.pauseAt(new Date('2024-02-02T09:00:00Z'));
await expect(page.getByTestId('current-time')).toHaveText('2/2/2024, 9:00:00 AM');
await page.clock.setSystemTime(new Date('2030-12-31T23:59:00Z'));
expect(await page.evaluate(() => new Date().toISOString())).toBe('2030-12-31T23:59:00.000Z');
// The text on the page is only refreshed by a timer tick.
await expect(page.getByTestId('current-time')).toHaveText('2/2/2024, 9:00:00 AM');
await page.clock.runFor(1000);
await expect(page.getByTestId('current-time')).toHaveText('12/31/2030, 11:59:01 PM');
});
test('the clock also fakes performance.now and requestAnimationFrame', async ({ page }) => {
await page.clock.install({ time: new Date('2024-02-02T08:00:00Z') });
await page.goto('/clock-demo');
await page.clock.pauseAt(new Date('2024-02-02T09:00:00Z'));
await page.evaluate(() => {
(window as any).frames = 0;
const loop = () => { (window as any).frames++; requestAnimationFrame(loop); };
requestAnimationFrame(loop);
(window as any).t0 = performance.now();
});
await page.clock.runFor(1000);
const { frames, elapsed } = await page.evaluate(() => ({ frames: (window as any).frames, elapsed: performance.now() - (window as any).t0 }));
expect(elapsed).toBe(1000);
expect(frames).toBeGreaterThan(50);
});
Notes: the five-minute inactivity test finishes in tens of milliseconds, with no waitForTimeout(); the one test that
does wait (runFor ticks through time) does so on purpose, to show that a paused clock does not move. setFixedTime()
tests that use real timers need a real second to pass for the page’s setInterval() to re-render (the first test in
the group takes about a second for that reason); use install() and runFor() when a test must be instant.
API testing with the request fixture
Playwright can send HTTP requests without a browser page. APIRequestContext has get(), post(), put(),
patch(), delete(), head() and the general fetch(). It runs in Node.js, so it is fast and not subject to
CORS. Use it to test the API directly, to arrange server state before a UI test and to check server state afterwards.
In Playwright Test, the built-in request fixture is an APIRequestContext that honours these configuration options:
baseURL, extraHTTPHeaders, httpCredentials, proxy, ignoreHTTPSErrors, storageState, userAgent
and clientCertificates. It is created per test, so cookies set in one test do not leak into another.
Request options:
| Option | Meaning |
|---|---|
|
Query parameters as an object, a |
|
The body. Objects are sent as JSON; strings and Buffers as-is. Use |
|
Per-request headers, merged over |
|
Throw for any status other than 2xx and 3xx (since 1.51). The default is to return the response, whatever its status. |
|
Redirect limit (default 20), retries on |
|
Accept invalid certificates for this request. |
Responses expose status(), statusText(), ok(), url(), headers() (lower-cased names), headersArray(),
json(), text() and body(). The expect(response).toBeOK() assertion checks for a 2xx status and, on
failure, reports the response details, which gives a more useful failure message than expect(response.ok()).toBe(true).
Validating responses
Bookshelf’s API supplies cases for each kind of check: the contract of a successful call, a not-found result, a refused anonymous write and a validation failure with a structured body. The tests below sign in once with the same request context (its cookie jar keeps the session) and verify the status, the header and the JSON of each.
test('GET with query parameters, status, headers and body', async ({ request }) => {
const response = await request.get('/api/books', { params: { q: 'fiction', page: 2 } });
expect(response).toBeOK();
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toContain('application/json');
const body = await response.json();
expect(body).toMatchObject({ page: 2, pageSize: 6 });
expect(body.items.every((b: { genre: string }) => b.genre === 'Fiction')).toBe(true);
});
test('a 404 is a response, not an exception', async ({ request }) => {
const response = await request.get('/api/books/9999');
expect(response.status()).toBe(404);
expect(await response.json()).toEqual({ error: 'not found' });
});
test('failOnStatusCode throws for 4xx and 5xx', async ({ request }) => {
await expect(request.get('/api/books/9999', { failOnStatusCode: true })).rejects.toThrow(/404/);
});
test('anonymous POST is rejected with 401', async ({ request }) => {
const response = await request.post('/api/reviews', { data: { bookId: 1, rating: 5, text: 'Long enough review.' } });
expect(response.status()).toBe(401);
});
test('sign in once, the cookie jar is reused, and validation errors are 422', async ({ request }) => {
const signIn = await request.post('/api/session', { data: { username: 'reader', password: readerPassword } });
expect(signIn.ok()).toBe(true);
expect(signIn.headers()['set-cookie']).toContain('bookshelf_session=');
const session = await (await request.get('/api/session')).json();
expect(session).toEqual({ authenticated: true, name: 'reader', role: 'reader' });
const invalid = await request.post('/api/reviews', { data: { bookId: 1, rating: 9, text: 'x' } });
expect(invalid.status()).toBe(422);
expect((await invalid.json()).errors).toEqual({ rating: 'Choose a rating from 1 to 5', text: 'Write at least 10 characters' });
const created = await request.post('/api/reviews', { data: { bookId: 1, rating: 5, text: 'A wonderful, long read.' } });
expect(created.status()).toBe(201);
expect(await created.json()).toMatchObject({ bookId: 1, author: 'reader', rating: 5 });
});
test('the type argument types json()', async ({ request }) => {
interface Session { authenticated: boolean }
const response = await request.get<Session>('/api/session');
const session = await response.json(); // typed as Session
expect(session.authenticated).toBe(false);
});
Assert on the parts of the response that matter, with toMatchObject(), toEqual() and
expect.objectContaining(), rather than on the entire body: a full-body toEqual() fails every time the server
adds a field. Since 1.63 the methods accept a type argument (request.get<Session>(url)) that types response.json().
Configuration, headers and authentication
Set baseURL and common headers once in the config or per file with test.use(). The official guide shows a
token for the GitHub API in extraHTTPHeaders; Bookshelf signs in with a cookie, so the example below uses a small
echo server (started by the spec) in place of a token-protected API. The token comes from an environment variable and
never from the test file.
// playwright.config.ts (excerpt for an API that uses a bearer token)
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: process.env.API_URL ?? 'http://localhost:3000',
extraHTTPHeaders: {
Accept: 'application/json',
// Read the secret from the environment; never write it in the repository.
Authorization: `Bearer ${process.env.API_TOKEN}`,
},
},
});
This configuration excerpt was not run as written because Bookshelf does not use bearer tokens; the header mechanism it relies on is exercised by the spec that follows.
test.describe('extraHTTPHeaders and bearer tokens', () => {
// Bookshelf signs in with a cookie, so a header-echo server stands in for a token-protected API.
let echo: http.Server;
let echoUrl: string;
test.beforeAll(async () => {
echo = http.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ authorization: req.headers.authorization ?? null, accept: req.headers.accept, run: req.headers['x-test-run'] ?? null }));
});
await new Promise<void>((resolve) => echo.listen(0, '127.0.0.1', resolve));
echoUrl = `http://127.0.0.1:${(echo.address() as AddressInfo).port}`;
});
test.afterAll(() => echo.close());
test.use({ extraHTTPHeaders: { Accept: 'application/vnd.bookshelf.v1+json', 'X-Test-Run': 'docs' } });
test('headers from the config apply to every request of the fixture', async ({ request }) => {
const body = await (await request.get(`${echoUrl}/whatever`)).json();
expect(body).toEqual({ authorization: null, accept: 'application/vnd.bookshelf.v1+json', run: 'docs' });
});
test('a per-request header and an env token', async ({ request }) => {
const token = process.env.BOOKSHELF_API_TOKEN ?? 'example-token';
const body = await (await request.get(`${echoUrl}/whatever`, { headers: { Authorization: `Bearer ${token}` } })).json();
expect(body.authorization).toBe(`Bearer ${token}`);
expect(body.run).toBe('docs');
});
});
For HTTP basic authentication use httpCredentials: { username, password } (with send: 'always' to send the
Authorization header up front instead of only after a 401 challenge, since 1.45); for a proxy, proxy: { server,
username, password } in the same use block, which the request fixture also honours. Proxy and HTTP credentials
are documented here but were not exercised, because the local setup has neither.
// playwright.config.ts (excerpt)
export default defineConfig({
use: {
httpCredentials: { username: 'bill', password: process.env.API_PASSWORD ?? '' },
proxy: { server: process.env.HTTP_PROXY ?? 'http://my-proxy:8080' },
},
});
Mixing API calls with UI checks
The most valuable use of the API in a UI suite is to shorten the path to the screen under test and to confirm what the screen did. Arranging state by clicking is slow and fails for reasons unrelated to the feature; checking only the DOM can pass when the server never saved anything.
Establish preconditions through the API, assert in the UI
The test signs in through the API using page.request, which shares cookies with the browser context, creates a review,
and only then opens the page. Whatever the UI renders must have come from the server.
test('create a review through the API, then see it in the UI', async ({ page }) => {
// page.request shares cookies with the browser context, so the page is signed in as well.
await page.request.post('/api/session', { data: { username: 'reader', password: readerPassword } });
const created = await page.request.post('/api/reviews', { data: { bookId: 3, rating: 4, text: 'Created through the REST API.' } });
expect(created.status()).toBe(201);
await page.goto('/books/3');
await expect(page.getByTestId('review').filter({ hasText: 'Created through the REST API.' })).toBeVisible();
await page.goto('/account');
await expect(page.getByTestId('my-reviews')).toContainText('Created through the REST API.');
});
Act in the UI, verify the postcondition through the API
The mirror test fills in the form, waits for the optimistic row to be marked as saved, and then asks the server directly whether it holds the review. This catches a UI that shows success without persisting.
test('add a review through the UI, then verify it through the API', async ({ page, request }) => {
await page.request.post('/api/session', { data: { username: 'reader', password: readerPassword } });
await page.goto('/books/5');
await page.getByLabel('Rating').selectOption('5');
await page.getByLabel('Your review').fill('Saved from the form, checked on the server.');
await page.getByTestId('review-submit').click();
const saved = page.locator('[data-testid=review][data-state=saved]');
await expect(saved).toBeVisible();
const reviews = await (await request.get('/api/reviews', { params: { bookId: 5 } })).json();
expect(reviews).toContainEqual(expect.objectContaining({ author: 'reader', rating: 5, text: 'Saved from the form, checked on the server.' }));
});
Seeding and reusing a login
The seed endpoint arranges data in one call, and apiRequestContext.storageState() exports the cookies of an API login
so that a new browser context starts signed in, with no sign-in form. Storage state is interchangeable between
BrowserContext and APIRequestContext. How to turn this into a reusable setup project is covered in
Authentication & Isolation.
test('seed through the API to arrange state', async ({ page, request }) => {
await request.post('/api/test/seed', { data: { books: [{ title: 'Only Book', author: 'Test Author' }], reviews: [] } });
await page.goto('/books');
await expect(page.getByTestId('book-card')).toHaveCount(1);
});
test('log in with the API and reuse the state in a new browser context', async ({ browser, baseURL }) => {
const api = await apiRequest.newContext({ baseURL });
await api.post('/api/session', { data: { username: 'reader', password: readerPassword } });
const state = await api.storageState();
await api.dispose();
const context = await browser.newContext({ baseURL, storageState: state });
const page = await context.newPage();
await page.goto('/account');
await expect(page.getByTestId('account-heading')).toHaveText('Hello, reader');
await context.close();
});
Context request versus global request
There are two kinds of APIRequestContext, and the difference is the cookie jar:
-
Context request:
page.request,context.requestand therequestfixture’s browser-bound sibling. It fills theCookieheader from the browser context and writesSet-Cookieresponses back into it. Use it when API and UI must share a session. -
Global (isolated) request: created by
playwright.request.newContext()(orrequest.newContext()imported from@playwright/test). It has its own cookies,baseURLand headers, independent of any browser. Use it for admin or setup calls that should not change the page’s session, and inbeforeAllto share one context across the tests of a file. Alwaysdispose()it.
Since 1.64 a request context has addCookies(), cookies() and clearCookies(), mirroring the browser context.
test('page.request shares cookies with the page, a new context does not', async ({ page, playwright, baseURL }) => {
await page.request.post('/api/session', { data: { username: 'reader', password: readerPassword } });
const cookies = await page.context().cookies();
expect(cookies.map((c) => c.name)).toContain('bookshelf_session');
const isolated: APIRequestContext = await playwright.request.newContext({ baseURL });
expect((await (await isolated.get('/api/session')).json()).authenticated).toBe(false);
await isolated.dispose();
});
test.describe('a shared request context for the whole file', () => {
let api: APIRequestContext;
test.beforeAll(async ({ playwright, baseURL }) => {
api = await playwright.request.newContext({ baseURL });
});
// The reset in the top-level beforeEach clears every Bookshelf session, so sign in after it.
test.beforeEach(async () => {
await api.post('/api/session', { data: { username: 'reader', password: readerPassword } });
});
test.afterAll(async () => { await api.dispose(); });
test('is signed in at the start of every test', async () => {
expect((await (await api.get('/api/session')).json()).name).toBe('reader');
});
});
The last test shows a pitfall: the reset in the top-level beforeEach clears every Bookshelf session, so a request
context that was signed in once in beforeAll would be signed out by the time a test runs. The fix is to sign in in a
beforeEach that runs after the reset. Any shared stateful server has the same trap: login state created before a reset
does not survive it.
route.fetch() and request.fetch(route.request()) both exist for use inside route handlers: the first uses the
page’s context, the second a context you choose, which is how to answer a mocked request with a real call made under a
different identity.
Running the examples
The page’s specs live in one folder with its own configuration and were run against a local Bookshelf, with the WebSocket helper package on the module path:
BOOKSHELF_REVIEW_DELAY_MS=100 PORT=3916 node server.js &
BASE_URL=http://localhost:3916 npx playwright test -c ex-network-mocking/playwright.config.ts
Source: Playwright: Command line
The run covered 102 tests (51 in Chromium and 51 in WebKit): 98 passed and 4 were skipped by design, namely the four
service-worker tests in WebKit. The run was headless and took about 25 seconds.
Because the specs read BASE_URL, the same command runs unchanged inside the Playwright Docker image with
BASE_URL=http://host.docker.internal:3000; no browser window or display is involved, so local headed and CI
headless runs behave the same for everything on this page. Two points differ between environments: a CI machine is
slower, so keep the delays in mock handlers short and rely on web-first assertions rather than fixed waits; and HAR
files must be committed, because CI starts from a clean checkout.
Common pitfalls
| Symptom | Cause and fix |
|---|---|
The mock is never used |
The route was registered after the request, the pattern does not match the whole URL (a |
The test hangs after a route is added |
A handler returned without calling |
A route affects more pages than intended |
|
|
Only |
The response body is missing from the HAR |
The page never read the body. Read it, or record with the route and |
Headers set in |
|
Clock-dependent test is flaky |
|
|
Set |
The API request is signed out |
A reset or sign-out cleared the session, or a global request context does not share the browser’s cookies. |
What changed recently
-
Playwright 1.64:
apiRequestContext.addCookies(),cookies()andclearCookies()manage the cookies of a request context. -
Playwright 1.63:
apiRequestContext.get()and the other request methods accept a type argument that typesresponse.json(). -
Playwright 1.61: HAR and trace recordings include WebSocket requests.
-
Playwright 1.60:
tracing.startHar()andstopHar()record a HAR through the tracing API;webSocketRoute.protocols()returns the requested sub-protocols;test.abort()can be called from a route handler to end a test whose precondition is broken. -
Playwright 1.59:
route(),addInitScript()and many other APIs return async disposables, which allowsawait usingto remove a route at the end of a block. -
Playwright 1.57: requests and console messages from service workers are reported through the browser context and can be routed there (Chromium only).
-
Playwright 1.52: glob patterns no longer support
?and[];route.continue()ignores aCookieheader;maxRedirectsis available when creating a request context. -
Playwright 1.51:
failOnStatusCodemakes request methods throw on status codes other than 2xx and 3xx. -
Playwright 1.48:
page.routeWebSocket()andcontext.routeWebSocket()intercept and mock WebSockets. -
Playwright 1.46:
maxRetriesretries a request onECONNRESET. -
Playwright 1.45: the Clock API (
page.clock) andhttpCredentials.send. -
Playwright 1.41:
page.unrouteAll()andcontext.unrouteAll(). -
Playwright 1.33:
updateModeandupdateContentforrouteFromHAR(), and atimeoutoption forroute.fetch(). -
Playwright 1.29:
route.fetch()and thejsonoption ofroute.fulfill().