E2E Testing Skill
End-to-end testing with Playwright for CAIO incubator projects. Run real browser tests to catch UI regressions, broken flows, and accessibility issues before shipping.
When to Use
- •Pre-ship validation — Run full E2E suite before deploying
- •Critical flow protection — Auth, payments, core user journeys
- •Visual regression — Catch unintended UI changes
- •Cross-browser validation — Ensure compatibility
- •Accessibility audits — Automated a11y checks
Setup
1. Install Playwright
bash
bun add -d @playwright/test bunx playwright install
2. Configuration
typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { open: 'never' }],
['json', { outputFile: 'test-results/results.json' }],
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
// Auth setup - runs once before all tests
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'],
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
dependencies: ['setup'],
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
dependencies: ['setup'],
},
{
name: 'mobile',
use: { ...devices['iPhone 14'] },
dependencies: ['setup'],
},
],
// Dev server
webServer: {
command: 'bun run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
})
3. Package.json Scripts
json
{
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:e2e:headed": "playwright test --headed",
"test:e2e:report": "playwright show-report"
}
}
4. File Structure
code
tests/
├── e2e/
│ ├── auth.setup.ts # Auth state setup (runs once)
│ ├── auth.spec.ts # Auth flow tests
│ ├── onboarding.spec.ts # Onboarding flow tests
│ ├── dashboard.spec.ts # Dashboard tests
│ └── [feature].spec.ts # Feature-specific tests
├── fixtures/
│ ├── index.ts # Custom fixtures
│ └── auth.ts # Auth fixtures
├── pages/ # Page Object Models
│ ├── base.page.ts
│ ├── login.page.ts
│ ├── dashboard.page.ts
│ └── onboarding.page.ts
└── utils/
├── test-data.ts # Test data generators
└── helpers.ts # Test helpers
Authentication Setup
Global Auth State
typescript
// tests/e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test'
import path from 'path'
const authFile = path.join(__dirname, '../.auth/user.json')
setup('authenticate', async ({ page }) => {
// Go to login
await page.goto('/login')
// Fill credentials
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!)
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!)
await page.getByRole('button', { name: 'Sign in' }).click()
// Wait for redirect to dashboard
await page.waitForURL('/dashboard')
// Verify logged in
await expect(page.getByRole('button', { name: 'Account' })).toBeVisible()
// Save auth state
await page.context().storageState({ path: authFile })
})
Using Auth State
typescript
// tests/e2e/dashboard.spec.ts
import { test, expect } from '@playwright/test'
// This test uses the authenticated state from setup
test.use({ storageState: 'tests/.auth/user.json' })
test('shows user dashboard', async ({ page }) => {
await page.goto('/dashboard')
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
})
Multiple Auth States
typescript
// tests/fixtures/auth.ts
import { test as base } from '@playwright/test'
type AuthFixtures = {
adminPage: Page
userPage: Page
}
export const test = base.extend<AuthFixtures>({
adminPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'tests/.auth/admin.json',
})
const page = await context.newPage()
await use(page)
await context.close()
},
userPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'tests/.auth/user.json',
})
const page = await context.newPage()
await use(page)
await context.close()
},
})
export { expect } from '@playwright/test'
Page Object Model
Base Page
typescript
// tests/pages/base.page.ts
import { Page, Locator, expect } from '@playwright/test'
export abstract class BasePage {
readonly page: Page
constructor(page: Page) {
this.page = page
}
// Common elements
get header() { return this.page.getByRole('banner') }
get footer() { return this.page.getByRole('contentinfo') }
get loadingSpinner() { return this.page.getByTestId('loading') }
get toast() { return this.page.getByRole('alert') }
// Common actions
async waitForLoad() {
await this.loadingSpinner.waitFor({ state: 'hidden', timeout: 10000 })
}
async expectToast(message: string) {
await expect(this.toast).toContainText(message)
}
async screenshot(name: string) {
await this.page.screenshot({ path: `screenshots/${name}.png`, fullPage: true })
}
}
Login Page
typescript
// tests/pages/login.page.ts
import { Page, expect } from '@playwright/test'
import { BasePage } from './base.page'
export class LoginPage extends BasePage {
readonly emailInput = this.page.getByLabel('Email')
readonly passwordInput = this.page.getByLabel('Password')
readonly submitButton = this.page.getByRole('button', { name: 'Sign in' })
readonly errorMessage = this.page.getByRole('alert')
readonly forgotPasswordLink = this.page.getByRole('link', { name: 'Forgot password' })
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)
}
async expectLoggedIn() {
await this.page.waitForURL('/dashboard')
}
}
Dashboard Page
typescript
// tests/pages/dashboard.page.ts
import { Page, expect } from '@playwright/test'
import { BasePage } from './base.page'
export class DashboardPage extends BasePage {
readonly heading = this.page.getByRole('heading', { name: 'Dashboard' })
readonly createButton = this.page.getByRole('button', { name: 'Create' })
readonly planCards = this.page.getByTestId('plan-card')
readonly emptyState = this.page.getByTestId('empty-state')
async goto() {
await this.page.goto('/dashboard')
await this.waitForLoad()
}
async createNewPlan() {
await this.createButton.click()
await this.page.waitForURL('/plans/new')
}
async selectPlan(name: string) {
await this.page.getByRole('link', { name }).click()
}
async expectPlanCount(count: number) {
await expect(this.planCards).toHaveCount(count)
}
async expectEmpty() {
await expect(this.emptyState).toBeVisible()
}
}
Test Patterns
Basic Flow Test
typescript
// tests/e2e/onboarding.spec.ts
import { test, expect } from '@playwright/test'
import { OnboardingPage } from '../pages/onboarding.page'
test.describe('Onboarding Flow', () => {
test('completes full onboarding', async ({ page }) => {
const onboarding = new OnboardingPage(page)
// Step 1: Goals
await onboarding.goto()
await onboarding.selectGoal('marathon')
await onboarding.clickNext()
// Step 2: Experience
await onboarding.selectExperience('intermediate')
await onboarding.clickNext()
// Step 3: Schedule
await onboarding.setDaysPerWeek(4)
await onboarding.clickNext()
// Step 4: Review & Submit
await expect(onboarding.summary).toContainText('Marathon')
await expect(onboarding.summary).toContainText('4 days/week')
await onboarding.clickFinish()
// Verify redirect to generated plan
await page.waitForURL(/\/plans\/[a-z0-9-]+/)
await expect(page.getByRole('heading')).toContainText('Your Training Plan')
})
test('validates required fields', async ({ page }) => {
const onboarding = new OnboardingPage(page)
await onboarding.goto()
await onboarding.clickNext() // Try to proceed without selection
await expect(onboarding.errorMessage).toContainText('Please select a goal')
})
test('allows going back', async ({ page }) => {
const onboarding = new OnboardingPage(page)
await onboarding.goto()
await onboarding.selectGoal('5k')
await onboarding.clickNext()
await onboarding.clickBack()
// Should be back at step 1 with selection preserved
await expect(onboarding.goalOption('5k')).toBeChecked()
})
})
Form Validation Test
typescript
// tests/e2e/forms.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Plan Editor Form', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/plans/new')
})
test('validates plan name', async ({ page }) => {
const submitBtn = page.getByRole('button', { name: 'Create Plan' })
const nameInput = page.getByLabel('Plan Name')
const errorMsg = page.getByText('Name is required')
// Empty submission
await submitBtn.click()
await expect(errorMsg).toBeVisible()
// Too short
await nameInput.fill('ab')
await submitBtn.click()
await expect(page.getByText('Name must be at least 3 characters')).toBeVisible()
// Valid
await nameInput.fill('My Marathon Plan')
await submitBtn.click()
await expect(errorMsg).not.toBeVisible()
})
test('prevents duplicate plan names', async ({ page }) => {
await page.getByLabel('Plan Name').fill('Existing Plan Name')
await page.getByRole('button', { name: 'Create Plan' }).click()
await expect(page.getByRole('alert')).toContainText('already exists')
})
})
API Mocking
typescript
// tests/e2e/dashboard-states.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Dashboard States', () => {
test('shows empty state for new user', async ({ page }) => {
// Mock empty response
await page.route('**/api/plans', async (route) => {
await route.fulfill({ json: [] })
})
await page.goto('/dashboard')
await expect(page.getByTestId('empty-state')).toBeVisible()
await expect(page.getByText('Create your first plan')).toBeVisible()
})
test('shows error state on API failure', async ({ page }) => {
await page.route('**/api/plans', async (route) => {
await route.fulfill({ status: 500, json: { error: 'Server error' } })
})
await page.goto('/dashboard')
await expect(page.getByRole('alert')).toContainText('Failed to load')
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible()
})
test('shows loading state', async ({ page }) => {
// Delay response
await page.route('**/api/plans', async (route) => {
await new Promise(resolve => setTimeout(resolve, 2000))
await route.fulfill({ json: [{ id: '1', name: 'Test Plan' }] })
})
await page.goto('/dashboard')
await expect(page.getByTestId('loading')).toBeVisible()
await expect(page.getByTestId('loading')).not.toBeVisible({ timeout: 5000 })
})
})
Visual Regression
typescript
// tests/e2e/visual.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Visual Regression', () => {
test('dashboard matches snapshot', async ({ page }) => {
await page.goto('/dashboard')
await page.waitForLoadState('networkidle')
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixels: 100,
})
})
test('login page matches snapshot', async ({ page }) => {
await page.goto('/login')
await expect(page).toHaveScreenshot('login.png')
})
test('plan card component', async ({ page }) => {
await page.goto('/dashboard')
const card = page.getByTestId('plan-card').first()
await expect(card).toHaveScreenshot('plan-card.png')
})
// Dark mode variant
test('dashboard dark mode', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' })
await page.goto('/dashboard')
await expect(page).toHaveScreenshot('dashboard-dark.png')
})
})
Accessibility Testing
typescript
// tests/e2e/accessibility.spec.ts
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
test.describe('Accessibility', () => {
test('login page has no a11y violations', async ({ page }) => {
await page.goto('/login')
const results = await new AxeBuilder({ page }).analyze()
expect(results.violations).toEqual([])
})
test('dashboard has no a11y violations', async ({ page }) => {
await page.goto('/dashboard')
const results = await new AxeBuilder({ page })
.exclude('.third-party-widget') // Exclude things we don't control
.analyze()
expect(results.violations).toEqual([])
})
test('forms are keyboard navigable', async ({ page }) => {
await page.goto('/plans/new')
// Tab through form
await page.keyboard.press('Tab')
await expect(page.getByLabel('Plan Name')).toBeFocused()
await page.keyboard.press('Tab')
await expect(page.getByLabel('Goal')).toBeFocused()
await page.keyboard.press('Tab')
await expect(page.getByRole('button', { name: 'Create' })).toBeFocused()
})
test('modals trap focus', async ({ page }) => {
await page.goto('/dashboard')
await page.getByRole('button', { name: 'Delete' }).first().click()
const modal = page.getByRole('dialog')
await expect(modal).toBeVisible()
// Focus should be trapped in modal
await page.keyboard.press('Tab')
await page.keyboard.press('Tab')
await page.keyboard.press('Tab')
const focusedElement = page.locator(':focus')
await expect(focusedElement).toBeVisible()
await expect(modal).toContainText(await focusedElement.textContent() || '')
})
})
Mobile Testing
typescript
// tests/e2e/mobile.spec.ts
import { test, expect, devices } from '@playwright/test'
test.use({ ...devices['iPhone 14'] })
test.describe('Mobile Experience', () => {
test('navigation menu works', async ({ page }) => {
await page.goto('/dashboard')
// Menu should be collapsed on mobile
await expect(page.getByRole('navigation')).not.toBeVisible()
// Open hamburger menu
await page.getByRole('button', { name: 'Menu' }).click()
await expect(page.getByRole('navigation')).toBeVisible()
// Navigate
await page.getByRole('link', { name: 'Settings' }).click()
await page.waitForURL('/settings')
})
test('touch gestures work', async ({ page }) => {
await page.goto('/plans/123')
// Swipe to delete workout
const workout = page.getByTestId('workout-item').first()
const box = await workout.boundingBox()
if (box) {
await page.mouse.move(box.x + box.width - 20, box.y + box.height / 2)
await page.mouse.down()
await page.mouse.move(box.x + 20, box.y + box.height / 2)
await page.mouse.up()
}
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible()
})
})
Critical Flows to Always Test
1. Authentication
typescript
// tests/e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
import { LoginPage } from '../pages/login.page'
test.describe('Authentication', () => {
test('successful login', async ({ page }) => {
const login = new LoginPage(page)
await login.goto()
await login.login('test@example.com', 'password123')
await login.expectLoggedIn()
})
test('failed login shows error', async ({ page }) => {
const login = new LoginPage(page)
await login.goto()
await login.login('test@example.com', 'wrongpassword')
await login.expectError('Invalid credentials')
})
test('logout clears session', async ({ page }) => {
// Start logged in
await page.goto('/dashboard')
// Logout
await page.getByRole('button', { name: 'Account' }).click()
await page.getByRole('menuitem', { name: 'Sign out' }).click()
// Should redirect to login
await page.waitForURL('/login')
// Try to access protected route
await page.goto('/dashboard')
await page.waitForURL('/login') // Should redirect back
})
test('session expiry redirects to login', async ({ page }) => {
await page.goto('/dashboard')
// Simulate expired session
await page.evaluate(() => {
localStorage.removeItem('auth-token')
})
// Next navigation should redirect
await page.getByRole('link', { name: 'Settings' }).click()
await page.waitForURL('/login')
})
})
2. Payment Flow (if applicable)
typescript
// tests/e2e/payments.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Payment Flow', () => {
test('completes subscription', async ({ page }) => {
await page.goto('/pricing')
await page.getByRole('button', { name: 'Subscribe to Pro' }).click()
// Stripe checkout (use test mode)
const stripeFrame = page.frameLocator('iframe[name^="__privateStripeFrame"]')
await stripeFrame.getByPlaceholder('Card number').fill('4242424242424242')
await stripeFrame.getByPlaceholder('MM / YY').fill('12/30')
await stripeFrame.getByPlaceholder('CVC').fill('123')
await page.getByRole('button', { name: 'Pay' }).click()
// Wait for success
await page.waitForURL('/dashboard?upgraded=true')
await expect(page.getByRole('alert')).toContainText('Welcome to Pro!')
})
test('handles declined card', async ({ page }) => {
await page.goto('/pricing')
await page.getByRole('button', { name: 'Subscribe to Pro' }).click()
const stripeFrame = page.frameLocator('iframe[name^="__privateStripeFrame"]')
// Use Stripe's test declined card
await stripeFrame.getByPlaceholder('Card number').fill('4000000000000002')
await stripeFrame.getByPlaceholder('MM / YY').fill('12/30')
await stripeFrame.getByPlaceholder('CVC').fill('123')
await page.getByRole('button', { name: 'Pay' }).click()
await expect(page.getByRole('alert')).toContainText('Card was declined')
})
})
3. Data CRUD Operations
typescript
// tests/e2e/plans-crud.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Plan CRUD', () => {
test('creates a new plan', async ({ page }) => {
await page.goto('/dashboard')
await page.getByRole('button', { name: 'Create Plan' }).click()
await page.getByLabel('Plan Name').fill('My Test Plan')
await page.getByLabel('Goal').selectOption('marathon')
await page.getByRole('button', { name: 'Create' }).click()
await page.waitForURL(/\/plans\/[a-z0-9-]+/)
await expect(page.getByRole('heading')).toContainText('My Test Plan')
})
test('edits an existing plan', async ({ page }) => {
await page.goto('/plans/test-plan-id')
await page.getByRole('button', { name: 'Edit' }).click()
await page.getByLabel('Plan Name').fill('Updated Name')
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByRole('heading')).toContainText('Updated Name')
await expect(page.getByRole('alert')).toContainText('Saved')
})
test('deletes a plan with confirmation', async ({ page }) => {
await page.goto('/plans/test-plan-id')
await page.getByRole('button', { name: 'Delete' }).click()
// Confirmation dialog
const dialog = page.getByRole('dialog')
await expect(dialog).toContainText('Are you sure')
await dialog.getByRole('button', { name: 'Delete' }).click()
await page.waitForURL('/dashboard')
await expect(page.getByRole('alert')).toContainText('Plan deleted')
})
})
CI Integration
GitHub Actions
yaml
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Install Playwright browsers
run: bunx playwright install --with-deps
- name: Run E2E tests
run: bun test:e2e
env:
BASE_URL: http://localhost:3000
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
Harness Integration
As Phase Gate
yaml
# In build spec
phases:
- name: development
steps: [...]
- name: testing
steps:
- run: bun test # Unit tests
- run: bun test:e2e # E2E tests
on_failure: block
- name: staging
depends_on: testing
steps: [...]
Selective E2E Runs
bash
# Quick smoke test (critical flows only) bun test:e2e --grep "@smoke" # Full regression suite bun test:e2e # Visual regression only bun test:e2e --grep "visual" # Single browser (faster) bun test:e2e --project=chromium
Test Tagging
typescript
// Tag tests for selective runs
test('login flow @smoke @critical', async ({ page }) => {
// ...
})
test('edge case handling @regression', async ({ page }) => {
// ...
})
Debugging
Interactive Mode
bash
# UI mode - see tests run visually bun test:e2e:ui # Debug mode - step through bun test:e2e:debug # Headed mode - watch browser bun test:e2e:headed
Trace Viewer
bash
# Run with trace bun test:e2e --trace on # View trace bunx playwright show-trace trace.zip
Screenshots & Videos
Automatically captured on failure (configured in playwright.config.ts). Find in:
- •
test-results/— Screenshots, videos, traces - •
playwright-report/— HTML report
Best Practices
✅ Do
- •Use data-testid for test-specific selectors
- •Keep tests independent (no shared state between tests)
- •Use page objects for reusable interactions
- •Mock external services (Stripe, APIs)
- •Run critical paths on every PR
- •Use meaningful test names that describe behavior
❌ Don't
- •Don't use CSS selectors that break on style changes
- •Don't depend on test execution order
- •Don't hardcode wait times (use waitFor* methods)
- •Don't test third-party UI (Stripe's iframe internals)
- •Don't run visual regression on every commit (slow)
- •Don't leave console.logs in test files
Quick Reference
bash
# Run all E2E tests bun test:e2e # Run specific file bun test:e2e tests/e2e/auth.spec.ts # Run tests matching pattern bun test:e2e --grep "login" # Run tagged tests bun test:e2e --grep "@smoke" # Single browser bun test:e2e --project=chromium # Debug mode bun test:e2e --debug # Update snapshots bun test:e2e --update-snapshots # Show report bun test:e2e:report