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