
Playwright Best Practices
- 65.5k installs
- 341 repo stars
- Updated July 21, 2026
- currents-dev/playwright-best-practices-skill
playwright-best-practices is a comprehensive Claude Code skill providing guidance for Playwright test development across all testing types and scenarios.
About
Comprehensive Playwright testing guidance covering E2E, component, API, visual regression, accessibility, security, and specialized testing (Electron, extensions, PWA, GraphQL). Provides patterns for debugging, fixing flaky tests, handling authentication/OAuth, mocking APIs, and testing complex features. Use for all Playwright test development and maintenance.
- Write E2E, component, API, visual, accessibility, security, Electron, and extension tests
- Fix flaky tests with strategies for race conditions, state leaks, and timeout issues
- Handle complex scenarios: authentication, multi-tab, file operations, WebSockets, mobile, geolocation
Playwright Best Practices by the numbers
- 65,455 all-time installs (skills.sh)
- +2,856 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #7 of 2,184 Testing & QA skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
playwright-best-practices capabilities & compatibility
- Capabilities
- e2e testing · component testing · api testing · visual testing · accessibility testing
- Use cases
- testing
What playwright-best-practices says it does
Use when writing Playwright tests, fixing flaky tests, debugging failures, implementing Page Object Model, configuring CI/CD, optimizing performance
This skill provides comprehensive guidance for all aspects of Playwright test development, from writing new tests to debugging and maintaining existing test suites.
npx skills add https://github.com/currents-dev/playwright-best-practices-skill --skill playwright-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 65.5k |
|---|---|
| repo stars | ★ 341 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 21, 2026 |
| Repository | currents-dev/playwright-best-practices-skill ↗ |
How do you fix flaky Playwright tests in CI?
Comprehensive Playwright testing guidance covering E2E, component, API, visual regression, accessibility, security, and specialized testing (Electron, extensions, PWA, GraphQL). Provides patterns for
Who is it for?
Writing robust Playwright tests, debugging test failures, fixing flaky tests, and implementing testing patterns for complex scenarios
Skip if: Skip playwright-best-practices when the stack uses Cypress or Selenium only and no Playwright adoption is planned.
When should I use this skill?
A developer is writing, debugging, or maintaining Playwright tests
What you get
Stable Playwright specs, Page Object Model structure, CI config, mocked APIs, accessibility assertions, and tagged test suites.
- Playwright test files
- Debugging reports
- Fixed test flakiness
By the numbers
- Covers 8 test types: E2E, component, API, visual, accessibility, security, Electron, extension
- Detailed guidance for 30+ testing scenarios
Files
Playwright Best Practices
This skill provides comprehensive guidance for all aspects of Playwright test development, from writing new tests to debugging and maintaining existing test suites.
Activity-Based Reference Guide
Consult these references based on what you're doing:
Writing New Tests
When to use: Creating new test files, writing test cases, implementing test scenarios
| Activity | Reference Files |
|---|---|
| Writing E2E tests | test-suite-structure.md, locators.md, assertions-waiting.md |
| Writing component tests | component-testing.md, test-suite-structure.md |
| Writing API tests | api-testing.md, test-suite-structure.md |
| Writing GraphQL tests | graphql-testing.md, api-testing.md |
| Writing visual regression tests | visual-regression.md, canvas-webgl.md |
| Structuring test code with POM | page-object-model.md, test-suite-structure.md |
| Setting up test data/fixtures | fixtures-hooks.md, test-data.md |
| Handling authentication | authentication.md, authentication-flows.md |
| Testing date/time features | clock-mocking.md |
| Testing file upload/download | file-operations.md, file-upload-download.md |
| Testing forms/validation | forms-validation.md |
| Testing drag and drop | drag-drop.md |
| Testing accessibility | accessibility.md |
| Testing security (XSS, CSRF) | security-testing.md |
| Using test annotations | annotations.md |
| Using test tags | test-tags.md |
| Testing iframes | iframes.md |
| Testing canvas/WebGL | canvas-webgl.md |
| Internationalization (i18n) | i18n.md |
| Testing Electron apps | electron.md |
| Testing browser extensions | browser-extensions.md |
Mobile & Responsive Testing
When to use: Testing mobile devices, touch interactions, responsive layouts
| Activity | Reference Files |
|---|---|
| Device emulation | mobile-testing.md |
| Touch gestures (swipe, tap) | mobile-testing.md |
| Viewport/breakpoint testing | mobile-testing.md |
| Mobile-specific UI | mobile-testing.md, locators.md |
Real-Time & Browser APIs
When to use: Testing WebSockets, geolocation, permissions, multi-tab flows
| Activity | Reference Files |
|---|---|
| WebSocket/real-time testing | websockets.md |
| Geolocation mocking | browser-apis.md |
| Permission handling | browser-apis.md |
| Clipboard testing | browser-apis.md |
| Camera/microphone mocking | browser-apis.md |
| Multi-tab/popup flows | multi-context.md |
| OAuth popup handling | third-party.md, multi-context.md |
Debugging & Troubleshooting
When to use: Test failures, element not found, timeouts, unexpected behavior
| Activity | Reference Files |
|---|---|
| Debugging test failures | debugging.md, assertions-waiting.md |
| Fixing flaky tests | flaky-tests.md, debugging.md, assertions-waiting.md |
| Debugging flaky parallel runs | flaky-tests.md, performance.md, fixtures-hooks.md |
| Ensuring test isolation / avoiding state leak | flaky-tests.md, fixtures-hooks.md, performance.md |
| Fixing selector issues | locators.md, debugging.md |
| Investigating timeout issues | assertions-waiting.md, debugging.md |
| Using trace viewer | debugging.md |
| Debugging race conditions | flaky-tests.md, debugging.md, assertions-waiting.md |
| Debugging console/JS errors | console-errors.md, debugging.md |
Error & Edge Case Testing
When to use: Testing error states, offline mode, network failures, validation
| Activity | Reference Files |
|---|---|
| Error boundary testing | error-testing.md |
| Network failure simulation | error-testing.md, network-advanced.md |
| Offline mode testing | error-testing.md, service-workers.md |
| Service worker testing | service-workers.md |
| Loading state testing | error-testing.md |
| Form validation testing | error-testing.md |
Multi-User & Collaboration Testing
When to use: Testing features involving multiple users, roles, or real-time collaboration
| Activity | Reference Files |
|---|---|
| Multiple users in one test | multi-user.md |
| Real-time collaboration | multi-user.md, websockets.md |
| Role-based access testing | multi-user.md |
| Concurrent action testing | multi-user.md |
Architecture Decisions
When to use: Choosing test patterns, deciding between approaches, planning test architecture
| Activity | Reference Files |
|---|---|
| POM vs fixtures decision | pom-vs-fixtures.md |
| Test type selection | test-architecture.md |
| Mock vs real services | when-to-mock.md |
| Test suite structure | test-suite-structure.md |
Framework-Specific Testing
When to use: Testing React, Angular, Vue, or Next.js applications
| Activity | Reference Files |
|---|---|
| Testing React apps | react.md |
| Testing Angular apps | angular.md |
| Testing Vue/Nuxt apps | vue.md |
| Testing Next.js apps | nextjs.md |
Refactoring & Maintenance
When to use: Improving existing tests, code review, reducing duplication
| Activity | Reference Files |
|---|---|
| Refactoring to Page Object Model | page-object-model.md, test-suite-structure.md |
| Improving test organization | test-suite-structure.md, page-object-model.md |
| Extracting common setup/teardown | fixtures-hooks.md |
| Replacing brittle selectors | locators.md |
| Removing explicit waits | assertions-waiting.md |
| Creating test data factories | test-data.md |
| Configuration setup | configuration.md |
Infrastructure & Configuration
When to use: Setting up projects, configuring CI/CD, optimizing performance
| Activity | Reference Files |
|---|---|
| Configuring Playwright project | configuration.md, projects-dependencies.md |
| Setting up CI/CD pipelines | ci-cd.md, github-actions.md |
| GitHub Actions setup | github-actions.md |
| GitLab CI setup | gitlab.md |
| Other CI providers | other-providers.md |
| Docker/container setup | docker.md |
| Global setup & teardown | global-setup.md |
| Project dependencies | projects-dependencies.md |
| Optimizing test performance | performance.md, test-suite-structure.md |
| Configuring parallel execution | parallel-sharding.md, performance.md |
| Isolating test data between workers | fixtures-hooks.md, performance.md |
| Test coverage | test-coverage.md |
| Test reporting/artifacts | reporting.md |
Advanced Patterns
When to use: Complex scenarios, API mocking, network interception
| Activity | Reference Files |
|---|---|
| Mocking API responses | test-suite-structure.md, network-advanced.md |
| Network interception | network-advanced.md, assertions-waiting.md |
| GraphQL mocking | network-advanced.md |
| HAR recording/playback | network-advanced.md |
| Custom fixtures | fixtures-hooks.md |
| Advanced waiting strategies | assertions-waiting.md |
| OAuth/SSO mocking | third-party.md, multi-context.md |
| Payment gateway mocking | third-party.md |
| Email/SMS verification mocking | third-party.md |
| Failing on console errors | console-errors.md |
| Security testing (XSS, CSRF) | security-testing.md |
| Performance budgets & Web Vitals | performance-testing.md |
| Lighthouse integration | performance-testing.md |
| Test annotations (skip, fixme) | annotations.md |
| Test tags (@smoke, @fast) | test-tags.md |
| Test steps for reporting | annotations.md |
Quick Decision Tree
What are you doing?
│
├─ Writing a new test?
│ ├─ E2E test → core/test-suite-structure.md, core/locators.md, core/assertions-waiting.md
│ ├─ Component test → testing-patterns/component-testing.md
│ ├─ API test → testing-patterns/api-testing.md, core/test-suite-structure.md
│ ├─ GraphQL test → testing-patterns/graphql-testing.md
│ ├─ Visual regression → testing-patterns/visual-regression.md
│ ├─ Visual/canvas test → testing-patterns/canvas-webgl.md, core/test-suite-structure.md
│ ├─ Accessibility test → testing-patterns/accessibility.md
│ ├─ Mobile/responsive test → advanced/mobile-testing.md
│ ├─ i18n/locale test → testing-patterns/i18n.md
│ ├─ Electron app test → testing-patterns/electron.md
│ ├─ Browser extension test → testing-patterns/browser-extensions.md
│ ├─ Multi-user test → advanced/multi-user.md
│ ├─ Form validation test → testing-patterns/forms-validation.md
│ └─ Drag and drop test → testing-patterns/drag-drop.md
│
├─ Testing specific features?
│ ├─ File upload/download → testing-patterns/file-operations.md, testing-patterns/file-upload-download.md
│ ├─ Date/time dependent → advanced/clock-mocking.md
│ ├─ WebSocket/real-time → browser-apis/websockets.md
│ ├─ Geolocation/permissions → browser-apis/browser-apis.md
│ ├─ OAuth/SSO mocking → advanced/third-party.md, advanced/multi-context.md
│ ├─ Payments/email/SMS → advanced/third-party.md
│ ├─ iFrames → browser-apis/iframes.md
│ ├─ Canvas/WebGL/charts → testing-patterns/canvas-webgl.md
│ ├─ Service workers/PWA → browser-apis/service-workers.md
│ ├─ i18n/localization → testing-patterns/i18n.md
│ ├─ Security (XSS, CSRF) → testing-patterns/security-testing.md
│ └─ Performance/Web Vitals → testing-patterns/performance-testing.md
│
├─ Architecture decisions?
│ ├─ POM vs fixtures → architecture/pom-vs-fixtures.md
│ ├─ Test type selection → architecture/test-architecture.md
│ ├─ Mock vs real services → architecture/when-to-mock.md
│ └─ Test suite structure → core/test-suite-structure.md
│
├─ Framework-specific testing?
│ ├─ React app → frameworks/react.md
│ ├─ Angular app → frameworks/angular.md
│ ├─ Vue/Nuxt app → frameworks/vue.md
│ └─ Next.js app → frameworks/nextjs.md
│
├─ Authentication testing?
│ ├─ Basic auth patterns → advanced/authentication.md
│ └─ Complex flows (MFA, reset) → advanced/authentication-flows.md
│
├─ Test is failing/flaky?
│ ├─ Flaky test investigation → debugging/flaky-tests.md
│ ├─ Element not found → core/locators.md, debugging/debugging.md
│ ├─ Timeout issues → core/assertions-waiting.md, debugging/debugging.md
│ ├─ Race conditions → debugging/flaky-tests.md, debugging/debugging.md
│ ├─ Flaky only with multiple workers → debugging/flaky-tests.md, infrastructure-ci-cd/performance.md
│ ├─ State leak / isolation → debugging/flaky-tests.md, core/fixtures-hooks.md
│ ├─ Console/JS errors → debugging/console-errors.md, debugging/debugging.md
│ └─ General debugging → debugging/debugging.md
│
├─ Testing error scenarios?
│ ├─ Network failures → debugging/error-testing.md, advanced/network-advanced.md
│ ├─ Offline (unexpected) → debugging/error-testing.md
│ ├─ Offline-first/PWA → browser-apis/service-workers.md
│ ├─ Error boundaries → debugging/error-testing.md
│ └─ Form validation → testing-patterns/forms-validation.md, debugging/error-testing.md
│
├─ Refactoring existing code?
│ ├─ Implementing POM → core/page-object-model.md
│ ├─ Improving selectors → core/locators.md
│ ├─ Extracting fixtures → core/fixtures-hooks.md
│ ├─ Creating data factories → core/test-data.md
│ └─ Configuration setup → core/configuration.md
│
├─ Setting up infrastructure?
│ ├─ CI/CD → infrastructure-ci-cd/ci-cd.md
│ ├─ GitHub Actions → infrastructure-ci-cd/github-actions.md
│ ├─ GitLab CI → infrastructure-ci-cd/gitlab.md
│ ├─ Other CI providers → infrastructure-ci-cd/other-providers.md
│ ├─ Docker/containers → infrastructure-ci-cd/docker.md
│ ├─ Sharding/parallel → infrastructure-ci-cd/parallel-sharding.md
│ ├─ Reporting/artifacts → infrastructure-ci-cd/reporting.md
│ ├─ Global setup/teardown → core/global-setup.md
│ ├─ Project dependencies → core/projects-dependencies.md
│ ├─ Test performance → infrastructure-ci-cd/performance.md
│ ├─ Test coverage → infrastructure-ci-cd/test-coverage.md
│ └─ Project config → core/configuration.md, core/projects-dependencies.md
│
├─ Organizing tests?
│ ├─ Skip/fixme/slow tests → core/annotations.md
│ ├─ Test tags (@smoke, @fast) → core/test-tags.md
│ ├─ Filtering tests (--grep) → core/test-tags.md
│ ├─ Test steps → core/annotations.md
│ └─ Conditional execution → core/annotations.md
│
└─ Running subset of tests?
├─ By tag (@smoke, @critical) → core/test-tags.md
├─ Exclude slow/flaky tests → core/test-tags.md
├─ PR vs nightly tests → core/test-tags.md, infrastructure-ci-cd/ci-cd.md
└─ Project-specific filtering → core/test-tags.md, core/configuration.mdTest Validation Loop
After writing or modifying tests:
1. Run tests: npx playwright test --reporter=list 2. If tests fail:
- Review error output and trace (
npx playwright show-trace) - Fix locators, waits, or assertions
- Re-run tests
3. Only proceed when all tests pass 4. Run multiple times for critical tests: npx playwright test --repeat-each=5
[rules]
disabled_rules = ["AS-017"]name: Validate AI Skill
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
agnix:
name: Agnix Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate agent configs
uses: agent-sh/agnix@12a1917d13e7c804bc91768c30dd4159f536c5c6
Complex Authentication Flow Patterns
Table of Contents
1. Email Verification Flows 2. Password Reset 3. Session Timeout 4. Remember Me Persistence 5. Logout Patterns 6. Tips 7. Related
When to use: Testing email verification, password reset, session timeout/expiration, or remember-me functionality. For basic auth setup (storage state, OAuth mocking, MFA, role-based access), see authentication.md.
---
Email Verification Flows
Capturing Verification Tokens
Intercept API responses to capture verification tokens for testing:
test('completes registration with email verification', async ({ page }) => {
let capturedToken = '';
await page.route('**/api/auth/register', async (route) => {
const response = await route.fetch();
const body = await response.json();
capturedToken = body.verificationToken;
await route.fulfill({ response });
});
await page.goto('/register');
await page.getByLabel('Name').fill('New User');
await page.getByLabel('Email').fill('newuser@test.com');
await page.getByLabel('Password', { exact: true }).fill('SecurePass!');
await page.getByLabel('Confirm password').fill('SecurePass!');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByText('Check your inbox')).toBeVisible();
expect(capturedToken).toBeTruthy();
await page.goto(`/verify?token=${capturedToken}`);
await expect(page.getByText('Email confirmed')).toBeVisible();
});Fully Mocked Verification
test('verifies email with mocked endpoints', async ({ page }) => {
const mockToken = 'test-verification-abc123';
await page.route('**/api/auth/register', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ message: 'Verification sent', verificationToken: mockToken }),
});
});
await page.route(`**/api/auth/verify?token=${mockToken}`, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ verified: true }),
});
});
await page.goto('/register');
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password', { exact: true }).fill('Password123!');
await page.getByRole('button', { name: 'Sign up' }).click();
await expect(page.getByText('Check your inbox')).toBeVisible();
await page.goto(`/verify?token=${mockToken}`);
await expect(page.getByText('Email confirmed')).toBeVisible();
});---
Password Reset
Complete Reset Flow
test('resets password through email link', 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@test.com');
await page.getByRole('button', { name: 'Send link' }).click();
await expect(page.getByText('Reset email sent')).toBeVisible();
expect(resetToken).toBeTruthy();
await page.goto(`/reset-password?token=${resetToken}`);
await page.getByLabel('New password', { exact: true }).fill('NewPassword456!');
await page.getByLabel('Confirm password').fill('NewPassword456!');
await page.getByRole('button', { name: 'Update password' }).click();
await expect(page.getByText('Password updated')).toBeVisible();
});Expired Token Handling
test('shows error for expired reset token', async ({ page }) => {
await page.goto('/reset-password?token=expired-token');
await page.getByLabel('New password', { exact: true }).fill('NewPass!');
await page.getByLabel('Confirm password').fill('NewPass!');
await page.getByRole('button', { name: 'Update password' }).click();
await expect(page.getByRole('alert')).toContainText(/expired|invalid/i);
});Password Strength Validation
test('enforces password requirements on reset', async ({ page }) => {
await page.goto('/reset-password?token=valid-token');
await page.getByLabel('New password', { exact: true }).fill('weak');
await page.getByLabel('Confirm password').fill('weak');
await page.getByRole('button', { name: 'Update password' }).click();
await expect(page.getByText(/at least 8 characters/i)).toBeVisible();
});---
Session Timeout
Detecting Expired Sessions
test('redirects to signin after session expires', async ({ page, context }) => {
await page.goto('/signin');
await page.getByLabel('Email').fill('user@test.com');
await page.getByLabel('Password').fill('Password!');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/home');
const cookies = await context.cookies();
const sessionCookie = cookies.find((c) => c.name.includes('session'));
if (sessionCookie) {
await context.clearCookies({ name: sessionCookie.name });
}
await page.goto('/profile');
await expect(page).toHaveURL(/\/signin/);
await expect(page.getByText(/session.*expired|sign in again/i)).toBeVisible();
});Session Extension Warning
test('shows warning before session expires', async ({ page }) => {
await page.route('**/api/auth/session', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ valid: true, expiresIn: 60 }),
});
});
await page.goto('/home');
await expect(page.getByText(/session.*expir/i)).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('button', { name: /extend|stay signed in/i })).toBeVisible();
});Session Extension Action
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: 60 }),
});
});
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('/home');
await expect(page.getByRole('button', { name: /extend|stay signed in/i })).toBeVisible({
timeout: 10000,
});
await page.getByRole('button', { name: /extend|stay signed in/i }).click();
expect(sessionExtended).toBe(true);
await expect(page.getByText(/session.*expir/i)).not.toBeVisible();
});---
Remember Me Persistence
Persistent Session
test('persists session with remember me enabled', async ({ browser }) => {
const ctx1 = await browser.newContext();
const page1 = await ctx1.newPage();
await page1.goto('/signin');
await page1.getByLabel('Email').fill('user@test.com');
await page1.getByLabel('Password').fill('Password!');
await page1.getByLabel('Keep me signed in').check();
await page1.getByRole('button', { name: 'Sign in' }).click();
await expect(page1).toHaveURL('/home');
const state = await ctx1.storageState();
await ctx1.close();
const ctx2 = await browser.newContext({ storageState: state });
const page2 = await ctx2.newPage();
await page2.goto('/home');
await expect(page2).toHaveURL('/home');
await expect(page2.getByText('Welcome')).toBeVisible();
await ctx2.close();
});Session-Only Login
test('session-only login does not persist across browser restarts', async ({ browser }) => {
const ctx1 = await browser.newContext();
const page1 = await ctx1.newPage();
await page1.goto('/signin');
await page1.getByLabel('Email').fill('user@test.com');
await page1.getByLabel('Password').fill('Password!');
// Leave "Remember me" unchecked
await expect(page1.getByLabel('Keep me signed in')).not.toBeChecked();
await page1.getByRole('button', { name: 'Sign in' }).click();
await expect(page1).toHaveURL('/home');
// Only keep persistent cookies (filter out session cookies)
const cookies = await ctx1.cookies();
await ctx1.close();
const persistentCookies = cookies.filter((c) => c.expires > 0);
const ctx2 = await browser.newContext();
await ctx2.addCookies(persistentCookies);
const page2 = await ctx2.newPage();
await page2.goto('/home');
// Should redirect to login since session was not persisted
await expect(page2).toHaveURL(/\/signin/);
await ctx2.close();
});---
Logout Patterns
Standard Logout with Session Cleanup
test.use({ storageState: '.auth/user.json' });
test('logs out and clears session', async ({ page, context }) => {
await page.goto('/home');
await page.getByRole('button', { name: /account|menu/i }).click();
await page.getByRole('menuitem', { name: 'Sign out' }).click();
await expect(page).toHaveURL('/signin');
const cookies = await context.cookies();
const sessionCookies = cookies.filter((c) => c.name.includes('session') || c.name.includes('token'));
expect(sessionCookies).toHaveLength(0);
await page.goto('/home');
await expect(page).toHaveURL(/\/signin/);
});Logout from All Devices
test('logs out 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 everywhere' }),
});
});
await page.goto('/settings/security');
await page.getByRole('button', { name: 'Sign out everywhere' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();
expect(logoutAllCalled).toBe(true);
await expect(page).toHaveURL(/\/signin/);
});---
Tips
1. Configure shorter session timeouts in test environments — Enables testing timeout behavior without slow tests 2. Test token expiration edge cases — Expired tokens, invalid tokens, already-used tokens 3. Verify cleanup on logout — Check both cookies and localStorage are cleared 4. Test the full flow end-to-end — Password reset should verify login with new password works
---
Related
- authentication.md — Storage state, OAuth mocking, MFA, role-based access, API login
- fixtures-hooks.md — Creating auth fixtures
- third-party.md — Mocking external auth providers
Authentication Testing
Table of Contents
1. Quick Reference 2. Patterns 3. Decision Guide 4. Anti-Patterns 5. Troubleshooting 6. Related
When to use: Apps with login, session management, or protected routes. Authentication is the most common source of slow test suites.
Quick Reference
// Storage state reuse — the #1 pattern for fast auth
await page.goto("/login");
await page.getByLabel("Username").fill("testuser@example.com");
await page.getByLabel("Password").fill("secretPass123");
await page.getByRole("button", { name: "Log in" }).click();
await page.context().storageState({ path: ".auth/session.json" });
// Reuse in config — every test starts authenticated
{
use: {
storageState: ".auth/session.json"
}
}
// API login — skip the UI entirely
const context = await browser.newContext();
const response = await context.request.post("/api/auth/login", {
data: { email: "testuser@example.com", password: "secretPass123" },
});
await context.storageState({ path: ".auth/session.json" });Patterns
Storage State Reuse
Use when: You need authenticated tests and want to avoid logging in before every test. Avoid when: Tests require completely fresh sessions, or you are testing the login flow itself.
storageState serializes cookies and localStorage to a JSON file. Load it in any browser context to start authenticated instantly.
// scripts/generate-auth.ts — run once to generate the state file
import { chromium } from "@playwright/test";
async function generateAuthState() {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto("http://localhost:4000/login");
await page.getByLabel("Username").fill("testuser@example.com");
await page.getByLabel("Password").fill("secretPass123");
await page.getByRole("button", { name: "Log in" }).click();
await page.waitForURL("/home");
await context.storageState({ path: ".auth/session.json" });
await browser.close();
}
generateAuthState();// playwright.config.ts — load saved state for all tests
import { defineConfig } from "@playwright/test";
export default defineConfig({
use: {
baseURL: "http://localhost:4000",
storageState: ".auth/session.json",
},
});// tests/home.spec.ts — test starts already logged in
import { test, expect } from "@playwright/test";
test("authenticated user sees home page", async ({ page }) => {
await page.goto("/home");
await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
});Global Setup Authentication
Use when: You want to authenticate once before the entire test suite runs. Avoid when: Different tests need different users, or your tokens expire faster than your suite runs.
// 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("Username").fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole("button", { name: "Log in" }).click();
await page.waitForURL("**/home");
await context.storageState({ path: ".auth/session.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:4000",
storageState: ".auth/session.json",
},
});Add .auth/ to .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 for tests that modify server-side state. Avoid when: Tests are read-only and a modifying shared session is safe, you can use a single shared account.
Sharded runs:parallelIndexresets per shard, so different shards can have workers with the same index. To avoid collisions, include the shard identifier in the username (e.g.,worker-${SHARD_INDEX}-${parallelIndex}@example.com) by passing aSHARD_INDEXenvironment variable from your CI matrix.
// 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("Username")
.fill(`worker-${test.info().parallelIndex}@example.com`);
await page.getByLabel("Password").fill("secretPass123");
await page.getByRole("button", { name: "Log in" }).click();
await page.waitForURL("/home");
await page.close();
await use(context);
await context.close();
},
{ scope: "worker" },
],
});
export { expect } from "@playwright/test";// tests/settings.spec.ts
import { test, expect } from "../fixtures/auth";
test("update display 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 saved")).toBeVisible();
});Multiple Roles
Use when: Your app has role-based access control and you need to test different permission levels. Avoid when: Your app has a single user role.
// global-setup.ts — authenticate all roles
import { chromium, type FullConfig } from "@playwright/test";
const accounts = [
{
role: "admin",
email: "admin@example.com",
password: process.env.ADMIN_PASSWORD!,
},
{
role: "member",
email: "member@example.com",
password: process.env.MEMBER_PASSWORD!,
},
{
role: "guest",
email: "guest@example.com",
password: process.env.GUEST_PASSWORD!,
},
];
async function globalSetup(config: FullConfig) {
const { baseURL } = config.projects[0].use;
for (const { role, email, password } of accounts) {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(`${baseURL}/login`);
await page.getByLabel("Username").fill(email);
await page.getByLabel("Password").fill(password);
await page.getByRole("button", { name: "Log in" }).click();
await page.waitForURL("**/home");
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: "member",
use: { storageState: ".auth/member.json" },
testMatch: "**/*.member.spec.ts",
},
{
name: "guest",
use: { storageState: ".auth/guest.json" },
testMatch: "**/*.guest.spec.ts",
},
{
name: "anonymous",
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: "Remove user" })).toBeEnabled();
});// tests/admin-panel.guest.spec.ts
import { test, expect } from "@playwright/test";
test("guest cannot access admin panel", async ({ page }) => {
await page.goto("/admin/users");
await expect(page.getByText("Access denied")).toBeVisible();
});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" | "member" | "guest") => 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}`
);
}
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 remove button, guest does not", async ({ loginAs }) => {
const adminPage = await loginAs("admin");
await adminPage.goto("/admin/users");
await expect(
adminPage.getByRole("button", { name: "Remove user" })
).toBeVisible();
const guestPage = await loginAs("guest");
await guestPage.goto("/admin/users");
await expect(guestPage.getByText("Access denied")).toBeVisible();
});OAuth/SSO Mocking
Use when: Your app authenticates via a third-party OAuth provider and you cannot hit the real provider in tests. Avoid when: You have a dedicated test tenant on the OAuth provider.
A typical OAuth flow works like this:
1. User clicks "Sign in with Provider" → browser navigates to https://accounts.provider.com/authorize?... 2. User authenticates on the provider's page → provider redirects back to your app's callback route (e.g. http://localhost:4000/auth/callback?code=ABC&state=XYZ) 3. Your backend exchanges the code for an access token, creates a session, and redirects the user to a logged-in page
In tests you can short-circuit step 2 with page.route(): intercept the outbound request to the provider and respond with a 302 redirect straight to your callback route, supplying a mock code and state. Your backend still executes its normal callback handler — the only part that's mocked is the provider's authorization page.
For cases where you want to skip the browser redirect entirely, a second approach calls a test-only API endpoint that creates the session server-side and returns the session cookie directly.
// tests/oauth-login.spec.ts — mock the callback route
import { test, expect } from "@playwright/test";
test("login via mocked OAuth flow", async ({ page }) => {
await page.route("https://accounts.provider.com/**", async (route) => {
const callbackUrl = new URL("http://localhost:4000/auth/callback");
callbackUrl.searchParams.set("code", "mock-auth-code-xyz");
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 Provider" }).click();
await page.waitForURL("/home");
await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
});// tests/oauth-login.spec.ts — API-based session injection
import { test, expect } from "@playwright/test";
test("bypass OAuth entirely via API session injection", async ({
page,
}) => {
// Call a test-only endpoint that creates a session without OAuth
const response = await page.request.post("/api/test/create-session", {
data: {
email: "oauth-user@example.com",
provider: "provider",
role: "member",
},
});
expect(response.ok()).toBeTruthy();
await page.context().storageState({ path: ".auth/oauth-user.json" });
await page.goto("/home");
await expect(page.getByRole("heading", { name: "Home" })).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.
MFA Handling
Use when: Your app requires two-factor authentication (TOTP, SMS, email codes). Avoid when: MFA is optional and you can disable it for test accounts.
Strategy 1: Generate real TOTP codes from a shared secret.
// 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("Username").fill("mfa-user@example.com");
await page.getByLabel("Password").fill("secretPass123");
await page.getByRole("button", { name: "Log 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("/home");
await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
});Strategy 2: Mock MFA at the backend level. Have your backend accept a known bypass code (e.g., 000000) when NODE_ENV=test.
Strategy 3: Disable MFA for test accounts at the infrastructure level.
Session Refresh
Use when: Your tokens expire during long test runs. Avoid when: Your test suite runs quickly and tokens outlast the entire run.
// 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/session.json";
let context: BrowserContext;
if (fs.existsSync(statePath)) {
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();
}
context = await browser.newContext();
const page = await context.newPage();
await page.goto("/login");
await page.getByLabel("Username").fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole("button", { name: "Log in" }).click();
await page.waitForURL("/home");
await context.storageState({ path: statePath });
await use(page);
await context.close();
},
});
export { expect } from "@playwright/test";Login Page Object
Use when: Multiple test files need to log in and you want consistent, maintainable login logic. Avoid when: You use storageState everywhere and never navigate through the login UI in tests.
// page-objects/LoginPage.ts
import { type Page, type Locator, expect } from "@playwright/test";
export class LoginPage {
readonly page: Page;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
readonly forgotPasswordLink: Locator;
constructor(page: Page) {
this.page = page;
this.usernameInput = page.getByLabel("Username");
this.passwordInput = page.getByLabel("Password");
this.loginButton = page.getByRole("button", { name: "Log in" });
this.errorMessage = page.getByRole("alert");
this.forgotPasswordLink = page.getByRole("link", {
name: "Forgot password",
});
}
async goto() {
await this.page.goto("/login");
await expect(this.loginButton).toBeVisible();
}
async login(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
async loginAndWaitForHome(username: string, password: string) {
await this.login(username, password);
await this.page.waitForURL("/home");
}
async expectError(message: string | RegExp) {
await expect(this.errorMessage).toContainText(message);
}
async expectFieldError(field: "username" | "password", message: string) {
const input =
field === "username" ? this.usernameInput : 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);
}
}
}// tests/login.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "../page-objects/LoginPage";
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 home", async ({ page }) => {
await loginPage.loginAndWaitForHome(
"testuser@example.com",
"secretPass123"
);
await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
});
test("wrong password shows error", async () => {
await loginPage.login("testuser@example.com", "wrong-password");
await loginPage.expectError("Invalid username or password");
});
test("empty fields show validation errors", async () => {
await loginPage.loginButton.click();
await loginPage.expectFieldError("username", "Username 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();
});
});API-Based Login
Use when: You want the fastest possible authentication without any browser interaction. Avoid when: You are specifically testing the login UI.
API login is typically 5-10x faster than UI login.
// 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 });
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/session.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) => {
const apiContext = await playwright.request.newContext({
baseURL: "http://localhost:4000",
});
await apiContext.post("/api/auth/login", {
data: {
email: "testuser@example.com",
password: "secretPass123",
},
});
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";Unauthenticated Tests
Use when: Testing the login page, signup flow, password reset, public pages, 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.
// tests/public-pages.spec.ts
import { test, expect } from "@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: "Log in" })).toBeVisible();
});
test("protected route redirects to login", async ({ page }) => {
await page.goto("/home");
await page.waitForURL("**/login**");
expect(page.url()).toContain("redirect=%2Fhome");
});
test("expired session shows re-login prompt", async ({ page, context }) => {
await page.goto("/home");
await context.clearCookies();
await page.goto("/settings");
await page.waitForURL("**/login**");
await expect(page.getByText("Your session has expired")).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()}@example.com`);
await page.getByLabel("Password", { exact: true }).fill("secretPass123");
await page.getByLabel("Confirm password").fill("secretPass123");
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 |
| Tests modify user state | Per-worker fixture | Fast | Per worker | Tests update profile, change settings, or mutate data |
| Multiple user roles | Per-project storageState | Fastest | Per role | App has admin/member/guest roles |
| Testing the login page | No storageState | N/A | Full | Use test.use({ storageState: { cookies: [], origins: [] } }) |
| OAuth/SSO provider | Mock the callback | Fast | Per test | Never hit real OAuth providers in CI |
| MFA is required | TOTP generation or bypass | Moderate | Per test | Generate real TOTP codes or use a test-mode bypass |
| Token expires mid-suite | Session refresh fixture | Fast | Per check | Fixture validates the session before use |
| Single test needs different user | loginAs(role) fixture | Moderate | Per call | Rare: prefer per-project roles |
| API-first app (no login UI) | API login via request.post() | Fastest | Per test | No browser needed for auth |
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 fineAnti-Patterns
| Don't Do This | Problem | Do This Instead |
|---|---|---|
| Log in via UI before every test | Adds 2-5 seconds per test | Use storageState to skip login entirely |
| Share a single auth state file across parallel workers that mutate state | Race conditions | Use per-worker fixtures with { scope: 'worker' } |
| Hardcode credentials in test files | Security risk | Use environment variables and .env files |
| Ignore token expiration | Tests fail intermittently with 401 errors | Add a session validity check in your auth fixture |
| Hit real OAuth providers in CI | Flaky: rate limits, CAPTCHA, network issues | Mock the OAuth callback or use API session injection |
Use page.waitForTimeout(2000) after login | Arbitrary delay | await page.waitForURL('/home') or await expect(heading).toBeVisible() |
Store .auth/*.json files in git | Tokens in version control | Add .auth/ to .gitignore |
| Create one "god" test account with all permissions | Cannot test role-based access control | Create separate accounts per role |
Use browser.newContext() without storageState for authenticated tests | Every context starts unauthenticated | Pass storageState when creating the context |
| Test MFA by disabling it everywhere | You never test the MFA flow | Use TOTP generation for at least one test |
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.
Fix:
- Add
await page.waitForURL()after the login action - Check that
baseURLin your config matches the actual server URL and protocol - Add error handling to global setup:
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.
Fix:
- Use the session refresh fixture pattern
- Increase token expiry in test environment configuration
- Switch to API-based login in a worker-scoped fixture
storageState file is empty or contains no cookies
Cause: storageState() was called before the login response set cookies.
Fix:
- Wait for the post-login page to load:
await page.waitForURL('/home') - Verify cookies exist before saving:
const cookies = await context.cookies();
if (cookies.length === 0) {
throw new Error("No cookies found after login");
}
await context.storageState({ path: ".auth/session.json" });Different browsers get different cookies
Cause: Some auth flows set cookies with SameSite=Strict or use browser-specific cookie behavior.
Fix:
- Generate separate auth state files per browser project
- Check if your auth uses
SameSite=None; Securecookies that require HTTPS:
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: '.auth/chromium-session.json' },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'], storageState: '.auth/firefox-session.json' },
},
],Parallel tests interfere with each other's sessions
Cause: Multiple workers share the same test account and one worker's actions affect others.
Fix:
- Use per-worker test accounts:
worker-${test.info().parallelIndex}@example.com - Use the per-worker authentication fixture pattern
- Make tests idempotent
OAuth mock does not work — still redirects to real provider
Cause: page.route() was registered after the navigation that triggers the OAuth redirect.
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.provider")) {
console.log("OAuth request:", req.url());
}
});Related
- fixtures-hooks.md — custom fixtures for auth setup and teardown
- configuration.md —
storageState, projects, and global setup configuration - global-setup.md — global setup patterns and project dependencies
- network-advanced.md — route interception patterns used in OAuth mocking
- api-testing.md — API request context used in API-based login
- flaky-tests.md — diagnosing auth-related flakiness
Date, Time & Clock Mocking
Table of Contents
1. Clock API Basics 2. Fixed Time Testing 3. Time Advancement 4. Timezone Testing 5. Timer Mocking
Clock API Basics
Install Clock
test("mock current time", async ({ page }) => {
// Install clock before navigating
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
await page.goto("/dashboard");
// Page sees January 15, 2025 as current date
await expect(page.getByText("January 15, 2025")).toBeVisible();
});Clock with Fixture
// fixtures/clock.fixture.ts
import { test as base } from "@playwright/test";
type ClockFixtures = {
mockTime: (date: Date | string) => Promise<void>;
};
export const test = base.extend<ClockFixtures>({
mockTime: async ({ page }, use) => {
await use(async (date) => {
const time = typeof date === "string" ? new Date(date) : date;
await page.clock.install({ time });
});
},
});
// Usage
test("subscription expiry", async ({ page, mockTime }) => {
await mockTime("2025-12-31T23:59:00");
await page.goto("/subscription");
await expect(page.getByText("Expires today")).toBeVisible();
});Fixed Time Testing
Test Date-Dependent Features
test("show holiday banner in December", async ({ page }) => {
await page.clock.install({ time: new Date("2025-12-20T10:00:00") });
await page.goto("/");
await expect(page.getByRole("banner", { name: /holiday/i })).toBeVisible();
});
test("no holiday banner in January", async ({ page }) => {
await page.clock.install({ time: new Date("2025-01-15T10:00:00") });
await page.goto("/");
await expect(page.getByRole("banner", { name: /holiday/i })).toBeHidden();
});Test Relative Time Display
test("shows relative time correctly", async ({ page }) => {
// Fix time to control "posted 2 hours ago" text
await page.clock.install({ time: new Date("2025-06-15T14:00:00") });
// Mock API to return post with known timestamp
await page.route("**/api/posts/1", (route) =>
route.fulfill({
json: {
id: 1,
title: "Test Post",
createdAt: "2025-06-15T12:00:00Z", // 2 hours before mock time
},
}),
);
await page.goto("/posts/1");
await expect(page.getByText("2 hours ago")).toBeVisible();
});Test Date Boundaries
test.describe("end of month billing", () => {
test("shows billing on last day of month", async ({ page }) => {
await page.clock.install({ time: new Date("2025-01-31T10:00:00") });
await page.goto("/billing");
await expect(page.getByText("Payment due today")).toBeVisible();
});
test("shows days remaining mid-month", async ({ page }) => {
await page.clock.install({ time: new Date("2025-01-15T10:00:00") });
await page.goto("/billing");
await expect(page.getByText("16 days until payment")).toBeVisible();
});
});Time Advancement
Advance Time Manually
test("session timeout warning", async ({ page }) => {
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
await page.goto("/dashboard");
// Advance 25 minutes (session timeout at 30 min)
await page.clock.fastForward("25:00");
await expect(page.getByText("Session expires in 5 minutes")).toBeVisible();
// Advance 5 more minutes
await page.clock.fastForward("05:00");
await expect(page.getByText("Session expired")).toBeVisible();
});Pause and Resume Time
test("countdown timer", async ({ page }) => {
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
await page.goto("/sale");
// Initial state
await expect(page.getByText("Sale ends in 2:00:00")).toBeVisible();
// Advance 1 hour
await page.clock.fastForward("01:00:00");
await expect(page.getByText("Sale ends in 1:00:00")).toBeVisible();
// Advance past end
await page.clock.fastForward("01:00:01");
await expect(page.getByText("Sale ended")).toBeVisible();
});Run Pending Timers
test("debounced search", async ({ page }) => {
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
await page.goto("/search");
await page.getByLabel("Search").fill("playwright");
// Search is debounced by 300ms, won't fire yet
await expect(page.getByTestId("search-results")).toBeHidden();
// Fast forward past debounce
await page.clock.fastForward(300);
// Now search should execute
await expect(page.getByTestId("search-results")).toBeVisible();
});Timezone Testing
Test Different Timezones
test.describe("timezone display", () => {
test("shows correct time in PST", async ({ browser }) => {
const context = await browser.newContext({
timezoneId: "America/Los_Angeles",
});
const page = await context.newPage();
await page.clock.install({ time: new Date("2025-01-15T17:00:00Z") }); // 5 PM UTC
await page.goto("/schedule");
// Should show 9 AM PST
await expect(page.getByText("9:00 AM")).toBeVisible();
await context.close();
});
test("shows correct time in JST", async ({ browser }) => {
const context = await browser.newContext({
timezoneId: "Asia/Tokyo",
});
const page = await context.newPage();
await page.clock.install({ time: new Date("2025-01-15T17:00:00Z") }); // 5 PM UTC
await page.goto("/schedule");
// Should show 2 AM next day JST
await expect(page.getByText("2:00 AM")).toBeVisible();
await context.close();
});
});Timezone Fixture
// fixtures/timezone.fixture.ts
import { test as base } from "@playwright/test";
type TimezoneFixtures = {
pageInTimezone: (timezone: string) => Promise<Page>;
};
export const test = base.extend<TimezoneFixtures>({
pageInTimezone: async ({ browser }, use) => {
const pages: Page[] = [];
await use(async (timezone) => {
const context = await browser.newContext({ timezoneId: timezone });
const page = await context.newPage();
pages.push(page);
return page;
});
// Cleanup
for (const page of pages) {
await page.context().close();
}
},
});Timer Mocking
Mock setInterval
test("auto-refresh data", async ({ page }) => {
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
let apiCalls = 0;
await page.route("**/api/data", (route) => {
apiCalls++;
route.fulfill({ json: { value: apiCalls } });
});
await page.goto("/live-data"); // Sets up 30s refresh interval
expect(apiCalls).toBe(1); // Initial load
// Advance 30 seconds
await page.clock.fastForward("00:30");
expect(apiCalls).toBe(2); // First refresh
// Advance another 30 seconds
await page.clock.fastForward("00:30");
expect(apiCalls).toBe(3); // Second refresh
});Mock setTimeout Chains
test("notification queue", async ({ page }) => {
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
await page.goto("/notifications");
// Trigger 3 notifications that show sequentially
await page.getByRole("button", { name: "Show All" }).click();
// First notification appears immediately
await expect(page.getByText("Notification 1")).toBeVisible();
// Second appears after 2 seconds
await page.clock.fastForward("00:02");
await expect(page.getByText("Notification 2")).toBeVisible();
// Third appears after 2 more seconds
await page.clock.fastForward("00:02");
await expect(page.getByText("Notification 3")).toBeVisible();
});Test Animation Frames
test("animation completes", async ({ page }) => {
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
await page.goto("/animation-demo");
await page.getByRole("button", { name: "Animate" }).click();
// Animation runs for 500ms
const element = page.getByTestId("animated-box");
await expect(element).toHaveCSS("opacity", "0");
// Fast forward through animation
await page.clock.fastForward(500);
await expect(element).toHaveCSS("opacity", "1");
});Best Practices
Always Install Clock Before Navigation
// Good
test("date test", async ({ page }) => {
await page.clock.install({ time: new Date("2025-01-15") });
await page.goto("/"); // Page loads with mocked time
});
// Bad - time already captured by page
test("date test", async ({ page }) => {
await page.goto("/");
await page.clock.install({ time: new Date("2025-01-15") }); // Too late!
});Use ISO Strings for Clarity
// Good - explicit timezone
await page.clock.install({ time: new Date("2025-01-15T09:00:00Z") });
// Ambiguous - uses local timezone
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Installing clock after navigation | Page already captured real time | Install clock before goto() |
| Hardcoded relative dates | Tests break over time | Use fixed dates with clock mock |
| Not accounting for timezone | Tests fail in different regions | Use explicit UTC times or set timezone |
Using waitForTimeout with mocked clock | Conflicts with mocked timers | Use fastForward instead |
Related References
- Assertions: See assertions-waiting.md for time-based assertions
- Fixtures: See fixtures-hooks.md for clock fixtures
Mobile & Responsive Testing
Table of Contents
1. Device Emulation 2. Touch Gestures 3. Viewport Testing 4. Mobile-Specific UI 5. Responsive Breakpoints
Device Emulation
Use Built-in Devices
import { test, devices } from "@playwright/test";
// Configure in playwright.config.ts
export default defineConfig({
projects: [
{ name: "Desktop Chrome", use: { ...devices["Desktop Chrome"] } },
{ name: "Mobile Safari", use: { ...devices["iPhone 14"] } },
{ name: "Mobile Chrome", use: { ...devices["Pixel 7"] } },
{ name: "Tablet", use: { ...devices["iPad Pro 11"] } },
],
});Custom Device Configuration
test.use({
viewport: { width: 390, height: 844 },
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
userAgent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15",
});
test("custom mobile device", async ({ page }) => {
await page.goto("/");
// Test runs with custom device settings
});Test Across Multiple Devices
const mobileDevices = ["iPhone 14", "Pixel 7", "Galaxy S21"];
for (const deviceName of mobileDevices) {
test(`checkout on ${deviceName}`, async ({ browser }) => {
const device = devices[deviceName];
const context = await browser.newContext({ ...device });
const page = await context.newPage();
await page.goto("/checkout");
await expect(page.getByRole("button", { name: "Pay" })).toBeVisible();
await context.close();
});
}Touch Gestures
Tap
test.use({ hasTouch: true });
test("tap to interact", async ({ page }) => {
await page.goto("/gallery");
// Tap is like click but for touch devices
await page.getByRole("img", { name: "Photo 1" }).tap();
await expect(page.getByRole("dialog")).toBeVisible();
});Swipe
test("swipe carousel", async ({ page }) => {
await page.goto("/carousel");
const carousel = page.getByTestId("carousel");
const box = await carousel.boundingBox();
if (box) {
// Swipe left
await page.touchscreen.tap(box.x + box.width - 50, box.y + box.height / 2);
await page.mouse.move(box.x + 50, box.y + box.height / 2);
// Or use drag
await carousel.dragTo(carousel, {
sourcePosition: { x: box.width - 50, y: box.height / 2 },
targetPosition: { x: 50, y: box.height / 2 },
});
}
await expect(page.getByText("Slide 2")).toBeVisible();
});Swipe Fixture
// fixtures/touch.fixture.ts
import { test as base, Page } from "@playwright/test";
type TouchFixtures = {
swipe: (
element: Locator,
direction: "left" | "right" | "up" | "down",
) => Promise<void>;
};
export const test = base.extend<TouchFixtures>({
swipe: async ({ page }, use) => {
await use(async (element, direction) => {
const box = await element.boundingBox();
if (!box) throw new Error("Element not visible");
const centerX = box.x + box.width / 2;
const centerY = box.y + box.height / 2;
const distance = 100;
const moves = {
left: {
startX: centerX + distance,
endX: centerX - distance,
y: centerY,
},
right: {
startX: centerX - distance,
endX: centerX + distance,
y: centerY,
},
up: {
startX: centerX,
endX: centerX,
startY: centerY + distance,
endY: centerY - distance,
},
down: {
startX: centerX,
endX: centerX,
startY: centerY - distance,
endY: centerY + distance,
},
};
const move = moves[direction];
await page.touchscreen.tap(move.startX, move.startY ?? move.y);
await page.mouse.move(move.endX, move.endY ?? move.y, { steps: 10 });
await page.mouse.up();
});
},
});
// Usage
test("swipe to delete", async ({ page, swipe }) => {
await page.goto("/inbox");
const message = page.getByTestId("message-1");
await swipe(message, "left");
await expect(page.getByRole("button", { name: "Delete" })).toBeVisible();
});Long Press
test("long press for context menu", async ({ page }) => {
await page.goto("/files");
const file = page.getByText("document.pdf");
const box = await file.boundingBox();
if (box) {
// Touch down
await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2);
// Hold for 500ms
await page.waitForTimeout(500);
// Context menu should appear
await expect(page.getByRole("menu")).toBeVisible();
}
});Pinch Zoom
test("pinch to zoom image", async ({ page }) => {
await page.goto("/map");
// Pinch zoom requires two touch points
// Playwright doesn't have native pinch support, so we simulate via evaluate
await page.evaluate(() => {
const element = document.querySelector("#map");
if (element) {
// Simulate wheel event as fallback for zoom
element.dispatchEvent(
new WheelEvent("wheel", {
deltaY: -100, // Negative = zoom in
ctrlKey: true, // Ctrl+wheel = pinch on many apps
}),
);
}
});
// Or trigger the app's zoom function directly
await page.evaluate(() => {
(window as any).mapInstance?.setZoom(15);
});
});Viewport Testing
Test Different Sizes
const viewports = [
{ name: "mobile", width: 375, height: 667 },
{ name: "tablet", width: 768, height: 1024 },
{ name: "desktop", width: 1920, height: 1080 },
];
for (const { name, width, height } of viewports) {
test(`navigation on ${name}`, async ({ page }) => {
await page.setViewportSize({ width, height });
await page.goto("/");
if (width < 768) {
// Mobile: should have hamburger menu
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
} else {
// Desktop: should have visible nav links
await expect(page.getByRole("link", { name: "Products" })).toBeVisible();
}
});
}Dynamic Viewport Changes
test("responsive layout change", async ({ page }) => {
await page.setViewportSize({ width: 1200, height: 800 });
await page.goto("/dashboard");
// Desktop: sidebar visible
await expect(page.getByRole("complementary")).toBeVisible();
// Resize to mobile
await page.setViewportSize({ width: 375, height: 667 });
// Mobile: sidebar hidden, hamburger visible
await expect(page.getByRole("complementary")).toBeHidden();
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
});Mobile-Specific UI
Hamburger Menu
test("mobile navigation", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto("/");
// Open hamburger menu
await page.getByRole("button", { name: "Menu" }).click();
// Navigation drawer should appear
const nav = page.getByRole("navigation");
await expect(nav).toBeVisible();
// Navigate via mobile menu
await nav.getByRole("link", { name: "Products" }).click();
await expect(page).toHaveURL("/products");
// Menu should close after navigation
await expect(nav).toBeHidden();
});Bottom Sheet
test("bottom sheet interaction", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto("/product/123");
await page.getByRole("button", { name: "Add to Cart" }).click();
// Bottom sheet appears
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
// Select options
await sheet.getByRole("combobox", { name: "Size" }).selectOption("Large");
await sheet.getByRole("button", { name: "Confirm" }).click();
await expect(page.getByText("Added to cart")).toBeVisible();
});Pull to Refresh
test("pull to refresh", async ({ page }) => {
await page.goto("/feed");
const feed = page.getByTestId("feed");
const initialFirstItem = await feed.locator("> *").first().textContent();
// Simulate pull down
const box = await feed.boundingBox();
if (box) {
await page.touchscreen.tap(box.x + box.width / 2, box.y + 50);
await page.mouse.move(box.x + box.width / 2, box.y + 200, { steps: 20 });
await page.mouse.up();
}
// Wait for refresh
await expect(page.getByTestId("loading")).toBeVisible();
await expect(page.getByTestId("loading")).toBeHidden();
// Content should be updated (in a real app)
});Responsive Breakpoints
Test All Breakpoints
const breakpoints = {
xs: 320,
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
"2xl": 1536,
};
test.describe("responsive header", () => {
for (const [name, width] of Object.entries(breakpoints)) {
test(`header at ${name} (${width}px)`, async ({ page }) => {
await page.setViewportSize({ width, height: 800 });
await page.goto("/");
if (width < 768) {
await expect(page.getByTestId("mobile-menu-button")).toBeVisible();
await expect(page.getByTestId("desktop-nav")).toBeHidden();
} else {
await expect(page.getByTestId("mobile-menu-button")).toBeHidden();
await expect(page.getByTestId("desktop-nav")).toBeVisible();
}
});
}
});Visual Regression at Breakpoints
test.describe("visual regression", () => {
const sizes = [
{ width: 375, height: 667, name: "mobile" },
{ width: 768, height: 1024, name: "tablet" },
{ width: 1440, height: 900, name: "desktop" },
];
for (const { width, height, name } of sizes) {
test(`homepage at ${name}`, async ({ page }) => {
await page.setViewportSize({ width, height });
await page.goto("/");
await expect(page).toHaveScreenshot(`homepage-${name}.png`);
});
}
});Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Only testing one viewport | Misses responsive bugs | Test multiple breakpoints |
| Ignoring touch events | Features broken on mobile | Test tap, swipe, long press |
| Hardcoded viewport in tests | Can't test multiple sizes | Use page.setViewportSize() |
| Not testing orientation | Landscape bugs missed | Test both portrait and landscape |
Related References
- Visual Testing: See test-suite-structure.md for screenshot testing
- Locators: See locators.md for mobile-friendly selectors
- Browser APIs: See browser-apis.md for permissions (camera, geolocation, notifications)
- Canvas/Touch: See canvas-webgl.md for touch gestures on canvas elements
Multi-Tab, Window & Popup Testing
This file covers single-user scenarios with multiple browser tabs, windows, and popups. For multi-user collaboration testing (multiple users interacting simultaneously), see multi-user.md.
Table of Contents
1. Popup Handling 2. New Tab Navigation 3. OAuth Flows 4. Multiple Windows 5. Tab Coordination
Popup Handling
Basic Popup
test("handle popup window", async ({ page }) => {
await page.goto("/");
// Start waiting for popup before triggering it
const popupPromise = page.waitForEvent("popup");
await page.getByRole("button", { name: "Open Support Chat" }).click();
const popup = await popupPromise;
// Wait for popup to load
await popup.waitForLoadState();
// Interact with popup
await popup.getByLabel("Message").fill("Need help");
await popup.getByRole("button", { name: "Send" }).click();
await expect(popup.getByText("Message sent")).toBeVisible();
// Close popup
await popup.close();
});Popup with Authentication
test("popup login flow", async ({ page }) => {
await page.goto("/dashboard");
const popupPromise = page.waitForEvent("popup");
await page.getByRole("button", { name: "Connect Account" }).click();
const popup = await popupPromise;
await popup.waitForLoadState();
// Complete login in popup
await popup.getByLabel("Email").fill("user@example.com");
await popup.getByLabel("Password").fill("password123");
await popup.getByRole("button", { name: "Log In" }).click();
// Popup should close automatically after auth
await popup.waitForEvent("close");
// Main page should reflect connected state
await expect(page.getByText("Account connected")).toBeVisible();
});Handle Blocked Popups
test("handle popup blocker", async ({ page }) => {
await page.goto("/share");
// Listen for console messages about blocked popup
page.on("console", (msg) => {
if (msg.text().includes("popup blocked")) {
console.log("Popup was blocked");
}
});
const popupPromise = page.waitForEvent("popup").catch(() => null);
await page.getByRole("button", { name: "Share to Twitter" }).click();
const popup = await popupPromise;
if (!popup) {
// Popup blocked - app should show fallback
await expect(page.getByText("Copy share link instead")).toBeVisible();
}
});New Tab Navigation
Link Opens in New Tab
test("external link opens in new tab", async ({ page, context }) => {
await page.goto("/resources");
// Wait for new page in context
const pagePromise = context.waitForEvent("page");
await page.getByRole("link", { name: "Documentation" }).click();
const newPage = await pagePromise;
await newPage.waitForLoadState();
expect(newPage.url()).toContain("docs.example.com");
await expect(newPage.getByRole("heading", { level: 1 })).toBeVisible();
// Original page still there
expect(page.url()).toContain("/resources");
await newPage.close();
});Intercept New Tab
test("prevent new tab for testing", async ({ page }) => {
await page.goto("/links");
// Remove target="_blank" to keep navigation in same tab
await page.evaluate(() => {
document.querySelectorAll('a[target="_blank"]').forEach((a) => {
a.removeAttribute("target");
});
});
// Now link opens in same tab
await page.getByRole("link", { name: "External Site" }).click();
// Can test the destination page
await expect(page).toHaveURL(/external-site\.com/);
});OAuth Flows
Google OAuth Popup
test("Google OAuth login", async ({ page }) => {
await page.goto("/login");
const popupPromise = page.waitForEvent("popup");
await page.getByRole("button", { name: "Sign in with Google" }).click();
const popup = await popupPromise;
await popup.waitForLoadState();
// Handle Google's OAuth flow
await popup.getByLabel("Email or phone").fill("test@gmail.com");
await popup.getByRole("button", { name: "Next" }).click();
await popup.getByLabel("Enter your password").fill("password");
await popup.getByRole("button", { name: "Next" }).click();
// Wait for redirect back and popup close
await popup.waitForEvent("close");
// Verify logged in on main page
await expect(page.getByText("Welcome, Test User")).toBeVisible();
});Mock OAuth (Recommended)
test("mock OAuth flow", async ({ page, context }) => {
// Mock the OAuth callback instead of real flow
await page.route("**/auth/callback**", async (route) => {
// Simulate successful OAuth
const url = new URL(route.request().url());
url.searchParams.set("code", "mock-auth-code");
await route.fulfill({
status: 302,
headers: { Location: "/dashboard" },
});
});
// Mock token exchange
await page.route("**/api/auth/token", (route) =>
route.fulfill({
json: {
access_token: "mock-token",
user: { name: "Test User", email: "test@example.com" },
},
}),
);
await page.goto("/login");
await page.getByRole("button", { name: "Sign in with Google" }).click();
// Should redirect to dashboard without actual OAuth
await expect(page).toHaveURL("/dashboard");
await expect(page.getByText("Welcome, Test User")).toBeVisible();
});OAuth Fixture
For comprehensive OAuth mocking patterns (fixtures, multiple providers, SAML SSO), see third-party.md. This section focuses on popup window handling mechanics for OAuth flows.
Multiple Windows
Test Across Multiple Windows
test("sync between windows", async ({ context }) => {
// Open two pages
const page1 = await context.newPage();
const page2 = await context.newPage();
await page1.goto("/dashboard");
await page2.goto("/dashboard");
// Make change in first window
await page1.getByRole("button", { name: "Add Item" }).click();
await page1.getByLabel("Name").fill("New Item");
await page1.getByRole("button", { name: "Save" }).click();
// Should sync to second window (if app supports real-time sync)
await expect(page2.getByText("New Item")).toBeVisible({ timeout: 10000 });
});Different Users in Different Windows
For multi-user collaboration patterns (admin/user interactions, real-time collaboration, role-based testing, concurrent actions), see multi-user.md. This file focuses on single-user scenarios with multiple tabs/windows/popups.
Tab Coordination
Switch Between Tabs
test("manage multiple tabs", async ({ context }) => {
const page1 = await context.newPage();
await page1.goto("/editor");
const page2 = await context.newPage();
await page2.goto("/preview");
// Edit in first tab
await page1.bringToFront();
await page1.getByLabel("Content").fill("Hello World");
// Check preview in second tab
await page2.bringToFront();
await page2.reload(); // If preview needs refresh
await expect(page2.getByText("Hello World")).toBeVisible();
});Close All Tabs Except One
test("cleanup tabs after test", async ({ context }) => {
const mainPage = await context.newPage();
await mainPage.goto("/");
// Open several popups during test
for (let i = 0; i < 3; i++) {
const popup = await context.newPage();
await popup.goto(`/popup/${i}`);
}
// Close all except main page
for (const page of context.pages()) {
if (page !== mainPage) {
await page.close();
}
}
expect(context.pages()).toHaveLength(1);
});Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Not waiting for popup | Race condition | Use waitForEvent("popup") before trigger |
| Testing real OAuth | Slow, flaky, needs credentials | Mock OAuth endpoints |
| Assuming popup opens | May be blocked | Handle both open and blocked cases |
| Not closing extra pages | Resource leak | Close pages in cleanup |
Related References
- Authentication: See fixtures-hooks.md for auth patterns
- Network: See network-advanced.md for mocking OAuth
Multi-User & Collaboration Testing
Table of Contents
1. Multiple Browser Contexts 2. Real-Time Collaboration 3. Role-Based Testing 4. Concurrent Actions 5. Chat & Messaging
Multiple Browser Contexts
Two Users in Same Test
test("two users see each other's changes", async ({ browser }) => {
// Create two isolated contexts (like two browsers)
const userAContext = await browser.newContext();
const userBContext = await browser.newContext();
const userAPage = await userAContext.newPage();
const userBPage = await userBContext.newPage();
// Both users go to the same document
await userAPage.goto("/doc/shared-123");
await userBPage.goto("/doc/shared-123");
// User A types
await userAPage.getByLabel("Content").fill("Hello from User A");
// User B should see the change
await expect(userBPage.getByText("Hello from User A")).toBeVisible();
// Cleanup
await userAContext.close();
await userBContext.close();
});Multiple Users with Auth States
test("admin and user interaction", async ({ browser }) => {
// Load different auth states
const adminContext = await browser.newContext({
storageState: ".auth/admin.json",
});
const userContext = await browser.newContext({
storageState: ".auth/user.json",
});
const adminPage = await adminContext.newPage();
const userPage = await userContext.newPage();
// User submits request
await userPage.goto("/support");
await userPage.getByLabel("Message").fill("Need help!");
await userPage.getByRole("button", { name: "Submit" }).click();
// Admin sees and responds
await adminPage.goto("/admin/tickets");
await expect(adminPage.getByText("Need help!")).toBeVisible();
await adminPage.getByRole("button", { name: "Reply" }).click();
await adminPage.getByLabel("Response").fill("How can I help?");
await adminPage.getByRole("button", { name: "Send" }).click();
// User sees response
await expect(userPage.getByText("How can I help?")).toBeVisible();
await adminContext.close();
await userContext.close();
});Multi-User Fixture
// fixtures/multi-user.fixture.ts
import { test as base, Browser, BrowserContext, Page } from "@playwright/test";
type UserSession = {
context: BrowserContext;
page: Page;
};
type MultiUserFixtures = {
createUser: (authState?: string) => Promise<UserSession>;
};
export const test = base.extend<MultiUserFixtures>({
createUser: async ({ browser }, use) => {
const sessions: UserSession[] = [];
await use(async (authState) => {
const context = await browser.newContext({
storageState: authState,
});
const page = await context.newPage();
sessions.push({ context, page });
return { context, page };
});
// Cleanup all sessions
for (const session of sessions) {
await session.context.close();
}
},
});
// Usage
test("3 users collaborate", async ({ createUser }) => {
const alice = await createUser(".auth/alice.json");
const bob = await createUser(".auth/bob.json");
const charlie = await createUser(".auth/charlie.json");
// All navigate to same room
await alice.page.goto("/room/123");
await bob.page.goto("/room/123");
await charlie.page.goto("/room/123");
// Test interactions...
});Real-Time Collaboration
Collaborative Document
test("real-time collaborative editing", async ({ browser }) => {
const user1 = await browser.newContext();
const user2 = await browser.newContext();
const page1 = await user1.newPage();
const page2 = await user2.newPage();
await page1.goto("/docs/shared");
await page2.goto("/docs/shared");
// User 1 types at the beginning
const editor1 = page1.getByRole("textbox");
await editor1.click();
await editor1.press("Home");
await editor1.type("User 1: ");
// User 2 types at the end
const editor2 = page2.getByRole("textbox");
await editor2.click();
await editor2.press("End");
await editor2.type(" - User 2");
// Both should see combined result
await expect(page1.getByRole("textbox")).toContainText("User 1:");
await expect(page1.getByRole("textbox")).toContainText("- User 2");
await expect(page2.getByRole("textbox")).toContainText("User 1:");
await expect(page2.getByRole("textbox")).toContainText("- User 2");
await user1.close();
await user2.close();
});Cursor Presence
test("shows other user cursors", async ({ browser }) => {
const ctx1 = await browser.newContext();
const ctx2 = await browser.newContext();
const page1 = await ctx1.newPage();
const page2 = await ctx2.newPage();
// Mock to identify users
await page1.route("**/api/me", (route) =>
route.fulfill({ json: { id: "user-1", name: "Alice" } }),
);
await page2.route("**/api/me", (route) =>
route.fulfill({ json: { id: "user-2", name: "Bob" } }),
);
await page1.goto("/whiteboard/123");
await page2.goto("/whiteboard/123");
// Move cursor on page1
await page1.mouse.move(200, 200);
// Page2 should see Alice's cursor
await expect(page2.getByTestId("cursor-user-1")).toBeVisible();
await expect(page2.getByText("Alice")).toBeVisible();
await ctx1.close();
await ctx2.close();
});Role-Based Testing
Test RBAC
const roles = [
{ role: "admin", canDelete: true, canEdit: true, canView: true },
{ role: "editor", canDelete: false, canEdit: true, canView: true },
{ role: "viewer", canDelete: false, canEdit: false, canView: true },
];
for (const { role, canDelete, canEdit, canView } of roles) {
test(`${role} permissions`, async ({ browser }) => {
const context = await browser.newContext({
storageState: `.auth/${role}.json`,
});
const page = await context.newPage();
await page.goto("/document/123");
// Check view permission
if (canView) {
await expect(page.getByTestId("content")).toBeVisible();
} else {
await expect(page.getByText("Access denied")).toBeVisible();
}
// Check edit permission
const editButton = page.getByRole("button", { name: "Edit" });
if (canEdit) {
await expect(editButton).toBeEnabled();
} else {
await expect(editButton).toBeDisabled();
}
// Check delete permission
const deleteButton = page.getByRole("button", { name: "Delete" });
if (canDelete) {
await expect(deleteButton).toBeVisible();
} else {
await expect(deleteButton).toBeHidden();
}
await context.close();
});
}Permission Escalation Test
test("cannot access admin routes as user", async ({ browser }) => {
const userContext = await browser.newContext({
storageState: ".auth/user.json",
});
const page = await userContext.newPage();
// Try to access admin page directly
await page.goto("/admin/users");
// Should redirect or show error
await expect(page).not.toHaveURL("/admin/users");
await expect(page.getByText("Access denied")).toBeVisible();
await userContext.close();
});Concurrent Actions
Race Condition Testing
test("handles concurrent edits", async ({ browser }) => {
const ctx1 = await browser.newContext();
const ctx2 = await browser.newContext();
const page1 = await ctx1.newPage();
const page2 = await ctx2.newPage();
await page1.goto("/item/123");
await page2.goto("/item/123");
// Both click edit at the same time
await Promise.all([
page1.getByRole("button", { name: "Edit" }).click(),
page2.getByRole("button", { name: "Edit" }).click(),
]);
// Both try to save different values
await page1.getByLabel("Name").fill("Value from User 1");
await page2.getByLabel("Name").fill("Value from User 2");
await Promise.all([
page1.getByRole("button", { name: "Save" }).click(),
page2.getByRole("button", { name: "Save" }).click(),
]);
// One should succeed, one should get conflict error
const page1HasConflict = await page1.getByText("Conflict").isVisible();
const page2HasConflict = await page2.getByText("Conflict").isVisible();
// Exactly one should have conflict
expect(page1HasConflict || page2HasConflict).toBe(true);
expect(page1HasConflict && page2HasConflict).toBe(false);
await ctx1.close();
await ctx2.close();
});Optimistic Locking Test
test("optimistic locking prevents overwrites", async ({ browser }) => {
const ctx1 = await browser.newContext();
const ctx2 = await browser.newContext();
const page1 = await ctx1.newPage();
const page2 = await ctx2.newPage();
// Both load the same version
await page1.goto("/record/123");
await page2.goto("/record/123");
// User 1 edits and saves first
await page1.getByRole("button", { name: "Edit" }).click();
await page1.getByLabel("Value").fill("Updated by User 1");
await page1.getByRole("button", { name: "Save" }).click();
await expect(page1.getByText("Saved")).toBeVisible();
// User 2 tries to save with stale version
await page2.getByRole("button", { name: "Edit" }).click();
await page2.getByLabel("Value").fill("Updated by User 2");
await page2.getByRole("button", { name: "Save" }).click();
// Should fail with version conflict
await expect(page2.getByText("Someone else modified this")).toBeVisible();
await expect(page2.getByRole("button", { name: "Reload" })).toBeVisible();
await ctx1.close();
await ctx2.close();
});Chat & Messaging
Real-Time Chat
test("chat messages sync between users", async ({ browser }) => {
const aliceCtx = await browser.newContext();
const bobCtx = await browser.newContext();
const alicePage = await aliceCtx.newPage();
const bobPage = await bobCtx.newPage();
// Setup user identities
await alicePage.route("**/api/me", (r) =>
r.fulfill({ json: { name: "Alice" } }),
);
await bobPage.route("**/api/me", (r) => r.fulfill({ json: { name: "Bob" } }));
await alicePage.goto("/chat/room-1");
await bobPage.goto("/chat/room-1");
// Alice sends message
await alicePage.getByLabel("Message").fill("Hi Bob!");
await alicePage.getByRole("button", { name: "Send" }).click();
// Bob sees it
await expect(bobPage.getByText("Alice: Hi Bob!")).toBeVisible();
// Bob replies
await bobPage.getByLabel("Message").fill("Hey Alice!");
await bobPage.getByRole("button", { name: "Send" }).click();
// Alice sees it
await expect(alicePage.getByText("Bob: Hey Alice!")).toBeVisible();
await aliceCtx.close();
await bobCtx.close();
});Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Sharing context between users | State leaks, not isolated | Create separate contexts |
| Not closing contexts | Memory leak, browser overload | Always close in cleanup |
| Hardcoded timing for sync | Flaky tests | Use expect().toBeVisible() |
| Testing only single user | Misses collaboration bugs | Test multi-user scenarios |
Related References
- Authentication: See fixtures-hooks.md for auth setup
- WebSockets: See websockets.md for real-time mocking
Advanced Network Interception
Table of Contents
1. Request Modification 2. GraphQL Mocking 3. HAR Recording & Playback 4. Conditional Mocking 5. Network Throttling
Request Modification
Modify Request Headers
test("add auth header to requests", async ({ page }) => {
await page.route("**/api/**", (route) => {
const headers = {
...route.request().headers(),
Authorization: "Bearer test-token",
"X-Test-Header": "test-value",
};
route.continue({ headers });
});
await page.goto("/dashboard");
});Modify Request Body
test("modify POST body", async ({ page }) => {
await page.route("**/api/orders", async (route) => {
if (route.request().method() === "POST") {
const postData = route.request().postDataJSON();
// Add test metadata
const modifiedData = {
...postData,
testMode: true,
testTimestamp: Date.now(),
};
await route.continue({
postData: JSON.stringify(modifiedData),
});
} else {
await route.continue();
}
});
await page.goto("/checkout");
await page.getByRole("button", { name: "Place Order" }).click();
});Transform Response
test("modify API response", async ({ page }) => {
await page.route("**/api/products", async (route) => {
// Fetch real response
const response = await route.fetch();
const json = await response.json();
// Modify response
const modified = json.map((product: any) => ({
...product,
price: product.price * 0.9, // 10% discount
testMode: true,
}));
await route.fulfill({
response,
json: modified,
});
});
await page.goto("/products");
});GraphQL Mocking
Mock by Operation Name
test("mock GraphQL query", async ({ page }) => {
await page.route("**/graphql", async (route) => {
const postData = route.request().postDataJSON();
if (postData.operationName === "GetUser") {
return route.fulfill({
json: {
data: {
user: {
id: "1",
name: "Test User",
email: "test@example.com",
},
},
},
});
}
if (postData.operationName === "GetProducts") {
return route.fulfill({
json: {
data: {
products: [
{ id: "1", name: "Product A", price: 29.99 },
{ id: "2", name: "Product B", price: 49.99 },
],
},
},
});
}
// Pass through unmocked operations
return route.continue();
});
await page.goto("/dashboard");
});GraphQL Mock Fixture
// fixtures/graphql.fixture.ts
type GraphQLMock = {
operation: string;
variables?: Record<string, any>;
response: { data?: any; errors?: any[] };
};
type GraphQLFixtures = {
mockGraphQL: (mocks: GraphQLMock[]) => Promise<void>;
};
export const test = base.extend<GraphQLFixtures>({
mockGraphQL: async ({ page }, use) => {
await use(async (mocks) => {
await page.route("**/graphql", async (route) => {
const postData = route.request().postDataJSON();
const mock = mocks.find((m) => {
if (m.operation !== postData.operationName) return false;
// Optionally match variables
if (m.variables) {
return (
JSON.stringify(m.variables) === JSON.stringify(postData.variables)
);
}
return true;
});
if (mock) {
return route.fulfill({ json: mock.response });
}
return route.continue();
});
});
},
});
// Usage
test("dashboard with mocked GraphQL", async ({ page, mockGraphQL }) => {
await mockGraphQL([
{
operation: "GetDashboardStats",
response: {
data: { stats: { users: 100, revenue: 50000 } },
},
},
{
operation: "GetUser",
variables: { id: "1" },
response: {
data: { user: { id: "1", name: "John" } },
},
},
]);
await page.goto("/dashboard");
await expect(page.getByText("100 users")).toBeVisible();
});Mock GraphQL Mutations
test("mock GraphQL mutation", async ({ page }) => {
await page.route("**/graphql", async (route) => {
const postData = route.request().postDataJSON();
if (postData.operationName === "CreateOrder") {
const { input } = postData.variables;
return route.fulfill({
json: {
data: {
createOrder: {
id: "order-123",
status: "PENDING",
items: input.items,
total: input.items.reduce(
(sum: number, item: any) => sum + item.price * item.quantity,
0,
),
},
},
},
});
}
return route.continue();
});
await page.goto("/checkout");
await page.getByRole("button", { name: "Place Order" }).click();
await expect(page.getByText("Order #order-123")).toBeVisible();
});HAR Recording & Playback
Record HAR File
// Record network traffic
test("record HAR", async ({ page, context }) => {
// Start recording
await context.routeFromHAR("./recordings/checkout.har", {
update: true, // Create/update HAR file
url: "**/api/**",
});
await page.goto("/checkout");
await page.getByRole("button", { name: "Place Order" }).click();
// HAR file is saved automatically
});Playback HAR File
// Use recorded HAR for offline testing
test("playback HAR", async ({ page, context }) => {
await context.routeFromHAR("./recordings/checkout.har", {
url: "**/api/**",
update: false, // Don't update, just playback
});
await page.goto("/checkout");
// All API calls served from HAR file
await expect(page.getByText("Order confirmed")).toBeVisible();
});HAR with Fallback
test("HAR with live fallback", async ({ page, context }) => {
await context.routeFromHAR("./recordings/api.har", {
url: "**/api/**",
update: false,
notFound: "fallback", // Use real network if not in HAR
});
await page.goto("/dashboard");
});Conditional Mocking
Mock Based on Request Body
test("conditional mock by body", async ({ page }) => {
await page.route("**/api/search", async (route) => {
const body = route.request().postDataJSON();
if (body.query === "error") {
return route.fulfill({
status: 500,
json: { error: "Search failed" },
});
}
if (body.query === "empty") {
return route.fulfill({
json: { results: [] },
});
}
// Default response
return route.fulfill({
json: {
results: [{ id: 1, title: `Result for: ${body.query}` }],
},
});
});
await page.goto("/search");
// Test different scenarios
await page.getByLabel("Search").fill("error");
await page.getByLabel("Search").press("Enter");
await expect(page.getByText("Search failed")).toBeVisible();
});Mock Nth Request
test("different response on retry", async ({ page }) => {
let callCount = 0;
await page.route("**/api/status", (route) => {
callCount++;
if (callCount < 3) {
return route.fulfill({
status: 503,
json: { error: "Service unavailable" },
});
}
// Succeed on 3rd attempt
return route.fulfill({
json: { status: "ok" },
});
});
await page.goto("/dashboard");
// App should retry and eventually succeed
await expect(page.getByText("Connected")).toBeVisible();
});Mock with Delay
test("slow network simulation", async ({ page }) => {
await page.route("**/api/data", async (route) => {
// Simulate 2 second delay
await new Promise((resolve) => setTimeout(resolve, 2000));
return route.fulfill({
json: { data: "loaded" },
});
});
await page.goto("/dashboard");
// Loading state should appear
await expect(page.getByText("Loading...")).toBeVisible();
// Then data appears
await expect(page.getByText("loaded")).toBeVisible();
});Network Throttling
Slow 3G Simulation
test("slow network experience", async ({ page, context }) => {
// Create CDP session for network throttling
const client = await context.newCDPSession(page);
await client.send("Network.emulateNetworkConditions", {
offline: false,
downloadThroughput: (500 * 1024) / 8, // 500 Kbps
uploadThroughput: (500 * 1024) / 8,
latency: 400, // 400ms
});
await page.goto("/");
// Test loading states appear
await expect(page.getByTestId("skeleton-loader")).toBeVisible();
});Offline Mode
Use context.setOffline(true/false) to simulate network connectivity changes.
For comprehensive offline testing patterns:
>
- Network failure simulation (error recovery, graceful degradation): See error-testing.md
- Offline-first/PWA testing (service workers, caching, background sync): See service-workers.md
Network Throttling Fixture
// fixtures/network.fixture.ts
type NetworkCondition = "slow3g" | "fast3g" | "offline";
const conditions = {
slow3g: { downloadThroughput: 50000, uploadThroughput: 50000, latency: 2000 },
fast3g: { downloadThroughput: 180000, uploadThroughput: 75000, latency: 150 },
};
type NetworkFixtures = {
setNetworkCondition: (condition: NetworkCondition) => Promise<void>;
};
export const test = base.extend<NetworkFixtures>({
setNetworkCondition: async ({ page, context }, use) => {
const client = await context.newCDPSession(page);
await use(async (condition) => {
if (condition === "offline") {
await context.setOffline(true);
} else {
await client.send("Network.emulateNetworkConditions", {
offline: false,
...conditions[condition],
});
}
});
// Reset
await context.setOffline(false);
},
});Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Mocking all requests | Tests don't reflect reality | Mock only what's necessary |
| No cleanup of routes | Routes persist across tests | Use fixtures with cleanup |
| Ignoring request method | Mock applies to wrong requests | Check route.request().method() |
| Hardcoded mock responses | Brittle, hard to maintain | Use factories for mock data |
Related References
- Basic Mocking: See test-suite-structure.md for simple mocking
- WebSockets: See websockets.md for real-time mocking
WebSocket & Real-Time Testing
Table of Contents
1. WebSocket Basics 2. Mocking WebSocket Messages 3. Testing Real-Time Features 4. Server-Sent Events 5. Reconnection Testing
WebSocket Basics
Wait for WebSocket Connection
test("chat connects via websocket", async ({ page }) => {
// Listen for WebSocket connection
const wsPromise = page.waitForEvent("websocket");
await page.goto("/chat");
const ws = await wsPromise;
expect(ws.url()).toContain("/ws/chat");
// Wait for connection to be established
await ws.waitForEvent("framesent");
});Monitor WebSocket Messages
test("receives real-time updates", async ({ page }) => {
const messages: string[] = [];
// Set up listener before navigation
page.on("websocket", (ws) => {
ws.on("framereceived", (frame) => {
messages.push(frame.payload as string);
});
});
await page.goto("/dashboard");
// Wait for some messages
await expect.poll(() => messages.length).toBeGreaterThan(0);
// Verify message format
const data = JSON.parse(messages[0]);
expect(data).toHaveProperty("type");
});Capture Sent Messages
test("sends correct message format", async ({ page }) => {
const sentMessages: string[] = [];
page.on("websocket", (ws) => {
ws.on("framesent", (frame) => {
sentMessages.push(frame.payload as string);
});
});
await page.goto("/chat");
await page.getByLabel("Message").fill("Hello!");
await page.getByRole("button", { name: "Send" }).click();
// Verify sent message
await expect.poll(() => sentMessages.length).toBeGreaterThan(0);
const sent = JSON.parse(sentMessages[sentMessages.length - 1]);
expect(sent).toEqual({
type: "message",
content: "Hello!",
});
});Mocking WebSocket Messages
Inject Messages via Page Evaluate
test("displays incoming chat message", async ({ page }) => {
await page.goto("/chat");
// Wait for WebSocket to be ready
await page.waitForFunction(
() => (window as any).chatSocket?.readyState === 1,
);
// Simulate incoming message
await page.evaluate(() => {
const event = new MessageEvent("message", {
data: JSON.stringify({
type: "message",
from: "Alice",
content: "Hello there!",
}),
});
(window as any).chatSocket.dispatchEvent(event);
});
await expect(page.getByText("Alice: Hello there!")).toBeVisible();
});Mock WebSocket with Route Handler
test("mock websocket entirely", async ({ page, context }) => {
// Intercept the WebSocket upgrade
await context.route("**/ws/**", async (route) => {
// For WebSocket routes, we can't fulfill directly
// Instead, use page.evaluate to mock the client-side
});
// Alternative: Mock at application level
await page.addInitScript(() => {
const OriginalWebSocket = window.WebSocket;
(window as any).WebSocket = function (url: string) {
const ws = {
readyState: 1,
send: (data: string) => {
console.log("WS Send:", data);
},
close: () => {},
addEventListener: () => {},
removeEventListener: () => {},
};
setTimeout(() => ws.onopen?.(), 100);
return ws;
};
});
await page.goto("/chat");
});WebSocket Mock Fixture
// fixtures/websocket.fixture.ts
import { test as base, Page } from "@playwright/test";
type WsMessage = { type: string; [key: string]: any };
type WebSocketFixtures = {
mockWebSocket: {
injectMessage: (message: WsMessage) => Promise<void>;
getSentMessages: () => Promise<WsMessage[]>;
};
};
export const test = base.extend<WebSocketFixtures>({
mockWebSocket: async ({ page }, use) => {
const sentMessages: WsMessage[] = [];
// Capture sent messages
await page.addInitScript(() => {
(window as any).__wsSent = [];
const OriginalWebSocket = window.WebSocket;
window.WebSocket = function (url: string) {
const ws = new OriginalWebSocket(url);
const originalSend = ws.send.bind(ws);
ws.send = (data: string) => {
(window as any).__wsSent.push(JSON.parse(data));
originalSend(data);
};
(window as any).__ws = ws;
return ws;
} as any;
});
await use({
injectMessage: async (message) => {
await page.evaluate((msg) => {
const event = new MessageEvent("message", {
data: JSON.stringify(msg),
});
(window as any).__ws?.dispatchEvent(event);
}, message);
},
getSentMessages: async () => {
return page.evaluate(() => (window as any).__wsSent || []);
},
});
},
});
// Usage
test("chat with mocked websocket", async ({ page, mockWebSocket }) => {
await page.goto("/chat");
// Inject incoming message
await mockWebSocket.injectMessage({
type: "message",
from: "Bob",
content: "Hi!",
});
await expect(page.getByText("Bob: Hi!")).toBeVisible();
// Send a reply
await page.getByLabel("Message").fill("Hello Bob!");
await page.getByRole("button", { name: "Send" }).click();
// Verify sent message
const sent = await mockWebSocket.getSentMessages();
expect(sent).toContainEqual(
expect.objectContaining({ content: "Hello Bob!" }),
);
});Testing Real-Time Features
Live Notifications
test("displays live notification", async ({ page }) => {
await page.goto("/dashboard");
// Simulate notification via WebSocket
await page.evaluate(() => {
const event = new MessageEvent("message", {
data: JSON.stringify({
type: "notification",
title: "New Order",
message: "Order #123 received",
}),
});
(window as any).notificationSocket.dispatchEvent(event);
});
await expect(page.getByRole("alert")).toContainText("Order #123 received");
});Live Data Updates
test("updates stock price in real-time", async ({ page }) => {
await page.goto("/stocks/AAPL");
const priceElement = page.getByTestId("stock-price");
const initialPrice = await priceElement.textContent();
// Simulate price update
await page.evaluate(() => {
const event = new MessageEvent("message", {
data: JSON.stringify({
type: "price_update",
symbol: "AAPL",
price: 150.25,
}),
});
(window as any).stockSocket.dispatchEvent(event);
});
await expect(priceElement).not.toHaveText(initialPrice!);
await expect(priceElement).toContainText("150.25");
});Collaborative Editing
test("shows collaborator cursor", async ({ page }) => {
await page.goto("/document/123");
// Simulate another user's cursor position
await page.evaluate(() => {
const event = new MessageEvent("message", {
data: JSON.stringify({
type: "cursor",
userId: "user-456",
userName: "Alice",
position: { x: 100, y: 200 },
}),
});
(window as any).docSocket.dispatchEvent(event);
});
await expect(page.getByTestId("cursor-user-456")).toBeVisible();
await expect(page.getByText("Alice")).toBeVisible();
});Server-Sent Events
Test SSE Updates
test("receives SSE updates", async ({ page }) => {
// Mock SSE endpoint
await page.route("**/api/events", (route) => {
route.fulfill({
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
body: `data: {"type":"update","value":42}\n\n`,
});
});
await page.goto("/live-data");
await expect(page.getByTestId("value")).toHaveText("42");
});Simulate Multiple SSE Events
test("handles multiple SSE events", async ({ page }) => {
await page.route("**/api/events", async (route) => {
const encoder = new TextEncoder();
const events = [
`data: {"count":1}\n\n`,
`data: {"count":2}\n\n`,
`data: {"count":3}\n\n`,
];
route.fulfill({
status: 200,
headers: { "Content-Type": "text/event-stream" },
body: events.join(""),
});
});
await page.goto("/counter");
// Should receive all events
await expect(page.getByTestId("count")).toHaveText("3");
});Reconnection Testing
Test Connection Loss
test("handles connection loss gracefully", async ({ page }) => {
await page.goto("/chat");
// Simulate connection close
await page.evaluate(() => {
(window as any).chatSocket.close();
});
// Should show disconnected state
await expect(page.getByText("Reconnecting...")).toBeVisible();
});Test Reconnection
test("reconnects after connection loss", async ({ page }) => {
await page.goto("/chat");
// Simulate disconnect
await page.evaluate(() => {
(window as any).chatSocket.close();
});
await expect(page.getByText("Reconnecting...")).toBeVisible();
// Simulate reconnection
await page.evaluate(() => {
const event = new Event("open");
(window as any).chatSocket = { readyState: 1 };
(window as any).chatSocket.dispatchEvent?.(event);
});
// Force component to re-check connection
await page.evaluate(() => {
window.dispatchEvent(new Event("online"));
});
await expect(page.getByText("Connected")).toBeVisible();
});Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Not waiting for WebSocket ready | Messages sent too early | Wait for readyState === 1 |
| Testing against real WebSocket server | Flaky, timing-dependent | Mock WebSocket messages |
| Ignoring connection state | Tests pass but feature broken | Test connected/disconnected states |
| No cleanup of listeners | Memory leaks in tests | Clean up event listeners |
Related References
- Network: See network-advanced.md for HTTP mocking patterns
- Assertions: See assertions-waiting.md for polling patterns
- Multi-User: See multi-user.md for real-time collaboration testing with multiple users
Related skills
Forks & variants (1)
Playwright Best Practices has 1 known copy in the catalog totaling 15 installs. They canonicalize to this original listing.
- sanity-io - 15 installs
FAQ
What Playwright topics does playwright-best-practices cover?
playwright-best-practices covers test writing, flaky debugging, Page Object Model, CI/CD, API mocking, OAuth, axe-core accessibility, WebSockets, GraphQL, mobile layouts, and tagged runs with --grep filters.
Does playwright-best-practices help with CI configuration?
playwright-best-practices includes CI/CD configuration, global setup and teardown, test annotations like skip and fixme, and tag-based filtering with @smoke and @critical for pipeline control.
Is Playwright Best Practices safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.