Playwright API Testing & Network Mocking

Playwright isn't only a browser tool. It has a built-in HTTP client for API testing, and it can intercept the browser's own network calls — to mock responses, simulate failures, block noise and assert what the page sent. This guide covers both with complete code. The API tests were run against a local API and passed; the mocking examples type-check against Playwright 1.63.

Advertisement

Part 1 — API Testing With the request Fixture

Every test can ask for a request fixture — an APIRequestContext that sends HTTP requests directly, with no browser. It uses baseURL from the config, returns an APIResponse, and works with the same expect() as UI tests.

test('get a booking', async ({ request }) => {
  const res = await request.get('/bookings/1001');
  await expect(res).toBeOK();                               // status 200–299
  expect(await res.json()).toMatchObject({ firstname: 'Asha' });
});

An authenticated client and a full CRUD flow ⭐

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

let api: APIRequestContext;

test.beforeAll(async ({ playwright }) => {
  const login = await request.newContext({ baseURL: 'http://localhost:8767' });
  const res = await login.post('/auth', {
    data: {
      username: 'admin',
      password: process.env.API_PASSWORD
    }
  });

  expect(res.ok()).toBeTruthy();

  const { token } = await res.json();
  await login.dispose();

  api = await playwright.request.newContext({
    baseURL: 'http://localhost:8767',
    extraHTTPHeaders: {
      Authorization: `Bearer ${token}`
    },
  });
});

test.afterAll(async () => {
  await api.dispose();
});

test('create, read, update and delete a booking', async () => {
  const created = await api.post('/bookings', {
    data: {
      firstname: 'Asha',
      totalprice: 150
    }
  });

  expect(created.status()).toBe(201);

  const { bookingid } = await created.json();

  const read = await api.get(`/bookings/${bookingid}`);
  await expect(read).toBeOK();

  expect(await read.json()).toMatchObject({
    firstname: 'Asha',
    totalprice: 150
  });

  const updated = await api.put(`/bookings/${bookingid}`, {
    data: {
      firstname: 'Asha',
      totalprice: 200
    }
  });

  expect((await updated.json()).totalprice).toBe(200);

  expect(
    (await api.delete(`/bookings/${bookingid}`)).status()
  ).toBe(204);

  expect(
    (await api.get(`/bookings/${bookingid}`)).status()
  ).toBe(404);    // really gone
});

test('negative cases', async ({ request }) => {
  expect(
    (await request.post('/bookings', {
      data: {
        firstname: 'X',
        totalprice: 1
      }
    })).status()
  ).toBe(401);  // no token

  expect(
    (await api.post('/bookings', {
      data: {
        totalprice: 10
      }
    })).status()
  ).toBe(400);  // missing field

  expect(
    (await api.post('/bookings', {
      data: {
        firstname: 'X',
        totalprice: -5
      }
    })).status()
  ).toBe(422);  // business rule

  const wrongType = await api.post('/bookings', {
    data: 'firstname=X',
    headers: {
      'Content-Type': 'text/plain'
    }
  });

  expect(wrongType.status()).toBe(415);  // wrong content type
});

Result against the local API: 2 passed.

Notes: data with an object is sent as JSON automatically; use form or multipart for form posts and uploads; params adds query parameters; dispose() frees contexts you create yourself.

Where API calls help UI tests

  • Fast setup — create the user, cart or order through the API, then test only the UI step that matters.
  • Log in once — call the login API, save the session as storage state, reuse it across tests: Storage State & Session Reuse.
  • Verify the backend — after a UI action, fetch the record through the API to confirm it was saved correctly.
  • Clean up — delete test data in afterEach.

Part 2 — Intercepting the Browser's Network Calls

page.route(url, handler) intercepts matching requests from the page. The handler decides what happens:

Method Effect
route.fulfill() Answer with your own status, headers and body — the server is never called
route.fetch() + fulfill({ response }) Call the real server, then modify its response
route.continue() Let it through, optionally changing URL, headers or body
route.abort() Block the request (simulates a network failure)

Register routes before the page makes the call — usually before page.goto().

Mock a response

await page.route('**/api/products', route =>
  route.fulfill({
    status: 200,
    json: [
      {
        id: 1,
        title: 'Mock Backpack',
        price: 10
      }
    ]
  })
);

await page.goto('/shop');

await expect(
  page.getByText('Mock Backpack')
).toBeVisible();

Simulate errors, empty data and slowness

await page.route('**/api/orders', route =>
  route.fulfill({
    status: 500,
    json: {
      error: 'Server error'
    }
  })
);

await page.route('**/api/products', route =>
  route.fulfill({
    json: []
  })
); // empty state

await page.route('**/api/search**', async route => {
  await new Promise(r => setTimeout(r, 3000)); // slow backend
  await route.continue();
});

These states are hard to produce on a real server — and they're exactly where UIs break: missing error messages, spinners that never stop, blank pages instead of "no results".

Modify a real response

await page.route('**/api/products', async route => {
  const response = await route.fetch(); // real call

  const json = await response.json();

  json.push({
    id: 999,
    title: 'Injected item',
    price: 0
  }); // add an edge case

  await route.fulfill({
    response,
    json
  }); // keep real status and headers
});

Block noise

await page.route(
  /\.(png|jpg|jpeg|gif|woff2?)$/,
  route => route.abort()
); // images and fonts

await page.route(
  '**/*google-analytics*/**',
  route => route.abort()
); // analytics

Blocking third-party scripts makes tests faster and more stable — but keep at least one unblocked run so you still test the page as users see it.

Assert what the page sent and received

const reqPromise = page.waitForRequest(
  r =>
    r.url().endsWith('/api/orders') &&
    r.method() === 'POST'
);

const resPromise = page.waitForResponse(
  r =>
    r.url().includes('/api/orders') &&
    r.status() === 201
);

await page
  .getByRole('button', { name: 'Place order' })
  .click();

expect(
  (await reqPromise).postDataJSON()
).toMatchObject({
  items: [
    {
      sku: 'BAG-1'
    }
  ]
}); // right payload

const orderId =
  (await (await resPromise).json()).id;

await expect(
  await request.get(`/api/orders/${orderId}`)
).toBeOK(); // saved on the backend

Start waiting before the click, then await after it — the same pattern as popups and downloads.

Record and replay with HAR

await page.routeFromHAR(
  'hars/products.har',
  {
    url: '**/api/**',
    update: false
  }
);

Run once with update: true to record real responses into a HAR file, then replay them — stable, realistic data without hand-writing mocks. Re-record when the API changes.

When to Mock — and When Not To

  • Mock for UI states (errors, empty, slow), unfinished backends, third-party services (payments, maps) and flaky dependencies.
  • Don't mock in the end-to-end tests that prove the real system works together — a suite that mocks everything can pass while production is broken.
  • Keep mocks close to the real contract; validate API responses against a schema in separate API tests. More: Mock Servers & Advanced API Testing.

Coming From Selenium or Cypress

Task Selenium Cypress Playwright
API call in a test A separate client (REST Assured) cy.request() request.get()
Mock a response Not built in (proxy or CDP) cy.intercept(url, body) page.route() + fulfill()
Wait for a call Not built in cy.wait('@alias') page.waitForResponse()

Convert more code between the three in the Selenium ⇄ Playwright ⇄ Cypress Translator.

From Real Projects

My own automation experience is with Selenium WebDriver, Java and TestNG in a POM-based hybrid framework, running in batch, parallel and cross-browser mode. The concepts on this page carry straight over to Playwright — page objects, independent tests, parallel execution and reliable synchronization — which makes it a natural next tool for Selenium testers. The biggest difference you'll notice is how much waiting Playwright handles for you. Testing Testsigma — a platform where users write automated tests in plain English — meant thinking like its users, who are testers themselves. Register routes before navigating to the page.

📚 Official documentation: Playwright docs: Mock APIs · Playwright docs: API testing

FAQs

Can Playwright do API testing?

Yes — the request fixture (an APIRequestContext) sends GET, POST, PUT, PATCH and DELETE requests without a browser and works with Playwright's expect().

How do you send an auth token with every request?

Create a context with playwright.request.newContext({ baseURL, extraHTTPHeaders: { Authorization: 'Bearer …' } }).

How do you mock an API response?

Register page.route(url, route => route.fulfill({ status, json })) before the page makes the request.

How do you change part of a real response?

In the route handler, call route.fetch(), edit the JSON, and route.fulfill({ response, json }).

How do you block images or analytics?

page.route(pattern, route => route.abort()).

How do you check what the page sent to the server?

page.waitForRequest() before the action, then inspect postDataJSON(), headers or URL.