Skip to content

End-to-End Testing

This document describes the end-to-end testing approach used in the Account Overview project. We use Playwright to test complete user journeys across the application.

Overview

End-to-end (E2E) tests verify that the application works correctly from a user's perspective by testing complete workflows. Unlike unit tests that focus on individual components, E2E tests validate entire user journeys.

Testing Stack

  • Playwright - Browser automation and testing framework
  • MSW (Mock Service Worker) - API mocking at the network level
  • @axe-core/playwright - Accessibility testing (separate test suite)

Configuration

Tests are configured in playwright.config.ts:

  • Test directory: ./tests/e2e/
  • Base URL: http://localhost:3000/
  • Browsers: Chromium, Firefox, WebKit
  • Parallel execution: Enabled locally, single worker in CI
  • Retries: 2 attempts in CI for reliability
  • Traces: Captured on first retry for debugging

Project Structure

tests/
├── e2e/                    # End-to-end test suites   ├── helpers.ts         # Shared utilities   └── *.test.ts         # Test files
├── visual/                # Visual regression tests
├── accessibility/         # Accessibility tests
└── mocks/                 # Mock server setup
    ├── server.ts
    └── handlers/

Testing Approach

Philosophy

E2E tests focus on complete user journeys rather than isolated features. A good E2E test follows the path a real user would take through the application.

Key principles:

  • Test complete workflows from start to finish
  • Use realistic test data and scenarios
  • Follow sequential user actions
  • Maintain user context throughout the flow
  • Test from the user's entry point

What We Test

Complete user journeys:

  • Registration and onboarding flows
  • Login and authentication
  • Product discovery and comparison
  • Purchase and upgrade flows
  • Account management

Page functionality:

  • Text content and headings
  • Navigation and links
  • Button labels and interactions
  • Form submissions
  • Success and error messages

What we avoid testing:

  • Images and graphics
  • Styling and visual design
  • Animations and transitions
  • Analytics and tracking code (GTM)
  • Meta tags and SEO elements
  • Responsive layouts
  • Implementation details

Selector Strategy

Selectors match how users identify elements, prioritized as follows:

  1. Role-based selectors (preferred):

    page.getByRole("button", { name: "Login" });
    page.getByRole("heading", { name: "Welcome" });
    
  2. Label-based selectors:

    page.getByLabel("Email address");
    page.getByLabel("Password");
    
  3. Text content selectors:

    page.getByText("You are logged in");
    
  4. Attribute selectors (use sparingly):

    page.fill("[name=email]", "user@statista.com");
    

Note: The project doesn't use data-testid attributes. All selectors should be semantic and user-focused.

User Context Management

Tests simulate real user contexts using cookies:

test.beforeEach(async ({ context }) => {
  // Set geographic location
  await addCookie(context, "uao-iso-country", "US");

  // Enable feature toggles
  await addCookie(context, "uao-toggles", "new-feature");

  // Set platform
  await addCookie(context, "uao-platform", "web");
});

test.afterEach(async ({ context }) => {
  await context.clearCookies();
});

Common Patterns

Basic User Journey Test

import { expect, test } from "@playwright/test";
import { href } from "react-router";
import { addCookie } from "./helpers.js";

test.describe("User Registration", () => {
  test.beforeEach(async ({ context }) => {
    await addCookie(context, "uao-iso-country", "US");
  });

  test.afterEach(async ({ context }) => {
    await context.clearCookies();
  });

  test("new user completes registration", async ({ page }) => {
    await page.goto(route("/"));

    await page.getByRole("button", { name: "Get Started" }).click();
    await expect(page).toHaveURL(/register/);

    await page.getByLabel("Email").fill("user@statista.com");
    await page.getByLabel("Password").fill("SecurePass123!");
    await page.getByRole("button", { name: "Create Account" }).click();

    await expect(page).toHaveURL(/success/);
    await expect(page.getByText("Welcome to Statista")).toBeVisible();
  });
});

Multi-Step Workflows

test("user discovers, compares, and purchases product", async ({ page }) => {
  // Step 1: Discover product
  await page.goto(route("/products"));
  await expect(
    page.getByRole("heading", { name: "Our Products" }),
  ).toBeVisible();

  // Step 2: View details
  await page.getByRole("link", { name: "Premium Account" }).click();
  await expect(page.getByText("Premium Features")).toBeVisible();

  // Step 3: Initiate purchase
  await page.getByRole("button", { name: "Buy Now" }).click();
  await expect(page).toHaveURL(/checkout/);

  // Step 4: Complete purchase
  await page.getByLabel("Card number").fill("4242424242424242");
  await page.getByRole("button", { name: "Complete Purchase" }).click();

  // Verify success
  await expect(page.getByText("Purchase successful")).toBeVisible();
});

Form Interactions

import { fillRegistrationForm } from "./helpers.js";

test("user fills out registration form", async ({ page }) => {
  await page.goto(route("/register"));

  await fillRegistrationForm(page, {
    email: "test@statista.com",
    firstName: "John",
    lastName: "Doe",
  });

  await page.getByRole("button", { name: "Submit" }).click();
  await expect(page.getByText("Registration successful")).toBeVisible();
});

Authentication Flows

import { handleLogin } from "./helpers.js";

test("returning user logs in", async ({ page }) => {
  await page.goto(route("/login"));

  await handleLogin(page, {
    email: "existing@statista.com",
    password: "SecurePass123!",
  });

  await expect(page).toHaveURL(route("/dashboard"));
  await expect(page.getByText("Welcome back")).toBeVisible();
});
import { getPathname } from "./helpers.js";

test("user navigates through main sections", async ({ page }) => {
  await page.goto(route("/"));

  await page.getByRole("link", { name: "Products" }).click();
  await page.waitForURL(/products/);
  expect(getPathname(page.url())).toContain("/products");

  await page.getByRole("link", { name: "Pricing" }).click();
  await page.waitForURL(/pricing/);
  expect(getPathname(page.url())).toContain("/pricing");
});

Error Handling

test("user sees validation error for invalid email", async ({ page }) => {
  await page.goto(route("/register"));

  await page.getByLabel("Email").fill("invalid-email");
  await page.getByRole("button", { name: "Submit" }).click();

  await expect(page.getByText("Please enter a valid email")).toBeVisible();
  await expect(page).toHaveURL(/register/); // Still on registration page
});

Best Practices

Test Organization

Tests are grouped by user journeys rather than technical features:

test.describe("New User Onboarding Journey", () => {
  // Multiple related tests for new user flow
});

test.describe("Account Upgrade Journey", () => {
  // Tests for upgrade workflow
});

Wait Strategies

Playwright automatically waits for elements to be actionable, but explicit waits are sometimes needed:

// Navigation
await page.waitForURL(/success/);

// Network idle (for visual tests)
await page.goto(route("/"), { waitUntil: "networkidle" });

// Specific condition
await page.waitForSelector('[aria-label="Loading complete"]');

Always clean up cookies between tests:

test.afterEach(async ({ context }) => {
  await context.clearCookies();
});

Test Isolation

Each test should be independent and able to run in any order:

  • Create necessary data within the test
  • Clean up state after the test
  • Don't depend on other tests running first

Helper Functions

The project provides helper functions in tests/e2e/helpers.ts:

  • addCookie() - Set cookies for feature toggles and location
  • fillRegistrationForm() - Fill registration forms consistently
  • handleLogin() - Perform login actions
  • route() - Generate consistent URLs
  • getPathname() - Extract pathname from full URL

Running Tests

Available commands:

# Run E2E tests
pnpm run integration

# Run with UI for debugging
pnpm run integration --headed

# Run specific test file
pnpm run integration tests/e2e/registration.test.ts

# Run in debug mode
pnpm run integration --debug

# Cross-browser testing
pnpm run integration:all

# Visual regression tests
pnpm run integration:visual

# Accessibility tests
pnpm run integration:a11y

CI Integration

Tests run automatically in CI with these configurations:

  • Single worker (no parallel execution)
  • 2 retry attempts for flaky test resilience
  • Automatic trace capture on failures
  • Screenshot capture on failures
  • GitHub Actions reporter for PR status checks

Environment variables are set in playwright.config.ts to enable mocking and configure the application.

Debugging

Local Debugging

# Run with visible browser
pnpm run integration --headed

# Step through tests
pnpm run integration --debug

# Run with slow motion
pnpm run integration --headed --slow-mo=1000

CI Debugging

When tests fail in CI:

  1. Check the HTML report artifact
  2. Review captured traces
  3. Examine failure screenshots
  4. Check console output in test logs

Common Issues

Flaky tests:

  • Use proper wait strategies instead of fixed timeouts
  • Ensure stable selectors that don't depend on dynamic content
  • Clean up cookies and state between tests

Timeout errors:

  • Increase timeout for slow operations
  • Check if page is actually loading
  • Verify mock data is being served correctly

Test Data

All test data uses realistic values:

  • Email addresses: user@statista.com (never example.com)
  • Names: Realistic first and last names
  • Dates: Valid date formats
  • URLs: Real or realistic paths

API Mocking

API requests are mocked using MSW, configured in tests/mocks/server.ts. Tests can add custom handlers as needed:

import { server } from "../mocks/server.js";
import { http, HttpResponse } from "msw";

test("handles API error gracefully", async ({ page }) => {
  server.use(
    http.get("/api/user", () => {
      return HttpResponse.json({ error: "Not found" }, { status: 404 });
    }),
  );

  await page.goto(route("/profile"));
  await expect(page.getByText("Profile not found")).toBeVisible();
});

Further Reading