Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Comprehensive Playwright testing guide covering E2E, component, API, visual, accessibility, and security tests.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
advanced/authentication.md
1# Authentication Testing23## Table of Contents451. [Quick Reference](#quick-reference)62. [Patterns](#patterns)73. [Decision Guide](#decision-guide)84. [Anti-Patterns](#anti-patterns)95. [Troubleshooting](#troubleshooting)106. [Related](#related)1112> **When to use**: Apps with login, session management, or protected routes. Authentication is the most common source of slow test suites.1314## Quick Reference1516```typescript17// Storage state reuse — the #1 pattern for fast auth18await page.goto("/login");19await page.getByLabel("Username").fill("[email protected]");20await page.getByLabel("Password").fill("secretPass123");21await page.getByRole("button", { name: "Log in" }).click();22await page.context().storageState({ path: ".auth/session.json" });2324// Reuse in config — every test starts authenticated25{26use: {27storageState: ".auth/session.json"28}29}3031// API login — skip the UI entirely32const context = await browser.newContext();33const response = await context.request.post("/api/auth/login", {34data: { email: "[email protected]", password: "secretPass123" },35});36await context.storageState({ path: ".auth/session.json" });37```3839## Patterns4041### Storage State Reuse4243**Use when**: You need authenticated tests and want to avoid logging in before every test.44**Avoid when**: Tests require completely fresh sessions, or you are testing the login flow itself.4546`storageState` serializes cookies and localStorage to a JSON file. Load it in any browser context to start authenticated instantly.4748```typescript49// scripts/generate-auth.ts — run once to generate the state file50import { chromium } from "@playwright/test";5152async function generateAuthState() {53const browser = await chromium.launch();54const context = await browser.newContext();55const page = await context.newPage();5657await page.goto("http://localhost:4000/login");58await page.getByLabel("Username").fill("[email protected]");59await page.getByLabel("Password").fill("secretPass123");60await page.getByRole("button", { name: "Log in" }).click();61await page.waitForURL("/home");6263await context.storageState({ path: ".auth/session.json" });64await browser.close();65}6667generateAuthState();68```6970```typescript71// playwright.config.ts — load saved state for all tests72import { defineConfig } from "@playwright/test";7374export default defineConfig({75use: {76baseURL: "http://localhost:4000",77storageState: ".auth/session.json",78},79});80```8182```typescript83// tests/home.spec.ts — test starts already logged in84import { test, expect } from "@playwright/test";8586test("authenticated user sees home page", async ({ page }) => {87await page.goto("/home");88await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();89});90```9192### Global Setup Authentication9394**Use when**: You want to authenticate once before the entire test suite runs.95**Avoid when**: Different tests need different users, or your tokens expire faster than your suite runs.9697```typescript98// global-setup.ts99import { chromium, type FullConfig } from "@playwright/test";100101async function globalSetup(config: FullConfig) {102const { baseURL } = config.projects[0].use;103const browser = await chromium.launch();104const context = await browser.newContext();105const page = await context.newPage();106107await page.goto(`${baseURL}/login`);108await page.getByLabel("Username").fill(process.env.TEST_USER_EMAIL!);109await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!);110await page.getByRole("button", { name: "Log in" }).click();111await page.waitForURL("**/home");112113await context.storageState({ path: ".auth/session.json" });114await browser.close();115}116117export default globalSetup;118```119120```typescript121// playwright.config.ts122import { defineConfig } from "@playwright/test";123124export default defineConfig({125globalSetup: require.resolve("./global-setup"),126use: {127baseURL: "http://localhost:4000",128storageState: ".auth/session.json",129},130});131```132133Add `.auth/` to `.gitignore`. Auth state files contain session tokens and should never be committed.134135### Per-Worker Authentication136137**Use when**: Each parallel worker needs its own authenticated session to avoid race conditions for tests that modify server-side state.138**Avoid when**: Tests are read-only and a modifying shared session is safe, you can use a single shared account.139140> **Sharded runs**: `parallelIndex` resets per shard, so different shards can have workers with the same index. To avoid collisions, include the shard identifier in the username (e.g., `worker-${SHARD_INDEX}-${parallelIndex}@example.com`) by passing a `SHARD_INDEX` environment variable from your CI matrix.141142```typescript143// fixtures/auth.ts144import { test as base, type BrowserContext } from "@playwright/test";145146type AuthFixtures = {147authenticatedContext: BrowserContext;148};149150export const test = base.extend<{}, AuthFixtures>({151authenticatedContext: [152async ({ browser }, use) => {153const context = await browser.newContext();154const page = await context.newPage();155156await page.goto("/login");157await page158.getByLabel("Username")159.fill(`worker-${test.info().parallelIndex}@example.com`);160await page.getByLabel("Password").fill("secretPass123");161await page.getByRole("button", { name: "Log in" }).click();162await page.waitForURL("/home");163await page.close();164165await use(context);166await context.close();167},168{ scope: "worker" },169],170});171172export { expect } from "@playwright/test";173```174175```typescript176// tests/settings.spec.ts177import { test, expect } from "../fixtures/auth";178179test("update display name", async ({ authenticatedContext }) => {180const page = await authenticatedContext.newPage();181await page.goto("/settings/profile");182await page.getByLabel("Display name").fill("Updated Name");183await page.getByRole("button", { name: "Save" }).click();184await expect(page.getByText("Profile saved")).toBeVisible();185});186```187188### Multiple Roles189190**Use when**: Your app has role-based access control and you need to test different permission levels.191**Avoid when**: Your app has a single user role.192193```typescript194// global-setup.ts — authenticate all roles195import { chromium, type FullConfig } from "@playwright/test";196197const accounts = [198{199role: "admin",200email: "[email protected]",201password: process.env.ADMIN_PASSWORD!,202},203{204role: "member",205email: "[email protected]",206password: process.env.MEMBER_PASSWORD!,207},208{209role: "guest",210email: "[email protected]",211password: process.env.GUEST_PASSWORD!,212},213];214215async function globalSetup(config: FullConfig) {216const { baseURL } = config.projects[0].use;217218for (const { role, email, password } of accounts) {219const browser = await chromium.launch();220const context = await browser.newContext();221const page = await context.newPage();222223await page.goto(`${baseURL}/login`);224await page.getByLabel("Username").fill(email);225await page.getByLabel("Password").fill(password);226await page.getByRole("button", { name: "Log in" }).click();227await page.waitForURL("**/home");228229await context.storageState({ path: `.auth/${role}.json` });230await browser.close();231}232}233234export default globalSetup;235```236237```typescript238// playwright.config.ts — one project per role239import { defineConfig } from "@playwright/test";240241export default defineConfig({242globalSetup: require.resolve("./global-setup"),243projects: [244{245name: "admin",246use: { storageState: ".auth/admin.json" },247testMatch: "**/*.admin.spec.ts",248},249{250name: "member",251use: { storageState: ".auth/member.json" },252testMatch: "**/*.member.spec.ts",253},254{255name: "guest",256use: { storageState: ".auth/guest.json" },257testMatch: "**/*.guest.spec.ts",258},259{260name: "anonymous",261use: { storageState: { cookies: [], origins: [] } },262testMatch: "**/*.anon.spec.ts",263},264],265});266```267268```typescript269// tests/admin-panel.admin.spec.ts270import { test, expect } from "@playwright/test";271272test("admin can access user management", async ({ page }) => {273await page.goto("/admin/users");274await expect(275page.getByRole("heading", { name: "User Management" })276).toBeVisible();277await expect(page.getByRole("button", { name: "Remove user" })).toBeEnabled();278});279```280281```typescript282// tests/admin-panel.guest.spec.ts283import { test, expect } from "@playwright/test";284285test("guest cannot access admin panel", async ({ page }) => {286await page.goto("/admin/users");287await expect(page.getByText("Access denied")).toBeVisible();288});289```290291**Alternative**: Use a fixture that accepts a role parameter when you need role switching within a single spec file.292293```typescript294// fixtures/auth.ts — role-based fixture295import { test as base, type Page } from "@playwright/test";296import fs from "fs";297298type RoleFixtures = {299loginAs: (role: "admin" | "member" | "guest") => Promise<Page>;300};301302export const test = base.extend<RoleFixtures>({303loginAs: async ({ browser }, use) => {304const pages: Page[] = [];305306await use(async (role) => {307const statePath = `.auth/${role}.json`;308if (!fs.existsSync(statePath)) {309throw new Error(310`Auth state for role "${role}" not found at ${statePath}`311);312}313const context = await browser.newContext({ storageState: statePath });314const page = await context.newPage();315pages.push(page);316return page;317});318319for (const page of pages) {320await page.context().close();321}322},323});324325export { expect } from "@playwright/test";326```327328```typescript329// tests/role-comparison.spec.ts330import { test, expect } from "../fixtures/auth";331332test("admin sees remove button, guest does not", async ({ loginAs }) => {333const adminPage = await loginAs("admin");334await adminPage.goto("/admin/users");335await expect(336adminPage.getByRole("button", { name: "Remove user" })337).toBeVisible();338339const guestPage = await loginAs("guest");340await guestPage.goto("/admin/users");341await expect(guestPage.getByText("Access denied")).toBeVisible();342});343```344345### OAuth/SSO Mocking346347**Use when**: Your app authenticates via a third-party OAuth provider and you cannot hit the real provider in tests.348**Avoid when**: You have a dedicated test tenant on the OAuth provider.349350A typical OAuth flow works like this:3513521. User clicks "Sign in with Provider" → browser navigates to `https://accounts.provider.com/authorize?...`3532. User authenticates on the provider's page → provider redirects back to your app's **callback route** (e.g. `http://localhost:4000/auth/callback?code=ABC&state=XYZ`)3543. Your backend exchanges the `code` for an access token, creates a session, and redirects the user to a logged-in page355356In tests you can short-circuit step 2 with `page.route()`: intercept the outbound request to the provider and respond with a `302` redirect straight to your callback route, supplying a mock `code` and `state`. Your backend still executes its normal callback handler — the only part that's mocked is the provider's authorization page.357358For cases where you want to skip the browser redirect entirely, a second approach calls a **test-only API endpoint** that creates the session server-side and returns the session cookie directly.359360```typescript361// tests/oauth-login.spec.ts — mock the callback route362import { test, expect } from "@playwright/test";363364test("login via mocked OAuth flow", async ({ page }) => {365await page.route("https://accounts.provider.com/**", async (route) => {366const callbackUrl = new URL("http://localhost:4000/auth/callback");367callbackUrl.searchParams.set("code", "mock-auth-code-xyz");368callbackUrl.searchParams.set("state", "expected-state-value");369await route.fulfill({370status: 302,371headers: { location: callbackUrl.toString() },372});373});374375await page.goto("/login");376await page.getByRole("button", { name: "Sign in with Provider" }).click();377378await page.waitForURL("/home");379await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();380});381```382383```typescript384// tests/oauth-login.spec.ts — API-based session injection385import { test, expect } from "@playwright/test";386387test("bypass OAuth entirely via API session injection", async ({388page,389}) => {390// Call a test-only endpoint that creates a session without OAuth391const response = await page.request.post("/api/test/create-session", {392data: {393email: "[email protected]",394provider: "provider",395role: "member",396},397});398expect(response.ok()).toBeTruthy();399400await page.context().storageState({ path: ".auth/oauth-user.json" });401await page.goto("/home");402await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();403});404```405406**Backend requirement**: Your backend must expose a test-only session creation endpoint (guarded by `NODE_ENV=test`) or accept a known test OAuth code.407408### MFA Handling409410**Use when**: Your app requires two-factor authentication (TOTP, SMS, email codes).411**Avoid when**: MFA is optional and you can disable it for test accounts.412413**Strategy 1**: Generate real TOTP codes from a shared secret.414415```typescript416// helpers/totp.ts417import * as OTPAuth from "otpauth";418419export function generateTOTP(secret: string): string {420const totp = new OTPAuth.TOTP({421secret: OTPAuth.Secret.fromBase32(secret),422digits: 6,423period: 30,424algorithm: "SHA1",425});426return totp.generate();427}428```429430```typescript431// tests/mfa-login.spec.ts432import { test, expect } from "@playwright/test";433import { generateTOTP } from "../helpers/totp";434435test("login with TOTP two-factor auth", async ({ page }) => {436await page.goto("/login");437await page.getByLabel("Username").fill("[email protected]");438await page.getByLabel("Password").fill("secretPass123");439await page.getByRole("button", { name: "Log in" }).click();440441await expect(page.getByText("Enter your authentication code")).toBeVisible();442443const code = generateTOTP(process.env.MFA_TOTP_SECRET!);444await page.getByLabel("Authentication code").fill(code);445await page.getByRole("button", { name: "Verify" }).click();446447await page.waitForURL("/home");448await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();449});450```451452**Strategy 2**: Mock MFA at the backend level. Have your backend accept a known bypass code (e.g., `000000`) when `NODE_ENV=test`.453454**Strategy 3**: Disable MFA for test accounts at the infrastructure level.455456### Session Refresh457458**Use when**: Your tokens expire during long test runs.459**Avoid when**: Your test suite runs quickly and tokens outlast the entire run.460461```typescript462// fixtures/auth-with-refresh.ts463import { test as base, type BrowserContext } from "@playwright/test";464import fs from "fs";465466type AuthFixtures = {467authenticatedPage: import("@playwright/test").Page;468};469470export const test = base.extend<AuthFixtures>({471authenticatedPage: async ({ browser }, use) => {472const statePath = ".auth/session.json";473474let context: BrowserContext;475if (fs.existsSync(statePath)) {476context = await browser.newContext({ storageState: statePath });477const page = await context.newPage();478479const response = await page.request.get("/api/auth/me");480if (response.ok()) {481await use(page);482await context.close();483return;484}485await context.close();486}487488context = await browser.newContext();489const page = await context.newPage();490await page.goto("/login");491await page.getByLabel("Username").fill(process.env.TEST_USER_EMAIL!);492await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!);493await page.getByRole("button", { name: "Log in" }).click();494await page.waitForURL("/home");495496await context.storageState({ path: statePath });497498await use(page);499await context.close();500},501});502503export { expect } from "@playwright/test";504```505506### Login Page Object507508**Use when**: Multiple test files need to log in and you want consistent, maintainable login logic.509**Avoid when**: You use `storageState` everywhere and never navigate through the login UI in tests.510511```typescript512// page-objects/LoginPage.ts513import { type Page, type Locator, expect } from "@playwright/test";514515export class LoginPage {516readonly page: Page;517readonly usernameInput: Locator;518readonly passwordInput: Locator;519readonly loginButton: Locator;520readonly errorMessage: Locator;521readonly forgotPasswordLink: Locator;522523constructor(page: Page) {524this.page = page;525this.usernameInput = page.getByLabel("Username");526this.passwordInput = page.getByLabel("Password");527this.loginButton = page.getByRole("button", { name: "Log in" });528this.errorMessage = page.getByRole("alert");529this.forgotPasswordLink = page.getByRole("link", {530name: "Forgot password",531});532}533534async goto() {535await this.page.goto("/login");536await expect(this.loginButton).toBeVisible();537}538539async login(username: string, password: string) {540await this.usernameInput.fill(username);541await this.passwordInput.fill(password);542await this.loginButton.click();543}544545async loginAndWaitForHome(username: string, password: string) {546await this.login(username, password);547await this.page.waitForURL("/home");548}549550async expectError(message: string | RegExp) {551await expect(this.errorMessage).toContainText(message);552}553554async expectFieldError(field: "username" | "password", message: string) {555const input =556field === "username" ? this.usernameInput : this.passwordInput;557await expect(input).toHaveAttribute("aria-invalid", "true");558const errorId = await input.getAttribute("aria-describedby");559if (errorId) {560await expect(this.page.locator(`#${errorId}`)).toContainText(message);561}562}563}564```565566```typescript567// tests/login.spec.ts568import { test, expect } from "@playwright/test";569import { LoginPage } from "../page-objects/LoginPage";570571test.use({ storageState: { cookies: [], origins: [] } });572573test.describe("login page", () => {574let loginPage: LoginPage;575576test.beforeEach(async ({ page }) => {577loginPage = new LoginPage(page);578await loginPage.goto();579});580581test("successful login redirects to home", async ({ page }) => {582await loginPage.loginAndWaitForHome(583"[email protected]",584"secretPass123"585);586await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();587});588589test("wrong password shows error", async () => {590await loginPage.login("[email protected]", "wrong-password");591await loginPage.expectError("Invalid username or password");592});593594test("empty fields show validation errors", async () => {595await loginPage.loginButton.click();596await loginPage.expectFieldError("username", "Username is required");597});598599test("forgot password link navigates correctly", async ({ page }) => {600await loginPage.forgotPasswordLink.click();601await page.waitForURL("/forgot-password");602await expect(603page.getByRole("heading", { name: "Reset password" })604).toBeVisible();605});606});607```608609### API-Based Login610611**Use when**: You want the fastest possible authentication without any browser interaction.612**Avoid when**: You are specifically testing the login UI.613614API login is typically 5-10x faster than UI login.615616```typescript617// global-setup.ts — API-based login (fastest)618import { request, type FullConfig } from "@playwright/test";619620async function globalSetup(config: FullConfig) {621const { baseURL } = config.projects[0].use;622623const requestContext = await request.newContext({ baseURL });624625const response = await requestContext.post("/api/auth/login", {626data: {627email: process.env.TEST_USER_EMAIL!,628password: process.env.TEST_USER_PASSWORD!,629},630});631632if (!response.ok()) {633throw new Error(634`API login failed: ${response.status()} ${await response.text()}`635);636}637638await requestContext.storageState({ path: ".auth/session.json" });639await requestContext.dispose();640}641642export default globalSetup;643```644645```typescript646// fixtures/api-auth.ts — fixture version for per-test authentication647import { test as base } from "@playwright/test";648649export const test = base.extend({650authenticatedPage: async ({ browser, playwright }, use) => {651const apiContext = await playwright.request.newContext({652baseURL: "http://localhost:4000",653});654655await apiContext.post("/api/auth/login", {656data: {657email: "[email protected]",658password: "secretPass123",659},660});661662const state = await apiContext.storageState();663const context = await browser.newContext({ storageState: state });664const page = await context.newPage();665666await use(page);667668await context.close();669await apiContext.dispose();670},671});672673export { expect } from "@playwright/test";674```675676### Unauthenticated Tests677678**Use when**: Testing the login page, signup flow, password reset, public pages, or redirect behavior for unauthenticated users.679**Avoid when**: The test requires a logged-in user.680681When your config sets a default `storageState`, you must explicitly clear it for unauthenticated tests.682683```typescript684// tests/public-pages.spec.ts685import { test, expect } from "@playwright/test";686687test.use({ storageState: { cookies: [], origins: [] } });688689test.describe("unauthenticated access", () => {690test("homepage is accessible without login", async ({ page }) => {691await page.goto("/");692await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();693await expect(page.getByRole("link", { name: "Log in" })).toBeVisible();694});695696test("protected route redirects to login", async ({ page }) => {697await page.goto("/home");698await page.waitForURL("**/login**");699expect(page.url()).toContain("redirect=%2Fhome");700});701702test("expired session shows re-login prompt", async ({ page, context }) => {703await page.goto("/home");704await context.clearCookies();705706await page.goto("/settings");707await page.waitForURL("**/login**");708await expect(page.getByText("Your session has expired")).toBeVisible();709});710711test("signup flow creates account", async ({ page }) => {712await page.goto("/signup");713await page.getByLabel("Name").fill("New User");714await page.getByLabel("Email").fill(`test-${Date.now()}@example.com`);715await page.getByLabel("Password", { exact: true }).fill("secretPass123");716await page.getByLabel("Confirm password").fill("secretPass123");717await page.getByRole("button", { name: "Create account" }).click();718719await page.waitForURL("/onboarding");720await expect(page.getByText("Welcome, New User")).toBeVisible();721});722});723```724725## Decision Guide726727| Scenario | Approach | Speed | Isolation | When to Choose |728| -------------------------------- | ------------------------------ | -------- | -------------- | -------------------------------------------------------------- |729| Most tests need auth | Global setup + `storageState` | Fastest | Shared session | Default for nearly every project |730| Tests modify user state | Per-worker fixture | Fast | Per worker | Tests update profile, change settings, or mutate data |731| Multiple user roles | Per-project `storageState` | Fastest | Per role | App has admin/member/guest roles |732| Testing the login page | No `storageState` | N/A | Full | Use `test.use({ storageState: { cookies: [], origins: [] } })` |733| OAuth/SSO provider | Mock the callback | Fast | Per test | Never hit real OAuth providers in CI |734| MFA is required | TOTP generation or bypass | Moderate | Per test | Generate real TOTP codes or use a test-mode bypass |735| Token expires mid-suite | Session refresh fixture | Fast | Per check | Fixture validates the session before use |736| Single test needs different user | `loginAs(role)` fixture | Moderate | Per call | Rare: prefer per-project roles |737| API-first app (no login UI) | API login via `request.post()` | Fastest | Per test | No browser needed for auth |738739### UI Login vs API Login vs Storage State740741```text742Need to test the login page itself?743├── Yes → UI login with LoginPage POM, no storageState744└── No → Do you have a login API endpoint?745├── Yes → API login in global setup, save storageState (fastest)746└── No → UI login in global setup, save storageState747└── Tokens expire quickly?748├── Yes → Add session refresh fixture749└── No → Standard storageState reuse is fine750```751752## Anti-Patterns753754| Don't Do This | Problem | Do This Instead |755| ------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------- |756| Log in via UI before every test | Adds 2-5 seconds per test | Use `storageState` to skip login entirely |757| Share a single auth state file across parallel workers that mutate state | Race conditions | Use per-worker fixtures with `{ scope: 'worker' }` |758| Hardcode credentials in test files | Security risk | Use environment variables and `.env` files |759| Ignore token expiration | Tests fail intermittently with 401 errors | Add a session validity check in your auth fixture |760| Hit real OAuth providers in CI | Flaky: rate limits, CAPTCHA, network issues | Mock the OAuth callback or use API session injection |761| Use `page.waitForTimeout(2000)` after login | Arbitrary delay | `await page.waitForURL('/home')` or `await expect(heading).toBeVisible()` |762| Store `.auth/*.json` files in git | Tokens in version control | Add `.auth/` to `.gitignore` |763| Create one "god" test account with all permissions | Cannot test role-based access control | Create separate accounts per role |764| Use `browser.newContext()` without `storageState` for authenticated tests | Every context starts unauthenticated | Pass `storageState` when creating the context |765| Test MFA by disabling it everywhere | You never test the MFA flow | Use TOTP generation for at least one test |766767## Troubleshooting768769### Global setup fails with "Target page, context or browser has been closed"770771**Cause**: The login page redirected unexpectedly, or the browser closed before `storageState()` was called.772773**Fix**:774775- Add `await page.waitForURL()` after the login action776- Check that `baseURL` in your config matches the actual server URL and protocol777- Add error handling to global setup:778779```typescript780const response = await page.waitForResponse("**/api/auth/**");781if (!response.ok()) {782throw new Error(783`Login failed in global setup: ${response.status()} ${await response.text()}`784);785}786```787788### Tests fail with 401 Unauthorized after running for a while789790**Cause**: The session token saved in `storageState` has expired.791792**Fix**:793794- Use the session refresh fixture pattern795- Increase token expiry in test environment configuration796- Switch to API-based login in a worker-scoped fixture797798### `storageState` file is empty or contains no cookies799800**Cause**: `storageState()` was called before the login response set cookies.801802**Fix**:803804- Wait for the post-login page to load: `await page.waitForURL('/home')`805- Verify cookies exist before saving:806807```typescript808const cookies = await context.cookies();809if (cookies.length === 0) {810throw new Error("No cookies found after login");811}812await context.storageState({ path: ".auth/session.json" });813```814815### Different browsers get different cookies816817**Cause**: Some auth flows set cookies with `SameSite=Strict` or use browser-specific cookie behavior.818819**Fix**:820821- Generate separate auth state files per browser project822- Check if your auth uses `SameSite=None; Secure` cookies that require HTTPS:823824```typescript825projects: [826{827name: 'chromium',828use: { ...devices['Desktop Chrome'], storageState: '.auth/chromium-session.json' },829},830{831name: 'firefox',832use: { ...devices['Desktop Firefox'], storageState: '.auth/firefox-session.json' },833},834],835```836837### Parallel tests interfere with each other's sessions838839**Cause**: Multiple workers share the same test account and one worker's actions affect others.840841**Fix**:842843- Use per-worker test accounts: `worker-${test.info().parallelIndex}@example.com`844- Use the per-worker authentication fixture pattern845- Make tests idempotent846847### OAuth mock does not work — still redirects to real provider848849**Cause**: `page.route()` was registered after the navigation that triggers the OAuth redirect.850851**Fix**:852853- Register route handlers before any navigation: call `page.route()` before `page.goto()`854- Log the actual redirect URL to verify the pattern:855856```typescript857page.on("request", (req) => {858if (req.url().includes("oauth") || req.url().includes("accounts.provider")) {859console.log("OAuth request:", req.url());860}861});862```863864## Related865866- [fixtures-hooks.md](../core/fixtures-hooks.md) — custom fixtures for auth setup and teardown867- [configuration.md](../core/configuration.md) — `storageState`, projects, and global setup configuration868- [global-setup.md](../core/global-setup.md) — global setup patterns and project dependencies869- [network-advanced.md](network-advanced.md) — route interception patterns used in OAuth mocking870- [api-testing.md](../testing-patterns/api-testing.md) — API request context used in API-based login871- [flaky-tests.md](../debugging/flaky-tests.md) — diagnosing auth-related flakiness872