Skip to content

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