Playwright API testing: a working suite, and what 1.62 to 1.64 added
Most people know Playwright as a browser testing tool. It also has a full HTTP client built in, the request fixture, and you can write API tests with it without launching a browser at all. The tests run in the same runner, with the same reports, retries and parallel workers as your UI tests.
This post builds a small API test suite with it, against our free test API. Every example was run with Playwright 1.64.0, released on 7 October 2026, and all 12 tests pass. The last part covers what the last three releases added for API testing: typed responses, locks for tests that share data, cookies on the request context, response timing and cancelling a request.
Setting up
You need Node.js 20 or later. API tests don't need browsers, so there's no npx playwright install step:
$ mkdir playwright-api && cd playwright-api
$ npm init -y
$ npm install --save-dev @playwright/test@1.64.0The config sets a base URL, so tests can use paths like /api/Products, and a default Accept header for every request:
import { defineConfig } from "@playwright/test";
// API tests only: no browsers are launched, so `npx playwright install` isn't needed.
export default defineConfig({
testDir: "./tests",
reporter: "list",
retries: 1,
// Playwright 1.62: run retries at the end, one at a time, instead of straight away in parallel.
retryStrategy: "isolated",
use: {
// Point the tests at another server with BASE_URL, e.g. a copy of the API on your machine.
baseURL: process.env.BASE_URL ?? "https://api.testingapis.com",
extraHTTPHeaders: { Accept: "application/json" },
},
});BASE_URL lets you point the same tests at another server, such as a local copy of the API or a staging environment, without editing them.
Your first API test
Ask for the request fixture in a test, and you get an APIRequestContext with get, post, put, patch, delete and fetch. Each returns an APIResponse with the status, headers and body. This file checks a list endpoint, an error body, a validation error and a handful of status codes:
import { expect, test } from "@playwright/test";
import type { ApiError, Product } from "./types";
test("lists products filtered by category, with paging headers", async ({ request }) => {
const response = await request.get<Product[]>("/api/Products", {
params: { category: "electronics", limit: 2 },
});
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("application/json");
expect(Number(response.headers()["x-total-count"])).toBeGreaterThan(2);
const products = await response.json();
expect(products).toHaveLength(2);
for (const product of products) {
expect(product.category).toBe("electronics");
expect(typeof product.price).toBe("number");
}
});
test("returns 404 with an error body for a product that doesn't exist", async ({ request }) => {
const response = await request.get<ApiError>("/api/Products/99999");
expect(response.status()).toBe(404);
expect(await response.json()).toEqual({
status: 404,
error: "Not Found",
message: "Product with ID 99999 does not exist.",
});
});
test("rejects a product without a title", async ({ request }) => {
const response = await request.post<ApiError>("/api/Products", { data: { price: 5 } });
expect(response.status()).toBe(400);
expect((await response.json()).message).toBe("Missing required field: title");
});
for (const code of [400, 401, 429, 500, 503]) {
test(`handles a ${code} response`, async ({ request }) => {
const response = await request.get(`/api/status/${code}`);
expect(response.status()).toBe(code);
expect(response.ok()).toBe(false);
});
}paramsbuilds the query string, so you don't have to encode?category=electronics&limit=2by hand.response.headers()returns header names in lower case. Our API returns the total number of matching products inX-Total-Count, so the test checks paging as well as the body.dataon apostis sent as JSON, withContent-Type: application/jsonset for you.- The
forloop creates one test per status code./api/status/{code}returns whichever status you ask for, which is handy for checking how your own client code reacts to a 429 or a 503.
A 404 or a 500 doesn't make request.getthrow. You get the response back and assert on it, which is what you want when the error is the thing you're testing. When an error status should simply fail the test, pass failOnStatusCode:
// Throw on any 4xx or 5xx instead of returning the response.
const response = await request.get("/api/Products/1", { failOnStatusCode: true });Typing the responses
Since Playwright 1.63, request methods take a type argument, and response.json()returns that type. That's what request.get<Product[]> did above. The types live in one file:
// The shapes the tests read. Only the fields they check are listed.
export interface Product {
id: number;
title: string;
price: number;
category: string;
inStock: boolean;
}
/** What POST and PUT on /api/Products return. */
export interface ProductEnvelope {
message: string;
data: Product;
}
/** The API's error body, for 4xx and 5xx responses. */
export interface ApiError {
status: number;
error: string;
message: string;
}This is a hint to TypeScript, not a check. If the API sends a string where you declared a number, the test still compiles and runs. Keep the assertions on the values that matter, as the tests above do with typeof product.price. To validate whole response bodies against a schema, see our response validation lesson.
A create, read, update and delete test that cleans up
Products on our API are shared: a product you create is visible to everyone until it's deleted. Tests against a shared environment have to clean up after themselves, including when they fail halfway through:
import { expect, test } from "@playwright/test";
import type { Product, ProductEnvelope } from "./types";
// Products are shared by everyone who uses the API. Playwright 1.63's lock keeps any other test that writes
// products from running at the same time as this file, in any worker.
test.describe.configure({ lock: "products" });
test("creates, reads, updates and deletes a product", async ({ request }) => {
const created = await request.post<ProductEnvelope>("/api/Products", {
data: { title: "Playwright test product", price: 12.5, category: "electronics" },
});
expect(created.status()).toBe(201);
const { data: product } = await created.json();
try {
await test.step("read it back", async () => {
const read = await request.get<Product>(`/api/Products/${product.id}`);
expect(read.status()).toBe(200);
expect(await read.json()).toMatchObject({ id: product.id, title: "Playwright test product", price: 12.5 });
});
await test.step("change the price", async () => {
const updated = await request.put<ProductEnvelope>(`/api/Products/${product.id}`, {
data: { title: "Playwright test product", price: 9.99, category: "electronics" },
});
expect(updated.status()).toBe(200);
expect((await updated.json()).data.price).toBe(9.99);
});
} finally {
// Clean up even when a step fails, so a broken run doesn't leave products behind.
const deleted = await request.delete(`/api/Products/${product.id}`);
expect(deleted.status()).toBe(200);
}
const gone = await request.get(`/api/Products/${product.id}`);
expect(gone.status()).toBe(404);
});- The
finallyblock deletes the product even if reading or updating it fails, so a broken run doesn't leave test data behind. test.stepnames each stage in the report and the trace viewer, so a failure says "change the price" instead of pointing at line 23.- The last request checks the product is really gone. A delete that returns 200 but leaves the record in place is a real bug, and only a follow-up read catches it.
test.describe.configure({ lock: "products" })is new in 1.63. Tests that share a lock name never run at the same time, across files and workers. Give every test that writes products the same lock, and they can't trip over each other's data, while the rest of the suite still runs in parallel.
Our CRUD testing lesson goes through which checks each of these steps needs, and why deleting twice is a useful test too.
What 1.62 to 1.64 added for API tests
| Version | Addition | What it's for |
|---|---|---|
| 1.64 | request.addCookies() / cookies() / clearCookies() | Set or clear cookies on a request context, the same way you can on a browser context |
| 1.64 | --shuffle [seed] | Run tests in random order to find tests that only pass after another one |
| 1.63 | request.get<T>() | Typed response.json(), shown above |
| 1.63 | lock | Keep tests that change the same data from running at the same time |
| 1.62 | apiResponse.timing() | DNS, connect, request and response times for one call |
| 1.62 | signal | Cancel a request with an AbortSignal |
| 1.62 | retryStrategy: "isolated" | Run retries at the end, one at a time |
This file exercises the request-level ones. Our /api/echo endpoint returns the request it received, which makes it easy to see exactly what Playwright sent:
import { expect, test } from "@playwright/test";
// /api/echo returns what it received, which makes it easy to see what the request context sent.
interface Echo {
method: string;
cookies: Record<string, string>;
}
test("1.64: cookies added to the request context are sent with every request", async ({ request, baseURL }) => {
await request.addCookies([{ name: "session-id", value: "42", url: baseURL! }]);
const withCookie = await request.get<Echo>("/api/echo");
expect((await withCookie.json()).cookies).toEqual({ "session-id": "42" });
await request.clearCookies({ name: "session-id" });
const withoutCookie = await request.get<Echo>("/api/echo");
expect((await withoutCookie.json()).cookies).toEqual({});
});
test("1.62: apiResponse.timing() shows where the time went", async ({ request }) => {
const response = await request.get("/api/health");
const timing = response.timing();
// Each value is milliseconds after startTime, or -1 when it doesn't apply (no DNS lookup on a reused
// connection, for example).
expect(timing.startTime).toBeGreaterThan(0);
expect(timing.responseEnd).toBeGreaterThanOrEqual(timing.responseStart);
expect(timing.responseEnd).toBeLessThan(5_000);
});
test("1.62: an AbortSignal cancels a request that takes too long", async ({ request }) => {
// ?delay= makes the API wait before answering. The signal gives up after 500 ms.
const pending = request.get("/api/echo", { params: { delay: 3000 }, signal: AbortSignal.timeout(500) });
await expect(pending).rejects.toThrow();
});- Cookies. A request context has always kept cookies that responses set, and could start with some from
storageState, but you couldn't add or remove one directly. Now you can, mid-test: for example, clear the session cookie and check that the next request is refused. - Timing.
timing()returns the same fields as the browser's Resource Timing API, in milliseconds afterstartTime, with -1 for phases that didn't happen. A reused connection has no DNS lookup, so checkresponseEndrather than adding up every field. The 5-second limit in the test is deliberately loose: a tight timing assertion against a server on the internet makes a test flaky. - Cancelling. The 1.62 release notes describe
signalfor actions and assertions. It works on request methods too, which we checked in the 1.64 type definitions and in the test above:?delay=3000makes our API wait three seconds, andAbortSignal.timeout(500)gives up after half a second. The request's owntimeoutoption does a similar job; a signal is useful when something else decides when to stop, such as a test that ends early.
The config at the top already uses retryStrategy: "isolated". By default a failed test is retried as soon as a worker is free, while the rest of the suite is still running. Isolated retries wait until the end and run one at a time. If a test fails in the parallel run but passes on its isolated retry, the likely cause is another test getting in its way, such as two tests changing the same record, and a lock is the fix. --shuffle finds the opposite problem: run the suite in random order now and then, and a test that only passes after another one has run will show up.
$ npx playwright test --shuffleRunning the suite
$ npx playwright testRunning 12 tests using 3 workers
✓ 1 tests\read.spec.ts:4:5 › lists products filtered by category, with paging headers (285ms)
✓ 2 tests\new-in-1-62-to-1-64.spec.ts:9:5 › 1.64: cookies added to the request context are sent with every request (263ms)
✓ 3 tests\crud.spec.ts:8:5 › creates, reads, updates and deletes a product (334ms)
✓ 4 tests\read.spec.ts:21:5 › returns 404 with an error body for a product that doesn't exist (90ms)
✓ 5 tests\new-in-1-62-to-1-64.spec.ts:20:5 › 1.62: apiResponse.timing() shows where the time went (104ms)
✓ 6 tests\read.spec.ts:32:5 › rejects a product without a title (118ms)
✓ 8 tests\read.spec.ts:40:7 › handles a 400 response (109ms)
✓ 9 tests\read.spec.ts:40:7 › handles a 401 response (107ms)
✓ 10 tests\read.spec.ts:40:7 › handles a 429 response (120ms)
✓ 7 tests\new-in-1-62-to-1-64.spec.ts:31:5 › 1.62: an AbortSignal cancels a request that takes too long (512ms)
✓ 11 tests\read.spec.ts:40:7 › handles a 500 response (107ms)
✓ 12 tests\read.spec.ts:40:7 › handles a 503 response (97ms)
12 passed (2.1s)The whole suite takes about two seconds. Download it as playwright-api-examples.zip, run npm install and npx playwright test, and it runs against the same API. Every endpoint it uses is free, with no account or key needed.
API calls inside UI tests
The same client is available in browser tests as page.request. It shares cookies with the page, so you can create test data or log in through the API, then check the result in the UI, which is much faster than clicking through forms to set up every test:
test("shows a product created through the API", async ({ page }) => {
// page.request shares cookies with the page, so a session set up here carries over to the browser.
const created = await page.request.post("https://api.testingapis.com/api/Products", {
data: { title: "Seeded", price: 1 },
});
// ...then open the page that lists products and check it appears.
});The endpoints these tests call are documented in our products API docs and status code API docs, with a Send button to try each request before you write a test for it.