mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 17:22:38 +02:00
1410 lines
49 KiB
Markdown
1410 lines
49 KiB
Markdown
|
|
# Authentication Testing
|
||
|
|
|
||
|
|
> **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](configuration.md), [core/fixtures-and-hooks.md](fixtures-and-hooks.md)
|
||
|
|
|
||
|
|
## Quick Reference
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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' });
|
||
|
|
```
|
||
|
|
|
||
|
|
## Patterns
|
||
|
|
|
||
|
|
### Storage State Reuse
|
||
|
|
|
||
|
|
**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**
|
||
|
|
```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();
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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**
|
||
|
|
```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();
|
||
|
|
```
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// playwright.config.js
|
||
|
|
const { defineConfig } = require('@playwright/test');
|
||
|
|
|
||
|
|
module.exports = defineConfig({
|
||
|
|
use: {
|
||
|
|
baseURL: 'http://localhost:3000',
|
||
|
|
storageState: '.auth/user.json',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### In-Place State Refresh With `setStorageState()` (Playwright 1.59+)
|
||
|
|
|
||
|
|
**Use when**: You already have a browser context open and want to replace its cookies and local storage without destroying the context and creating a new one.
|
||
|
|
**Avoid when**: Starting a brand-new isolated context is simpler, or when the test itself is validating the login flow from scratch.
|
||
|
|
|
||
|
|
Playwright 1.59 added `browserContext.setStorageState()`, which clears the current cookies, local storage, and IndexedDB state and applies a new storage snapshot in-place.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
import { test, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
test('swap to a different authenticated state without recreating the context', async ({ page, context }) => {
|
||
|
|
await page.goto('/dashboard');
|
||
|
|
|
||
|
|
await context.setStorageState('.auth/admin.json');
|
||
|
|
await page.reload();
|
||
|
|
|
||
|
|
await expect(page.getByRole('heading', { name: 'Admin dashboard' })).toBeVisible();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
const { test, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
test('swap to a different authenticated state without recreating the context', async ({ page, context }) => {
|
||
|
|
await page.goto('/dashboard');
|
||
|
|
|
||
|
|
await context.setStorageState('.auth/admin.json');
|
||
|
|
await page.reload();
|
||
|
|
|
||
|
|
await expect(page.getByRole('heading', { name: 'Admin dashboard' })).toBeVisible();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Global Setup Authentication
|
||
|
|
|
||
|
|
**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**
|
||
|
|
```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;
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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**
|
||
|
|
```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;
|
||
|
|
```
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// 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.
|
||
|
|
|
||
|
|
### Per-Worker Authentication
|
||
|
|
|
||
|
|
**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**
|
||
|
|
```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';
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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**
|
||
|
|
```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 };
|
||
|
|
```
|
||
|
|
|
||
|
|
### Multiple Roles
|
||
|
|
|
||
|
|
**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**
|
||
|
|
```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;
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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',
|
||
|
|
},
|
||
|
|
],
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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**
|
||
|
|
```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;
|
||
|
|
```
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// 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.
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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';
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### OAuth/SSO Mocking
|
||
|
|
|
||
|
|
**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**
|
||
|
|
```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';
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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**
|
||
|
|
```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();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
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.
|
||
|
|
|
||
|
|
### MFA Handling
|
||
|
|
|
||
|
|
**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**
|
||
|
|
```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();
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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**
|
||
|
|
```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 };
|
||
|
|
```
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// 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`.
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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.
|
||
|
|
|
||
|
|
### Session Refresh
|
||
|
|
|
||
|
|
**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**
|
||
|
|
```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';
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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**
|
||
|
|
```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 };
|
||
|
|
```
|
||
|
|
|
||
|
|
### Login Page Object
|
||
|
|
|
||
|
|
**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**
|
||
|
|
```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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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**
|
||
|
|
```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 };
|
||
|
|
```
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// 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');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### API-Based Login
|
||
|
|
|
||
|
|
**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**
|
||
|
|
```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;
|
||
|
|
```
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// 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**
|
||
|
|
```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;
|
||
|
|
```
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// 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 };
|
||
|
|
```
|
||
|
|
|
||
|
|
### Unauthenticated Tests
|
||
|
|
|
||
|
|
**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**
|
||
|
|
```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**
|
||
|
|
```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();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## Decision Guide
|
||
|
|
|
||
|
|
| 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. |
|
||
|
|
|
||
|
|
### UI Login vs API Login vs Storage State
|
||
|
|
|
||
|
|
```
|
||
|
|
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
|
||
|
|
```
|
||
|
|
|
||
|
|
## Anti-Patterns
|
||
|
|
|
||
|
|
| 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. |
|
||
|
|
|
||
|
|
## Troubleshooting
|
||
|
|
|
||
|
|
### Global setup fails with "Target page, context or browser has been closed"
|
||
|
|
|
||
|
|
**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 `baseURL` in your config matches the actual server URL and protocol.
|
||
|
|
- Add error handling to global setup with a meaningful error message:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const response = await page.waitForResponse('**/api/auth/**');
|
||
|
|
if (!response.ok()) {
|
||
|
|
throw new Error(`Login failed in global setup: ${response.status()} ${await response.text()}`);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Tests fail with 401 Unauthorized after running for a while
|
||
|
|
|
||
|
|
**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.
|
||
|
|
|
||
|
|
### `storageState` file is empty or contains no cookies
|
||
|
|
|
||
|
|
**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:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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' });
|
||
|
|
```
|
||
|
|
|
||
|
|
### Different browsers get different cookies (cross-browser auth issues)
|
||
|
|
|
||
|
|
**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; Secure` cookies that require HTTPS.
|
||
|
|
- In `playwright.config`, set separate `storageState` paths per project:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
projects: [
|
||
|
|
{
|
||
|
|
name: 'chromium',
|
||
|
|
use: { ...devices['Desktop Chrome'], storageState: '.auth/chromium-user.json' },
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'firefox',
|
||
|
|
use: { ...devices['Desktop Firefox'], storageState: '.auth/firefox-user.json' },
|
||
|
|
},
|
||
|
|
],
|
||
|
|
```
|
||
|
|
|
||
|
|
### Parallel tests interfere with each other's sessions
|
||
|
|
|
||
|
|
**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.
|
||
|
|
|
||
|
|
### OAuth mock does not work — still redirects to real provider
|
||
|
|
|
||
|
|
**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()` before `page.goto()`.
|
||
|
|
- Log the actual redirect URL to verify the pattern:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
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.
|
||
|
|
|
||
|
|
## Related
|
||
|
|
|
||
|
|
- [core/fixtures-and-hooks.md](fixtures-and-hooks.md) -- custom fixtures for auth setup and teardown
|
||
|
|
- [core/configuration.md](configuration.md) -- `storageState`, projects, and global setup configuration
|
||
|
|
- [ci/global-setup-teardown.md](../ci/global-setup-teardown.md) -- global setup patterns and project dependencies
|
||
|
|
- [core/network-mocking.md](network-mocking.md) -- route interception patterns used in OAuth mocking
|
||
|
|
- [core/api-testing.md](api-testing.md) -- API request context used in API-based login
|
||
|
|
- [core/flaky-tests.md](flaky-tests.md) -- diagnosing auth-related flakiness
|
||
|
|
- [core/auth-flows.md](auth-flows.md) -- complete recipes for specific auth providers (Auth0, Okta, Firebase)
|