E2E Tests
End-to-end tests drive a real browser through your full stack — UI, API, database — and verify whole user flows. Playwright is the modern default: fast, cross-browser, with auto-waits, network mocking, and parallel sharding for CI.
Playwright in CI, fixtures, sharding
EXAMPLE
# 1) Install
npm init playwright@latest
# Wizard generates playwright.config.ts, tests/, GitHub Action.
# 2) playwright.config.ts (typical)
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list',
use: {
baseURL: process.env.E2E_URL ?? 'http://localhost:3000',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 10_000,
navigationTimeout: 30_000,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
],
webServer: process.env.CI ? undefined : {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: true,
timeout: 120_000,
},
});
# 3) First test
import { test, expect } from '@playwright/test';
test('sign-up flow', async ({ page }) => {
await page.goto('/signup');
await page.getByLabel('Email').fill('mara@example.com');
await page.getByLabel('Password').fill('correct-horse-battery-staple');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByText('Welcome')).toBeVisible();
});
# 4) Selectors — prefer roles + accessible names + test ids
await page.getByRole('button', { name: 'Save' });
await page.getByLabel('Email');
await page.getByPlaceholder('Search…');
await page.getByText('Welcome', { exact: true });
await page.getByTestId('user-menu');
# Avoid: page.locator('div > div:nth-child(2) > button.foo') // brittle
# 5) Web-first assertions — auto-retry until pass or timeout
await expect(page.getByRole('heading')).toHaveText('Dashboard');
await expect(page.getByRole('list')).toHaveCount(5);
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole('button')).toBeEnabled();
# 6) Fixtures — share setup, log in once per test
import { test as base, expect } from '@playwright/test';
type Fixtures = { authedPage: import('@playwright/test').Page };
export const test = base.extend<Fixtures>({
authedPage: async ({ page }, use) => {
await page.goto('/login');
await page.getByLabel('Email').fill('mara@example.com');
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText('Dashboard')).toBeVisible();
await use(page);
},
});
test('only logged-in users see settings', async ({ authedPage }) => {
await authedPage.goto('/settings');
await expect(authedPage.getByRole('heading', { name: 'Settings' })).toBeVisible();
});
# 7) Storage state — log in once, reuse session across tests
# global-setup.ts
import { chromium, FullConfig } from '@playwright/test';
export default async function (config: FullConfig) {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(`${config.projects[0].use.baseURL}/login`);
await page.getByLabel('Email').fill('mara@example.com');
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.context().storageState({ path: 'storageState.json' });
await browser.close();
}
# config
use: { storageState: 'storageState.json' }
# 8) Network mocking
await page.route('**/api/users/42', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ id: 42, name: 'Mocked' }),
});
});
await page.goto('/users/42');
await expect(page.getByText('Mocked')).toBeVisible();
# 9) Visual regression
await expect(page).toHaveScreenshot('home.png', { maxDiffPixels: 100 });
# First run creates the baseline. Subsequent runs compare; failures attach diff images.
# 10) CI integration — GitHub Actions
name: e2e
on: [push, pull_request]
jobs:
test:
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run build
- run: npx playwright test
env:
E2E_URL: https://staging.example.com
TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
- if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
# 11) Sharding — split the suite across runners
strategy:
fail-fast: false
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- run: npx playwright test --shard=${{ matrix.shard }}
# Cuts 30-minute runs to 8-10. Merge reports with @playwright/test's blob reporter.
# 12) Trace viewer + flake debugging
npx playwright show-trace test-results/.../trace.zip
# Frame-by-frame replay with network log, console, screenshots — debug flakes without re-running.
# 13) Stable selectors — add data-testid where text isn't reliable
<button data-testid="cart-checkout">Continue</button>
# Tests reference: page.getByTestId('cart-checkout')
# 14) Tagging tests for selective runs
test('@smoke landing page renders', async ({ page }) => { /* … */ });
test('@critical checkout completes', async ({ page }) => { /* … */ });
npx playwright test --grep '@smoke'
# 15) Auth strategies
# • Storage state (above) — sign in once, reuse cookies across many tests
# • API-based session creation — POST to /api/login, save cookies via context.addCookies
# • Magic-link flows — use a mailbox API (mailosaur, mailpit) to fetch the email + extract link
# • SSO providers — usually shareable storageState across the org; skip OAuth dance in CI
# 16) Test data — isolate by tenant or randomise
const email = `e2e+${Date.now()}@example.com`;
await createUser({ email });
# After the test, clean up — or use ephemeral test accounts created by CI fixtures.
# 17) Parallelism + test independence
# Every test must work with sister tests running concurrently.
# Common bugs that break parallelism:
# • Shared user in DB → tests overwrite each other
# • Hard-coded entity IDs
# • Global counters not reset between tests
# Use unique data per test; tag with a run-id to scope cleanup.
# 18) Headed vs headless
# • CI: headless (fast, no display)
# • Local: --headed --debug for stepping through
# • Trace: always on in CI; replay locally with show-trace
# 19) Visual + accessibility audits
# Pair Playwright with axe-core:
npm i -D @axe-core/playwright
import AxeBuilder from '@axe-core/playwright';
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
# 20) Common bugs
# • Tests pass locally, fail in CI → usually viewport size, locale, or slow network
# • Selectors break after refactor → switch to roles or data-testid
# • Flakes from race conditions → use web-first assertions, not setTimeout
# • CI runs forever on one stuck test → set test.setTimeout per test + per-action timeouts
# • Authenticated tests trip the rate limiter → cache sessions; whitelist test IPs
# • Storage state stale → rebuild on each CI run, don't commit to git
# • Screenshots don't match across OS → run visual tests in a Docker image for consistent rendering
Why it matters
Run Playwright in CI with retries, traces on failure, and sharding across 4-8 runners so the suite stays under 10 minutes. Use roles and accessible names for selectors, save signed-in storageState.json so most tests skip login, and treat flaky tests as bugs — web-first assertions plus the trace viewer almost always pinpoint a real race.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
- name: Playwright
run: |
npx playwright install --with-deps chromium
npm run test:e2e
- if: failure()
uses: actions/upload-artifact@v4
with: { name: traces, path: test-results/ }
Try it Yourself »
Discussion
Loading…