33 KiB
Executable file
Authentication Flow Recipes
When to use: You need to test login, signup, logout, session management, or any authentication-related user flow.
Recipe 1: Basic Login
Complete Example
TypeScript
import { test, expect } from '@playwright/test';
test('logs in with valid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('SecurePass123!');
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for navigation to complete after login
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByText('Welcome, user@example.com')).toBeVisible();
});
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('WrongPassword');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('alert')).toContainText('Invalid email or password');
// Should remain on login page
await expect(page).toHaveURL('/login');
});
test('shows validation errors for empty fields', async ({ page }) => {
await page.goto('/login');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText('Email is required')).toBeVisible();
await expect(page.getByText('Password is required')).toBeVisible();
});
JavaScript
const { test, expect } = require('@playwright/test');
test('logs in with valid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('SecurePass123!');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByText('Welcome, user@example.com')).toBeVisible();
});
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('WrongPassword');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('alert')).toContainText('Invalid email or password');
await expect(page).toHaveURL('/login');
});
test('shows validation errors for empty fields', async ({ page }) => {
await page.goto('/login');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText('Email is required')).toBeVisible();
await expect(page.getByText('Password is required')).toBeVisible();
});
Recipe 2: Login with "Remember Me"
Complete Example
TypeScript
import { test, expect, type BrowserContext } from '@playwright/test';
test('remember me persists session across browser restarts', async ({ browser }) => {
// First session: log in with "remember me" checked
const context1 = await browser.newContext();
const page1 = await context1.newPage();
await page1.goto('/login');
await page1.getByLabel('Email').fill('user@example.com');
await page1.getByLabel('Password').fill('SecurePass123!');
await page1.getByLabel('Remember me').check();
await page1.getByRole('button', { name: 'Sign in' }).click();
await expect(page1).toHaveURL('/dashboard');
// Save storage state (cookies + localStorage)
const storageState = await context1.storageState();
await context1.close();
// Second session: restore state to simulate browser restart
const context2 = await browser.newContext({ storageState });
const page2 = await context2.newPage();
await page2.goto('/dashboard');
// Should still be logged in without re-authenticating
await expect(page2).toHaveURL('/dashboard');
await expect(page2.getByText('Welcome, user@example.com')).toBeVisible();
await context2.close();
});
test('no remember me does not persist session', async ({ browser }) => {
const context1 = await browser.newContext();
const page1 = await context1.newPage();
await page1.goto('/login');
await page1.getByLabel('Email').fill('user@example.com');
await page1.getByLabel('Password').fill('SecurePass123!');
// Explicitly leave "Remember me" unchecked
await expect(page1.getByLabel('Remember me')).not.toBeChecked();
await page1.getByRole('button', { name: 'Sign in' }).click();
await expect(page1).toHaveURL('/dashboard');
// Capture only cookies (not sessionStorage) to simulate new tab/window
const cookies = await context1.cookies();
await context1.close();
// New context with only persistent cookies (session cookies are excluded)
const persistentCookies = cookies.filter(c => c.expires > 0);
const context2 = await browser.newContext();
await context2.addCookies(persistentCookies);
const page2 = await context2.newPage();
await page2.goto('/dashboard');
// Should redirect to login since session was not persisted
await expect(page2).toHaveURL(/\/login/);
await context2.close();
});
JavaScript
const { test, expect } = require('@playwright/test');
test('remember me persists session across browser restarts', async ({ browser }) => {
const context1 = await browser.newContext();
const page1 = await context1.newPage();
await page1.goto('/login');
await page1.getByLabel('Email').fill('user@example.com');
await page1.getByLabel('Password').fill('SecurePass123!');
await page1.getByLabel('Remember me').check();
await page1.getByRole('button', { name: 'Sign in' }).click();
await expect(page1).toHaveURL('/dashboard');
const storageState = await context1.storageState();
await context1.close();
const context2 = await browser.newContext({ storageState });
const page2 = await context2.newPage();
await page2.goto('/dashboard');
await expect(page2).toHaveURL('/dashboard');
await expect(page2.getByText('Welcome, user@example.com')).toBeVisible();
await context2.close();
});
Recipe 3: Signup with Email Verification (Mocked)
Complete Example
TypeScript
import { test, expect } from '@playwright/test';
test('completes signup flow with mocked email verification', async ({ page }) => {
// Intercept the verification email API so we can capture the token
let verificationToken = '';
await page.route('**/api/auth/send-verification', async (route) => {
// Let the request go through to the server
const response = await route.fetch();
const body = await response.json();
// Capture the token from the API response (test environments expose this)
verificationToken = body.verificationToken;
await route.fulfill({ response });
});
// Step 1: Fill out signup form
await page.goto('/signup');
await page.getByLabel('Full name').fill('Jane Tester');
await page.getByLabel('Email').fill('jane@example.com');
await page.getByLabel('Password', { exact: true }).fill('SecurePass123!');
await page.getByLabel('Confirm password').fill('SecurePass123!');
await page.getByLabel('I agree to the Terms of Service').check();
await page.getByRole('button', { name: 'Create account' }).click();
// Step 2: Verify "check your email" screen appears
await expect(page.getByText('Check your email')).toBeVisible();
await expect(page.getByText('jane@example.com')).toBeVisible();
// Step 3: Navigate directly to verification URL using captured token
expect(verificationToken).toBeTruthy();
await page.goto(`/verify-email?token=${verificationToken}`);
// Step 4: Verify account is now active
await expect(page.getByText('Email verified successfully')).toBeVisible();
await expect(page).toHaveURL('/login');
// Step 5: Log in with the new account
await page.getByLabel('Email').fill('jane@example.com');
await page.getByLabel('Password').fill('SecurePass123!');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome, Jane Tester')).toBeVisible();
});
test('signup with fully mocked email API (no server dependency)', async ({ page }) => {
const fakeToken = 'test-verification-token-12345';
// Mock the signup API endpoint
await page.route('**/api/auth/signup', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
message: 'Verification email sent',
verificationToken: fakeToken,
}),
});
});
// Mock the verification endpoint
await page.route(`**/api/auth/verify-email?token=${fakeToken}`, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ message: 'Email verified', redirectTo: '/login' }),
});
});
await page.goto('/signup');
await page.getByLabel('Full name').fill('Jane Tester');
await page.getByLabel('Email').fill('jane@example.com');
await page.getByLabel('Password', { exact: true }).fill('SecurePass123!');
await page.getByLabel('Confirm password').fill('SecurePass123!');
await page.getByLabel('I agree to the Terms of Service').check();
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByText('Check your email')).toBeVisible();
// Simulate clicking the verification link from the email
await page.goto(`/verify-email?token=${fakeToken}`);
await expect(page.getByText('Email verified successfully')).toBeVisible();
});
JavaScript
const { test, expect } = require('@playwright/test');
test('completes signup flow with mocked email verification', async ({ page }) => {
let verificationToken = '';
await page.route('**/api/auth/send-verification', async (route) => {
const response = await route.fetch();
const body = await response.json();
verificationToken = body.verificationToken;
await route.fulfill({ response });
});
await page.goto('/signup');
await page.getByLabel('Full name').fill('Jane Tester');
await page.getByLabel('Email').fill('jane@example.com');
await page.getByLabel('Password', { exact: true }).fill('SecurePass123!');
await page.getByLabel('Confirm password').fill('SecurePass123!');
await page.getByLabel('I agree to the Terms of Service').check();
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByText('Check your email')).toBeVisible();
expect(verificationToken).toBeTruthy();
await page.goto(`/verify-email?token=${verificationToken}`);
await expect(page.getByText('Email verified successfully')).toBeVisible();
await expect(page).toHaveURL('/login');
});
Recipe 4: Password Reset Flow
Complete Example
TypeScript
import { test, expect } from '@playwright/test';
test('completes password reset flow', async ({ page }) => {
let resetToken = '';
// Intercept the password reset API to capture the reset token
await page.route('**/api/auth/forgot-password', async (route) => {
const response = await route.fetch();
const body = await response.json();
resetToken = body.resetToken;
await route.fulfill({ response });
});
// Step 1: Request password reset
await page.goto('/forgot-password');
await page.getByLabel('Email').fill('user@example.com');
await page.getByRole('button', { name: 'Send reset link' }).click();
await expect(page.getByText('Reset link sent')).toBeVisible();
await expect(page.getByText('user@example.com')).toBeVisible();
// Step 2: Navigate to the reset page with token
expect(resetToken).toBeTruthy();
await page.goto(`/reset-password?token=${resetToken}`);
// Step 3: Set new password
await page.getByLabel('New password', { exact: true }).fill('NewSecurePass456!');
await page.getByLabel('Confirm new password').fill('NewSecurePass456!');
await page.getByRole('button', { name: 'Reset password' }).click();
await expect(page.getByText('Password reset successfully')).toBeVisible();
// Step 4: Log in with new password
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('NewSecurePass456!');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
});
test('password reset with expired token shows error', async ({ page }) => {
await page.goto('/reset-password?token=expired-token-123');
await page.getByLabel('New password', { exact: true }).fill('NewSecurePass456!');
await page.getByLabel('Confirm new password').fill('NewSecurePass456!');
await page.getByRole('button', { name: 'Reset password' }).click();
await expect(page.getByRole('alert')).toContainText(
/token has expired|link is no longer valid/i
);
await expect(page.getByRole('link', { name: 'Request a new reset link' })).toBeVisible();
});
test('password reset enforces password strength requirements', async ({ page }) => {
await page.goto('/reset-password?token=valid-token');
// Try a weak password
await page.getByLabel('New password', { exact: true }).fill('123');
await page.getByLabel('Confirm new password').fill('123');
await page.getByRole('button', { name: 'Reset password' }).click();
await expect(page.getByText(/at least 8 characters/i)).toBeVisible();
});
JavaScript
const { test, expect } = require('@playwright/test');
test('completes password reset flow', async ({ page }) => {
let resetToken = '';
await page.route('**/api/auth/forgot-password', async (route) => {
const response = await route.fetch();
const body = await response.json();
resetToken = body.resetToken;
await route.fulfill({ response });
});
await page.goto('/forgot-password');
await page.getByLabel('Email').fill('user@example.com');
await page.getByRole('button', { name: 'Send reset link' }).click();
await expect(page.getByText('Reset link sent')).toBeVisible();
expect(resetToken).toBeTruthy();
await page.goto(`/reset-password?token=${resetToken}`);
await page.getByLabel('New password', { exact: true }).fill('NewSecurePass456!');
await page.getByLabel('Confirm new password').fill('NewSecurePass456!');
await page.getByRole('button', { name: 'Reset password' }).click();
await expect(page.getByText('Password reset successfully')).toBeVisible();
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('NewSecurePass456!');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
});
Recipe 5: OAuth Login (Mocked Callback)
Complete Example
TypeScript
import { test, expect } from '@playwright/test';
test('logs in via mocked Google OAuth callback', async ({ page }) => {
// Intercept the OAuth redirect to Google and simulate the callback
await page.route('**/accounts.google.com/**', async (route) => {
// Instead of actually going to Google, redirect back to our callback URL
// with a mock authorization code
const callbackUrl = new URL('/auth/callback/google', 'http://localhost:3000');
callbackUrl.searchParams.set('code', 'mock-auth-code-12345');
callbackUrl.searchParams.set('state', route.request().url().match(/state=([^&]+)/)?.[1] || '');
await route.fulfill({
status: 302,
headers: { location: callbackUrl.toString() },
});
});
// Mock the token exchange on our backend
await page.route('**/api/auth/callback/google**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
user: {
id: '1',
email: 'googleuser@gmail.com',
name: 'Google User',
avatar: 'https://example.com/avatar.jpg',
},
token: 'mock-jwt-token',
}),
});
});
await page.goto('/login');
// Click the OAuth button
await page.getByRole('button', { name: /Sign in with Google/i }).click();
// After the mocked OAuth flow, we should land on the dashboard
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Google User')).toBeVisible();
});
test('handles OAuth login failure gracefully', async ({ page }) => {
// Simulate OAuth provider returning an error
await page.route('**/accounts.google.com/**', async (route) => {
const callbackUrl = new URL('/auth/callback/google', 'http://localhost:3000');
callbackUrl.searchParams.set('error', 'access_denied');
callbackUrl.searchParams.set('error_description', 'User denied access');
await route.fulfill({
status: 302,
headers: { location: callbackUrl.toString() },
});
});
await page.goto('/login');
await page.getByRole('button', { name: /Sign in with Google/i }).click();
await expect(page.getByRole('alert')).toContainText(/authentication failed|access denied/i);
await expect(page).toHaveURL(/\/login/);
});
JavaScript
const { test, expect } = require('@playwright/test');
test('logs in via mocked Google OAuth callback', async ({ page }) => {
await page.route('**/accounts.google.com/**', async (route) => {
const callbackUrl = new URL('/auth/callback/google', 'http://localhost:3000');
callbackUrl.searchParams.set('code', 'mock-auth-code-12345');
callbackUrl.searchParams.set('state', route.request().url().match(/state=([^&]+)/)?.[1] || '');
await route.fulfill({
status: 302,
headers: { location: callbackUrl.toString() },
});
});
await page.route('**/api/auth/callback/google**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
user: { id: '1', email: 'googleuser@gmail.com', name: 'Google User' },
token: 'mock-jwt-token',
}),
});
});
await page.goto('/login');
await page.getByRole('button', { name: /Sign in with Google/i }).click();
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Google User')).toBeVisible();
});
Recipe 6: Role-Based Access Testing
Complete Example
TypeScript
import { test, expect, type Page } from '@playwright/test';
import path from 'path';
// Store auth state for each role in separate files
const authDir = path.resolve(__dirname, '../.auth');
// Setup: create auth state files for each role (run in globalSetup or auth.setup)
// This pattern uses Playwright's storage state for efficient role switching
const roles = ['admin', 'editor', 'viewer'] as const;
type Role = typeof roles[number];
function authFile(role: Role): string {
return path.join(authDir, `${role}.json`);
}
// Auth setup project — runs before all tests
test.describe('auth setup', () => {
const credentials: Record<Role, { email: string; password: string }> = {
admin: { email: 'admin@example.com', password: 'AdminPass123!' },
editor: { email: 'editor@example.com', password: 'EditorPass123!' },
viewer: { email: 'viewer@example.com', password: 'ViewerPass123!' },
};
for (const role of roles) {
test(`authenticate as ${role}`, async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(credentials[role].email);
await page.getByLabel('Password').fill(credentials[role].password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
await page.context().storageState({ path: authFile(role) });
});
}
});
// Admin tests
test.describe('admin access', () => {
test.use({ storageState: authFile('admin') });
test('admin can access user management', async ({ page }) => {
await page.goto('/admin/users');
await expect(page).toHaveURL('/admin/users');
await expect(page.getByRole('heading', { name: 'User Management' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Add user' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Delete' }).first()).toBeVisible();
});
test('admin can access system settings', async ({ page }) => {
await page.goto('/admin/settings');
await expect(page).toHaveURL('/admin/settings');
await expect(page.getByRole('heading', { name: 'System Settings' })).toBeVisible();
});
});
// Editor tests
test.describe('editor access', () => {
test.use({ storageState: authFile('editor') });
test('editor can create and edit content', async ({ page }) => {
await page.goto('/content');
await expect(page.getByRole('button', { name: 'New post' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Edit' }).first()).toBeVisible();
});
test('editor cannot access admin area', async ({ page }) => {
await page.goto('/admin/users');
// Should be redirected or shown forbidden
await expect(page).toHaveURL(/\/(403|dashboard|login)/);
});
});
// Viewer tests
test.describe('viewer access', () => {
test.use({ storageState: authFile('viewer') });
test('viewer can read content', async ({ page }) => {
await page.goto('/content');
await expect(page.getByRole('article').first()).toBeVisible();
});
test('viewer cannot create content', async ({ page }) => {
await page.goto('/content');
await expect(page.getByRole('button', { name: 'New post' })).not.toBeVisible();
});
test('viewer cannot access admin area', async ({ page }) => {
await page.goto('/admin/users');
await expect(page).toHaveURL(/\/(403|dashboard|login)/);
});
});
JavaScript
const { test, expect } = require('@playwright/test');
const path = require('path');
const authDir = path.resolve(__dirname, '../.auth');
function authFile(role) {
return path.join(authDir, `${role}.json`);
}
// Admin tests
test.describe('admin access', () => {
test.use({ storageState: authFile('admin') });
test('admin can access user management', async ({ page }) => {
await page.goto('/admin/users');
await expect(page).toHaveURL('/admin/users');
await expect(page.getByRole('heading', { name: 'User Management' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Add user' })).toBeVisible();
});
test('admin can access system settings', async ({ page }) => {
await page.goto('/admin/settings');
await expect(page).toHaveURL('/admin/settings');
});
});
// Viewer tests
test.describe('viewer access', () => {
test.use({ storageState: authFile('viewer') });
test('viewer cannot create content', async ({ page }) => {
await page.goto('/content');
await expect(page.getByRole('button', { name: 'New post' })).not.toBeVisible();
});
test('viewer cannot access admin area', async ({ page }) => {
await page.goto('/admin/users');
await expect(page).toHaveURL(/\/(403|dashboard|login)/);
});
});
Recipe 7: Session Timeout Handling
Complete Example
TypeScript
import { test, expect } from '@playwright/test';
test('redirects to login after session timeout', async ({ page, context }) => {
// Log in first
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('SecurePass123!');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
// Simulate session expiration by clearing the session cookie
const cookies = await context.cookies();
const sessionCookie = cookies.find(
(c) => c.name === 'session' || c.name === 'sid' || c.name === 'connect.sid'
);
if (sessionCookie) {
await context.clearCookies({ name: sessionCookie.name });
}
// Try to navigate to a protected page
await page.goto('/settings');
// Should redirect to login with a return URL
await expect(page).toHaveURL(/\/login/);
await expect(page.getByText(/session.*expired|please.*log in again/i)).toBeVisible();
});
test('shows session expiry warning before timeout', async ({ page }) => {
// Mock the session check endpoint to return "expiring soon"
await page.route('**/api/auth/session', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
valid: true,
expiresIn: 120, // 2 minutes remaining
}),
});
});
await page.goto('/dashboard');
// The app should show a warning when session is about to expire
await expect(page.getByText(/session.*expir/i)).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('button', { name: /extend|stay logged in/i })).toBeVisible();
});
test('extends session when user clicks extend', async ({ page }) => {
let sessionExtended = false;
await page.route('**/api/auth/session', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ valid: true, expiresIn: 120 }),
});
});
await page.route('**/api/auth/refresh', async (route) => {
sessionExtended = true;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ valid: true, expiresIn: 3600 }),
});
});
await page.goto('/dashboard');
await expect(page.getByRole('button', { name: /extend|stay logged in/i })).toBeVisible({
timeout: 10000,
});
await page.getByRole('button', { name: /extend|stay logged in/i }).click();
expect(sessionExtended).toBe(true);
await expect(page.getByText(/session.*expir/i)).not.toBeVisible();
});
JavaScript
const { test, expect } = require('@playwright/test');
test('redirects to login after session timeout', async ({ page, context }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('SecurePass123!');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
const cookies = await context.cookies();
const sessionCookie = cookies.find(
(c) => c.name === 'session' || c.name === 'sid' || c.name === 'connect.sid'
);
if (sessionCookie) {
await context.clearCookies({ name: sessionCookie.name });
}
await page.goto('/settings');
await expect(page).toHaveURL(/\/login/);
await expect(page.getByText(/session.*expired|please.*log in again/i)).toBeVisible();
});
Recipe 8: Logout
Complete Example
TypeScript
import { test, expect } from '@playwright/test';
// Use a logged-in state
test.use({ storageState: '.auth/user.json' });
test('logs out and redirects to login page', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByText('Welcome')).toBeVisible();
// Open user menu and click logout
await page.getByRole('button', { name: /user menu|account|profile/i }).click();
await page.getByRole('menuitem', { name: 'Log out' }).click();
// Should redirect to login
await expect(page).toHaveURL('/login');
// Verify we can no longer access protected pages
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login/);
});
test('logout clears all session data', async ({ page, context }) => {
await page.goto('/dashboard');
await page.getByRole('button', { name: /user menu|account|profile/i }).click();
await page.getByRole('menuitem', { name: 'Log out' }).click();
await expect(page).toHaveURL('/login');
// Verify cookies are cleared
const cookies = await context.cookies();
const sessionCookies = cookies.filter(
(c) => c.name === 'session' || c.name === 'sid' || c.name === 'token'
);
expect(sessionCookies).toHaveLength(0);
// Verify localStorage is cleared
const token = await page.evaluate(() => localStorage.getItem('authToken'));
expect(token).toBeNull();
});
test('logout from all devices', async ({ page }) => {
let logoutAllCalled = false;
await page.route('**/api/auth/logout-all', async (route) => {
logoutAllCalled = true;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ message: 'Logged out from all devices' }),
});
});
await page.goto('/settings/security');
await page.getByRole('button', { name: 'Log out of all devices' }).click();
// Confirm the action in the dialog
await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();
expect(logoutAllCalled).toBe(true);
await expect(page).toHaveURL(/\/login/);
});
JavaScript
const { test, expect } = require('@playwright/test');
test.use({ storageState: '.auth/user.json' });
test('logs out and redirects to login page', async ({ page }) => {
await page.goto('/dashboard');
await page.getByRole('button', { name: /user menu|account|profile/i }).click();
await page.getByRole('menuitem', { name: 'Log out' }).click();
await expect(page).toHaveURL('/login');
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login/);
});
test('logout clears all session data', async ({ page, context }) => {
await page.goto('/dashboard');
await page.getByRole('button', { name: /user menu|account|profile/i }).click();
await page.getByRole('menuitem', { name: 'Log out' }).click();
await expect(page).toHaveURL('/login');
const cookies = await context.cookies();
const sessionCookies = cookies.filter(
(c) => c.name === 'session' || c.name === 'sid' || c.name === 'token'
);
expect(sessionCookies).toHaveLength(0);
});
Variations
Login via API for Speed
Skip the UI for login when testing non-auth features. Use request context to get tokens faster.
TypeScript
import { test as setup } from '@playwright/test';
setup('authenticate via API', async ({ request }) => {
const response = await request.post('/api/auth/login', {
data: {
email: 'user@example.com',
password: 'SecurePass123!',
},
});
expect(response.ok()).toBeTruthy();
// Save the auth state
await request.storageState({ path: '.auth/user.json' });
});
Multi-Factor Authentication
test('logs in with MFA (TOTP)', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('mfa-user@example.com');
await page.getByLabel('Password').fill('SecurePass123!');
await page.getByRole('button', { name: 'Sign in' }).click();
// MFA challenge screen appears
await expect(page.getByText('Enter verification code')).toBeVisible();
// In test environments, use a predictable TOTP code or mock the verification
await page.route('**/api/auth/verify-mfa', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, token: 'jwt-token' }),
});
});
await page.getByLabel('Verification code').fill('123456');
await page.getByRole('button', { name: 'Verify' }).click();
await expect(page).toHaveURL('/dashboard');
});
Testing Auth with Different Browser Contexts
test('two users interact simultaneously', async ({ browser }) => {
const adminContext = await browser.newContext({
storageState: '.auth/admin.json',
});
const userContext = await browser.newContext({
storageState: '.auth/viewer.json',
});
const adminPage = await adminContext.newPage();
const userPage = await userContext.newPage();
// Admin bans a user
await adminPage.goto('/admin/users');
await adminPage.getByRole('row', { name: 'viewer@example.com' })
.getByRole('button', { name: 'Ban' }).click();
await adminPage.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();
// User tries to navigate and should be blocked
await userPage.goto('/dashboard');
await expect(userPage.getByText(/account.*banned|access.*revoked/i)).toBeVisible();
await adminContext.close();
await userContext.close();
});
Tips
-
Use
storageStatefor reusable auth. Run login once in a setup project, save the state to a JSON file, and reuse it across all tests. This dramatically speeds up your test suite since you only log in once per role. -
Never hard-code credentials in test files. Use environment variables or a
.envfile loaded viadotenv. Inplaywright.config.ts, setprocess.envvalues or use theenvoption inuse. -
Prefer API-based login for non-auth tests. Only test the login UI in your auth-specific tests. For everything else, authenticate via the API or by restoring
storageState. This makes tests faster and less brittle. -
Test the negative paths too. Invalid credentials, expired tokens, locked accounts, rate limiting. These are the scenarios users actually encounter in production and are frequently undertested.
-
Set a shorter session timeout in your test environment. If your app has a 30-minute session timeout, configure it to 30 seconds in the test environment so you can test timeout behavior without slow tests.
Related
- Playwright Authentication Docs
patterns/page-objects.md-- Encapsulate login flows in page objectsfoundations/assertions.md-- Assertion patterns for auth statesrecipes/crud-testing.md-- Test CRUD operations once authenticated