Playwright tests can send HTTP requests directly, no browser window involved. Every test receives a request fixture, an APIRequestContext with methods for each HTTP verb:
import { test, expect } from '@playwright/test';
test('GET /api/monitors returns a list', async ({ request }) => {
const response = await request.get('/api/monitors');
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.monitors.length).toBeGreaterThan(0);
});This runs in the same runner as your UI tests, with the same test structure, config, parallelism, and HTML report. For teams already invested in Playwright, that is the main draw: one tool, one CI job, one set of conventions for both layers.
expect(response).toBeOK() is the idiomatic status check. It passes for any 2xx or 3xx status and, on failure, prints the response body in the error message, which saves a debugging round trip:
const response = await request.get('/api/monitors');
await expect(response).toBeOK();toBeOK accepts a range. When the test is about a specific status code, assert it exactly:
const response = await request.get('/api/monitors/does-not-exist');
expect(response.status()).toBe(404);For bodies, response.json() parses once and you assert with standard matchers. toMatchObject checks the fields you care about while ignoring the rest, which keeps tests stable when the API adds fields:
const response = await request.get('/api/monitors/mon_123');
const monitor = await response.json();
expect(monitor).toMatchObject({
name: 'Marketing site',
protocol: 'https',
paused: false,
});
expect(monitor.regions).toContain('europe');Avoid asserting deep equality on entire response bodies. Timestamps, generated ids, and new optional fields will fail the test without indicating a real regression. Pin down the fields that carry the behavior under test and leave the envelope alone. The assertions guide covers the matcher families in more detail.
Most real endpoints require authentication. Pass headers per request:
const response = await request.get('/api/monitors', {
headers: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
},
});For POST and PUT, the data option serializes an object to JSON and sets the content type for you:
const response = await request.post('/api/monitors', {
headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
data: {
name: 'Checkout API',
url: 'https://api.example.com/health',
},
});
expect(response.status()).toBe(201);Read tokens from environment variables, the same discipline as login credentials. A token pasted into a spec file will outlive its secrecy.
Repeating the host and the auth header in every call is noise. Both belong in playwright.config.ts:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: 'https://staging.example.com',
extraHTTPHeaders: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
Accept: 'application/json',
},
},
});With this in place, request.get('/api/monitors') resolves against the base URL and carries the header automatically. One caution: extraHTTPHeaders under a shared use block applies to browser page requests too. If that would leak an API token into frontend traffic, split API tests into their own project and scope the headers there:
projects: [
{
name: 'api',
testMatch: /.*\.api\.spec\.ts/,
use: {
baseURL: 'https://api.staging.example.com',
extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN}` },
},
},
],The fixture covers tests, but sometimes you need an API context outside one, in a global setup script for example, or a second context with different credentials inside a test. Create one explicitly:
import { request } from '@playwright/test';
const adminContext = await request.newContext({
baseURL: 'https://api.staging.example.com',
extraHTTPHeaders: { Authorization: `Bearer ${process.env.ADMIN_TOKEN}` },
});
const response = await adminContext.get('/api/audit-log');
await adminContext.dispose();Call dispose() when you are done, since the context holds sockets and pending responses. A standalone context is also the clean way to test permission boundaries: one context per role, each hitting the same endpoint, asserting who gets a 200 and who gets a 403.
The strongest reason to test APIs with Playwright is combining both layers in a single test. UI tests spend most of their time on setup: clicking through forms to create the data they want to verify. Do the setup over HTTP instead, and keep the browser for the part users actually see:
test('new monitor appears in the dashboard', async ({ page, request }) => {
const response = await request.post('/api/monitors', {
data: { name: 'Signup API', url: 'https://api.example.com/signup' },
});
await expect(response).toBeOK();
await page.goto('/monitors');
await expect(page.getByRole('link', { name: 'Signup API' })).toBeVisible();
});The API call takes tens of milliseconds where the equivalent form flow takes seconds, and it removes the creation UI as a source of unrelated failures. The reverse direction works too: act in the UI, then assert through the API that the backend recorded the right thing. Note that this pattern calls the real backend. When you want the browser to see responses without any server involved, that is network interception, a different tool for a different job.
The standalone request fixture has its own cookie storage, so it does not know about your UI login. When the API call should run as the logged-in user, go through context.request or page.request, which share cookies with the browser context:
test('session cookie authenticates API calls', async ({ page }) => {
await page.goto('/login');
// ... perform UI login ...
const response = await page.request.get('/api/me');
await expect(response).toBeOK();
expect((await response.json()).email).toBe('user@example.com');
});The sharing works in both directions: a Set-Cookie header on an API response through page.request lands in the browser context, and the page sees it on its next request. This is the natural fit for cookie-session apps, where there is no bearer token to put in a header in the first place.
APIs earn their keep in the failure cases, and HTTP tests reach them far more easily than a UI can. Send invalid payloads and assert both the status and the shape of the error body:
test('rejects a monitor without a URL', async ({ request }) => {
const response = await request.post('/api/monitors', {
data: { name: 'No URL' },
});
expect(response.status()).toBe(422);
const error = await response.json();
expect(error.errors[0].field).toBe('url');
});
test('rejects requests without a token', async ({ request }) => {
const response = await request.get('/api/monitors', {
headers: { Authorization: '' },
});
expect(response.status()).toBe(401);
});Cover the boundaries a frontend rarely triggers: missing required fields, wrong types, ids that belong to another account, expired tokens. A UI validates before submitting, so these server-side checks stay untested unless something exercises them directly.
Playwright handles the API testing that lives next to an end-to-end suite: seeding data, smoke-testing the endpoints the UI depends on, checking auth boundaries, verifying error contracts. If that is your scope, adding a second tool buys you configuration overhead and little else.
A dedicated API client earns its place at a different scale. Contract testing against OpenAPI schemas, load and performance testing, or a large API-only suite maintained by a backend team are better served by purpose-built tools, since Playwright offers no schema validation or request-rate tooling. The practical rule: while your API tests exist to support your product tests, keep them in Playwright, and revisit when the API suite becomes a project of its own.
The endpoints your tests cover in CI are the same ones that page you when they fail at night. Hyperping synthetic monitoring checks your API endpoints on a schedule from multiple regions, asserts on status and response content, and alerts you before your users notice.