QA How-To
Playwright APIRequestContext: Examples and Best Practices
Use Playwright APIRequestContext examples for CRUD, auth fixtures, polling, uploads, idempotency, cleanup, diagnostics, and reliable API test patterns.
20 min read | 1,941 words
TL;DR
Production APIRequestContext patterns need more than request syntax. Isolate actors, make data unique, clean up in teardown, poll only safe reads, prove idempotency, and report redacted response evidence.
Key Takeaways
- Model separate actors as fixture-owned request contexts so authentication cannot leak between roles.
- Keep a dependent CRUD lifecycle inside one independently runnable test with reliable cleanup.
- Use expect.poll around safe reads for asynchronous jobs, never around the mutation that starts them.
- Verify negative responses and the absence of forbidden state changes.
- Test retry behavior with idempotency keys and business postconditions instead of blind request repetition.
- Attach only redacted API evidence and preserve raw status and stack information in client helpers.
The most useful playwright APIRequestContext examples go beyond a single GET request. A production API suite needs isolated authentication, reusable fixtures, CRUD lifecycle tests, negative contracts, polling, uploads, idempotency checks, safe cleanup, and diagnostics that explain failures without exposing secrets.
This cookbook uses TypeScript and current @playwright/test APIs. Each pattern states what it proves and where it can mislead you. If you are new to the client and need its context and cookie model first, read how Playwright APIRequestContext works, then return here for implementation patterns.
TL;DR
| Need | Recommended pattern | Avoid |
|---|---|---|
| Ordinary API test | Built-in request fixture |
Creating a browser page |
| Separate user roles | One context or fixture per actor | Swapping shared auth implicitly |
| Eventual backend result | expect.poll() on a safe GET |
Fixed sleeps |
| Negative response | Assert exact status and error code | failOnStatusCode: true |
| File upload | multipart with buffer or stream |
Handwritten multipart boundaries |
| Test cleanup | try/finally or fixture teardown |
Depending on test success |
| Retry safety | Idempotency key plus state assertions | Blindly repeating mutations |
All examples assume API_BASE_URL points to a test environment unless the snippet creates its own local server. Keep tokens and credentials in environment variables.
1. Configure a Strong API Test Baseline
Central configuration removes repeated hostnames and headers while preserving per-request options. Use a reporter and trace policy suited to the wider suite, but remember that browser traces are not a substitute for explicit API diagnostics.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/api',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: process.env.API_BASE_URL ?? 'http://127.0.0.1:3000',
extraHTTPHeaders: {
Accept: 'application/json',
'X-Test-Suite': 'playwright-api',
},
},
});
Start each test from an explicit contract. The code below proves the endpoint is successful, returns JSON, and contains a supported version value. It does not compare the entire payload, so additive metadata will not create noise.
import { test, expect } from '@playwright/test';
test('returns service metadata', async ({ request }) => {
const response = await request.get('/api/meta');
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toContain('application/json');
const body = await response.json() as {
service: string;
apiVersion: string;
};
expect(body.service).toBe('orders');
expect(body.apiVersion).toMatch(/^v\d+$/);
});
Use exact assertions for contractual values and flexible assertions for intentionally variable values. Do not assert server timestamps, generated identifiers, or field order unless those details are actually part of the public API.
2. Run Self-Contained playwright APIRequestContext examples
This example starts an ephemeral Node HTTP server, creates a standalone request context, verifies a JSON response, and disposes everything. Save it as tests/api/local-api.spec.ts in a Playwright project and run npx playwright test tests/api/local-api.spec.ts.
import { createServer, type Server } from 'node:http';
import { request, test, expect } from '@playwright/test';
let server: Server;
let baseURL: string;
test.beforeAll(async () => {
server = createServer((req, res) => {
if (req.method === 'GET' && req.url === '/api/health') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ code: 'NOT_FOUND' }));
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('No TCP address');
baseURL = `http://127.0.0.1:${address.port}`;
});
test.afterAll(async () => {
await new Promise<void>((resolve, reject) => {
server.close(error => error ? reject(error) : resolve());
});
});
test('reads health from a local server', async () => {
const api = await request.newContext({ baseURL });
try {
const response = await api.get('/api/health');
await expect(response).toBeOK();
expect(await response.json()).toEqual({ status: 'ok' });
} finally {
await api.dispose();
}
});
The example uses a dynamic port, so parallel processes do not fight over a fixed port. In a real test environment, service orchestration belongs outside individual tests, but this pattern is useful for learning and for testing a local HTTP adapter without external dependencies.
3. Create Reusable Authenticated Actor Fixtures
Complex authorization tests become clearer when each actor has a named request context. A fixture owns construction and disposal, while the test expresses the permission matrix. Do not mutate a single context's Authorization header between calls because leaked identity can invalidate the result.
// fixtures/api.ts
import {
test as base,
request,
type APIRequestContext,
} from '@playwright/test';
type Actors = {
adminApi: APIRequestContext;
viewerApi: APIRequestContext;
};
async function actorContext(token: string | undefined) {
if (!token) throw new Error('Required API token is missing');
return request.newContext({
baseURL: process.env.API_BASE_URL,
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
});
}
export const test = base.extend<Actors>({
adminApi: async ({}, use) => {
const api = await actorContext(process.env.ADMIN_TOKEN);
await use(api);
await api.dispose();
},
viewerApi: async ({}, use) => {
const api = await actorContext(process.env.VIEWER_TOKEN);
await use(api);
await api.dispose();
},
});
export { expect } from '@playwright/test';
// tests/api/permissions.spec.ts
import { test, expect } from '../../fixtures/api';
test('viewer cannot delete a project', async ({ adminApi, viewerApi }) => {
const create = await adminApi.post('/api/projects', {
data: { name: `rbac-${Date.now()}` },
});
expect(create.status()).toBe(201);
const project = await create.json() as { id: string };
try {
const denied = await viewerApi.delete(`/api/projects/${project.id}`);
expect(denied.status()).toBe(403);
expect(await denied.json()).toMatchObject({ code: 'FORBIDDEN' });
const stillExists = await adminApi.get(`/api/projects/${project.id}`);
expect(stillExists.status()).toBe(200);
} finally {
await adminApi.delete(`/api/projects/${project.id}`);
}
});
The postcondition is crucial. A 403 response is insufficient if the server still performed the delete. Verify both response and state.
4. Test a Full CRUD Lifecycle Without Coupling Tests
A CRUD scenario can be one coherent test when each operation depends on the identifier created earlier. Splitting it into ordered tests creates shared state and makes a failure cascade. Keep unrelated validation cases separate, but keep one resource lifecycle atomic.
import { test, expect } from '@playwright/test';
test('creates, reads, updates, and deletes a project', async ({ request }) => {
const create = await request.post('/api/projects', {
data: { name: `api-${Date.now()}`, archived: false },
});
expect(create.status()).toBe(201);
const project = await create.json() as { id: string; name: string };
try {
const read = await request.get(`/api/projects/${project.id}`);
expect(read.status()).toBe(200);
expect(await read.json()).toMatchObject({
id: project.id,
archived: false,
});
const update = await request.patch(`/api/projects/${project.id}`, {
data: { archived: true },
});
expect(update.status()).toBe(200);
expect(await update.json()).toMatchObject({ archived: true });
} finally {
const remove = await request.delete(`/api/projects/${project.id}`);
expect([204, 404]).toContain(remove.status());
}
const missing = await request.get(`/api/projects/${project.id}`);
expect(missing.status()).toBe(404);
});
Allowing 404 during cleanup makes teardown idempotent if the resource was already removed, but only use that rule when 404 is a valid cleanup outcome. The final read proves deletion semantics. If the API uses soft deletion, assert the documented archived state instead.
Generate unique data per test worker or use server-issued IDs. Avoid hard-coded resource names such as test-project, which collide across parallel runs and old failed data.
5. Validate Query, Headers, Redirects, and Cache Behavior
API correctness includes protocol behavior around the JSON body. params safely constructs a query. maxRedirects: 0 lets you assert the redirect rather than the destination. Response headers can prove content type, caching, pagination, correlation, and deprecation contracts.
test('paginates active users', async ({ request }) => {
const response = await request.get('/api/users', {
params: {
status: 'active',
page: 2,
pageSize: 25,
},
});
expect(response.status()).toBe(200);
expect(response.headers()).toMatchObject({
'content-type': expect.stringContaining('application/json'),
});
const body = await response.json() as {
items: Array<{ id: string; status: string }>;
page: number;
pageSize: number;
};
expect(body.page).toBe(2);
expect(body.pageSize).toBe(25);
expect(body.items.every(user => user.status === 'active')).toBe(true);
});
Redirect test:
const response = await request.get('/legacy/account', { maxRedirects: 0 });
expect(response.status()).toBe(301);
expect(response.headers()['location']).toBe('/account');
For cache validation, save an ETag from the first response, send it in If-None-Match, and assert the documented 304 behavior. Do not assume every service uses ETags. For repeated headers such as set-cookie, use headersArray() when preserving separate values matters because headers() returns a normalized object.
Use head() when the contract promises metadata without a response body. A document service might expose content length, media type, and cache validators before a client downloads a large artifact.
test('reports document metadata through HEAD', async ({ request }) => {
const response = await request.head('/api/documents/manual.pdf');
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toBe('application/pdf');
expect(Number(response.headers()['content-length'])).toBeGreaterThan(0);
expect(await response.body()).toHaveLength(0);
});
Only assert an empty body if the service and HTTP behavior guarantee it. Some gateways can normalize headers, and compressed transfer behavior can make naïve size expectations misleading. Prefer contract fields that the client actually uses.
The general fetch() method is useful in data-driven protocol tests where the HTTP method is a parameter. Convenience methods remain clearer for ordinary cases.
const cases = [
{ method: 'GET', path: '/api/projects', expected: 200 },
{ method: 'OPTIONS', path: '/api/projects', expected: 204 },
];
for (const example of cases) {
const response = await request.fetch(example.path, {
method: example.method,
});
expect(response.status()).toBe(example.expected);
}
Keep the case table small and behaviorally coherent. Large loops produce one test with poor case-level reporting. Use Playwright parameterized test declarations when each case should have its own title, retry, timing, and failure result.
6. Upload Files and Submit Forms Correctly
Use form for URL-encoded fields and multipart for file uploads. Let Playwright construct content types and boundaries. The buffer pattern is portable and easy to keep synthetic.
test('uploads a CSV import', async ({ request }) => {
const csv = [
'email,role',
'ava@example.test,viewer',
].join('\n');
const response = await request.post('/api/imports', {
multipart: {
mode: 'validate-only',
file: {
name: 'users.csv',
mimeType: 'text/csv',
buffer: Buffer.from(csv),
},
},
});
expect(response.status()).toBe(202);
expect(await response.json()).toMatchObject({
state: 'queued',
fileName: 'users.csv',
});
});
Form example:
const response = await request.post('/oauth/token', {
form: {
grant_type: 'client_credentials',
client_id: process.env.CLIENT_ID ?? '',
client_secret: process.env.CLIENT_SECRET ?? '',
},
});
expect(response.status()).toBe(200);
Add negative upload cases for wrong media type, empty content, malformed records, size boundaries, and unauthorized access. Validate the service does not persist partial data after rejection. Do not include malware strings or sensitive source files unless the isolated security-test environment explicitly supports them.
7. Poll Eventual State Without Fixed Sleeps
Asynchronous APIs often return 202 Accepted and process work later. A fixed sleep either wastes time or flakes. expect.poll() repeatedly calls a safe observation until the assertion passes or its timeout expires.
test('waits for an import to complete', async ({ request }) => {
const start = await request.post('/api/imports', {
data: { source: 'nightly-test-fixture' },
});
expect(start.status()).toBe(202);
const job = await start.json() as { id: string };
await expect.poll(async () => {
const response = await request.get(`/api/imports/${job.id}`);
expect(response.status()).toBe(200);
const body = await response.json() as { state: string };
return body.state;
}, {
message: `import ${job.id} should complete`,
timeout: 30_000,
intervals: [250, 500, 1_000, 2_000],
}).toBe('completed');
});
Poll only idempotent reads. Repeating the POST inside the poll could create many jobs. If a terminal failed state appears, throwing a detailed error immediately is often better than waiting for the whole timeout. Keep the maximum duration aligned with the service contract, not with guesswork.
Polling proves eventual consistency while maintaining fast success. It is also useful for verifying that a browser action eventually updates a backend record, provided the API observation belongs to the same test identity and tenant.
8. Test Idempotency and Safe Retry Semantics
For payment, order, or message creation, a client retry must not create duplicate business effects. Send the same idempotency key with the same request twice and assert the service's documented response and resulting state.
import { randomUUID } from 'node:crypto';
test('does not duplicate an order for one idempotency key', async ({ request }) => {
const key = randomUUID();
const options = {
headers: { 'Idempotency-Key': key },
data: { sku: 'BOOK-1', quantity: 1 },
};
const first = await request.post('/api/orders', options);
expect(first.status()).toBe(201);
const firstOrder = await first.json() as { id: string };
const replay = await request.post('/api/orders', options);
expect([200, 201]).toContain(replay.status());
const replayedOrder = await replay.json() as { id: string };
expect(replayedOrder.id).toBe(firstOrder.id);
const list = await request.get('/api/orders', {
params: { idempotencyKey: key },
});
expect(list.status()).toBe(200);
const matches = await list.json() as Array<{ id: string }>;
expect(matches).toHaveLength(1);
});
The accepted replay status is an example policy and must match your API specification. Some services replay 201, some return 200, and some include a replay header. Never copy the assertion without confirming the contract.
Playwright's maxRetries is not an HTTP status retry engine. It currently retries a narrow network error class such as ECONNRESET. Business retry logic still needs idempotency, bounded attempts, and assertions. See testing API idempotency for failure injection and concurrent duplicate cases.
9. Combine Browser and API Assertions Across One Flow
Cross-layer tests are powerful when they verify one business outcome without duplicating all UI setup. Use the API to seed prerequisites, use the browser for the interaction under test, then query the API for durable state. Keep identities aligned. An isolated request fixture may use a token while the browser uses storage state.
test('UI approval persists in the API', async ({ page, request }) => {
const create = await request.post('/api/invoices', {
data: { customer: 'Northwind', amount: 125 },
});
expect(create.status()).toBe(201);
const invoice = await create.json() as { id: string };
try {
await page.goto(`/invoices/${invoice.id}`);
await page.getByRole('button', { name: 'Approve invoice' }).click();
await expect(page.getByRole('status')).toHaveText('Invoice approved');
await expect.poll(async () => {
const response = await request.get(`/api/invoices/${invoice.id}`);
const body = await response.json() as { status: string };
return body.status;
}).toBe('approved');
} finally {
await request.delete(`/api/test-invoices/${invoice.id}`);
}
});
Avoid asserting every layer in every test. Cross-layer coverage is most valuable for high-risk persistence and integration paths. Component or API tests can cover validation combinations more cheaply, while a smaller set of browser flows proves wiring and user behavior.
If the page action stalls, the Playwright actionability troubleshooting guide helps separate UI readiness from backend persistence.
10. Maintain playwright APIRequestContext examples and Diagnostics
Small resource clients reduce repeated paths and parsing without hiding response evidence. Inject APIRequestContext so fixtures control identity and lifecycle. Return APIResponse for tests that need protocol assertions, or return a validated domain object only when the helper owns that contract explicitly.
import type { APIRequestContext, APIResponse } from '@playwright/test';
export class ProjectsApi {
constructor(private readonly request: APIRequestContext) {}
create(name: string): Promise<APIResponse> {
return this.request.post('/api/projects', { data: { name } });
}
get(id: string): Promise<APIResponse> {
return this.request.get(`/api/projects/${encodeURIComponent(id)}`);
}
remove(id: string): Promise<APIResponse> {
return this.request.delete(`/api/projects/${encodeURIComponent(id)}`);
}
}
Attach safe response evidence when an assertion needs more context. Use testInfo.attach() with a redacted JSON object, not raw headers or an entire sensitive body.
test('project creation contract', async ({ request }, testInfo) => {
const response = await new ProjectsApi(request).create(`contract-${Date.now()}`);
const text = await response.text();
await testInfo.attach('create-project-response', {
body: JSON.stringify({ status: response.status(), body: text.slice(0, 1000) }),
contentType: 'application/json',
});
expect(response.status()).toBe(201);
});
In production, redact secrets and personal fields before attachment. Include method, safe path, status, elapsed time from your wrapper if measured accurately, and a correlation ID. Do not make helpers catch and convert every error into false; preserve stack and HTTP evidence.
Interview Questions and Answers
Q: How would you structure Playwright API tests for multiple roles?
I create one named APIRequestContext fixture per actor or credential boundary. Each fixture owns its headers and disposal. Tests then express the permission matrix clearly and verify both response and postcondition, which prevents identity leakage from a mutable shared client.
Q: How do you wait for a 202 job to finish?
I use expect.poll() around an idempotent GET that returns job state. The mutation happens once outside the poll. I define bounded intervals and a timeout based on the service contract, and I fail early if the job reaches a terminal failure state.
Q: How do you make cleanup reliable after an assertion fails?
I place cleanup in finally or in fixture teardown. The cleanup operation is idempotent where possible and scoped to a unique test resource. It tolerates partial setup without ignoring unexpected authorization or server failures.
Q: What should a negative API test assert?
It should assert the exact status, stable error code, safe message or field details, and absence of the forbidden state change. A 403 alone is not enough if the server applied the mutation. I read the resource afterward through a permitted actor when risk warrants it.
Q: Why avoid ordered CRUD tests?
They create shared state and cascading failures, so a failed create makes read, update, and delete misleading. I keep one dependent resource lifecycle in a single test with cleanup. Independent validation cases each create their own minimal state.
Q: How do you test multipart upload?
I pass fields through the multipart option and represent the file with a stream or a file-like object containing name, MIME type, and buffer. I let Playwright generate the content type and boundary. Then I assert acceptance plus persisted metadata or processing state.
Q: Does maxRetries retry 500 responses?
No. It covers a narrow network retry behavior, currently including ECONNRESET, not HTTP response codes. Retrying 500 or 503 requires explicit policy and safe request semantics, especially for mutations.
Q: How do you keep API failure diagnostics secure?
I attach status, safe path, correlation ID, and a redacted excerpt. I remove authorization headers, cookies, secrets, personal fields, and large binary bodies. The redaction happens before data reaches the report, not after publication.
Common Mistakes
- Reusing one mutable authenticated client for different roles.
- Splitting one dependent CRUD lifecycle into ordered tests.
- Polling by repeating the mutation instead of a safe status read.
- Accepting a negative status without verifying the forbidden change did not happen.
- Manually constructing multipart boundaries.
- Using fixed resource names that collide in parallel execution.
- Blindly retrying payments, orders, or other non-idempotent requests.
- Comparing complete dynamic payloads when only stable contract fields matter.
- Attaching raw headers and leaking tokens or cookies into reports.
- Swallowing errors inside a client wrapper and losing status and stack evidence.
Conclusion
Strong playwright APIRequestContext examples combine correct HTTP calls with deliberate identity, state, cleanup, retry, and diagnostic design. Begin with the request fixture, keep each actor isolated, assert protocol and business effects, and use polling only for safe observations.
Choose one pattern from this guide that your suite currently handles with shared state or fixed sleeps. Convert it into a fixture-owned, independently runnable test with a verified postcondition. That change usually improves speed, parallel safety, and failure clarity at the same time.
Interview Questions and Answers
How would you design multi-role API tests in Playwright?
I create a named request-context fixture for each actor and let the fixture own authentication and disposal. Tests then show the permission matrix directly. I verify both the response and resulting server state.
How do you avoid shared state in CRUD testing?
Each test creates a unique resource and owns its lifecycle. Dependent CRUD steps remain in one test, while unrelated validation cases create separate data. Cleanup runs even after an assertion failure.
Why should polling only contain a safe read?
The polling callback runs multiple times. Repeating a POST or another mutation can duplicate state and make the test destructive. I trigger work once, then poll an idempotent GET for the terminal condition.
How do you prove a 403 response is correct?
I assert the exact response and stable error code. Then I read the resource through an authorized actor to prove the forbidden change did not occur. Response-only checks can miss systems that mutate and still return an error.
How do you test idempotent retries?
I repeat the same request with the same idempotency key according to the API contract. I compare returned resource identity and query final state for duplicate effects. I do not assume a universal replay status because APIs differ.
What belongs in a reusable API client wrapper?
It can centralize resource paths, encoding, request construction, and approved validation. It should preserve status, body, and stack evidence rather than catch everything. Identity and lifecycle remain injected through `APIRequestContext`.
How do you test eventual consistency without slowing the suite?
I use bounded polling with short initial intervals and a service-based maximum. The test ends as soon as the expected state appears. A fixed long sleep makes every success pay the worst-case duration.
How do you secure API test diagnostics?
I redact before attaching or logging, not afterward. Reports include only safe path, status, correlation data, and a limited response excerpt. Secrets, cookies, personal fields, and binary content are excluded.
Frequently Asked Questions
How do I create reusable API fixtures in Playwright?
Extend Playwright Test with a typed fixture that creates an `APIRequestContext`, passes it to `use`, and disposes it afterward. Create separate named fixtures for identities that must remain isolated.
How do I test a full CRUD flow with APIRequestContext?
Create the resource, use its returned identifier for read and update, and delete it in `finally`. Keep the dependent lifecycle in one test so it does not rely on ordered shared state.
How do I poll an asynchronous API in Playwright?
Call the mutation once, then use `expect.poll()` around an idempotent status GET. Configure bounded intervals and timeout, and avoid repeating side effects inside the polling callback.
How do I test file upload with Playwright APIRequestContext?
Pass the file through `multipart` as a stream or as an object with name, MIME type, and buffer. Do not handwrite the multipart boundary or content type.
How can I test API idempotency?
Send the same mutation twice with one idempotency key and verify the documented replay response. Then query state and prove only one business resource or effect exists.
Should Playwright API tests run fully parallel?
They can when accounts, resource names, quotas, and cleanup are isolated. Parallel mode exposes shared-state assumptions, so partition data by worker or create unique resources per test.
What API details should I attach to a failed Playwright test?
Attach the method, safe path, status, correlation ID, and a short redacted body excerpt. Remove tokens, cookies, credentials, personal data, and large binary bodies before they enter the report.
Related Guides
- Playwright drag and drop: Examples and Best Practices
- Playwright mock date and time: Examples and Best Practices
- Playwright popup and new tab handling: Examples and Best Practices
- Playwright tag and grep filters: Examples and Best Practices
- Playwright aria snapshot: Examples and Best Practices
- Playwright clock API: Examples and Best Practices