End-to-End Testing - Like a User , But Automated¶
Table of Contents¶
- E2E Testing Tools : Playwright , Puppeteer , Cypress
- Installing and Configuring Playwright
- Basic Browser Automation : Navigation , Click , Fill , Screenshot
- Testing Full User Flows
- CI Integration for Browser Tests
- Security : Testing CSRF , XSS , Auth Flows Full-Stack
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