Skip to content

End-to-End Testing - Like a User , But Automated

Table of Contents


E2E Testing Tools : Playwright , Puppeteer , Cypress

Playwright - the current king. Multi-browser (Chromium , Firefox , WebKit) , auto-waits for elements , network interception , mobile emulation. Microsoft maintains it and it's rapidly replacing everything else

Puppeteer - Playwright's predecessor (by the same team actually). Chromium-only , excellent for scraping and screenshots , but losing ground to Playwright for testing

Cypress - developer experience king. Great debugging UI with time travel and DOM snapshots. But Chromium-only , limited to single origin per test , and the architecture requires running in the browser context which creates weird limitations with Node APIs

Verdict: use Playwright for new projects. It supports every browser , has the best API , and doesn't lock you into Chromium

Installing and Configuring Playwright

npm install --save-dev @playwright/test
npx playwright install

This downloads Chromium , Firefox , and WebKit binaries. Takes a few minutes. Be patient

playwright.config.js:

const { defineConfig, devices } = require('@playwright/test')

module.exports = defineConfig({
  testDir: './e2e',
  timeout: 30000,
  retries: process.env.CI ? 2 : 0,
  use: {
    baseURL: 'http://localhost:3000',
    headless: true,
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
})

Basic Browser Automation : Navigation , Click , Fill , Screenshot

const { test, expect } = require('@playwright/test')

test('homepage loads and has correct title', async ({ page }) => {
  await page.goto('/')
  await expect(page).toHaveTitle(/My App/)
})

test('user can search for products', async ({ page }) => {
  await page.goto('/')

  // fill search input
  await page.fill('input[name="search"]', 'wireless mouse')

  // click search button
  await page.click('button[type="submit"]')

  // wait for results
  await page.waitForSelector('.product-card')

  // verify results
  const results = await page.locator('.product-card')
  await expect(results).not.toHaveCount(0)
})

test('take a screenshot for visual review', async ({ page }) => {
  await page.goto('/dashboard')
  await page.screenshot({ path: 'screenshots/dashboard.png', fullPage: true })
})

Playwright auto-waits for elements to be visible before interacting. You don't need waitForTimeout (and you should never use it - it's a race condition in disguise)

Network interception for mocking APIs:

test('handles empty search results', async ({ page }) => {
  // intercept the API call and return empty results
  await page.route('**/api/search**', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ results: [] }),
    })
  })

  await page.goto('/search?q=nonexistent')
  await expect(page.locator('.no-results')).toBeVisible()
  await expect(page.locator('.no-results')).toContainText('No results found')
})

Testing Full User Flows

E2E tests shine at user journeys - sign up , browse , add to cart , checkout

const { test, expect } = require('@playwright/test')

test('complete purchase flow', async ({ page }) => {
  // 1. Navigate to login
  await page.goto('/login')
  await page.fill('#email', 'testuser@example.com')
  await page.fill('#password', 'testpassword123')
  await page.click('button[type="submit"]')

  // 2. Verify redirected to dashboard
  await expect(page).toHaveURL('/dashboard')

  // 3. Browse products
  await page.click('a[href="/products"]')
  await page.waitForSelector('.product-card')

  // 4. Add item to cart
  await page.click('.product-card:first-child .add-to-cart')
  await expect(page.locator('.cart-count')).toHaveText('1')

  // 5. Go to cart
  await page.click('a[href="/cart"]')
  await expect(page.locator('.cart-item')).toHaveCount(1)

  // 6. Checkout
  await page.click('button:has-text("Checkout")')
  await page.fill('#address', '123 Test St')
  await page.fill('#card-number', '4242424242424242')
  await page.fill('#expiry', '12/28')
  await page.fill('#cvc', '123')
  await page.click('button:has-text("Pay")')

  // 7. Verify success
  await expect(page.locator('.order-confirmation')).toBeVisible()
  await expect(page.locator('.order-confirmation')).toContainText('Order confirmed')
})

Test isolation: Each test gets a fresh browser context (cookies , localStorage , session) so they don't interfere

test('logged in user sees profile', async ({ browser }) => {
  const context = await browser.newContext({
    storageState: 'auth-state.json', // pre-saved login state
  })
  const page = await context.newPage()
  await page.goto('/profile')
  await expect(page.locator('.user-name')).toBeVisible()
  await context.close()
})

CI Integration for Browser Tests

Playwright has a dedicated CI image with all browsers pre-installed:

# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    container:
      image: mcr.microsoft.com/playwright:v1.45.0-focal

    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: |
          # start app in background
          npm start &
          # wait for it to be ready
          npx wait-on http://localhost:3000
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

Sharding tests across multiple CI runners:

# run on 4 parallel machines
npx playwright test --shard=1/4
npx playwright test --shard=2/4
npx playwright test --shard=3/4
npx playwright test --shard=4/4

Security : Testing CSRF , XSS , Auth Flows Full-Stack

E2E browser tests catch security bugs that unit and integration tests miss because they execute in the actual browser security model

test('CSRF token required for form submission', async ({ page }) => {
  // Intercept the page and tamper with the CSRF token
  let csrfToken

  await page.route('**/api/csrf-token', route => {
    csrfToken = 'tampered-token'
    route.fulfill({
      status: 200,
      body: JSON.stringify({ token: csrfToken }),
    })
  })

  await page.goto('/settings')
  await page.fill('#email', 'attacker@example.com')

  // Remove the CSRF token from the form
  await page.evaluate(() => {
    document.querySelector('input[name="_csrf"]').value = 'invalid-token'
  })

  await page.click('button[type="submit"]')

  // Should reject the tampered request
  await expect(page.locator('.error-message')).toContainText('Invalid CSRF token')
})

test('XSS payload in comment field is escaped', async ({ page }) => {
  await page.goto('/post/1')
  await page.fill('#comment', '<script>alert("xss")</script>')
  await page.click('button:has-text("Submit")')

  // The script should NOT execute
  // Check that the content is rendered as escaped text
  const commentText = await page.locator('.comment').last().textContent()
  expect(commentText).toContain('<script>')
  expect(commentText).not.toContain('<script>alert("xss")</script>')

  // Verify no dialogs appeared
  page.on('dialog', () => {
    throw new Error('XSS fired a dialog!')
  })
})

test('session cookie has secure flags', async ({ page, context }) => {
  await page.goto('/login')
  await page.fill('#email', 'testuser@example.com')
  await page.fill('#password', 'testpassword123')
  await page.click('button[type="submit"]')

  const cookies = await context.cookies()
  const sessionCookie = cookies.find(c => c.name === 'session')

  expect(sessionCookie.httpOnly).toBe(true)
  expect(sessionCookie.sameSite).toBe('Lax')
  // In production this should also be secure: true (HTTPS only)
})

prerequisites

test_05_debugging.md - debugging techniques , profiler , memory analysis

next -> perf_01_profiling.md