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

route.fulfill()

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.

route.continue()

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.

route.abort()

Fails the request as if the network had failed, with an optional error code.

Blocking images, fonts or trackers; simulating an offline server.

route.fallback()

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 calls fulfill(), continue() or abort() the chain stops. route.continue() does not fall through to other handlers; use fallback() 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 in request.headers() inside a handler and cannot be overridden; a Cookie header passed to route.continue() is ignored in favour of the browser’s cookie store. Use context.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"/>');
  }
});

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'] } },
  ],
});

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

status

HTTP status code, 200 by default.

headers

Response headers as an object. content-type is derived from body or json when omitted.

contentType

Shortcut for the content-type header.

body

A string or a Buffer.

json

A serializable value sent as the body with application/json. Sets the content type for you.

path

A file whose contents become the body; its extension chooses the content type.

response

An existing APIResponse, for example from route.fetch(). Its status, headers and body are used unless you override them with the other options.

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');
});

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');
});

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);
});

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');
});

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');
});

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/');
});

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']);
});

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

'**/api/books/*'

* matches any characters except /; ** matches anything including /; {a,b} matches alternatives; a backslash escapes the next character. The pattern must match the entire URL, protocol and query string included.

Regular expression

/\/api\/books\?.*q=ocean/

Matched against the full URL. Use it for anything a glob cannot express.

Predicate

(url) => url.pathname === '/api/reviews'

Receives a URL; return true to match. Use url.searchParams to look at the query.

URLPattern

new URLPattern({ pathname: '/api/session' })

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/books and /api/books?q=x, but not /api/books/2; **/api/books/* matches /api/books/2 and 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 baseURL is 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');
});

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);
});

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

url

Glob, regular expression or predicate. Only matching requests are served from (or recorded to) the HAR; others are unaffected. Without it, every request is.

update

When true, requests go to the real network and are written to the HAR when the context closes; when false (the default) requests are served from the file.

updateContent

'embed' stores bodies inline in the HAR; 'attach' stores them as separate files or, for a .zip path, as zip entries.

updateMode

'minimal' (the default) records only what replay needs and omits sizes, timings, cookies and similar; 'full' records everything.

notFound

What to do with a request that matches url but has no entry in the HAR: 'abort' (the default) fails it, 'fallback' sends it on to the next handler or the network.

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 route.fulfill()

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 second WebSocketRoute representing 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 },
  );
}

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');
  });
});

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:

  1. Define the mock in an init script and, if a test needs to drive it later, save it on window.

  2. Navigate; assert on what the page does with the mock.

  3. Use page.evaluate() to change the mock and watch the UI follow.

  4. 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);
});

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

geolocation and permissions context options, context.setGeolocation(), context.grantPermissions(), context.clearPermissions()

Sets the position and the permission state that navigator.geolocation and the Permissions API report.

page.emulateMedia(), or the colorScheme, reducedMotion, forcedColors and contrast options

Sets prefers-color-scheme and related media features that CSS and matchMedia() read.

locale and timezoneId

Changes navigator.language, Intl formatting and the time zone used by Date.

context.setOffline()

Makes every request fail as if the network were gone.

extraHTTPHeaders, userAgent, httpCredentials

Headers, user agent and HTTP basic authentication for every request of the context.

page.clock

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

clock.setFixedTime(time)

Date.now() and new Date() always return this time. Timers keep running in real time. The recommended choice when you only care about the current date.

clock.install({ time })

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.

clock.pauseAt(time)

Jumps to time firing the timers due on the way and pauses there: time stands still and no timer fires until you move it.

clock.fastForward(ticks)

Jumps forward, firing each due timer at most once, like closing a laptop lid and reopening it later.

clock.runFor(ticks)

Advances time, firing every timer and animation frame on the way, as if the time had really passed.

clock.resume()

Resumes the flow of time after pauseAt().

clock.setSystemTime(time)

Sets the current Date without firing any timer. Advanced use only.

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 after setInterval() and before clearInterval() gives undefined behaviour, because install() replaces the native functions.

  • setFixedTime() works without install() and does not touch timers. install() plus pauseAt() gives complete control.

  • After pauseAt(), nothing advances until you call fastForward(), runFor() or resume(); assertions about a paused page are deterministic.

  • The clock is per page (page.clock) and also available on the context as context.clock, so every page of the context shares one time.

  • Pick a timezoneId and locale for tests that print dates, because toLocaleString() 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 }));
});

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

params

Query parameters as an object, a URLSearchParams or a string.

data

The body. Objects are sent as JSON; strings and Buffers as-is. Use form for application/x-www-form-urlencoded and multipart for file uploads.

headers

Per-request headers, merged over extraHTTPHeaders.

failOnStatusCode

Throw for any status other than 2xx and 3xx (since 1.51). The default is to return the response, whatever its status.

maxRedirects, maxRetries, timeout

Redirect limit (default 20), retries on ECONNRESET (default 0) and timeout in milliseconds (default 30000).

ignoreHTTPSErrors

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.request and the request fixture’s browser-bound sibling. It fills the Cookie header from the browser context and writes Set-Cookie responses back into it. Use it when API and UI must share a session.

  • Global (isolated) request: created by playwright.request.newContext() (or request.newContext() imported from @playwright/test). It has its own cookies, baseURL and headers, independent of any browser. Use it for admin or setup calls that should not change the page’s session, and in beforeAll to share one context across the tests of a file. Always dispose() 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

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 ? or a missing * for the query string), or a service worker answered the request. Log page.on('request') and compare, or set serviceWorkers: 'block'.

The test hangs after a route is added

A handler returned without calling fulfill(), continue(), abort() or fallback().

A route affects more pages than intended

context.route() covers every page of the context, popups included. Use page.route() for a single page.

route.continue() did not reach the second handler

Only route.fallback() passes the request on; continue() goes to the network.

The response body is missing from the HAR

The page never read the body. Read it, or record with the route and update: true.

Headers set in route.continue() are ignored

Cookie, Host, Content-Length and other network-stack headers cannot be overridden.

Clock-dependent test is flaky

install() was called late, or the test mixes real timers with a fixed time. Install first and drive time with runFor() and fastForward().

toLocaleString() text differs between machines

Set locale and timezoneId in the context, or format in UTC as the demo page does.

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() and clearCookies() manage the cookies of a request context.

  • Playwright 1.63: apiRequestContext.get() and the other request methods accept a type argument that types response.json().

  • Playwright 1.61: HAR and trace recordings include WebSocket requests.

  • Playwright 1.60: tracing.startHar() and stopHar() 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 allows await using to 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 a Cookie header; maxRedirects is available when creating a request context.

  • Playwright 1.51: failOnStatusCode makes request methods throw on status codes other than 2xx and 3xx.

  • Playwright 1.48: page.routeWebSocket() and context.routeWebSocket() intercept and mock WebSockets.

  • Playwright 1.46: maxRetries retries a request on ECONNRESET.

  • Playwright 1.45: the Clock API (page.clock) and httpCredentials.send.

  • Playwright 1.41: page.unrouteAll() and context.unrouteAll().

  • Playwright 1.33: updateMode and updateContent for routeFromHAR(), and a timeout option for route.fetch().

  • Playwright 1.29: route.fetch() and the json option of route.fulfill().