When to use: Any app that has login, session management, or protected routes. Authentication is the most common source of slow test suites -- get this right and your entire suite speeds up. Prerequisites: core/configuration.md, core/fixtures-and-hooks.md
// Storage state reuse — the #1 pattern for fast auth
await page.goto('/login');
await page.getByLabel('Email').fill('user@test.com');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.context().storageState({ path: '.auth/user.json' });
// Reuse in config — every test starts authenticated, zero login overhead
{ use: { storageState: '.auth/user.json' } }
// API login — skip the UI entirely
const context = await browser.newContext();
const response = await context.request.post('/api/auth/login', {
data: { email: 'user@test.com', password: 'password' },
});
await context.storageState({ path: '.auth/user.json' });Use when: You need authenticated tests and want to avoid logging in before every test. This is the default pattern for nearly every project. Avoid when: Tests require completely fresh sessions with no prior state, or you are testing the login flow itself.
Playwright's storageState serializes cookies and localStorage to a JSON file. Load it in any browser context to start authenticated instantly.
TypeScript
// scripts/save-auth-state.ts — run once to generate the state file
import { chromium } from '@playwright/test';
async function saveAuthState() {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('http://localhost:3000/login');
await page.getByLabel('Email').fill('user@test.com');
await page.getByLabel('Password').fill('s3cure!Pass');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
// Save cookies + localStorage to a file
await context.storageState({ path: '.auth/user.json' });
await browser.close();
}
saveAuthState();// playwright.config.ts — load saved state for all tests
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: 'http://localhost:3000',
storageState: '.auth/user.json',
},
});// tests/dashboard.spec.ts — test starts already logged in
import { test, expect } from '@playwright/test';
test('authenticated user sees dashboard', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
// No login step needed — storageState handles it
});JavaScript
// scripts/save-auth-state.js
const { chromium } = require('@playwright/test');
async function saveAuthState() {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('http://localhost:3000/login');
await page.getByLabel('Email').fill('user@test.com');
await page.getByLabel('Password').fill('s3cure!Pass');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await context.storageState({ path: '.auth/user.json' });
await browser.close();
}
saveAuthState();// playwright.config.js
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
use: {
baseURL: 'http://localhost:3000',
storageState: '.auth/user.json',
},
});Use when: You want to authenticate once before the entire test suite runs, then reuse that session everywhere. The standard Playwright-recommended approach. Avoid when: Different tests need different users, or your tokens expire faster than your suite runs.
Global setup runs once per npx playwright test invocation. It logs in, saves the state, and every test project that references that state file starts authenticated.
TypeScript
// global-setup.ts
import { chromium, type FullConfig } from '@playwright/test';
async function globalSetup(config: FullConfig) {
const { baseURL } = config.projects[0].use;
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(`${baseURL}/login`);
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/dashboard');
await context.storageState({ path: '.auth/user.json' });
await browser.close();
}
export default globalSetup;// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
globalSetup: require.resolve('./global-setup'),
use: {
baseURL: 'http://localhost:3000',
storageState: '.auth/user.json',
},
});JavaScript
// global-setup.js
const { chromium } = require('@playwright/test');
async function globalSetup(config) {
const { baseURL } = config.projects[0].use;
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(`${baseURL}/login`);
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/dashboard');
await context.storageState({ path: '.auth/user.json' });
await browser.close();
}
module.exports = globalSetup;// playwright.config.js
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
globalSetup: require.resolve('./global-setup'),
use: {
baseURL: 'http://localhost:3000',
storageState: '.auth/user.json',
},
});Important: Add .auth/ to your .gitignore. Auth state files contain session tokens and should never be committed.
Use when: Each parallel worker needs its own authenticated session to avoid race conditions. Essential when tests modify user state (profile updates, settings changes, data mutations). Avoid when: Tests are read-only and a shared session is safe, or you only run tests serially.
Worker-scoped fixtures run once per worker process, not once per test. This gives each parallel worker its own isolated session.
TypeScript
// fixtures/auth.ts
import { test as base, type BrowserContext } from '@playwright/test';
type AuthFixtures = {
authenticatedContext: BrowserContext;
};
export const test = base.extend<{}, AuthFixtures>({
authenticatedContext: [async ({ browser }, use) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/login');
await page.getByLabel('Email').fill(`worker-${test.info().parallelIndex}@test.com`);
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.close();
await use(context);
await context.close();
}, { scope: 'worker' }],
});
export { expect } from '@playwright/test';// tests/profile.spec.ts
import { test, expect } from '../fixtures/auth';
test('update profile name', async ({ authenticatedContext }) => {
const page = await authenticatedContext.newPage();
await page.goto('/settings/profile');
await page.getByLabel('Display name').fill('Updated Name');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Profile updated')).toBeVisible();
});JavaScript
// fixtures/auth.js
const { test: base } = require('@playwright/test');
const test = base.extend({
authenticatedContext: [async ({ browser }, use) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/login');
await page.getByLabel('Email').fill(`worker-${test.info().parallelIndex}@test.com`);
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.close();
await use(context);
await context.close();
}, { scope: 'worker' }],
});
module.exports = { test, expect: require('@playwright/test').expect };Use when: Your app has role-based access control and you need to test that admins, regular users, and viewers see different things. Avoid when: Your app has a single user role.
Use separate Playwright projects with different storage states, one per role. This is cleaner than switching roles within tests.
TypeScript
// global-setup.ts — authenticate all roles
import { chromium, type FullConfig } from '@playwright/test';
const users = [
{ role: 'admin', email: 'admin@test.com', password: process.env.ADMIN_PASSWORD! },
{ role: 'user', email: 'user@test.com', password: process.env.USER_PASSWORD! },
{ role: 'viewer', email: 'viewer@test.com', password: process.env.VIEWER_PASSWORD! },
];
async function globalSetup(config: FullConfig) {
const { baseURL } = config.projects[0].use;
for (const { role, email, password } of users) {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(`${baseURL}/login`);
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/dashboard');
await context.storageState({ path: `.auth/${role}.json` });
await browser.close();
}
}
export default globalSetup;// playwright.config.ts — one project per role
import { defineConfig } from '@playwright/test';
export default defineConfig({
globalSetup: require.resolve('./global-setup'),
projects: [
{
name: 'admin',
use: { storageState: '.auth/admin.json' },
testMatch: '**/*.admin.spec.ts',
},
{
name: 'user',
use: { storageState: '.auth/user.json' },
testMatch: '**/*.user.spec.ts',
},
{
name: 'viewer',
use: { storageState: '.auth/viewer.json' },
testMatch: '**/*.viewer.spec.ts',
},
{
name: 'unauthenticated',
use: { storageState: { cookies: [], origins: [] } },
testMatch: '**/*.anon.spec.ts',
},
],
});// tests/admin-panel.admin.spec.ts
import { test, expect } from '@playwright/test';
test('admin can access user management', async ({ page }) => {
await page.goto('/admin/users');
await expect(page.getByRole('heading', { name: 'User Management' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Delete user' })).toBeEnabled();
});// tests/admin-panel.viewer.spec.ts
import { test, expect } from '@playwright/test';
test('viewer cannot access admin panel', async ({ page }) => {
await page.goto('/admin/users');
// Should redirect to forbidden or dashboard
await expect(page.getByText('Access denied')).toBeVisible();
});JavaScript
// global-setup.js
const { chromium } = require('@playwright/test');
const users = [
{ role: 'admin', email: 'admin@test.com', password: process.env.ADMIN_PASSWORD },
{ role: 'user', email: 'user@test.com', password: process.env.USER_PASSWORD },
{ role: 'viewer', email: 'viewer@test.com', password: process.env.VIEWER_PASSWORD },
];
async function globalSetup(config) {
const { baseURL } = config.projects[0].use;
for (const { role, email, password } of users) {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(`${baseURL}/login`);
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/dashboard');
await context.storageState({ path: `.auth/${role}.json` });
await browser.close();
}
}
module.exports = globalSetup;// playwright.config.js
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
globalSetup: require.resolve('./global-setup'),
projects: [
{
name: 'admin',
use: { storageState: '.auth/admin.json' },
testMatch: '**/*.admin.spec.js',
},
{
name: 'user',
use: { storageState: '.auth/user.json' },
testMatch: '**/*.user.spec.js',
},
{
name: 'viewer',
use: { storageState: '.auth/viewer.json' },
testMatch: '**/*.viewer.spec.js',
},
{
name: 'unauthenticated',
use: { storageState: { cookies: [], origins: [] } },
testMatch: '**/*.anon.spec.js',
},
],
});Alternative: Use a fixture that accepts a role parameter when you need role switching within a single spec file.
// fixtures/auth.ts — role-based fixture
import { test as base, type Page } from '@playwright/test';
import fs from 'fs';
type RoleFixtures = {
loginAs: (role: 'admin' | 'user' | 'viewer') => Promise<Page>;
};
export const test = base.extend<RoleFixtures>({
loginAs: async ({ browser }, use) => {
const pages: Page[] = [];
await use(async (role) => {
const statePath = `.auth/${role}.json`;
if (!fs.existsSync(statePath)) {
throw new Error(`Auth state for role "${role}" not found at ${statePath}. Run global setup first.`);
}
const context = await browser.newContext({ storageState: statePath });
const page = await context.newPage();
pages.push(page);
return page;
});
for (const page of pages) {
await page.context().close();
}
},
});
export { expect } from '@playwright/test';// tests/role-comparison.spec.ts
import { test, expect } from '../fixtures/auth';
test('admin sees delete button, viewer does not', async ({ loginAs }) => {
const adminPage = await loginAs('admin');
await adminPage.goto('/admin/users');
await expect(adminPage.getByRole('button', { name: 'Delete user' })).toBeVisible();
const viewerPage = await loginAs('viewer');
await viewerPage.goto('/admin/users');
await expect(viewerPage.getByText('Access denied')).toBeVisible();
});Use when: Your app authenticates via a third-party OAuth provider (Google, GitHub, Okta, Auth0) and you cannot or should not hit the real provider in tests. Avoid when: You have a dedicated test tenant on the OAuth provider and want true end-to-end coverage of the OAuth flow.
The strategy: intercept the OAuth callback route and inject a valid session directly, bypassing the provider entirely.
TypeScript
// fixtures/oauth-mock.ts
import { test as base } from '@playwright/test';
export const test = base.extend({
// Mock OAuth by intercepting the callback and injecting session cookies
page: async ({ page }, use) => {
// Intercept the OAuth redirect to the provider
await page.route('**/auth/callback**', async (route) => {
// Instead of going to Google/GitHub, call your own backend
// with a test token that your backend recognizes in test mode
const url = new URL(route.request().url());
url.searchParams.set('code', 'test-oauth-code');
url.searchParams.set('state', url.searchParams.get('state') || '');
await route.continue({ url: url.toString() });
});
await use(page);
},
});
export { expect } from '@playwright/test';// tests/oauth-login.spec.ts — approach 1: mock the callback route
import { test, expect } from '@playwright/test';
test('login via mocked OAuth flow', async ({ page }) => {
// Intercept the redirect to the OAuth provider
await page.route('https://accounts.google.com/**', async (route) => {
// Redirect back to your app's callback with a fake auth code
const callbackUrl = new URL('http://localhost:3000/auth/callback');
callbackUrl.searchParams.set('code', 'mock-auth-code-12345');
callbackUrl.searchParams.set('state', 'expected-state-value');
await route.fulfill({
status: 302,
headers: { location: callbackUrl.toString() },
});
});
await page.goto('/login');
await page.getByRole('button', { name: 'Sign in with Google' }).click();
// Your backend must accept "mock-auth-code-12345" in test mode
// and exchange it for a real session
await page.waitForURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});// tests/oauth-login.spec.ts — approach 2: API-based session injection
import { test, expect } from '@playwright/test';
test('bypass OAuth entirely via API session injection', async ({ page, request }) => {
// Call a test-only endpoint that creates a session without OAuth
// Your backend exposes this only when NODE_ENV=test
const response = await request.post('/api/test/create-session', {
data: {
email: 'oauth-user@test.com',
provider: 'google',
role: 'user',
},
});
expect(response.ok()).toBeTruthy();
// The response sets session cookies; storageState captures them
await page.context().storageState({ path: '.auth/oauth-user.json' });
// Now navigate — already authenticated
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});JavaScript
const { test, expect } = require('@playwright/test');
test('login via mocked OAuth flow', async ({ page }) => {
await page.route('https://accounts.google.com/**', async (route) => {
const callbackUrl = new URL('http://localhost:3000/auth/callback');
callbackUrl.searchParams.set('code', 'mock-auth-code-12345');
callbackUrl.searchParams.set('state', 'expected-state-value');
await route.fulfill({
status: 302,
headers: { location: callbackUrl.toString() },
});
});
await page.goto('/login');
await page.getByRole('button', { name: 'Sign in with Google' }).click();
await page.waitForURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});test('bypass OAuth entirely via API session injection', async ({ page, request }) => {
const response = await request.post('/api/test/create-session', {
data: {
email: 'oauth-user@test.com',
provider: 'google',
role: 'user',
},
});
expect(response.ok()).toBeTruthy();
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});Backend requirement: Your backend must expose a test-only session creation endpoint (guarded by NODE_ENV=test) or accept a known test OAuth code. Never ship test auth endpoints to production.
Use when: Your app requires two-factor authentication (TOTP, SMS, email codes) and you need to handle it in tests. Avoid when: MFA is optional and you can disable it for test accounts.
Strategy 1: Generate real TOTP codes from a shared secret. This is the most reliable approach.
TypeScript
// helpers/totp.ts
import * as OTPAuth from 'otpauth';
export function generateTOTP(secret: string): string {
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(secret),
digits: 6,
period: 30,
algorithm: 'SHA1',
});
return totp.generate();
}// tests/mfa-login.spec.ts
import { test, expect } from '@playwright/test';
import { generateTOTP } from '../helpers/totp';
test('login with TOTP two-factor auth', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('mfa-user@test.com');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
// MFA challenge screen
await expect(page.getByText('Enter your authentication code')).toBeVisible();
// Generate a valid TOTP from the test account's secret
const code = generateTOTP(process.env.MFA_TOTP_SECRET!);
await page.getByLabel('Authentication code').fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
await page.waitForURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});JavaScript
// helpers/totp.js
const OTPAuth = require('otpauth');
function generateTOTP(secret) {
const totp = new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(secret),
digits: 6,
period: 30,
algorithm: 'SHA1',
});
return totp.generate();
}
module.exports = { generateTOTP };// tests/mfa-login.spec.js
const { test, expect } = require('@playwright/test');
const { generateTOTP } = require('../helpers/totp');
test('login with TOTP two-factor auth', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('mfa-user@test.com');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText('Enter your authentication code')).toBeVisible();
const code = generateTOTP(process.env.MFA_TOTP_SECRET);
await page.getByLabel('Authentication code').fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
await page.waitForURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});Strategy 2: Mock MFA at the backend level. Have your backend accept a known bypass code (e.g., 000000) when NODE_ENV=test.
// Simpler but less realistic — backend accepts bypass code in test mode
test('login with MFA bypass code', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('mfa-user@test.com');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
// Backend accepts "000000" as a valid MFA code in test environment
await page.getByLabel('Authentication code').fill('000000');
await page.getByRole('button', { name: 'Verify' }).click();
await page.waitForURL('/dashboard');
});Strategy 3: Disable MFA for test accounts at the infrastructure level. Set up dedicated test users with MFA disabled. This is the simplest approach when feasible.
Use when: Your tokens expire during long test runs or your app uses short-lived JWTs that need refreshing. Avoid when: Your test suite runs in under a few minutes and tokens outlast the entire run.
TypeScript
// fixtures/auth-with-refresh.ts
import { test as base, type BrowserContext } from '@playwright/test';
import fs from 'fs';
type AuthFixtures = {
authenticatedPage: import('@playwright/test').Page;
};
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ browser }, use) => {
const statePath = '.auth/user.json';
// Check if the stored session is still valid
let context: BrowserContext;
if (fs.existsSync(statePath)) {
context = await browser.newContext({ storageState: statePath });
const page = await context.newPage();
// Quick health check — does the session still work?
const response = await page.request.get('/api/auth/me');
if (response.ok()) {
await use(page);
await context.close();
return;
}
// Session expired — close and re-authenticate
await context.close();
}
// Re-authenticate
context = await browser.newContext();
const page = await context.newPage();
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
// Save refreshed state for subsequent tests
await context.storageState({ path: statePath });
await use(page);
await context.close();
},
});
export { expect } from '@playwright/test';// Alternative: intercept token refresh requests to ensure they work
import { test, expect } from '@playwright/test';
test('app refreshes expired token automatically', async ({ page }) => {
await page.goto('/dashboard');
// Simulate token expiration by clearing the access token cookie
await page.context().clearCookies({ name: 'access_token' });
// Next API call should trigger a token refresh via refresh_token cookie
const refreshPromise = page.waitForResponse('**/api/auth/refresh');
await page.getByRole('button', { name: 'Load data' }).click();
const refreshResponse = await refreshPromise;
expect(refreshResponse.status()).toBe(200);
await expect(page.getByTestId('data-table')).toBeVisible();
});JavaScript
// fixtures/auth-with-refresh.js
const { test: base } = require('@playwright/test');
const fs = require('fs');
const test = base.extend({
authenticatedPage: async ({ browser }, use) => {
const statePath = '.auth/user.json';
if (fs.existsSync(statePath)) {
const context = await browser.newContext({ storageState: statePath });
const page = await context.newPage();
const response = await page.request.get('/api/auth/me');
if (response.ok()) {
await use(page);
await context.close();
return;
}
await context.close();
}
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await context.storageState({ path: statePath });
await use(page);
await context.close();
},
});
module.exports = { test, expect: require('@playwright/test').expect };Use when: Multiple test files need to log in and you want consistent, maintainable login logic with proper error handling.
Avoid when: You use storageState everywhere and never navigate through the login UI in tests (still useful for testing the login page itself).
TypeScript
// page-objects/LoginPage.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly signInButton: Locator;
readonly errorMessage: Locator;
readonly forgotPasswordLink: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.signInButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole('alert');
this.forgotPasswordLink = page.getByRole('link', { name: 'Forgot password' });
}
async goto() {
await this.page.goto('/login');
await expect(this.signInButton).toBeVisible();
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.signInButton.click();
}
async loginAndWaitForDashboard(email: string, password: string) {
await this.login(email, password);
await this.page.waitForURL('/dashboard');
}
async expectError(message: string | RegExp) {
await expect(this.errorMessage).toContainText(message);
}
async expectFieldError(field: 'email' | 'password', message: string) {
const input = field === 'email' ? this.emailInput : this.passwordInput;
await expect(input).toHaveAttribute('aria-invalid', 'true');
// Error message associated with the field
const errorId = await input.getAttribute('aria-describedby');
if (errorId) {
await expect(this.page.locator(`#${errorId}`)).toContainText(message);
}
}
}// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../page-objects/LoginPage';
// These tests run WITHOUT storageState (unauthenticated)
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('login page', () => {
let loginPage: LoginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.goto();
});
test('successful login redirects to dashboard', async ({ page }) => {
await loginPage.loginAndWaitForDashboard('user@test.com', 'password');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('wrong password shows error', async () => {
await loginPage.login('user@test.com', 'wrong-password');
await loginPage.expectError('Invalid email or password');
});
test('empty fields show validation errors', async () => {
await loginPage.signInButton.click();
await loginPage.expectFieldError('email', 'Email is required');
});
test('forgot password link navigates correctly', async ({ page }) => {
await loginPage.forgotPasswordLink.click();
await page.waitForURL('/forgot-password');
await expect(page.getByRole('heading', { name: 'Reset password' })).toBeVisible();
});
});JavaScript
// page-objects/LoginPage.js
const { expect } = require('@playwright/test');
class LoginPage {
constructor(page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.signInButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole('alert');
this.forgotPasswordLink = page.getByRole('link', { name: 'Forgot password' });
}
async goto() {
await this.page.goto('/login');
await expect(this.signInButton).toBeVisible();
}
async login(email, password) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.signInButton.click();
}
async loginAndWaitForDashboard(email, password) {
await this.login(email, password);
await this.page.waitForURL('/dashboard');
}
async expectError(message) {
await expect(this.errorMessage).toContainText(message);
}
async expectFieldError(field, message) {
const input = field === 'email' ? this.emailInput : this.passwordInput;
await expect(input).toHaveAttribute('aria-invalid', 'true');
const errorId = await input.getAttribute('aria-describedby');
if (errorId) {
await expect(this.page.locator(`#${errorId}`)).toContainText(message);
}
}
}
module.exports = { LoginPage };// tests/login.spec.js
const { test, expect } = require('@playwright/test');
const { LoginPage } = require('../page-objects/LoginPage');
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('login page', () => {
let loginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.goto();
});
test('successful login redirects to dashboard', async ({ page }) => {
await loginPage.loginAndWaitForDashboard('user@test.com', 'password');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('wrong password shows error', async () => {
await loginPage.login('user@test.com', 'wrong-password');
await loginPage.expectError('Invalid email or password');
});
test('empty fields show validation errors', async () => {
await loginPage.signInButton.click();
await loginPage.expectFieldError('email', 'Email is required');
});
});Use when: You want the fastest possible authentication without any browser interaction. Ideal for generating storageState files in global setup or fixtures.
Avoid when: You are specifically testing the login UI. Use the Login Page Object pattern instead.
API login is typically 5-10x faster than UI login. Use request.post() to hit your auth endpoint directly, then capture the resulting cookies.
TypeScript
// global-setup.ts — API-based login (fastest)
import { request, type FullConfig } from '@playwright/test';
async function globalSetup(config: FullConfig) {
const { baseURL } = config.projects[0].use;
const requestContext = await request.newContext({ baseURL });
// Login via API — no browser needed
const response = await requestContext.post('/api/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL!,
password: process.env.TEST_USER_PASSWORD!,
},
});
if (!response.ok()) {
throw new Error(`API login failed: ${response.status()} ${await response.text()}`);
}
// Save the session cookies from the API response
await requestContext.storageState({ path: '.auth/user.json' });
await requestContext.dispose();
}
export default globalSetup;// fixtures/api-auth.ts — fixture version for per-test authentication
import { test as base } from '@playwright/test';
export const test = base.extend({
authenticatedPage: async ({ browser, playwright }, use) => {
// Create API context and login
const apiContext = await playwright.request.newContext({
baseURL: 'http://localhost:3000',
});
await apiContext.post('/api/auth/login', {
data: {
email: 'user@test.com',
password: 'password',
},
});
// Transfer API session to browser context
const state = await apiContext.storageState();
const context = await browser.newContext({ storageState: state });
const page = await context.newPage();
await use(page);
await context.close();
await apiContext.dispose();
},
});
export { expect } from '@playwright/test';JavaScript
// global-setup.js — API-based login
const { request } = require('@playwright/test');
async function globalSetup(config) {
const { baseURL } = config.projects[0].use;
const requestContext = await request.newContext({ baseURL });
const response = await requestContext.post('/api/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD,
},
});
if (!response.ok()) {
throw new Error(`API login failed: ${response.status()} ${await response.text()}`);
}
await requestContext.storageState({ path: '.auth/user.json' });
await requestContext.dispose();
}
module.exports = globalSetup;// fixtures/api-auth.js
const { test: base } = require('@playwright/test');
const test = base.extend({
authenticatedPage: async ({ browser, playwright }, use) => {
const apiContext = await playwright.request.newContext({
baseURL: 'http://localhost:3000',
});
await apiContext.post('/api/auth/login', {
data: { email: 'user@test.com', password: 'password' },
});
const state = await apiContext.storageState();
const context = await browser.newContext({ storageState: state });
const page = await context.newPage();
await use(page);
await context.close();
await apiContext.dispose();
},
});
module.exports = { test, expect: require('@playwright/test').expect };Use when: Testing the login page, signup flow, password reset, public pages, authentication error handling, or redirect behavior for unauthenticated users. Avoid when: The test requires a logged-in user.
When your config sets a default storageState, you must explicitly clear it for unauthenticated tests.
TypeScript
// tests/public-pages.spec.ts
import { test, expect } from '@playwright/test';
// Override storageState to empty — no cookies, no session
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('unauthenticated access', () => {
test('homepage is accessible without login', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible();
});
test('protected route redirects to login', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForURL('**/login**');
// Verify redirect preserves the intended destination
expect(page.url()).toContain('redirect=%2Fdashboard');
});
test('expired session shows re-login prompt', async ({ page, context }) => {
// Start with a valid session, then invalidate it
await page.goto('/dashboard');
// Clear all cookies to simulate session expiration
await context.clearCookies();
// Next navigation should detect the invalid session
await page.goto('/settings');
await page.waitForURL('**/login**');
await expect(page.getByText('Your session has expired')).toBeVisible();
});
test('login page shows social auth options', async ({ page }) => {
await page.goto('/login');
await expect(page.getByRole('button', { name: 'Sign in with Google' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign in with GitHub' })).toBeVisible();
});
test('signup flow creates account', async ({ page }) => {
await page.goto('/signup');
await page.getByLabel('Name').fill('New User');
await page.getByLabel('Email').fill(`test-${Date.now()}@test.com`);
await page.getByLabel('Password', { exact: true }).fill('s3cure!Pass');
await page.getByLabel('Confirm password').fill('s3cure!Pass');
await page.getByRole('button', { name: 'Create account' }).click();
await page.waitForURL('/onboarding');
await expect(page.getByText('Welcome, New User')).toBeVisible();
});
});JavaScript
// tests/public-pages.spec.js
const { test, expect } = require('@playwright/test');
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('unauthenticated access', () => {
test('homepage is accessible without login', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible();
});
test('protected route redirects to login', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForURL('**/login**');
expect(page.url()).toContain('redirect=%2Fdashboard');
});
test('signup flow creates account', async ({ page }) => {
await page.goto('/signup');
await page.getByLabel('Name').fill('New User');
await page.getByLabel('Email').fill(`test-${Date.now()}@test.com`);
await page.getByLabel('Password', { exact: true }).fill('s3cure!Pass');
await page.getByLabel('Confirm password').fill('s3cure!Pass');
await page.getByRole('button', { name: 'Create account' }).click();
await page.waitForURL('/onboarding');
await expect(page.getByText('Welcome, New User')).toBeVisible();
});
});| Scenario | Approach | Speed | Isolation | When to Choose |
|---|---|---|---|---|
| Most tests need auth | Global setup + storageState |
Fastest | Shared session | Default for nearly every project. Login happens once, all tests reuse the session file. |
| Tests modify user state | Per-worker fixture | Fast | Per worker | Tests update profile, change settings, or mutate data that could conflict between parallel workers. |
| Multiple user roles | Per-project storageState |
Fastest | Per role | App has admin/user/viewer roles. Each project gets its own state file from global setup. |
| Testing the login page | No storageState |
N/A | Full | Use test.use({ storageState: { cookies: [], origins: [] } }) to override defaults. |
| OAuth/SSO provider | Mock the callback | Fast | Per test | Never hit real OAuth providers in CI. Mock the redirect or use API session injection. |
| MFA is required | TOTP generation or bypass | Moderate | Per test | Generate real TOTP codes from a shared secret, or use a test-mode bypass code. |
| Token expires mid-suite | Session refresh fixture | Fast | Per check | Fixture validates the session before use and re-authenticates if expired. |
| Single test needs different user | loginAs(role) fixture |
Moderate | Per call | Rare: prefer per-project roles. Use when a single test must compare two roles side by side. |
| API-first app (no login UI) | API login via request.post() |
Fastest | Per test | No browser needed for auth. Hit the API directly and capture cookies. |
| CI with long-running suites | API login + session check | Fastest | Per worker | Combine API login speed with session refresh for reliability over long runs. |
Need to test the login page itself?
├── Yes → UI login with LoginPage POM, no storageState
└── No → Do you have a login API endpoint?
├── Yes → API login in global setup, save storageState (fastest)
└── No → UI login in global setup, save storageState
└── Tokens expire quickly?
├── Yes → Add session refresh fixture
└── No → Standard storageState reuse is fine
| Don't Do This | Problem | Do This Instead |
|---|---|---|
| Log in via UI before every test | Adds 2-5 seconds per test. A suite of 200 tests wastes 7-17 minutes just logging in. | Use storageState to skip login entirely. Log in once in global setup. |
| Share a single auth state file across parallel workers that mutate state | Race conditions: worker A changes the password while worker B is mid-test. | Use per-worker fixtures with { scope: 'worker' } or per-worker test accounts. |
| Hardcode credentials in test files | Security risk. Credentials leak into version control and CI logs. | Use environment variables (process.env.TEST_USER_PASSWORD) and .env files. |
| Ignore token expiration | Tests fail intermittently with 401 errors after running for a while. | Add a session validity check in your auth fixture and re-authenticate when expired. |
| Hit real OAuth providers in CI | Flaky: provider rate limits, CAPTCHA, network issues. Slow. May violate ToS. | Mock the OAuth callback or use API session injection with a test-only endpoint. |
Use page.waitForTimeout(2000) after login |
Arbitrary delay. Too slow in fast environments, too short in slow ones. | await page.waitForURL('/dashboard') or await expect(heading).toBeVisible(). |
Store .auth/*.json files in git |
Tokens and cookies in version control. Security incident waiting to happen. | Add .auth/ to .gitignore. Generate auth state in CI as part of the test run. |
| Create one "god" test account with all permissions | Cannot test role-based access control. Bugs in permission checks go undetected. | Create separate accounts per role (admin, user, viewer) with appropriate permissions. |
Use browser.newContext() without storageState for authenticated tests |
Every context starts unauthenticated. You end up re-logging in constantly. | Pass storageState when creating the context: browser.newContext({ storageState: '.auth/user.json' }). |
| Test MFA by disabling it everywhere | You never test the MFA flow. Real users hit MFA bugs you never catch. | Use TOTP generation from a shared secret for at least one test. Bypass MFA for the rest. |
Cause: The login page redirected unexpectedly, or the browser closed before storageState() was called. Common when the login URL requires HTTPS but your test server uses HTTP.
Fix:
- Add
await page.waitForURL()after the login action to ensure navigation completed. - Check that
baseURLin your config matches the actual server URL and protocol. - Add error handling to global setup with a meaningful error message:
const response = await page.waitForResponse('**/api/auth/**');
if (!response.ok()) {
throw new Error(`Login failed in global setup: ${response.status()} ${await response.text()}`);
}Cause: The session token saved in storageState has expired. Short-lived JWTs (15-minute expiry) will not survive a long test suite.
Fix:
- Use the session refresh fixture pattern (see Session Refresh section).
- Increase token expiry in test environment configuration.
- Switch to API-based login in a worker-scoped fixture that re-authenticates per worker.
Cause: storageState() was called before the login response set cookies. The login may be async (POST returns, then a redirect sets the cookie).
Fix:
- Wait for the post-login page to load:
await page.waitForURL('/dashboard'). - Wait for a specific cookie: use
await context.cookies()in a polling check. - Verify cookies exist before saving:
const cookies = await context.cookies();
if (cookies.length === 0) {
throw new Error('No cookies found after login. Check that the login flow sets cookies.');
}
await context.storageState({ path: '.auth/user.json' });Cause: Some auth flows set cookies with SameSite=Strict or use browser-specific cookie behavior. Chromium and Firefox handle third-party cookies differently.
Fix:
- Generate separate auth state files per browser project.
- Check if your auth uses
SameSite=None; Securecookies that require HTTPS. - In
playwright.config, set separatestorageStatepaths per project:
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: '.auth/chromium-user.json' },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'], storageState: '.auth/firefox-user.json' },
},
],Cause: Multiple workers share the same test account and one worker's actions (logout, password change, session invalidation) affect others.
Fix:
- Use per-worker test accounts:
worker-${test.info().parallelIndex}@test.com. - Use the per-worker authentication fixture pattern (see Per-Worker Authentication section).
- Make tests idempotent: each test should work regardless of what other tests did.
Cause: page.route() was registered after the navigation that triggers the OAuth redirect, or the route pattern does not match the actual redirect URL.
Fix:
- Register route handlers before any navigation: call
page.route()beforepage.goto(). - Log the actual redirect URL to verify the pattern:
page.on('request', (req) => {
if (req.url().includes('oauth') || req.url().includes('accounts.google')) {
console.log('OAuth request:', req.url());
}
});- Use a broad pattern first (
**accounts.google.com**) then narrow it down.
- core/fixtures-and-hooks.md -- custom fixtures for auth setup and teardown
- core/configuration.md --
storageState, projects, and global setup configuration - ci/global-setup-teardown.md -- global setup patterns and project dependencies
- core/network-mocking.md -- route interception patterns used in OAuth mocking
- core/api-testing.md -- API request context used in API-based login
- core/flaky-tests.md -- diagnosing auth-related flakiness
- core/auth-flows.md -- complete recipes for specific auth providers (Auth0, Okta, Firebase)