Playwright API Testing: Patterns That Actually Scale
Playwright API tests that pass locally still fail in CI? Here's the fixture architecture, per-worker isolation, and observability practices that fix that.

Playwright API tests fail in CI because of shared state, not because of APIRequestContext.
Most suites start the same way: a few request.get() calls, an auth setup in beforeAll, and a green local run. The problems show up once parallel workers enter the picture. Tests pass on retry with no code changes. The same test fails on Worker 1 and passes on Worker 3. An ICST 2024 industrial case study spanning five years of CI data found that flaky tests consume at least 2.5% of total productive developer time.
Playwright workers are isolated OS processes. They share no in-process state. But they still hit the same backend. Worker 1 and Worker 3 both target the same test account. One mutates session state that the other depends on. The failures look random because the instability comes from shared backend data, not from the test framework itself.
This guide covers fixture architecture, parallel-safe data strategies, and the observability practices we've seen work on teams running 200+ API tests across multiple CI workers.
Choosing the Right Request Surface
Before fixture architecture matters, the request surface has to match the problem. A mismatch won't break locally. It breaks in CI under parallel execution, when auth state from one test leaks into another.
Playwright exposes three ways to make API requests. The difference between them is cookie behavior and lifecycle ownership:
request fixture: test-scoped, auto-managed by Playwright, isolated cookie jar per test. Use this for pure API tests. It's the right default.
page.request / browserContext.request: these are the same object. page.request returns the APIRequestContext of the page's browser context, so cookies are shared at the context level, not per page. Use it when your API call needs the same session the browser already authenticated: it sends the context's cookies and updates them from Set-Cookie response headers. The danger: an API call that mutates session state (logout, token rotation) changes the cookies the browser is using mid-test.
playwright.request.newContext(): standalone, explicit lifecycle. You create it, you dispose it. Use this when you need full isolation from browser cookies, or when you need custom headers that shouldn't affect the browser context.
The most common mistake we see: teams start with playwright.request.newContext() in beforeAll because it feels explicit, then discover it creates lifecycle problems that the request fixture solves out of the box. Start with the request fixture. Upgrade to newContext() only when you need cross-test session sharing or independent header configuration.
Centralize config in playwright.config.ts. Setting baseURL and extraHTTPHeaders there eliminates per-test repetition and prevents drift between files:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: process.env.API_BASE_URL || 'https://api.staging.example.com',
extraHTTPHeaders: {
'Accept': 'application/json',
...(process.env.API_TOKEN ? { 'Authorization': `Bearer ${process.env.API_TOKEN}` } : {}),
},
},
});
Two gotchas that waste hours in CI:
First, toBeOK() only works with APIResponse objects returned by request.get(), request.post(), and similar methods. It does not work with the Response type returned by page.waitForResponse(). In TypeScript, this is a compile error. In JavaScript, it throws at runtime with a "toBeOK can be only used with APIResponse object" error. Easy to fix once you see it, but if you're mixing both response types in the same file, it's a recurring papercut.
Second, response.json() throws when the body isn't valid JSON. In CI, this happens regularly. Load balancers return HTML error pages on 502 or 504 responses. The stack trace points at the json() call, not at the upstream failure. Check content type before parsing:
const response = await apiContext.get('/api/resource');
const contentType = response.headers()['content-type'] || '';
if (!contentType.includes('application/json')) {
throw new Error(
`Expected JSON but got ${contentType}. Status: ${response.status()}. Body: ${await response.text()}`
);
}
const body = await response.json();
One more lifecycle detail: the request fixture handles disposal automatically. playwright.request.newContext() does not. If you skip await apiContext.dispose() in teardown, the context leaks connections until the worker process dies.
Building Fixture Architecture That Holds Up in CI
Knowing which request surface to use is only the starting point. The harder problem is lifecycle management and state isolation. Most suites that fail under parallelism are not suffering from the wrong request surface. The issue usually traces back to APIRequestContext setup living in the wrong place and scoped incorrectly.
Why Inline beforeAll Auth Breaks at Scale
This setup is common across Playwright codebases. It works locally, passes in a single-worker run, and starts failing in ways that are hard to trace once the suite grows:
// fragile-setup.spec.ts
let apiContext: APIRequestContext;
test.beforeAll(async ({ playwright }) => {
apiContext = await playwright.request.newContext({
baseURL: process.env.API_BASE_URL,
extraHTTPHeaders: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
},
});
});
test.afterAll(async () => {
await apiContext?.dispose();
});
The pattern breaks in CI in three ways:
- Token expiry. The context authenticates once in
beforeAlland holds that token for every test in the file. On long suites, tokens expire mid-run. Fixtures that re-authenticate per test (or per worker) avoid this. - Leaked cleanup. If
beforeAllthrows partway through, or a test in the file fails in a way that skips the rest,afterAllcleanup logic is easy to get wrong: it has to defensively handle every partial-setup state. Fixture teardown (the code afteruse()) runs even when a test fails, because Playwright manages its lifecycle internally. Neither survives a hard process kill (OOM, spot instance termination), which is why the data strategies later in this guide matter more than any hook. - Retry pollution. Playwright restarts the worker after a failure, so
beforeAlldoes run again on retry. What doesn't reset is the backend: the retry inherits whatever data the failed attempt created. That's not a hook problem, it's a data problem, and it applies to fixtures too. Setup logic has to be idempotent (upserts, check-before-create, per-attempt naming) or retries fail against leftovers from the first attempt.

The API Fixture Pattern
The right pattern moves APIRequestContext into a named fixture that extends the base test object:
// fixtures.ts
import { test as baseTest, APIRequestContext } from '@playwright/test';
type ApiFixtures = {
apiContext: APIRequestContext;
};
export const test = baseTest.extend<ApiFixtures>({
apiContext: async ({ playwright }, use) => {
const context = await playwright.request.newContext({
baseURL: process.env.API_BASE_URL,
extraHTTPHeaders: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
Accept: 'application/json',
},
});
await use(context);
await context.dispose();
},
});
export { expect } from '@playwright/test';
Test-scoped fixtures are the default choice for most suites because they fully isolate state between tests. The use() plus teardown pattern ensures dispose() runs even when a test fails, which afterAll does not guarantee.
Worker-scoped fixtures are only safe when every test in that worker shares the same auth identity and the session is treated as read-only. If any test mutates backend state, test scope is the safer default regardless of whether auth is shared.
Test-scoped fixtures re-run on every retry, so any backend data they create must be idempotent or uniquely namespaced per attempt. Upsert logic, check-before-create patterns, or worker-indexed identifiers all solve this. Without those safeguards, retries often fail against resources created in the first attempt rather than surfacing the original issue, which skews debugging toward the wrong failure.
The performance tradeoff matters. Creating an APIRequestContext itself is fast (milliseconds). But if your fixture authenticates against a real auth server, that round-trip costs whatever your auth endpoint costs. Say it's one second: across 200 tests at test scope, that's over three minutes of pure auth overhead per run. Worker-scoped fixtures amortize that cost across all tests in the worker.
The rule is simple: if every test in the worker uses the same identity and treats the session as read-only, scope to the worker. If any test mutates backend state, scope to the test. When in doubt, start with test scope and optimize later with profiling data.
Authenticated Fixtures with storageState
For suites where UI and API tests share the same session model, a setup project generates browser auth state once and storageState loads it into the fixture:
// fixtures.ts
import { test as baseTest, APIRequestContext } from '@playwright/test';
export const test = baseTest.extend<{ apiContext: APIRequestContext }>({
apiContext: async ({ playwright }, use) => {
const context = await playwright.request.newContext({
baseURL: process.env.API_BASE_URL,
storageState: 'playwright/.auth/user.json',
});
await use(context);
await context.dispose();
},
});
Cookie-backed sessions load cleanly from storageState. For apps that authenticate API calls through Authorization headers, storageState alone is insufficient. Extract the token during setup and pass it via extraHTTPHeaders. The complete Playwright authentication guide covers per-worker state using parallelIndex for this pattern.
Two caveats apply regardless of auth mechanism. storageState files go stale and should never be committed or cached across CI runs: generate them fresh in a setup project on every run. Across retries, storageState reuse requires idempotent auth setup, otherwise the retry authenticates against a modified session state.
With fixture-managed lifecycles in place, the next step is using API and UI layers together.
Hybrid API + UI Testing: The Real Payoff
An endpoint can return 200 while the UI renders nothing. A UI action can look successful while the backend state never changed. Pure API tests and pure UI tests both miss this. Hybrid tests catch it because each layer validates what the other can't.
Three patterns cover most hybrid scenarios, ranked by complexity and maintenance cost:
Pattern 1: Seed via API, assert in UI. The right default for most hybrid scenarios: an API call sets up backend state and a UI test validates that the state renders correctly. It is also the easiest of the three patterns to maintain over time.
Pattern 2: Act in UI, verify via API. A UI interaction triggers an action, then an API call confirms the backend state actually changed. This catches the gap between what the UI displays and what the backend actually recorded.
test('submitting order updates backend state', async ({ page, apiContext }) => {
await page.goto('/orders/new');
await page.getByLabel('Product').selectOption('widget-a');
await page.getByRole('button', { name: 'Submit Order' }).click();
await expect(page.getByText('Order confirmed')).toBeVisible();
const response = await apiContext.get('/api/orders/latest');
expect(response.ok()).toBeTruthy();
const order = await response.json();
expect(order.status).toBe('confirmed');
expect(order.product).toBe('widget-a');
});
The trap with Pattern 2 is eventual consistency. The UI shows "Order confirmed" but the API might not reflect the change for a few hundred milliseconds. Don't add a hard waitForTimeout(). Instead, use expect.poll() to retry the API check:
await expect.poll(async () => {
const response = await apiContext.get('/api/orders/latest');
const order = await response.json();
return order.status;
}, { timeout: 5000 }).toBe('confirmed');
Pattern 3: API to UI to API loop. Reserved for critical user journeys where full round-trip verification is justified. Seed data via API, validate it renders in the UI, perform a UI action, then verify the backend state changed. The maintenance cost is high. Use it for checkout flows, payment processing, or onboarding. Not for CRUD operations.
The apiContext fixture and the browser context do not share auth automatically. This is where most hybrid tests break silently: the browser authenticates fine, but the API call returns 401 mid-test because nobody passed the session to both contexts.
For cookie-based auth: load the same storageState into both the browser context and the API fixture. For header-based auth: extract the token during setup and pass it via extraHTTPHeaders in the API fixture. Don't assume that authenticating the browser magically authenticates API calls.
Once hybrid tests work with consistent auth, the next failure mode is data collision across parallel workers.
Parallel-Safe API Testing in CI
Hybrid tests expose integration failures. Parallel CI runs expose weaknesses in data architecture. The previous challenge was auth coordination across API and UI contexts. Once those tests execute across multiple workers simultaneously, the pressure shifts to data collision, isolation strategy, and observability.
Why Parallel Runs Break API Tests

Playwright workers isolate browser state but still operate against the same backend. Two workers targeting the same test account both assume exclusive ownership of that data. One changes a tenant-level setting the other relies on. Neither worker has visibility into the other's activity. The resulting failures appear random because the instability originates in shared backend state rather than in the test logic.
Per-worker data scoping reduces that risk. testInfo.workerIndex and testInfo.parallelIndex solve different problems, and using the wrong one creates avoidable collisions.
-
testInfo.workerIndexis a monotonically increasing ID starting at 1 that's unique across the entire run. Every new worker process (including restarts after failure) gets a new index. Use it for identifiers that must never collide, like dynamically created test resources. -
testInfo.parallelIndexranges from0toworkers - 1and stays stable across worker restarts. If Worker 2 crashes and Playwright spawns a replacement, the new worker keepsparallelIndex: 2but gets a freshworkerIndex. Use it when each parallel slot maps to a fixed pool of pre-provisioned resources. The pool must be at least as large as the configuredworkerscount. Ifworkersexceeds the pool size, multiple slots collide on the same resource and the isolation breaks.
const email = `test-user-${testInfo.workerIndex}@example.com`;
const accounts = ['admin@example.com', 'editor@example.com', 'viewer@example.com'];
// Pool size must be >= workers setting to avoid collisions
if (testInfo.parallelIndex >= accounts.length) {
throw new Error(`parallelIndex ${testInfo.parallelIndex} exceeds account pool size ${accounts.length}. Add more accounts or reduce workers.`);
}
const account = accounts[testInfo.parallelIndex];
Test Data Isolation Strategy
Per-worker scoping solves identity collision. Data lifecycle strategy determines how the remaining failures are contained:
-
Tests creating backend data should clean it up even after failure. Teardown belongs inside
try/finallyblocks within fixtures rather than looseafterEachhooks that may never execute. -
Ephemeral data strategies are more reliable than teardown alone. TTL-based records, tenant scoping, and namespaced resources prevent partial state from leaking into later runs.
-
Stable reference data belongs in
globalSetupand should remain read-only throughout the suite. -
Retries require idempotent setup logic. Upsert patterns, deterministic naming, or check-before-create flows prevent retries from failing against resources already created during earlier attempts.
apiContext: async ({ playwright }, use) => {
const context = await playwright.request.newContext({
baseURL: process.env.API_BASE_URL,
});
const testData = await createTestData(context);
try {
await use(context);
} finally {
await cleanupTestData(context, testData.id);
await context.dispose();
}
},
Observability: What to Do When an API Test Fails in CI
UI test failures give you screenshots, traces, and DOM snapshots. API test failures give you a status code and a timeout. That gap in debugging context is why API failures take longer to investigate, even though they're usually simpler problems.
Close the gap with testInfo.attach(). Attach the request URL, sanitized headers, status code, and response body to every failed API test. This turns "test timed out" into "POST /api/orders returned 502 with body: <html>Bad Gateway</html>." That's the difference between a 5-minute fix and a 30-minute investigation.
test('creates order', async ({ apiContext }, testInfo) => {
const response = await apiContext.post('/api/orders', {
data: { product: 'widget-a', quantity: 1 },
});
if (!response.ok()) {
await testInfo.attach('api-failure', {
body: JSON.stringify({
url: response.url(),
status: response.status(),
body: await response.text(),
}, null, 2),
contentType: 'application/json',
});
}
expect(response.ok()).toBeTruthy();
});
For suites with many API calls, wrap this pattern in a helper that attaches context automatically on non-2xx responses. That way you get debugging data without cluttering every test.
Per-run artifacts tell you what failed. They don't tell you why the failure only appears on Worker 3, after retries, on the feature/checkout branch. Those patterns need cross-run visibility. Currents surfaces those patterns across workers, branches, and retries in one place. That's what turns a repeated 401 into a diagnosable data collision rather than an unexplained flake. Teams dealing with persistent timeout failures should also review Playwright's timeout debugging guidance for CI-specific patterns.
Network Interception: When to Mock, When to Hit the Real Backend
Mocking and real API validation solve different problems. Mixing them up is one of the fastest ways to build a suite that passes every run and catches zero regressions.
The rule: test what you control, mock what you don't. Playwright's best practices say this directly. Your APIRequestContext should hit real endpoints. page.route() and browserContext.route() should intercept third-party dependencies you can't control: analytics, payment processors, CDNs.
The failure mode we see most often: a team mocks their own API to make a flaky test pass. The test goes green. Six months later, a backend field gets renamed and nothing catches it because the mock still returns the old shape. If the test is meant to validate your backend, it has to talk to your backend.
Route handler precedence matters in hybrid setups. When both page.route() and browserContext.route() match the same URL, the page-level handler wins. Register broad mocks at the context level (block all analytics), then override specific routes per test:
// Global mock at context level: stub analytics endpoint
await browserContext.route('**/analytics/**', route =>
route.fulfill({ status: 200, body: '{}' })
);
// Test-specific override: simulate a 500 from a third-party service
await page.route('**/analytics/track', async route => {
await route.fulfill({
status: 500,
body: JSON.stringify({ error: 'upstream failure' }),
});
});
For first-party endpoints, use interception only when you're intentionally testing failure handling or edge cases (simulating a 503, testing retry logic). Not when the goal is to validate real backend behavior. The Playwright network mocking playbook goes deeper on how to apply this without accumulating mock debt.
Schema Validation: Catching Contract Drift Before It Breaks the UI
Most API tests check a status code and assert one or two fields. That works until a backend team renames a property, adds a required field, or changes a type from string to number. The test still passes. The frontend breaks in production.
This is contract drift. The API shape changed, but nothing in the test suite noticed because no test validated the shape. Adding schema validation doesn't mean turning every test into a contract test. It means being intentional about where you check structure vs. where you check behavior.
Start with toMatchObject(). It's built into Playwright, requires no dependencies, and catches the most common drift: missing fields, wrong types, unexpected nulls. It does partial matching, so it won't break when the backend adds new optional fields.
// This misses structural drift. The field could be a number and this still passes.
expect(body.id).toBeDefined();
// This catches type changes and missing fields.
expect(body).toMatchObject({
id: expect.any(String),
status: expect.any(String),
createdAt: expect.any(String),
});
Upgrade to Zod when your API serves multiple consumers. If the frontend, mobile app, and a partner integration all depend on the same endpoint, toMatchObject() isn't strict enough. Zod's safeParse validates the full shape and produces CI-friendly error output that tells you exactly which field broke:
import { z } from 'zod';
const OrderSchema = z.object({
id: z.uuid(),
status: z.enum(['pending', 'confirmed', 'shipped']),
createdAt: z.iso.datetime(),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().int().positive(),
})),
});
test('order response matches contract', async ({ apiContext }) => {
const response = await apiContext.get('/api/orders/latest');
const body = await response.json();
const result = OrderSchema.safeParse(body);
if (!result.success) {
throw new Error(`Schema validation failed:\n${z.prettifyError(result.error)}`);
}
});
The example uses Zod 4 idioms: z.uuid() and z.iso.datetime() replaced the deprecated z.string().uuid() and z.string().datetime(), and z.prettifyError() turns the error into a readable string. On Zod 3, use JSON.stringify(result.error.format(), null, 2) instead; interpolating result.error.format() directly prints [object Object].
Don't validate schema in every test. Dedicate a small set of contract tests that validate response shapes against critical endpoints. Your feature tests should assert behavior (did the order get created?). Your contract tests should assert shape (does the response still match what the frontend expects?). Mixing both concerns in every test makes the suite brittle and hard to maintain.
If you have an OpenAPI spec, consider generating Zod schemas from it with tools like openapi-zod-client. That keeps test schemas in sync with the API definition automatically. (Don't confuse it with zod-openapi, which goes the other direction: it generates OpenAPI docs from Zod schemas.)
CI Configuration That Reflects the Architecture
None of this holds up if API and UI tests share a single Playwright project with identical settings. API tests have different timeout profiles, different failure modes, and different retry economics. Separate projects let you configure each independently. Playwright's CI documentation supports this directly.
Three configuration decisions matter specifically for API projects:
-
Timeouts. Set per-request timeouts on the API context:
playwright.request.newContext({ timeout: 15000 }). Don't confuse this withactionTimeout, which only applies to UI actions likeclick()andfill().actionTimeouthas no effect on API requests, regardless of whether you use therequestfixture orpage.request. -
Retries. Configure them independently. A flaky auth endpoint may justify one retry. A fragile UI flow may need two. Sharing the same retry count adds noise: you either over-retry API tests (wasting time) or under-retry UI tests (missing real flakes).
-
Failure output. API failures in CI give you a status code and a timeout. That's not enough. Use
testInfo.attach()to capture request URLs, sanitized headers, status codes, and response bodies. This is your equivalent of screenshots and traces for API tests.
Use project.dependencies only when the API project creates state that the UI project needs. If they're independent, run them in parallel. Linking unrelated projects slows feedback and makes it harder to tell whether a failure came from setup or from the test.
API test results belong in the same reporting view as UI results. Routing them to separate dashboards means debugging across disconnected systems, which adds time to every incident.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'api-setup',
testDir: './tests/api',
use: {
baseURL: process.env.API_BASE_URL,
extraHTTPHeaders: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
},
},
// Per-test timeout for the API project
// Set per-request timeout in the fixture via playwright.request.newContext({ timeout })
timeout: 15000,
retries: 1,
},
{
name: 'ui',
testDir: './tests/ui',
dependencies: ['api-setup'],
use: {
baseURL: process.env.BASE_URL,
actionTimeout: 10000,
},
retries: 2,
},
],
reporter: [['html'], ['junit', { outputFile: 'results.xml' }]],
});
Wrapping Up
The pattern behind every fix in this guide is the same: move lifecycle management into fixtures, scope data to the worker, and make failures visible without manual artifact hunting.
If you take one thing from this: stop using beforeAll for API context setup. Move it into a fixture. That single change fixes token expiry, leaked contexts, and retry pollution in one step.
API failures deserve the same cross-run visibility you expect from UI failures. Currents provides that without custom reporting infrastructure, surfacing failure patterns across workers, branches, and retries in one place.
Join hundreds of teams using Currents.
Trademarks and logos mentioned in this text belong to their respective owners.



