Root cause #
APIRequestContext is the object behind both playwright.request and the per-test request fixture. It maintains its own cookie jar, headers, and connection pool. When you create it from the same browser context (via context.request), it shares that context’s cookies. A login performed through the API fixture writes a session cookie that the browser page then reuses — and vice versa. In one test that is convenient; across a suite it becomes nondeterministic. Test B passes only because test A happened to authenticate first, and the failure surfaces the moment the runner reorders, shards, or retries specs.
The async execution model amplifies this. Contexts hold live sockets. If a context is never disposed, its keep-alive connections and pending cookies survive into the next test through the worker’s reused process. On CI, where workers run many specs back to back, a single undisposed context can silently authorize requests that should have failed with a 401, masking real auth regressions.
Step-by-step fix #
1. Create a dedicated API context with its own storageState #
Do not reach for context.request. Build a standalone context from the playwright fixture so it owns its cookie jar.
// tests/fixtures/api.js
const { test: base } = require('@playwright/test');
exports.test = base.extend({
apiContext: async ({ playwright }, use) => {
// Fresh storageState ({}) means zero inherited cookies/auth — full isolation.
const ctx = await playwright.request.newContext({
baseURL: process.env.API_URL,
storageState: { cookies: [], origins: [] }
});
await use(ctx);
// Trade-off: disposing every test costs a few ms but prevents socket/cookie bleed.
await ctx.dispose();
}
});
2. Authenticate the API context separately from the browser #
Persist the API session to its own file so it never overwrites the UI session.
// global-setup.js
const { request } = require('@playwright/test');
module.exports = async () => {
const api = await request.newContext({ baseURL: process.env.API_URL });
await api.post('/login', { data: { user: 'svc', pass: process.env.PW } });
// Write to a distinct path so browser storageState and API storageState never collide.
await api.storageState({ path: 'storage/api-state.json' });
await api.dispose();
};
3. Wire isolated states into the project config #
Give the UI project and any API-driven setup distinct state files.
// playwright.config.js
module.exports = {
globalSetup: require.resolve('./global-setup.js'),
use: {
// Browser pages load only the UI session — the API session lives elsewhere.
storageState: 'storage/ui-state.json'
}
};
4. Dispose explicitly when you create contexts ad hoc #
Inside a test that spins up a one-off context for seeding, always dispose it.
test('seed then assert UI', async ({ playwright, page }) => {
const seed = await playwright.request.newContext({ baseURL: process.env.API_URL });
await seed.post('/orders', { data: { sku: 'A1' } });
// Dispose before the UI assertion so the seed context can't leak cookies into `page`.
await seed.dispose();
await page.goto('/orders');
await expect(page.getByText('A1')).toBeVisible();
});
Pitfalls #
- Using
context.requestfor API calls — it shares the browser cookie jar. Mitigation: create standalone contexts withplaywright.request.newContext(). - Forgetting
dispose()— leaks sockets and cookies into later specs. Mitigation: dispose in the fixture teardown so it always runs, even on failure. - One shared
storageStatefile for both UI and API — auth from one overwrites the other. Mitigation: use distinct paths per session type. - Relying on test order for auth — passes locally, fails when sharded. Mitigation: each test authenticates its own context or loads an explicit state file.
- Reusing a module-level context across files — state survives between specs. Mitigation: scope context creation to the fixture, not the module.
Reliability targets #
| Target | Goal |
|---|---|
| Order-dependent flakiness | < 0.5% of runs |
| Context disposal coverage | 100% of created contexts |
| Auth-bleed incidents per 1k runs | 0 |
| CI pass rate (sharded) | ≥ 99.5% |
Frequently Asked Questions #
Q: Does the request fixture share cookies with page by default?
A: Only if you derive it from the same browser context. The top-level request fixture and playwright.request.newContext() are independent unless you explicitly load the same storageState.
Q: When should I call dispose() versus letting Playwright clean up?
A: Playwright disposes fixture-scoped contexts automatically, but any context you create manually inside a test must be disposed by you to free its sockets and cookies before the next test runs.
Q: Can I reuse one API context across a whole file for speed? A: You can, but it reintroduces cross-test state. Prefer per-test isolation; if performance matters, share read-only contexts only and never ones that mutate auth state.