Playwright API testing lets you call your application’s HTTP endpoints directly with APIRequestContext—either as a standalone API client or as a request client that shares authentication cookies with a browser context. Use it to test contracts, create deterministic test data, validate backend outcomes after UI actions, and clean up resources without driving every prerequisite through the browser.
The production-friendly pattern is API setup → UI action → backend assertion → API cleanup. The hard part is choosing the right request context, owning its data lifecycle, and asserting more than “the server returned 200.” This guide covers those decisions with TypeScript examples validated against a controlled local service.
Playwright API testing at a glance
| Need | Use | Why |
|---|---|---|
| Independent API test | Built-in request fixture |
Fresh, isolated APIRequestContext |
| Suite- or worker-level client | playwright.request.newContext() |
Explicit lifecycle and custom configuration |
| API login that authenticates the page | context.request |
Shares the browser context’s cookie jar |
| API call that must not alter the page session | Standalone newContext() |
Uses isolated cookie storage |
| Validate a UI action in the backend | UI assertion plus API GET |
Proves visible behavior and persisted state |
| Reusable create/delete lifecycle | Custom test fixture | Keeps setup, ownership, and cleanup together |
Playwright’s official API testing guide identifies three core uses: testing an API, preparing server-side state before a browser test, and validating server-side postconditions after browser actions. API calls should make a UI test faster and more deterministic—not erase the user behavior the test is supposed to prove.
What is APIRequestContext?
APIRequestContext is Playwright’s HTTP client. It sends GET, POST, PUT, PATCH, DELETE, and HEAD requests; fetch() handles an explicit method or a Playwright request object. Responses arrive as APIResponse objects with status, headers, body, JSON, and text accessors.
The most important design decision is not the HTTP verb. It is which cookie jar the request context owns.
Isolated request context
The built-in request fixture is isolated for each test. A context created with playwright.request.newContext() also has independent cookie storage. Use either when an API test should not read or mutate a browser session.
test('health endpoint is available', async ({ request }) => {
const response = await request.get('/health');
await expect(response).toBeOK();
});
Browser-associated request context
browserContext.request and page.request refer to the same request context associated with that browser context. The APIRequestContext reference says it populates requests from the browser context’s cookies and updates that cookie jar when a response contains Set-Cookie.
test('API login authenticates the page', async ({ context, page }) => {
const login = await context.request.post('/api/session');
await expect(login).toBeOK();
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
This coupling is useful only when intentional. If you need to inspect an endpoint with different credentials while the page stays signed in, create a separate context.

A reliable combined test gives each layer one job and cleans up the resource it created.
Install and configure Playwright for API tests
If the project does not already use Playwright Test, follow the Playwright installation guide. API-only tests do not open a browser, but the same runner, fixtures, projects, reporters, and trace workflow remain available.
Put non-secret defaults in playwright.config.ts. Inject secrets at runtime.
import { defineConfig } from '@playwright/test';
const apiToken = process.env.API_TOKEN;
if (!apiToken) throw new Error('API_TOKEN is required');
export default defineConfig({
testDir: './tests',
use: {
baseURL: process.env.API_BASE_URL ?? 'https://api.test.example.com',
extraHTTPHeaders: {
Accept: 'application/json',
Authorization: `Bearer ${apiToken}`,
},
trace: 'retain-on-failure',
},
});
The request fixture respects baseURL and extraHTTPHeaders. Keep environment selection explicit; a production hostname should never be the silent fallback for destructive test calls.
Do not put every header in global configuration
Centralize headers only when every test should send them. A multi-role or negative-authentication suite is clearer when credentials are local to a purpose-built context.
test('rejects an anonymous request', async ({ playwright }) => {
const anonymous = await playwright.request.newContext({
baseURL: process.env.API_BASE_URL,
});
try {
const response = await anonymous.get('/api/profile');
expect(response.status()).toBe(401);
} finally {
await anonymous.dispose();
}
});
Your first production-shaped Playwright API test
A useful API test proves creation, response contract, persisted state, and cleanup. It stores the generated identifier and uses finally so a failed assertion does not strand test data.
import { test, expect } from '@playwright/test';
type Task = { id: string; title: string; status: 'open' | 'complete' };
test('creates and persists a task', async ({ request }) => {
const created = await request.post('/api/tasks', {
data: { title: 'Validate checkout contract' },
});
await expect(created).toBeOK();
expect(created.status()).toBe(201);
expect(created.headers()['location']).toMatch(/^\/api\/tasks\/\d+$/);
const task = (await created.json()) as Task;
expect(task).toMatchObject({ title: 'Validate checkout contract', status: 'open' });
try {
const stored = await request.get(`/api/tasks/${task.id}`);
await expect(stored).toBeOK();
expect(await stored.json()).toEqual(task);
} finally {
const deleted = await request.delete(`/api/tasks/${task.id}`);
await expect(deleted).toBeOK();
}
});
This is more valuable than expect(response.ok()).toBeTruthy() alone. It verifies specific success semantics, one contract header, important body fields, and the backend read model.
Send JSON, forms, query parameters, and files
| Option | Encoding/use | Example |
|---|---|---|
data |
JSON for object values unless overridden | { data: { name: 'Asha' } } |
form |
application/x-www-form-urlencoded |
OAuth or legacy forms |
multipart |
multipart/form-data |
Fields plus streams or file-like values |
params |
URL query parameters | { params: { status: 'open' } } |
headers |
Per-request headers and redirects | Idempotency key or role auth |
timeout |
Response timeout in milliseconds | { timeout: 10_000 } |
const filtered = await request.get('/api/tasks', {
params: { status: 'open', limit: 20 },
});
const token = await request.post('/oauth/token', {
form: {
grant_type: 'client_credentials',
client_id: process.env.CLIENT_ID!,
client_secret: process.env.CLIENT_SECRET!,
},
});
const upload = await request.post('/api/imports', {
multipart: {
category: 'smoke',
file: {
name: 'tasks.csv',
mimeType: 'text/csv',
buffer: Buffer.from('title\nValidate checkout'),
},
},
});
Do not manually set a multipart boundary. Let Playwright construct it. For redirect-sensitive checks, set maxRedirects: 0 so the test can assert the original redirect.
Authentication patterns that scale
Bearer tokens and API keys
Read tokens from the CI secret store or developer environment, fail fast when a value is missing, and never print it. Use the least-privilege account. If tests need different roles, create separate named contexts instead of mutating one shared client.
const adminApi = await playwright.request.newContext({
baseURL: process.env.API_BASE_URL,
extraHTTPHeaders: {
Authorization: `Bearer ${process.env.ADMIN_API_TOKEN}`,
},
});
HTTP Basic authentication
Use httpCredentials rather than manually assembling a Basic header. The option can restrict credentials to an origin and control whether they are sent immediately or after an unauthorized challenge.
API login and storageState
An authenticated API request context can export storageState, and a browser context can start from that state. The official authentication guide warns that saved state can contain sensitive cookies and headers. Keep authentication files out of Git.
import { request } from '@playwright/test';
const authApi = await request.newContext({ baseURL: process.env.APP_URL });
try {
const login = await authApi.post('/api/login', {
data: {
email: process.env.E2E_USER,
password: process.env.E2E_PASSWORD,
},
});
await expect(login).toBeOK();
await authApi.storageState({ path: testInfo.outputPath('state.json') });
} finally {
await authApi.dispose();
}
For full environment, fixture, and project architecture, use the Playwright framework guide.
Assert the contract and the business outcome
expect(response).toBeOK() checks that the status is in the 200–299 range. It is a strong first gate, not a complete assertion strategy.
- Transport: exact status when the contract specifies one, plus relevant headers.
- Shape: required fields and types, not an enormous brittle snapshot.
- Semantics: returned values and business state are correct.
- Persistence: a follow-up read reflects the change.
- Side effects: important downstream behavior occurs once and only when expected.
const response = await request.get(`/api/orders/${orderId}`);
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toContain('application/json');
const order: unknown = await response.json();
expect(order).toEqual(expect.objectContaining({
id: orderId,
status: 'paid',
total: expect.any(Number),
}));
A TypeScript cast does not validate runtime JSON. If your organization publishes OpenAPI or JSON Schema contracts, validate the response with the approved contract tool. Keep detailed domain assertions in the test so a failure explains which behavior broke.
Negative tests should assert the error deliberately
Playwright returns an APIResponse for HTTP error statuses by default. That makes negative tests readable:
const response = await request.post('/api/tasks', {
data: { title: '' },
});
expect(response.status()).toBe(422);
expect(await response.json()).toMatchObject({
code: 'VALIDATION_ERROR',
fields: { title: 'required' },
});
failOnStatusCode: true is useful when any HTTP error should abort a helper, but it can make expected 4xx tests less expressive. Choose it per context or request.
Own setup and cleanup with fixtures
Hooks work for a small file-scoped suite. A fixture is better when the same lifecycle appears across files because it keeps creation and deletion in one reusable owner. Playwright’s fixture model is isolated, composable, and runs teardown after the test uses the value.
import { test as base, expect } from '@playwright/test';
type Task = { id: string; title: string };
type Fixtures = { task: Task };
export const test = base.extend<Fixtures>({
task: async ({ request }, use) => {
const response = await request.post('/api/tasks', {
data: { title: `e2e-${crypto.randomUUID()}` },
});
await expect(response).toBeOK();
const task = (await response.json()) as Task;
try {
await use(task);
} finally {
const deleted = await request.delete(`/api/tasks/${task.id}`);
await expect(deleted).toBeOK();
}
},
});
export { expect } from '@playwright/test';
Use unique identifiers for parallel tests, and make cleanup target the resource ID returned by setup. Do not delete “all test data” unless the environment and authorization model explicitly guarantee that scope. For wider ownership rules, see test data management.
Combine UI and API without hiding the user journey
Use API setup for prerequisites the scenario does not claim to test. Keep the behavior under test in the UI, assert the user-visible result, then inspect the backend if persistence matters.
test('user completes a task', async ({ context, page }) => {
const session = await context.request.post('/api/session');
await expect(session).toBeOK();
const created = await context.request.post('/api/tasks', {
data: { title: 'Approve release candidate' },
});
await expect(created).toBeOK();
const task = (await created.json()) as { id: string; title: string };
try {
await page.goto(`/tasks/${task.id}`);
await expect(page.getByRole('heading', { name: task.title })).toBeVisible();
await page.getByRole('button', { name: 'Complete task' }).click();
await expect(page.getByTestId('task-status')).toHaveText('complete');
const stored = await context.request.get(`/api/tasks/${task.id}`);
await expect(stored).toBeOK();
expect(await stored.json()).toMatchObject({ id: task.id, status: 'complete' });
} finally {
const deleted = await context.request.delete(`/api/tasks/${task.id}`);
await expect(deleted).toBeOK();
}
});
This proves four facts: the API creates a usable prerequisite, the UI exposes it, the user can complete it, and the backend persists the state. The visible assertion remains essential; a backend-only assertion can miss a stale or broken interface.
Use resilient locators for browser steps. The Playwright locators guide explains role, label, text, test-ID, filtering, and strictness choices.
Backend validation: choose the right boundary
Prefer the public or test-support API that represents the same business fact a client would consume. A direct database query can be justified for a data-layer contract, but it couples an end-to-end test to storage design and can bypass application consistency rules.
| Validation target | Best default | Risk |
|---|---|---|
| Public resource state | Public API GET |
Eventual consistency may require bounded polling |
| Internal workflow state | Approved test-support API | Do not expose it outside the test environment |
| Database mapping | Repository/data-layer integration test | Schema coupling in an E2E test |
| Message/event | Observable consumer outcome or test probe | Broker reads can assert implementation, not behavior |
If the system is eventually consistent, poll with a deadline and a meaningful final error. Do not add an arbitrary sleep. Put slow cross-service scenarios in the appropriate layer of your test strategy instead of turning every UI test into a distributed-system audit.
Timeouts, retries, redirects, and memory
Request retries are intentionally narrow
The API reference states that maxRetries currently retries only ECONNRESET; it does not retry HTTP status codes and defaults to zero. A test-runner retry is separate and reruns the test in a new worker after failure. Neither should conceal a deterministic 400, 409, 500, or contract mismatch.
Be especially careful retrying a non-idempotent POST. If the server completed the operation but the connection reset before the response arrived, a retry can create a duplicate. Use an idempotency key when the API supports one and assert the resulting identity.
Dispose contexts and large responses
Playwright retains response bodies in a request context so they remain available to body(), json(), or text(). The APIResponse reference notes that response.dispose() releases a body early; disposing a manually created context releases all its resources. This matters for large downloads and long-lived worker fixtures.
Debug Playwright API failures
- Log the method, sanitized URL, status, request correlation ID, and a redacted response excerpt.
- Remove tokens, cookies, personal data, and confidential fields before attaching diagnostics.
- Classify the failure: DNS/TLS/reset, timeout, HTTP status, parse error, shape mismatch, or business mismatch.
- Retain a trace on failure when browser steps are present so the request timeline can be connected to the UI action.
- Reproduce against the same environment and identity; never silently switch to production or a broader account.
A helper can centralize safe diagnostics, but it should not swallow the original status or response. Make errors easier to understand, not harder to inspect.
Security rules for API automation
- Use dedicated, least-privilege test identities and non-production data.
- Keep tokens, passwords, cookies, and saved authentication state out of source control.
- Allowlist base URLs and fail closed when the environment is missing or unexpected.
- Redact authorization headers and sensitive bodies from logs, reports, screenshots, and traces.
- Give cleanup permissions only for test-owned resources.
- Do not disable TLS validation globally. If
ignoreHTTPSErrorsis required, document and scope it. - Never let generated cleanup target an unverified collection, tenant, or production environment.
Where API tests belong in a Playwright project
playwright/
├── fixtures/
│ └── api.fixture.ts
├── clients/
│ ├── tasks.client.ts
│ └── users.client.ts
├── contracts/
│ └── task.ts
└── auth/
└── state.ts
tests/
├── api/
│ └── tasks.spec.ts
└── e2e/
└── complete-task.spec.ts
Keep request mechanics in small clients, lifecycle in fixtures, runtime contracts near the boundary, and business intent in tests. Avoid a “god” utility that hides every request, assertion, retry, and cleanup decision. The Playwright TypeScript guide and framework guide cover the wider structure.
Common Playwright API testing anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
Assert only ok() |
Misses wrong payloads and business state | Check status, contract, semantics, and persistence |
| Use UI for all setup | Slow and obscures the scenario | Create nonessential prerequisites through the API |
| Use API for the behavior under test | The UI journey is no longer proven | Keep the user action and visible assertion in the browser |
| Share one mutable resource | Parallel collisions and cleanup races | Generate unique per-test or per-worker resources |
| Leak a manual request context | Retained bodies and cookies consume resources | Dispose it in teardown or finally |
| Retry every failure | Duplicates writes and hides defects | Retry only understood transient modes |
| Log full headers and bodies | Secrets and user data enter artifacts | Sanitize an allowlisted diagnostic summary |
| Delete broad test data | Can remove another test’s data | Delete only captured, test-owned IDs |
Use AI assistance without outsourcing verification
An AI coding assistant can draft a typed client, convert a cURL example, identify missing negative cases, or suggest a fixture boundary. Give it a redacted contract and explicit constraints:
Using Playwright Test and TypeScript, draft an APIRequestContext client for
POST /api/tasks and GET/DELETE /api/tasks/{id}. Requirements:
- baseURL and bearer token come from environment variables
- fail fast when configuration is missing
- do not pretend a TypeScript cast validates JSON
- include exact status and business assertions
- capture the created ID and clean up only that resource in finally
- do not add retries to non-idempotent POST requests
- redact auth and payload data from diagnostics
Do not paste production credentials, customer data, raw authentication state, private traces, or an unapproved internal API specification into an AI service. Run generated code against a controlled environment, type-check it, execute it, and review the cleanup boundary.
Playwright API testing FAQ
Can Playwright test APIs without a browser?
Yes. The request fixture and playwright.request.newContext() send HTTP requests directly from the test process. A browser is needed only when the scenario includes UI behavior.
What is the difference between request and page.request?
The built-in request fixture is an isolated API request context for the test. page.request is associated with the page’s browser context and shares its cookie jar.
Does expect(response).toBeOK() check JSON?
No. It checks that the status is in the 200–299 range. Read and validate the body separately, then assert the business outcome.
Should API setup go in beforeAll?
Use beforeAll/afterAll for a simple file-owned resource that is safe to share serially. Prefer a custom fixture when the lifecycle must be reusable, isolated, or parameterized. Parallel workers should not mutate one shared resource.
Can API authentication log in the browser?
Yes. Use context.request when the login response sets cookies, or export storage state from an authenticated API context and initialize a browser context with it. Treat stored state as sensitive.
Does maxRetries retry HTTP 500 responses?
No. Playwright documents maxRetries as retrying only ECONNRESET network errors currently, not HTTP response codes. Test retries are a separate runner feature.
Should a combined UI+API test verify only the backend?
No. Assert the user-visible result in the browser and the persisted outcome through the API when both matter. A backend-only assertion can miss a stale or broken interface.
Build the API layer around ownership
Reliable Playwright API testing is less about sending requests and more about ownership: the right cookie jar, credentials, resource, assertions, and cleanup boundary.
Start with the isolated request fixture for direct API tests. Reach for context.request when cookie sharing is part of the scenario. Assert transport, shape, business state, and persistence. Combine API setup and cleanup with browser actions only where it makes the test faster and clearer. That strengthens the wider Playwright automation strategy instead of creating another hidden source of flaky state.
