Home / Blog / Playwright API testing

Playwright API testing: what it is good at, and where it stops

Quick answer

The request fixture puts an HTTP client in your test file — get, post, put, patch, delete, head and fetch — running under the same runner and CI job as the browser specs. One response assertion ships and no schema validation, so a contract suite belongs elsewhere. Use it beside a browser test: seed the state, then check the server agreed.

A lead asks why a suite of eighty specs signs in through the login form eighty times a day. Or an engineer finds request in the documentation and wants to know how far it goes before it turns into a second test suite nobody agreed to own. Both arrive at the same fixture.

Should your API tests live in Playwright at all?

The fixture is thin. Ask for request by name in a test signature and you can call your application's API directly, with the results reported by the same reporter, subject to the same retry policy, and failing the same CI job as everything else in the folder.

A contract suite is a different animal, and a dedicated tool houses it better. Every endpoint, every status, every field, schema-validated, run when the service deploys: REST Assured, Postman driven by Newman, or a schema-driven contract tool will carry that shape further than Playwright will. Ownership decides it. A suite that describes a service belongs to the team that ships the service, in their repository, on their pipeline. Playwright also gives you very little vocabulary for asserting on a response body.

What Playwright has instead is adjacency. The documentation's own introduction says you may want to "send requests to the server directly from Node.js without loading a page and running js code in it", and gives three examples: testing your server API, putting server-side state in place before the application is visited, and "Validate server side post-conditions after running some actions in the browser." Two of those three are about a test that also drives a browser.

Interception is a different job. page.route changes what the browser is told, while the request fixture makes a real call and the server keeps the result. Deciding which endpoints to fake, and what each fake stops proving, is a separate decision with its own costs.

How is a Playwright API test written?

The code here is written for Playwright 1.62 and was run on 1.62.1, the current npm patch of that line, against a throwaway Node service on port 4186. The error text quoted later was copied out of a terminal.

request is a fixture of Playwright's test runner, and that runner is JavaScript and TypeScript. The Python, Java and .NET bindings reach the same APIRequestContext class through their own harness — pytest, JUnit, NUnit — so the calls port across and the fixture does not.

The config options this hangs on are baseURL and extraHTTPHeaders. baseURL serves both page.goto and the request fixture, so a relative path works in either, and the headers are attached to every request the run makes.

import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    baseURL: process.env.APP_URL ?? 'http://localhost:4186',
    extraHTTPHeaders: {
      'Accept': 'application/json',
    },
  },
  webServer: {
    command: 'node server.js',
    url: 'http://localhost:4186',
    reuseExistingServer: true,
  },
});
Playwright 1.62.1 · TypeScript · playwright.config.ts

With that in place, a test that calls the API is unremarkable.

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

test('POST /api/tasks creates a task', async ({ request }) => {
  const response = await request.post('/api/tasks', {
    data: { title: 'Ship the invoice export' },
  });

  await expect(response).toBeOK();
  expect(response.status()).toBe(201);

  const task = await response.json();
  expect(task.title).toBe('Ship the invoice export');
});
Playwright 1.62.1 · TypeScript · tests/create-task.spec.ts · passes

toBeOK() passes for any status in the 200–299 range, so it answers "the call worked" and nothing more specific. The expect(response.status()).toBe(201) beside it is the line that says created rather than fine. After that, response.json() gives you a parsed body and the assertions are ordinary TypeScript.

The class behind the fixture, APIRequestContext, carries get, post, put, patch, delete, head, fetch, dispose and storageState. A reader scanning for patch or head has now seen them.

Why put an API call inside a browser test?

Three patterns put the fixture in a browser project, and the documentation demonstrates all three without ever arguing for them. Each one takes work out of a browser test.

Sign in over the API, once

A login form exercised in a beforeEach is a page load, a type, a type, a click and a navigation, repeated for every spec in the suite. Signed in over the API it is one request, and the form itself is still covered — by the one test whose subject is the login form.

The mechanism is a setup project. A setup file signs in with the request fixture and writes the resulting cookies to a file with request.storageState().

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

const authFile = 'playwright/.auth/user.json';

setup('sign in over the API', async ({ request }) => {
  const response = await request.post('/api/login', {
    data: { email: 'demo@example.com', password: 'sekrit' },
  });
  await expect(response).toBeOK();

  await request.storageState({ path: authFile });
});
Playwright 1.62.1 · TypeScript · tests/auth.setup.ts · passes

The config then runs that file as its own project and hands the file it produced to the project that needs it. dependencies orders the two: the documentation describes it as a list of projects that have to run before the tests in another project run.

import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    baseURL: process.env.APP_URL ?? 'http://localhost:4186',
  },
  projects: [
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: 'signed-in',
      testIgnore: /.*\.setup\.ts/,
      use: { storageState: 'playwright/.auth/user.json' },
      dependencies: ['setup'],
    },
  ],
  webServer: {
    command: 'node server.js',
    url: 'http://localhost:4186',
    reuseExistingServer: true,
  },
});
Playwright 1.62.1 · TypeScript · playwright.auth.config.ts

Run that way against the demo service, the setup test takes 45ms and every browser test after it opens the application already signed in. The state file it wrote holds one session cookie and an empty origins array, which is a reminder that a token kept in localStorage needs a browser to have put it there. Which accounts exist, how many of them a parallel run needs and what happens when a role changes are the subject of the article on signing in and reusing the state.

Seed the record, then open the page

The other cost a browser test carries is arriving at the state it wants to test. If the test is named for a task appearing on a list, six screens of navigation to create that task are a slow way to reach the first assertion, and when they break the failure has nothing to do with the list.

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

test('a new task appears on the task list', async ({ page, request }) => {
  const response = await request.post('/api/tasks', {
    data: { title: 'Renew the TLS certificate' },
  });
  await expect(response).toBeOK();

  await page.goto('/tasks');

  await expect(page.getByRole('link', { name: 'Renew the TLS certificate' })).toBeVisible();
});
Playwright 1.62.1 · TypeScript · tests/seed-then-ui.spec.ts · passes

Where the seed data comes from after the second or third spec — helpers, unique identifiers, who deletes the rows — turns into a strategy question, and test data has a page of its own.

Do it in the browser, then ask the server

A UI assertion tells you the interface rendered something after the click. It does not tell you the write landed, and a front end that optimistically renders a state it has not confirmed will pass that assertion during a backend outage.

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

test('marking a task done in the UI changes it on the server', async ({ page, request }) => {
  const created = await request.post('/api/tasks', {
    data: { title: 'Rotate the signing key' },
  });
  const { id } = await created.json();

  await page.goto(`/tasks/${id}`);
  await page.getByRole('button', { name: 'Mark done' }).click();
  await expect(page.getByTestId('status')).toHaveText('done');

  const afterwards = await request.get(`/api/tasks/${id}`);
  expect((await afterwards.json()).status).toBe('done');
});
Playwright 1.62.1 · TypeScript · tests/postcondition.spec.ts · passes

Two assertions, two different facts: the badge says done, and the server's copy of the record says done. The second one is four lines and it is the difference between testing an interface and testing a system through its interface. All three patterns are only available because the HTTP client and the browser are in the same test, holding the same ids, in the same file.

What should an API test check, beyond a 200?

An APIResponse gives you status(), ok(), json(), text(), headers() and a handful of siblings, and toBeOK() is the only assertion Playwright ships for it. When that assertion fails it prints the request line, the response headers and the body under Response text:, which is normally enough to see what happened without adding a console.log.

Shape is the next question: which fields have to be there and what they have to hold. Pinning the whole payload with a deep equality check produces a test that fails the first time the API adds a field, which is a change that broke nothing. Pinning the fields your interface reads gives you a test that fails when something your product depends on moves.

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

test('the created task carries the fields the list page reads', async ({ request }) => {
  const response = await request.post('/api/tasks', {
    data: { title: 'Archive the 2019 exports' },
  });

  expect(response.status()).toBe(201);
  expect(await response.json()).toMatchObject({
    title: 'Archive the 2019 exports',
    status: 'open',
  });
});
Playwright 1.62.1 · TypeScript · tests/shape.spec.ts · passes

Error paths deserve the same attention as the happy one, and here the defaults matter. The reference describes failOnStatusCode as whether to throw on response codes other than 2xx and 3xx, and says that by default the response object is returned for all status codes. A run confirms it: a GET on a missing record comes back with status() 404 and ok() false, and nothing is thrown, so a test that forgets to assert on the status passes.

Turn the option on and the failure is loud. The message from the run above starts apiRequestContext.get: 404 Not Found, then prints the response body and a call log of the request and its headers. The two mechanisms do not draw the line in the same place: a 301 fails toBeOK(), because that assertion wants 200–299, and does not trip failOnStatusCode: true, because a redirect is a 3xx. Both were run against a handler returning 301 with maxRedirects: 0.

One distinction applies to every API assertion you write. POST /orders returning 201 and an id is a claim about a payload. POST /orders followed by GET /orders/{id} showing the order in the right state, and the order then appearing on the account page, is a claim about a behaviour. The first breaks when the response format changes. The second breaks when the product breaks. Cheap assertions are the ones you can write without knowing what the system is supposed to do, and a suite made of them stays green through outages that cost money.

When does this bite you?

Playwright has no schema validation, and the API surface is the evidence for that. The APIResponseAssertions class ships exactly one assertion method, toBeOK, plus a not property. APIRequestContext has nine methods: seven send a request, one writes the storage state to a file and one disposes the context. Not one of them looks at the shape of a response. Teams that need schema checks add a validator library and call it from inside an expect. That works well, and it is a dependency you have chosen to keep current, not a feature of the framework that somebody else maintains.

Authentication carries a cost that shows up on long runs. extraHTTPHeaders is set in the config, so a token pasted there is fixed for the whole run, and a token minted while the config is evaluated is minted once no matter how many hours the suite takes. On a suite that runs in four minutes nobody notices. On a sharded nightly run against a service issuing short-lived tokens, the tests that fail are the ones that ran last, which reads exactly like flakiness. The ways out are a request context created at the point where the token is fresh, or a setup project that mints one and hands it on.

Environments split, too. One baseURL in use serves the page and the request context together, and that stops being enough the day the API moves to its own host. Either create a context with its own baseURL, as below, or split the run into projects, which the documentation covers under configuring projects for multiple environments.

Some of what comes to mind next is outside what the framework does. Calling an endpoint in a loop from a test is not a load test: Playwright measures one browser doing one thing well, it is not k6, JMeter or Gatling, and nothing on this site sells load testing. Security and penetration testing — auth bypass, injection, the things that occur to anyone who has just discovered they can send arbitrary HTTP from a spec — are not us either, and you will get a straight no on the call.

This last failure has no config fix. Setup calls grow into coverage, coverage grows into a contract suite nobody agreed to own, and now the browser repository holds a second suite duplicating tests the backend team already runs and does not read. What stops it is a boundary you can check one test at a time: the API calls in a browser project exist to put the system into a state and to check the state changed. The moment a test's subject is the endpoint itself, it belongs where that service's tests live.

Somebody has to decide where that line sits, and then hold it while the suite grows. That is the work we do on an engagement built around the API layer, and we write it in whichever of the four official bindings your repository already uses — TypeScript or JavaScript, Python, Java or .NET — which matters most to the team whose existing API tests are Java and are staying there.

Where should the API calls sit in the project?

Most calls should go through the fixture. The reference describes request as an "Isolated APIRequestContext instance for each test", and isolation is the part that surprises people: signing in through the fixture does not sign in the browser. Run it and the page still renders Not signed in, because that context has its own cookie jar.

The context reached through the browser behaves differently. page.request and browserContext.request share cookie storage with the browser context they belong to, so a login sent through page.request leaves the page signed in — the same demo test that fails through the fixture renders Signed in as demo@example.com when the call goes through page.request. Use the browser-tied one when the call is supposed to share a session with what the user is doing, and the fixture when it is not.

When a call needs options the rest of the run does not have, build a context by hand.

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

test('the API answers on its own host', async () => {
  const api = await request.newContext({
    baseURL: process.env.API_URL ?? 'http://localhost:4186',
  });

  const response = await api.get('/api/tasks');
  await expect(response).toBeOK();

  await api.dispose();
});
Playwright 1.62.1 · TypeScript · tests/other-host.spec.ts · passes

Notice what the test signature does not do. The imported request and the fixture request have the same name, so a test that destructures the fixture shadows the import, and the call fails with TypeError: request.newContext is not a function — which is a confusing error to meet at 2am, because the line looks exactly like the one in the documentation. Either leave the fixture out of the signature, as above, or rename it in the destructuring.

One behaviour to check before you rely on it, observed here on 1.62.1: a context built this way inside a test still picks up the use options from the config. With extraHTTPHeaders: { 'Accept': 'application/json' } set, both the fixture and the hand-built context sent that header; with the header taken out of the config, both sent */*. Options passed to newContext win over the config, and the ones you leave out still come from it. Dispose the context when you are done, and calling a disposed context returns apiRequestContext.get: Target page, context or browser has been closed.

Questions

Can Playwright replace Postman or REST Assured?

For setup, teardown and postcondition checks beside a browser suite, yes, and gladly: those calls belong in the same test as the browser work they support. For a contract suite that pins every endpoint, every status and every field, usually not. That suite describes a service, so it belongs to the team that ships the service and runs when the service deploys, and it needs schema assertions Playwright does not have.

Does Playwright do JSON schema validation?

No. The APIResponse assertion class ships one method, toBeOK, and the nine methods on APIRequestContext send requests, save the storage state or dispose the context — not one of them looks at the shape of a response. Teams that need schema checks call a validator library from inside an expect, which works fine, and that library then becomes yours to keep current.

Do Playwright API tests need a browser?

The documentation describes sending requests to the server directly from Node.js without loading a page and running js code in it, and a spec that asks only for the request fixture never calls page.goto. Read that as no page being loaded rather than as a promise about which processes start, which is Playwright's business and not something the documentation commits to.

How do I share a login between my API tests and my UI tests?

Sign in with request.post inside a setup project, then call request.storageState with a path to write the cookies and origins to a file. Any project that names that file in its storageState option starts every test already signed in, browser included. Roles, one account per worker and session storage go further than this, and the authentication article covers them.

Can the API tests point at a different environment from the UI tests?

Yes. Create a context with request.newContext and give it its own baseURL for the calls that go somewhere else, or split the run into projects with their own use blocks. A single baseURL in use serves both the page and the request fixture, so the split only becomes a problem on the day the API moves to another host.

Who owns your API tests today?

Tell us where the HTTP calls in your suite currently live — a Postman collection, a Newman step in CI, a REST Assured project the backend team runs, or a handful of request.post calls that grew out of a fixture — and which of those nobody has opened in six months. An engineer here will say which of them belongs beside your browser tests and which belongs on the service's own pipeline.