Test automation
Playwright API testing: a practical guide with TypeScript examples
Playwright API testing in practice: when API tests beat UI tests, the request fixture, a create to delete example with cleanup, faster UI setup and token auth.
Playwright API testing means sending HTTP requests from Playwright Test, without a browser, and asserting on the status code, headers and JSON that come back. The request fixture gives each test its own APIRequestContext, await expect(response).toBeOK() checks the status, and your API tests share one runner, one config, one report and one CI job with your UI tests.
This guide covers when API tests are the better choice, how the pieces fit together, a multistep example that creates, reads, updates and deletes a record and cleans up after itself, how API calls make UI tests faster, authentication with a token from an environment variable, and what Playwright is not for. The examples are TypeScript, checked with the TypeScript compiler against @playwright/test 1.64.1, the latest release on npm as of October 18, 2026.
When API tests beat UI tests
The Playwright API testing guide names three uses: testing your server API, preparing server state before a test visits the web app, and checking server state after a test did something in the browser. An API test skips the browser entirely: no page to load, nothing to render, no element to wait for. That makes it the better tool wherever the screen adds nothing to the check.
| What you check | Better as | Why |
|---|---|---|
| Prices, taxes, discounts | API test | Many cases, no screen needed |
| Validation and error responses | API test | Exact status code and error body |
| Permissions for each role | API test | Calls the endpoint as an attacker would |
| Response shape other clients use | API test | Mobile apps and partners read the JSON |
| A user signing up or checking out | UI test | Proves screens and API work together |
| Layout, browsers, accessibility | UI test | Only a browser shows them |
A healthy suite has both: a few UI tests for the critical flows, and many API tests underneath them for the rules. Our regression testing checklist shows where each kind fits before a release.
How Playwright API testing works
The request fixture and APIRequestContext
APIRequestContext is the class that sends requests, with get(), post(), put(), patch(), delete(), head() and fetch(). In Playwright Test you rarely create one yourself. The fixtures reference describes the request fixture as an “isolated APIRequestContext instance for each test”, and the API testing guide notes that it respects baseURL and extraHTTPHeaders from your config. A separate project keeps the API tests apart from the browser tests:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'api',
testDir: './tests/api',
use: {
// Keep the trailing slash: request paths are resolved against it
baseURL: process.env.API_BASE_URL ?? 'http://localhost:3000/api/',
extraHTTPHeaders: { Accept: 'application/json' },
},
},
],
});
A first test, here against a product list:
// tests/api/products.spec.ts
import { test, expect } from '@playwright/test';
test('lists the products in a category', async ({ request }) => {
const response = await request.get('products', { params: { category: 'books' } });
await expect(response).toBeOK();
expect(response.headers()['content-type']).toContain('application/json');
const body = await response.json();
expect(body.items.length).toBeGreaterThan(0);
for (const item of body.items) {
expect(item).toMatchObject({ category: 'books', price: expect.any(Number) });
}
});
Per the APIRequestContext reference, params becomes the query string, and an object passed as data is sent as JSON with a content-type of application/json unless you set another one.
Note the path without a leading slash. The APIRequest reference says the path is combined with baseURL by the URL() constructor. With http://localhost:3000/api/, the path products resolves to /api/products, but /products replaces the whole path and calls http://localhost:3000/products. Without the trailing slash on the base, products lands on /products as well. Keep the slash on the base and leave it off the paths.
Your own context with request.newContext()
Behind the scenes, the request fixture calls apiRequest.newContext(), and the API testing guide suggests calling it yourself when you want more control. A custom fixture is a clean way to do that. This one gives each test a context with the base URL and an Authorization header built from an environment variable:
// tests/api/fixtures.ts
import { test as base, expect, type APIRequestContext } from '@playwright/test';
export const test = base.extend<{ api: APIRequestContext }>({
api: async ({ playwright }, use) => {
const token = process.env.API_TOKEN;
// A missing CI secret arrives as an empty string: fail loudly, not with a 401 later
if (!token) throw new Error('API_TOKEN is not set');
const api = await playwright.request.newContext({
baseURL: process.env.API_BASE_URL ?? 'http://localhost:3000/api/',
extraHTTPHeaders: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
},
});
await use(api);
await api.dispose();
},
});
export { expect };
extraHTTPHeaders adds the headers to every request the context sends, and dispose() frees the responses the context keeps in memory. Tests import test and expect from this file instead of @playwright/test and receive an api fixture.
Checking status codes and JSON
The toBeOK() assertion passes when the status is between 200 and 299. Use it where any success will do, and an exact expect(response.status()).toBe(201) where your API promises a specific code. response.json() throws when the body is not valid JSON, which is a useful check on its own. For the body, toMatchObject() lets you check just a subset of the properties, and expect.any(String) matches any value of that type, which suits generated IDs and timestamps. When other clients depend on the full response shape, validate it against a schema as well.
A multistep example: create, read, update and delete
A journey test follows one record through its life. Each step feeds the next, and the test ends by checking that the record is really gone:
// tests/api/projects.spec.ts
import { test, expect } from './fixtures';
test('a project can be created, read, renamed and deleted', async ({ api }) => {
const name = `api-test ${Date.now()}`;
let id: string | undefined;
try {
const created = await api.post('projects', { data: { name } });
expect(created.status()).toBe(201);
const project = await created.json();
expect(project).toMatchObject({ id: expect.any(String), name });
id = project.id;
const read = await api.get(`projects/${id}`);
await expect(read).toBeOK();
expect(await read.json()).toMatchObject({ id, name });
const renamed = await api.patch(`projects/${id}`, { data: { name: `${name} v2` } });
await expect(renamed).toBeOK();
expect(await renamed.json()).toMatchObject({ id, name: `${name} v2` });
const deleted = await api.delete(`projects/${id}`);
expect(deleted.status()).toBe(204);
expect((await api.get(`projects/${id}`)).status()).toBe(404);
id = undefined;
} finally {
// Runs even when an assertion above failed, so no test data is left behind
if (id) await api.delete(`projects/${id}`);
}
});
Three habits make this kind of test reliable:
- Unique data for every run. Playwright runs test files in parallel by default (parallelism guide), so two tests that share one record will collide. A timestamp in the name keeps them apart.
- Cleanup that survives a failure. The
finallyblock deletes the record when an assertion fails halfway, so a red run does not leave data behind for the next one. - Negative cases next to the happy path. A missing required field should return a status in the 400s with an error body that names the field. A token for a role without access should be refused when it tries to rename or delete. For that, create a second context with that role’s token.
Set up state for UI tests through the API
Clicking through a login and a form to prepare a UI test is slow, and it adds failure points that have nothing to do with what the test checks. The Playwright authentication guide suggests logging in through the API when your app supports it and that is “easier/faster than interacting with the app UI”, then saving the session for the browser tests:
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
setup('log in through the API', async ({ request }) => {
const response = await request.post('/api/login', {
data: {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD,
},
});
await expect(response).toBeOK();
// Saves the session cookie from the login response for the browser tests
await request.storageState({ path: 'playwright/.auth/user.json' });
});
Then run this file in a setup project and give the browser project storageState: 'playwright/.auth/user.json' and dependencies: ['setup'], as the guide shows, so every browser test starts logged in. The guide warns that this file can contain cookies that let someone impersonate the test account, and recommends adding playwright/.auth to .gitignore.
For test data, use page.request: the API testing guide explains that it sends the cookies of the page’s browser context and stores the cookies a response sets, so whatever it creates belongs to the logged in user. A fixture creates the data before the test and removes it after:
// tests/ui/dashboard.spec.ts
import { test as base, expect } from '@playwright/test';
// Creates a project through the API before the test and deletes it after the test
const test = base.extend<{ project: { id: string; name: string } }>({
project: async ({ page }, use) => {
// page.request sends the browser's cookies, so the logged in user owns the project
const created = await page.request.post('/api/projects', {
data: { name: `ui-test ${Date.now()}` },
});
await expect(created).toBeOK();
const project = await created.json();
await use(project);
await page.request.delete(`/api/projects/${project.id}`);
},
});
test('a new project shows on the dashboard', async ({ page, project }) => {
await page.goto('/dashboard');
await expect(page.getByRole('link', { name: project.name })).toBeVisible();
});
The fixtures guide says test fixtures are torn down after each test, so the delete runs whether the test passed or failed. The browser only does the part the test is about.
Auth with a token from an environment variable
The api fixture above reads its token from API_TOKEN, never from the code. Three rules keep that safe:
- Keep the token out of the repository. Locally, set it in your shell. In CI, store it in the secret store and pass it to the test step. In GitHub Actions that is
API_TOKEN: ${{ secrets.API_TOKEN }}underenv:(see using secrets in GitHub Actions). - Fail fast when it is missing. GitHub documents that a secret that was never set evaluates to an empty string. The fixture checks for that before any request goes out, so a missing secret fails with a clear message instead of a page of 401 errors.
- One test account per role, with the least access it needs. Permission tests then compare roles directly: the same call, sent through a context for each role, must succeed for one and be refused for the other.
What Playwright is not for: load testing
Playwright Test runs each test to a pass or a fail, in a small pool of worker processes: by default, half the number of logical CPU cores (TestConfig reference). A load test needs the opposite: many concurrent virtual users, a ramp up profile, and response times and error rates measured under that load. Dedicated tools are built for that. Grafana k6, for example, describes itself as a performance testing tool designed for heavy load tests “such as spike, stress, or soak tests”. So do not raise workers to simulate traffic, and do not read the response times of a functional API suite as a performance result. Load testing is not part of our standard service either; ask, and you get a straight answer on whether we can help.
How we do it
In our service, API tests are part of the same work as the web and mobile tests: Playwright and TypeScript code in your repository, for single endpoints and multistep journeys, running in the same pipeline as the web tests on every pull request (our guide to running Playwright in GitHub Actions shows the setup). Claude agents write and maintain them, and a QA engineer reviews every change before it is merged and every failure before it becomes a bug report or a test fix. Our open-source sample project shows the approach on a demo shop, with endpoint tests and API journeys side by side and responses checked against schemas.
Sources
- API testing, Authentication, Fixtures and Parallelism, Playwright documentation
APIRequestContext,APIRequest,APIResponseAssertions,Fixtures,GenericAssertionsandTestConfig, Playwright API reference- Latest release of the test package, npm registry
- Using secrets in GitHub Actions, GitHub Docs
- Grafana k6 documentation, Grafana Labs
Researched and drafted with Claude, Anthropic's AI model, and checked against the sources it links. Spotted an error? Tell us and we will correct it.