End-to-End Testing¶
Status: 🟢 Active | Owner: Engineering Enablement | Last Reviewed: 2025-Q4
Overview¶
End-to-End (E2E) tests verify complete user journeys through the full application stack — from the UI or API surface, through all services, to the database and back. They provide the highest-confidence validation that the system works as users expect it to, but they are also the slowest, most expensive, and most brittle tests to maintain.
E2E tests should form a small, stable, high-value set of tests that cover the most critical user journeys. They are not a replacement for unit and integration tests — they are the final layer of confidence.
Approved E2E Testing Tool: Playwright¶
Playwright is the enterprise standard for E2E testing of web applications. It supports Chromium, Firefox, and WebKit; provides excellent async support, auto-wait functionality, and a rich assertion API; and integrates with CI effectively.
Why Playwright¶
- Auto-waiting eliminates most timing-related flakiness — Playwright waits for elements to be visible, enabled, and stable before interacting.
- Network interception allows tests to intercept and mock external API calls.
- Trace viewer provides a visual step-by-step replay of test failures including screenshots, network traffic, and DOM snapshots.
- Cross-browser testing in a single framework.
What to E2E Test¶
E2E tests cover critical user journeys — the paths through the application that represent the most business-critical functionality.
Test: - The most critical happy paths (the paths users take when everything works). - Critical error flows with high business impact (payment failure, authentication failure). - Journeys that cross multiple services (the integration points most likely to break).
Do not test: - Every edge case — unit tests do that faster and more reliably. - Pure UI presentation logic — screenshot testing or visual regression testing is a separate concern. - Performance (use dedicated load testing tools — see Performance & Load Testing). - Every browser and viewport combination — focus on primary supported environments.
Identifying Critical Journeys¶
For each service, identify the 3–7 most critical user journeys that: 1. Represent significant business value. 2. Cross multiple service boundaries. 3. Would be catastrophic if broken in production.
Examples: user registration + login, product search + add to cart + checkout, order placement + confirmation email.
Project Setup¶
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
retries: process.env.CI ? 2 : 0, // Retry on CI to handle transient issues
workers: process.env.CI ? 2 : undefined,
reporter: [
['html', { outputFolder: 'playwright-report' }],
['junit', { outputFile: 'test-results/junit.xml' }],
],
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
trace: 'on-first-retry', // Capture trace on failure
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
});
Writing Tests¶
Page Object Model¶
All E2E tests use the Page Object Model (POM) pattern. Never put selector logic in test files — encapsulate it in page objects.
// e2e/pages/checkout.page.ts — Page Object
export class CheckoutPage {
constructor(private page: Page) {}
async fillShippingAddress(address: ShippingAddress): Promise<void> {
await this.page.fill('[data-testid="shipping-first-name"]', address.firstName);
await this.page.fill('[data-testid="shipping-last-name"]', address.lastName);
await this.page.fill('[data-testid="shipping-address-line1"]', address.line1);
await this.page.selectOption('[data-testid="shipping-country"]', address.country);
}
async placeOrder(): Promise<void> {
await this.page.click('[data-testid="place-order-button"]');
await this.page.waitForURL(/\/order-confirmation\/\w+/);
}
async getOrderConfirmationNumber(): Promise<string> {
return this.page.locator('[data-testid="confirmation-number"]').innerText();
}
}
// e2e/tests/checkout.spec.ts — Test
import { test, expect } from '@playwright/test';
import { CheckoutPage } from '../pages/checkout.page';
import { CartPage } from '../pages/cart.page';
test.describe('Checkout journey', () => {
test('user can complete checkout with standard shipping', async ({ page }) => {
const cartPage = new CartPage(page);
const checkoutPage = new CheckoutPage(page);
await cartPage.addProduct('SKU-001', 2);
await cartPage.proceedToCheckout();
await checkoutPage.fillShippingAddress(TEST_ADDRESS_UK);
await checkoutPage.selectShippingMethod('standard');
await checkoutPage.fillPaymentDetails(TEST_CARD_VISA);
await checkoutPage.placeOrder();
const confirmationNumber = await checkoutPage.getOrderConfirmationNumber();
expect(confirmationNumber).toMatch(/^ORD-\d{8}$/);
});
});
Test Data Attributes¶
Use data-testid attributes for element selection — never CSS classes, IDs that may change, or text content that may be translated. This decouples tests from UI implementation details.
<!-- ✅ Use data-testid -->
<button data-testid="place-order-button">Place Order</button>
<!-- ❌ Avoid selecting by class or text -->
<button class="btn btn-primary checkout-submit">Place Order</button>
Test Data Management¶
- Use test-specific user accounts — never share accounts between tests or use production accounts.
- Create required test data via the API, not via UI. Set up state programmatically, not through UI actions.
- Clean up test data after each test. Use
afterEachhooks to delete created resources via the API. - For stateful E2E tests requiring specific database states, use dedicated seeding endpoints (available in non-production environments only).
Handling Flakiness¶
Flaky tests are worse than no tests — they erode confidence in the test suite and create alert fatigue.
Prevention: - Rely on Playwright's auto-wait rather than explicit waitForTimeout(). Hard-coded sleep calls are prohibited. - Wait for specific conditions: waitForResponse, waitForURL, waitForSelector with explicit expectations. - Use expect.poll() for assertions that depend on eventual consistency.
// ❌ Flaky — arbitrary sleep
await page.click('[data-testid="submit"]');
await page.waitForTimeout(2000); // PROHIBITED
await expect(page.locator('[data-testid="success"]')).toBeVisible();
// ✅ Deterministic — wait for the condition
await page.click('[data-testid="submit"]');
await expect(page.locator('[data-testid="success"]')).toBeVisible({ timeout: 10_000 });
Quarantine policy: A test that fails flakily in CI 3 or more times in a two-week period must be either fixed immediately or quarantined (moved to a separate, non-blocking suite) until it is fixed.
CI Integration¶
E2E tests run as a separate, later stage in the CI pipeline, after unit and integration tests have passed:
# Stage 3 — E2E tests (run against staging environment)
e2e:
stage: e2e
needs: [unit-tests, integration-tests, deploy-staging]
script:
- npx playwright install chromium firefox
- npx playwright test
artifacts:
when: on_failure
paths:
- playwright-report/
- test-results/
E2E tests run against the staging environment — never against production, and not against local developer machines in CI.
References¶
Last reviewed: 2025-Q4 | Owner: Engineering Enablement