End-to-End (E2E) Testing

End-to-end tests are at the top of our testing pyramid. They are designed to simulate real user journeys from start to finish, running against a fully built application in a browser.

Our E2E Testing Tool

We use Playwright (or Cypress, depending on the project) for E2E testing. These tools provide a robust way to automate browser interactions.

What to Test with E2E Tests

  • Critical User Paths: E2E tests should be reserved for the most critical workflows in your application. We can’t test everything with E2E tests, so we must prioritize.
  • Examples of Critical Paths:
    • User registration and login.
    • The checkout process for an e-commerce site.
    • Creating and publishing a blog post.
    • The main feature that your application provides.

What NOT to Test with E2E Tests

  • Edge Cases: Minor edge cases are better handled by unit or integration tests.
  • Visual Details: Don’t write E2E tests to check that a button is a certain color or that a title has the right font size. This makes tests brittle. Visual regression testing is a better tool for this, if needed.
  • All Possible User Interactions: It’s not feasible to have an E2E test for every single button and link.

Best Practices

Keep Tests Independent

  • Each E2E test should be able to run independently of others.
  • A test should not depend on the state created by a previous test.
  • Use beforeEach hooks to ensure a clean state before each test (e.g., by logging out the user or resetting the database to a known state).

Use a data-testid Attribute

  • To make your tests more resilient to changes in the UI, it’s a good practice to use a dedicated data-testid attribute on key elements that you need to interact with.
  • This decouples the test from the element’s CSS classes or text content, which may change frequently.

Example (in your React component):

<button data-testid="login-submit-button">Log In</button>

Example (in your Playwright test):

import { test, expect } from '@playwright/test';
 
test('user can log in successfully', async ({ page }) => {
  await page.goto('/login');
 
  // Fill in the form
  await page.getByLabel('Email').fill('test@example.com');
  await page.getByLabel('Password').fill('password123');
 
  // Click the submit button using the data-testid
  await page.getByTestId('login-submit-button').click();
 
  // Assert that we have been redirected to the dashboard
  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});

Running E2E Tests

  • Locally: Developers can run E2E tests on their local machine against a running dev server to verify a feature before creating a PR.
  • In CI/CD: E2E tests are run automatically in our CI pipeline for every Pull Request, usually against the preview deployment generated by Cloudflare Pages.
  • Headless Mode: In CI, tests are run in “headless” mode (without a visible browser window) for efficiency.

Handling Flakiness

  • E2E tests can sometimes be “flaky” (i.e., they sometimes pass and sometimes fail for no clear reason).
  • Causes of Flakiness:
    • Race conditions (e.g., the test tries to click a button before it’s fully loaded).
    • Network delays.
  • Mitigation:
    • Use built-in waits and retries in Playwright/Cypress. For example, expect(element).toBeVisible() will automatically wait for the element to appear.
    • For custom asynchronous operations, explicitly await them.
    • If a test is consistently flaky, it should be investigated and fixed or, in some cases, removed.

E2E tests provide the highest level of confidence that our application is working correctly from the user’s perspective, but they should be used judiciously due to their cost and maintenance overhead.