Skip to content

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 afterEach hooks 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