E2E Testing Patterns
Comprehensive Playwright patterns for building stable, fast, and maintainable E2E test suites.
Test File Organization
tests/
├── e2e/
│ ├── auth/
│ │ ├── login.spec.ts
│ │ ├── logout.spec.ts
│ │ └── register.spec.ts
│ ├── features/
│ │ ├── browse.spec.ts
│ │ ├── search.spec.ts
│ │ └── create.spec.ts
│ └── api/
│ └── endpoints.spec.ts
├── fixtures/
│ ├── auth.ts
│ └── data.ts
└── playwright.config.ts
Page Object Model (POM)
typescript1import { Page, Locator } from '@playwright/test' 2 3export class ItemsPage { 4 readonly page: Page 5 readonly searchInput: Locator 6 readonly itemCards: Locator 7 readonly createButton: Locator 8 9 constructor(page: Page) { 10 this.page = page 11 this.searchInput = page.locator('[data-testid="search-input"]') 12 this.itemCards = page.locator('[data-testid="item-card"]') 13 this.createButton = page.locator('[data-testid="create-btn"]') 14 } 15 16 async goto() { 17 await this.page.goto('/items') 18 await this.page.waitForLoadState('networkidle') 19 } 20 21 async search(query: string) { 22 await this.searchInput.fill(query) 23 await this.page.waitForResponse(resp => resp.url().includes('/api/search')) 24 await this.page.waitForLoadState('networkidle') 25 } 26 27 async getItemCount() { 28 return await this.itemCards.count() 29 } 30}
Test Structure
typescript1import { test, expect } from '@playwright/test' 2import { ItemsPage } from '../../pages/ItemsPage' 3 4test.describe('Item Search', () => { 5 let itemsPage: ItemsPage 6 7 test.beforeEach(async ({ page }) => { 8 itemsPage = new ItemsPage(page) 9 await itemsPage.goto() 10 }) 11 12 test('should search by keyword', async ({ page }) => { 13 await itemsPage.search('test') 14 15 const count = await itemsPage.getItemCount() 16 expect(count).toBeGreaterThan(0) 17 18 await expect(itemsPage.itemCards.first()).toContainText(/test/i) 19 await page.screenshot({ path: 'artifacts/search-results.png' }) 20 }) 21 22 test('should handle no results', async ({ page }) => { 23 await itemsPage.search('xyznonexistent123') 24 25 await expect(page.locator('[data-testid="no-results"]')).toBeVisible() 26 expect(await itemsPage.getItemCount()).toBe(0) 27 }) 28})
Playwright Configuration
typescript1import { defineConfig, devices } from '@playwright/test' 2 3export default defineConfig({ 4 testDir: './tests/e2e', 5 fullyParallel: true, 6 forbidOnly: !!process.env.CI, 7 retries: process.env.CI ? 2 : 0, 8 workers: process.env.CI ? 1 : undefined, 9 reporter: [ 10 ['html', { outputFolder: 'playwright-report' }], 11 ['junit', { outputFile: 'playwright-results.xml' }], 12 ['json', { outputFile: 'playwright-results.json' }] 13 ], 14 use: { 15 baseURL: process.env.BASE_URL || 'http://localhost:3000', 16 trace: 'on-first-retry', 17 screenshot: 'only-on-failure', 18 video: 'retain-on-failure', 19 actionTimeout: 10000, 20 navigationTimeout: 30000, 21 }, 22 projects: [ 23 { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, 24 { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, 25 { name: 'webkit', use: { ...devices['Desktop Safari'] } }, 26 { name: 'mobile-chrome', use: { ...devices['Pixel 5'] } }, 27 ], 28 webServer: { 29 command: 'npm run dev', 30 url: 'http://localhost:3000', 31 reuseExistingServer: !process.env.CI, 32 timeout: 120000, 33 }, 34})
Flaky Test Patterns
Quarantine
typescript1test('flaky: complex search', async ({ page }) => { 2 test.fixme(true, 'Flaky - Issue #123') 3 // test code... 4}) 5 6test('conditional skip', async ({ page }) => { 7 test.skip(process.env.CI, 'Flaky in CI - Issue #123') 8 // test code... 9})
Identify Flakiness
bash1npx playwright test tests/search.spec.ts --repeat-each=10 2npx playwright test tests/search.spec.ts --retries=3
Common Causes & Fixes
Race conditions:
typescript1// Bad: assumes element is ready 2await page.click('[data-testid="button"]') 3 4// Good: auto-wait locator 5await page.locator('[data-testid="button"]').click()
Network timing:
typescript1// Bad: arbitrary timeout 2await page.waitForTimeout(5000) 3 4// Good: wait for specific condition 5await page.waitForResponse(resp => resp.url().includes('/api/data'))
Animation timing:
typescript1// Bad: click during animation 2await page.click('[data-testid="menu-item"]') 3 4// Good: wait for stability 5await page.locator('[data-testid="menu-item"]').waitFor({ state: 'visible' }) 6await page.waitForLoadState('networkidle') 7await page.locator('[data-testid="menu-item"]').click()
Artifact Management
Screenshots
typescript1await page.screenshot({ path: 'artifacts/after-login.png' }) 2await page.screenshot({ path: 'artifacts/full-page.png', fullPage: true }) 3await page.locator('[data-testid="chart"]').screenshot({ path: 'artifacts/chart.png' })
Traces
typescript1await browser.startTracing(page, { 2 path: 'artifacts/trace.json', 3 screenshots: true, 4 snapshots: true, 5}) 6// ... test actions ... 7await browser.stopTracing()
Video
typescript1// In playwright.config.ts 2use: { 3 video: 'retain-on-failure', 4 videosPath: 'artifacts/videos/' 5}
CI/CD Integration
yaml1# .github/workflows/e2e.yml 2name: E2E Tests 3on: [push, pull_request] 4 5jobs: 6 test: 7 runs-on: ubuntu-latest 8 steps: 9 - uses: actions/checkout@v4 10 - uses: actions/setup-node@v4 11 with: 12 node-version: 20 13 - run: npm ci 14 - run: npx playwright install --with-deps 15 - run: npx playwright test 16 env: 17 BASE_URL: ${{ vars.STAGING_URL }} 18 - uses: actions/upload-artifact@v4 19 if: always() 20 with: 21 name: playwright-report 22 path: playwright-report/ 23 retention-days: 30
Test Report Template
markdown1# E2E Test Report 2 3**Date:** YYYY-MM-DD HH:MM 4**Duration:** Xm Ys 5**Status:** PASSING / FAILING 6 7## Summary 8- Total: X | Passed: Y (Z%) | Failed: A | Flaky: B | Skipped: C 9 10## Failed Tests 11 12### test-name 13**File:** `tests/e2e/feature.spec.ts:45` 14**Error:** Expected element to be visible 15**Screenshot:** artifacts/failed.png 16**Recommended Fix:** [description] 17 18## Artifacts 19- HTML Report: playwright-report/index.html 20- Screenshots: artifacts/*.png 21- Videos: artifacts/videos/*.webm 22- Traces: artifacts/*.zip
Wallet / Web3 Testing
typescript1test('wallet connection', async ({ page, context }) => { 2 // Mock wallet provider 3 await context.addInitScript(() => { 4 window.ethereum = { 5 isMetaMask: true, 6 request: async ({ method }) => { 7 if (method === 'eth_requestAccounts') 8 return ['0x1234567890123456789012345678901234567890'] 9 if (method === 'eth_chainId') return '0x1' 10 } 11 } 12 }) 13 14 await page.goto('/') 15 await page.locator('[data-testid="connect-wallet"]').click() 16 await expect(page.locator('[data-testid="wallet-address"]')).toContainText('0x1234') 17})
Financial / Critical Flow Testing
typescript1test('trade execution', async ({ page }) => { 2 // Skip on production — real money 3 test.skip(process.env.NODE_ENV === 'production', 'Skip on production') 4 5 await page.goto('/markets/test-market') 6 await page.locator('[data-testid="position-yes"]').click() 7 await page.locator('[data-testid="trade-amount"]').fill('1.0') 8 9 // Verify preview 10 const preview = page.locator('[data-testid="trade-preview"]') 11 await expect(preview).toContainText('1.0') 12 13 // Confirm and wait for blockchain 14 await page.locator('[data-testid="confirm-trade"]').click() 15 await page.waitForResponse( 16 resp => resp.url().includes('/api/trade') && resp.status() === 200, 17 { timeout: 30000 } 18 ) 19 20 await expect(page.locator('[data-testid="trade-success"]')).toBeVisible() 21})