QA Interview Hub · Knowledge base · Quizzes

Playwright

Network: route, mocks, API tests

Working with the network: page.route and mocks

page.route intercepts requests by a URL pattern or a regular expression. Then the request can be replaced with route.fulfill with your own status and body, passed on with route.continue with changed headers or body, or aborted with route.abort. The route must be declared before the action that triggers the request.

A common pattern is route.fetch(): get the real response, change part of the data and return it through fulfill. This lets you test boundary UI states without preparing data on the backend: an empty list, a 500 error, a very long text. For fully offline tests you can record traffic into a HAR and replay it with routeFromHAR.

To wait for a request use page.waitForResponse: the promise must be created before the action, and the await done after. context.route applies interception to all pages of the context. You shouldn't mock everything: some E2E tests must go to the real backend, otherwise integration bugs won't be visible.

API testing in Playwright

For APIs there is the request fixture of type APIRequestContext, which sends HTTP requests without a browser: request.get, post, put, patch, delete. The baseURL and extraHTTPHeaders, such as a token, are set in the config. The response is checked through response.status(), response.json() and the assertion expect(response).toBeOK().

The API is used not only for separate API tests, but also for preparing data in E2E: create a user or an order through the API, and check through the UI only the needed flow. This makes tests faster and more stable. The API is also convenient for cleaning up data after a test in a fixture's teardown.

page.request and context.request share cookies with the browser context, so after a login through the UI requests already go authorised, and vice versa. For validating the response schema, zod or ajv is added. A separate context with different settings is created through playwright.request.newContext.

Observe or replace: waitForResponse, waitForRequest, route

It is important here to distinguish two tasks. page.waitForResponse and page.waitForRequest only observe real traffic: the request goes to the backend, and I wait and check it. page.route intercepts the request and decides what to do with it.

Checking that the front end sent the right payload:

const reqPromise = page.waitForRequest((r) =>
  r.url().includes('/api/transfers') && r.method() === 'POST');
await page.getByRole('button', { name: 'Transfer' }).click();
const req = await reqPromise;
expect(req.postDataJSON()).toMatchObject({ amount: 500, currency: 'UAH' });

With waitForResponse I wait for a specific request to finish and can get data from the response, for example the id of the created object.

Nuances of route:

  • The pattern is a glob (**/api/users*), a RegExp or a function of the URL. A glob with * doesn't cross /, while ** does.
  • If several handlers are registered for a URL, the last one fires. route.fallback() passes the request to the previous handler, and route.continue() sends it straight to the network.
  • page.unroute removes a handler. page.unrouteAll({ behavior: 'ignoreErrors' }) is useful at the end of a test when handlers are still running.
  • A Service Worker may intercept requests before Playwright, and then route doesn't see them. The solution is serviceWorkers: 'block' in the config.
In an interview: I use waitForResponse and waitForRequest to observe real traffic and check the payload, and route to replace a response. I know that the last registered handler fires, and that fallback passes the request on down the chain, while continue sends it straight to the network.

HAR: recording and replaying traffic

A HAR is a file with a recording of HTTP traffic: requests, responses, headers, bodies. Playwright can both record it and replay it as a mock. This is useful when the backend is unstable, expensive or depends on third-party services, while you need to test the front end.

Recording and replay with one method:

test('catalogue from HAR', async ({ page }) => {
  await page.routeFromHAR('hars/catalog.har', {
    url: '**/api/**',
    update: process.env.UPDATE_HAR === '1',
    notFound: 'fallback',
  });
  await page.goto('/catalog');
  await expect(page.getByRole('listitem')).toHaveCount(12);
});

With update: true requests go to the real network, and the HAR is rewritten. Without it, responses are taken from the file. url limits which requests to mock: usually only the API, while static files load as they are. notFound: 'fallback' lets requests that aren't in the HAR through to the network; by default they are aborted.

Alternatives for recording: the context option recordHar: { path, urlFilter } or npx playwright codegen --save-har=file.har.

Risks worth mentioning: a HAR goes stale together with the API, so it must be updated regularly. Tokens and personal data end up in the file, so it must be checked before a commit. A request is matched by URL, method and body, so dynamic parameters, for example a timestamp in the query, may break the matching.

In an interview: I use HAR for stable front-end tests that don't depend on an unstable backend: I record traffic with routeFromHAR with update and then replay it. I limit it to the API only with the url option, update it regularly and check that no tokens end up in the file.

APIRequestContext in depth

Besides the request fixture, you can create a separate API context with its own settings. This is needed for another service, another user, or in a worker fixture:

import { request, expect } from '@playwright/test';

const admin = await request.newContext({
  baseURL: 'https://api.test.bank',
  extraHTTPHeaders: { Authorization: `Bearer ${process.env.ADMIN_TOKEN}` },
});
const res = await admin.post('/accounts', {
  data: { currency: 'UAH', type: 'savings' },
  params: { dryRun: 'false' },
});
await expect(res).toBeOK();
const { id } = await res.json();
await admin.dispose();

The body format is set by an option: data is JSON if an object is passed; form is application/x-www-form-urlencoded; multipart is multipart/form-data with files. Query parameters go through params.

By default request doesn't throw on 4xx or 5xx: the response is simply returned. This is convenient for negative tests where I expect 422. If I want any error to fail the call, there is the option failOnStatusCode: true. toBeOK() checks the range 200-299, and response.ok() returns the same result as a boolean.

APIRequestContext has its own cookies. After a login through the API you can save the state: await ctx.storageState({ path: '.auth/user.json' }), and use it in browser tests.

For purely API tests it is convenient to make a separate project without browser settings. The browser starts lazily, so if a test doesn't request page, it isn't launched.

In an interview: I create a separate API context with request.newContext with its own baseURL and token, and always call dispose. I remember that by default 4xx and 5xx don't throw, so I check the status explicitly. After a login through the API I can save storageState and use it in UI tests.