DOMAIN:TESTING — PLAYWRIGHT_E2E¶
OWNER: marije, judith
ALSO_USED_BY: ashley (adversarial flows), nessa (performance baselines)
UPDATED: 2026-03-24
SCOPE: end-to-end testing for all GE client projects (Next.js App Router)
PLAYWRIGHT_OVERVIEW¶
Playwright is GE's E2E test framework.
It tests REAL user flows in REAL browsers.
E2E tests are the top of the test pyramid — use them sparingly for critical paths only.
WHY_PLAYWRIGHT:
- Multi-browser (Chromium, Firefox, WebKit) from one API
- Auto-wait (no manual waitForSelector hacks)
- Network interception built-in
- Trace viewer for debugging failures
- Native mobile viewport support
- Parallel execution across browsers
PAGE_OBJECT_MODEL¶
Every page in the application gets a Page Object class.
Page Objects encapsulate selectors and actions — tests read like user stories.
POM_PATTERN¶
// e2e/page-objects/login-page.ts
import { type Page, type Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}
POM_USAGE_IN_TESTS¶
// e2e/features/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../page-objects/login-page';
import { DashboardPage } from '../page-objects/dashboard-page';
test.describe('Authentication', () => {
test('successful login redirects to dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'validpassword');
await expect(dashboardPage.welcomeMessage).toBeVisible();
await expect(page).toHaveURL('/dashboard');
});
test('invalid credentials show error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'wrongpassword');
await loginPage.expectError('Invalid credentials');
await expect(page).toHaveURL('/login');
});
});
RULE: tests NEVER use raw selectors — always go through Page Objects
RULE: Page Objects expose USER-FACING actions (login, addItem) — not technical operations (click, fill)
RULE: Page Objects use accessible selectors: getByRole, getByLabel, getByText — NEVER CSS selectors
RULE: Page Objects do not contain assertions — tests assert, Page Objects act
SELECTOR_PRIORITY¶
BEST: page.getByRole('button', { name: 'Submit' }) — accessible, resilient
GOOD: page.getByLabel('Email') — accessible, tied to label
GOOD: page.getByText('Welcome back') — user-visible text
OK: page.getByTestId('submit-btn') — stable but not accessible
BAD: page.locator('.btn-primary') — CSS class, brittle
WORST: page.locator('#app > div:nth-child(3) > button') — DOM structure, extremely brittle
RULE: use getByRole and getByLabel as primary selectors — they test accessibility for free
RULE: use getByTestId only when no accessible selector exists — and file a bug to add aria labels
TEST_ISOLATION¶
Every test must be independent. No test depends on another test's state.
STORAGE_STATE_FOR_AUTH¶
// e2e/fixtures/auth.ts
import { test as base, expect } from '@playwright/test';
// Create authenticated state once, reuse across tests
export const test = base.extend<{}, { authenticatedState: string }>({
authenticatedState: [async ({ browser }, use) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/login');
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password').fill('testpassword');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
const storageState = await context.storageState();
const statePath = 'e2e/.auth/state.json';
await context.storageState({ path: statePath });
await context.close();
await use(statePath);
}, { scope: 'worker' }],
});
// Usage — test starts already logged in
test('dashboard shows user data', async ({ browser, authenticatedState }) => {
const context = await browser.newContext({ storageState: authenticatedState });
const page = await context.newPage();
await page.goto('/dashboard');
await expect(page.getByText('Welcome')).toBeVisible();
});
RULE: authentication state is created ONCE per worker, not per test
RULE: each test gets a fresh browser context — cookies/storage are isolated
RULE: never rely on test execution order — each test must work in isolation
NETWORK_MOCKING¶
MOCK_API_RESPONSES¶
test('shows error state on API failure', async ({ page }) => {
// Intercept API call and return error
await page.route('**/api/users', (route) => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal server error' }),
});
});
await page.goto('/users');
await expect(page.getByText('Something went wrong')).toBeVisible();
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});
test('shows loading state while fetching', async ({ page }) => {
// Delay response to observe loading state
await page.route('**/api/users', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'User' }]),
});
});
await page.goto('/users');
await expect(page.getByText('Loading...')).toBeVisible();
await expect(page.getByText('User')).toBeVisible();
});
WAIT_FOR_API_RESPONSE¶
test('submits form and shows success', async ({ page }) => {
await page.goto('/items/new');
await page.getByLabel('Name').fill('New Item');
// Wait for the API call to complete
const responsePromise = page.waitForResponse('**/api/items');
await page.getByRole('button', { name: 'Create' }).click();
const response = await responsePromise;
expect(response.status()).toBe(201);
await expect(page.getByText('Item created successfully')).toBeVisible();
});
RULE: use page.route() to test error states and edge cases — don't rely on backend failures
RULE: use page.waitForResponse() to synchronize on real API calls — don't use arbitrary waits
ANTI_PATTERN: await page.waitForTimeout(3000) — flaky, slow
FIX: wait for a specific element, response, or navigation event
VISUAL_REGRESSION¶
test('homepage matches visual baseline', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixelRatio: 0.01, // 1% tolerance
});
});
test('dashboard layout matches baseline', async ({ page }) => {
await page.goto('/dashboard');
// Screenshot specific component
const sidebar = page.getByRole('navigation');
await expect(sidebar).toHaveScreenshot('sidebar.png');
});
RULE: visual regression baselines are committed to the repo
RULE: update baselines with npx playwright test --update-snapshots — review changes in PR diff
RULE: use maxDiffPixelRatio to handle anti-aliasing differences across OS/browser
RULE: mask dynamic content (timestamps, avatars) with mask option
await expect(page).toHaveScreenshot('dashboard.png', {
mask: [
page.getByTestId('timestamp'),
page.getByTestId('user-avatar'),
],
});
MOBILE_VIEWPORTS¶
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'desktop-chrome', use: { ...devices['Desktop Chrome'] } },
{ name: 'desktop-firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'desktop-webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
{ name: 'tablet', use: { ...devices['iPad Pro 11'] } },
],
});
RESPONSIVE_TESTS¶
test('mobile menu toggles on hamburger click', async ({ page, isMobile }) => {
test.skip(!isMobile, 'Mobile-only test');
await page.goto('/');
const nav = page.getByRole('navigation');
// Nav hidden on mobile by default
await expect(nav).not.toBeVisible();
// Open mobile menu
await page.getByRole('button', { name: 'Menu' }).click();
await expect(nav).toBeVisible();
// Close on navigation
await nav.getByRole('link', { name: 'About' }).click();
await expect(nav).not.toBeVisible();
});
RULE: test critical flows on mobile AND desktop — not just desktop
RULE: use test.skip() for platform-specific tests — not separate test files
RULE: test touch interactions on mobile (swipe, long press) using page.touchscreen
ACCESSIBILITY_TESTING¶
GE integrates @axe-core/playwright for automated accessibility checks.
SETUP¶
// e2e/fixtures/a11y.ts
import { test as base, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
export const test = base.extend({});
export async function checkA11y(page: Page, context?: string) {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
expect(
results.violations,
`Accessibility violations${context ? ` on ${context}` : ''}: ${
results.violations.map(v => `${v.id}: ${v.description}`).join(', ')
}`
).toEqual([]);
}
A11Y_IN_TESTS¶
import { test, expect } from '@playwright/test';
import { checkA11y } from '../fixtures/a11y';
test('login page is accessible', async ({ page }) => {
await page.goto('/login');
await checkA11y(page, 'login page');
});
test('dashboard is accessible after login', async ({ page }) => {
// ... login steps ...
await page.goto('/dashboard');
await checkA11y(page, 'dashboard');
});
test('form error states are accessible', async ({ page }) => {
await page.goto('/form');
await page.getByRole('button', { name: 'Submit' }).click();
// Errors should be visible now
await checkA11y(page, 'form with errors');
});
RULE: every page gets an accessibility check — no exceptions
RULE: target WCAG 2.1 AA compliance minimum
RULE: form errors must be announced to screen readers (aria-live or role="alert")
RULE: focus management after navigation — focus moves to main content or heading
CI_CONFIGURATION¶
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI
? [['html', { open: 'never' }], ['json', { outputFile: 'test-results.json' }]]
: [['html', { open: 'on-failure' }]],
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
webServer: process.env.CI ? undefined : {
command: 'npm run dev',
port: 3000,
reuseExistingServer: true,
},
});
CI_RULES:
- forbidOnly: true in CI — prevents .only from sneaking into main
- retries: 2 in CI — handles infrastructure flakiness (NOT application flakiness)
- trace: 'on-first-retry' — captures trace for debugging without slowing all tests
- screenshot: 'only-on-failure' — evidence for failing tests
- workers: 2 in CI — balance between speed and resource usage
RULE: if a test needs retries to pass, it is FLAKY — fix it, don't just retry
RULE: CI stores test artifacts (traces, screenshots, videos) as build artifacts
PARALLEL_EXECUTION¶
// Default: all tests run in parallel across files
// To serialize tests within a file:
test.describe.serial('Sequential checkout flow', () => {
test('add item to cart', async ({ page }) => { /* ... */ });
test('proceed to checkout', async ({ page }) => { /* ... */ });
test('complete payment', async ({ page }) => { /* ... */ });
});
RULE: tests run in parallel BY DEFAULT — write tests that support this
RULE: test.describe.serial only for flows that genuinely require order (rare)
RULE: each test gets its own browser context — no shared state between parallel tests
ANTI_PATTERN: using test.describe.serial because tests share state
FIX: each test sets up its own state — no dependencies between tests
TRACE_VIEWER_DEBUGGING¶
# View trace from failed test
npx playwright show-trace test-results/auth-login/trace.zip
# Record trace for specific test
npx playwright test --trace on tests/auth.spec.ts
TRACE_CONTAINS:
- DOM snapshot at each action
- Network requests and responses
- Console logs
- Action timeline with screenshots
- Source code location for each step
RULE: when debugging a failing E2E test, START with the trace viewer — not print statements
RULE: traces are the FIRST artifact to check in CI failures
COMMON_PATTERNS¶
WAITING_FOR_NAVIGATION¶
// Wait for Next.js App Router navigation
await page.getByRole('link', { name: 'Settings' }).click();
await page.waitForURL('/settings');
FILE_UPLOAD¶
test('uploads profile image', async ({ page }) => {
await page.goto('/profile');
const fileInput = page.getByLabel('Profile photo');
await fileInput.setInputFiles('e2e/fixtures/test-avatar.png');
await expect(page.getByAltText('Profile photo')).toBeVisible();
});
DIALOG_HANDLING¶
test('confirms deletion', async ({ page }) => {
page.on('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Item deleted')).toBeVisible();
});
MULTI_TAB¶
test('opens link in new tab', async ({ page, context }) => {
const [newPage] = await Promise.all([
context.waitForEvent('page'),
page.getByRole('link', { name: 'External docs' }).click(),
]);
await newPage.waitForLoadState();
expect(newPage.url()).toContain('docs.example.com');
});
CROSS_REFERENCES¶
VITEST: domains/testing/vitest-patterns.md — unit/integration testing
ADVERSARIAL: domains/testing/adversarial-testing.md — Ashley uses Playwright for chaos testing
PITFALLS: domains/testing/pitfalls.md — E2E-specific anti-patterns
ACCESSIBILITY: domains/accessibility/index.md — full accessibility standards