Week 15: Integration & E2E Testing

Week 14 tested components in isolation — one form, one hook, one unit at a time. That's necessary but not sufficient: it never proves that routing, data fetching, and a form actually work correctly together. This week zooms out, first with integration tests that exercise several pieces at once inside a simulated DOM, then all the way out to Playwright end-to-end tests that drive a real browser through a real user flow, start to finish.

Module 9 of 17 Week 15 of 28 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write an integration test that exercises routing, data fetching and a form together with MSW
  • Set up Playwright and write an e2e test using the page object pattern
  • Run e2e tests in CI and reason about where each layer of the testing pyramid earns its cost

1. The Testing Pyramid, Revisited

Week 14 built the base of the pyramid: many small, fast unit and component tests, each checking one piece in isolation. This week adds the two layers above it — integration tests, which check that several pieces work correctly together, and end-to-end (e2e) tests, which drive a real browser through a real user journey against something close to the real app.

the shape of the pyramid, and why
            /\
           /e2e\          few — slow (seconds per test), high confidence,
          /------\        real browser, real network stack, most brittle
         /integr. \       some — moderate speed, multiple real components
        /----------\      wired together, one layer of mocking (MSW)
       /   unit     \     many — milliseconds each, one function or
      /--------------\    component at a time, cheapest to write and run

The ratio isn't arbitrary — it's a direct tradeoff between speed and confidence. A unit test runs in milliseconds and tells you almost nothing about whether the app actually works end to end; an e2e test tells you the most about real user-facing correctness but costs seconds per test and is the most likely to break for reasons unrelated to a real bug (a slow network, an animation, a timing race). A healthy suite leans on unit tests for volume, integration tests for the handful of flows where components genuinely depend on each other, and e2e tests for the small number of journeys where nothing less than a real browser will do — checkout, signup, anything where a mocked-DOM approximation isn't good enough.

2. Integration Testing Multi-Component Flows

An integration test still runs inside the simulated DOM from Week 14 (RTL + jsdom, no real browser) but renders more of the app at once — typically a router with two or more routes — and asserts that navigating between them, with real data flowing through MSW, actually works. It's the same tools as Week 14, used at a wider scope.

src/mocks/handlers.ts — extends Week 14's handlers
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/products', () =>
    HttpResponse.json([{ id: '1', name: 'Mechanical Keyboard', price: 89 }])
  ),
  http.get('/api/products/:id', ({ params }) =>
    HttpResponse.json({ id: params.id, name: 'Mechanical Keyboard', price: 89, stock: 12 })
  ),
];
ProductFlow.integration.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { describe, it, expect } from 'vitest';
import ProductList from './ProductList';
import ProductDetail from './ProductDetail';

function renderApp() {
  return render(
    <MemoryRouter initialEntries={['/products']}>
      <Routes>
        <Route path="/products" element={<ProductList />} />
        <Route path="/products/:id" element={<ProductDetail />} />
      </Routes>
    </MemoryRouter>
  );
}

describe('product list -> detail flow', () => {
  it('navigates from the list to a product detail page with real data', async () => {
    const user = userEvent.setup();
    renderApp();

    // list page fetches via MSW-mocked /api/products
    const link = await screen.findByRole('link', { name: /mechanical keyboard/i });
    await user.click(link);

    // detail page fetches via MSW-mocked /api/products/:id — proves routing AND data flow work together
    expect(await screen.findByText(/12 in stock/i)).toBeInTheDocument();
  });
});

MemoryRouter gives React Router a fake in-memory history so routing works without a real browser URL bar. This single test proves three things at once that three separate unit tests couldn't: the list page renders a real link built from real fetched data, clicking it actually navigates, and the detail page correctly reads the URL param and fetches the right product — exactly the kind of gap that passes every unit test in isolation but breaks in production.

3. Playwright Setup & Writing E2E Tests

Playwright drives an actual browser (Chromium, Firefox, or WebKit) — no jsdom simulation, no shortcuts. It's the right tool once a test needs to verify things a simulated DOM can't: real navigation, real network requests hitting a real (often staging) backend, real rendering and layout.

terminal
npm init playwright@latest
playwright.config.ts — the essentials
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0, // real browsers flake; retry in CI, not locally
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry',
  },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
  },
});
e2e/checkout.spec.ts — add-to-cart-then-checkout flow
import { test, expect } from '@playwright/test';

test('user can add an item to the cart and complete checkout', async ({ page }) => {
  await page.goto('/products');

  await page.getByRole('link', { name: /mechanical keyboard/i }).click();
  await page.getByRole('button', { name: /add to cart/i }).click();

  await expect(page.getByText('1 item in cart')).toBeVisible();

  await page.getByRole('link', { name: /go to cart/i }).click();
  await page.getByRole('button', { name: /checkout/i }).click();

  await page.getByLabel('Full name').fill('Ada Lovelace');
  await page.getByLabel('Card number').fill('4242 4242 4242 4242');
  await page.getByRole('button', { name: /place order/i }).click();

  await expect(page.getByRole('heading', { name: /order confirmed/i })).toBeVisible();
});

getByRole and getByLabel are Playwright's equivalents of RTL's accessible queries (Week 14) — same reasoning applies: they find elements the way a real user or assistive technology would, so the test stays resilient to CSS and markup refactors that don't change what's actually on screen.

4. The Page Object Pattern

The checkout spec above reads fine as one test. Once ten specs all interact with the same cart and checkout UI, scattering the same getByRole/getByLabel calls across every file becomes a real maintenance cost — a single markup change (a relabeled button, a restructured form) means hunting down and fixing every spec that touches it.

e2e/pages/CheckoutPage.ts — wraps the same flow from Section 3
import type { Page, Locator } from '@playwright/test';

export class CheckoutPage {
  readonly page: Page;
  readonly fullNameInput: Locator;
  readonly cardNumberInput: Locator;
  readonly placeOrderButton: Locator;
  readonly confirmationHeading: Locator;

  constructor(page: Page) {
    this.page = page;
    this.fullNameInput = page.getByLabel('Full name');
    this.cardNumberInput = page.getByLabel('Card number');
    this.placeOrderButton = page.getByRole('button', { name: /place order/i });
    this.confirmationHeading = page.getByRole('heading', { name: /order confirmed/i });
  }

  async placeOrder(name: string, cardNumber: string) {
    await this.fullNameInput.fill(name);
    await this.cardNumberInput.fill(cardNumber);
    await this.placeOrderButton.click();
  }
}
e2e/checkout.spec.ts — rewritten with the page object
import { test, expect } from '@playwright/test';
import { CheckoutPage } from './pages/CheckoutPage';

test('user can add an item to the cart and complete checkout', async ({ page }) => {
  await page.goto('/products');
  await page.getByRole('link', { name: /mechanical keyboard/i }).click();
  await page.getByRole('button', { name: /add to cart/i }).click();
  await page.getByRole('link', { name: /go to cart/i }).click();
  await page.getByRole('button', { name: /checkout/i }).click();

  const checkoutPage = new CheckoutPage(page);
  await checkoutPage.placeOrder('Ada Lovelace', '4242 4242 4242 4242');

  await expect(checkoutPage.confirmationHeading).toBeVisible();
});

Now a markup change to the checkout form means updating CheckoutPage.ts once, and every spec using it is fixed automatically. It's the same underlying idea as a custom hook (Week 4) extracting reusable logic out of components — here it's extracting reusable browser interactions out of specs, keeping the test files themselves readable as a sequence of intent ("place an order") rather than a wall of raw selectors.

5. Visual Regression Basics

Everything so far asserts on text, roles, and DOM state — none of it catches a purely visual regression: a button rendering with the wrong padding, a broken CSS grid, text overlapping an icon. Playwright's screenshot assertions compare a rendered page against a saved baseline image, pixel by pixel.

e2e/product-list.visual.spec.ts
import { test, expect } from '@playwright/test';

test('product list renders consistently', async ({ page }) => {
  await page.goto('/products');
  await expect(page.getByRole('heading', { name: /products/i })).toBeVisible();

  await expect(page).toHaveScreenshot('product-list.png');
});
terminal — first run creates the baseline, later runs compare against it
npx playwright test --update-snapshots

The first run with --update-snapshots saves product-list.png as the accepted baseline; every subsequent run renders the page again and fails the test if the new screenshot differs beyond a small pixel threshold. When a change is intentional (a real redesign), re-running with --update-snapshots accepts the new look as the baseline going forward.

An honest caveat

Visual regression tests are the flakiest, highest-maintenance layer of the pyramid — font rendering differences between machines, animation timing, and dynamic content (a live timestamp, a random avatar) all produce false failures unrelated to any real bug. Use them sparingly, on a small number of visually critical, mostly-static pages, and expect to spend real time maintaining baselines as the design legitimately evolves.

6. Running E2E Tests in CI

E2E tests need a real browser and, per the config in Section 3, a running dev server — CI needs to install both before the tests can run, and should preserve evidence when a test fails, since a CI failure can't be debugged by watching the browser live the way a local run can.

.github/workflows/e2e.yml
name: E2E Tests

on: [pull_request]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Run Playwright tests
        run: npx playwright test

      - name: Upload HTML report
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

--with-deps installs the OS-level libraries the browsers need on a bare CI runner, not just the browser binaries themselves. The report upload is gated on if: failure() — on a passing run there's nothing worth keeping, but on a failure, Playwright's HTML report (screenshots, traces, and a step-by-step timeline of exactly where the test diverged) is often the only way to diagnose what went wrong without being able to reproduce it locally.

7. Hands-on Exercise

Hands-on

Cover one user flow at both the integration and e2e layers

Pick a flow you've already built — Week 5's signup form, or a cart flow — and prove it works at two different layers of the pyramid, then add cross-browser, visual, and accessibility coverage.

Part 1 — Integration and e2e:

  1. Write one integration test (RTL + MSW, Section 2) that renders at least two routes together and asserts navigation plus real fetched data both work correctly within the simulated DOM.
  2. Set up Playwright in the same project (Section 3), including a webServer config so npx playwright test can boot the app itself.
  3. Write one Playwright e2e spec covering the full flow end to end, using getByRole/getByLabel throughout.
  4. Extract the interactions from that spec into a page object class (Section 4), and rewrite the spec to use it instead of raw locator calls.
  5. Add a GitHub Actions workflow (Section 6) that installs Playwright's browsers, runs the suite, and uploads the HTML report as an artifact on failure.
Hint

If your integration test and your e2e test end up asserting almost exactly the same things, that's fine — they're proving the same behavior at different levels of confidence and speed. The point isn't to test different behavior, it's to know that the fast integration test alone isn't proof the app works in a real browser, and the slow e2e test alone isn't a substitute for fast day-to-day feedback.

Part 2 — Cross-browser, visual, and accessibility coverage:

  1. Configure playwright.config.ts with three projects entries — Chromium, Firefox, and WebKit — and re-run the suite, confirming it passes on all three (or noting any browser-specific failure and why).
  2. Add await expect(page).toHaveScreenshot() at one stable, visually-settled point in the flow, run the suite once to generate the baseline image, and commit it.
  3. Deliberately change a CSS value in the app (a button's color or padding), re-run the suite, and confirm the test fails with a visual diff — inspect the HTML report to see the expected/actual/diff images side by side, then revert the CSS change.
  4. Install @axe-core/playwright and add a dedicated accessibility-scan test asserting (await new AxeBuilder({ page }).analyze()).violations is empty on the flow's main page.
Hint

Screenshot tests are flaky by nature if anything on the page is still animating or a network image hasn't loaded — wait on a concrete signal (await expect(locator).toBeVisible(), or Playwright's built-in animation-settling) immediately before the screenshot call, rather than an arbitrary fixed delay.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What's the actual difference between a unit test, an integration test, and an e2e test — and why does that difference matter for how many of each you write?

A unit test checks one function or component in isolation; an integration test checks several real pieces (routing, data fetching, a form) working together, still inside a simulated DOM; an e2e test drives a real browser through a real flow against a running app. Each step up gives more confidence but costs more time and is more prone to unrelated flakiness, which is why the pyramid has many unit tests, fewer integration tests, and the fewest e2e tests.

Q2

How does the page object pattern reduce the maintenance cost of a Playwright suite as it grows?

It centralizes the locators and interactions for a given page or flow into one class instead of scattering getByRole/getByLabel calls across every spec that touches that page. When the underlying markup changes, only the page object needs updating — every spec that uses it keeps working without individual edits.

Q3

What kind of bug does expect(page).toHaveScreenshot() catch that a getByRole/getByText assertion never would?

A purely visual regression — broken padding, a collapsed CSS grid, overlapping elements — where the correct text and roles are all still present in the DOM, so every ordinary assertion would pass, but the page looks visibly wrong. Screenshot comparison catches exactly the class of bug that DOM-based assertions are structurally unable to see.

Q4

Why upload the Playwright HTML report as a CI artifact only when the job fails, rather than on every run?

On a passing run there's nothing informative in the report worth keeping, and uploading it every time wastes storage and CI time for no benefit. On a failure, though, it's often the only way to diagnose the problem — a CI runner can't be watched live the way a local browser can, so the report's screenshots, traces, and timeline are the evidence needed to figure out what actually went wrong.