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.
/\
/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.
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 })
),
];
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.
npm init playwright@latest
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,
},
});
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.
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();
}
}
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.
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');
});
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.
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.
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
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:
- 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.
- Set up Playwright in the same project (Section 3), including a
webServerconfig sonpx playwright testcan boot the app itself. - Write one Playwright e2e spec covering the full flow end to end, using
getByRole/getByLabelthroughout. - 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.
- 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.
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:
- Configure
playwright.config.tswith threeprojectsentries — Chromium, Firefox, and WebKit — and re-run the suite, confirming it passes on all three (or noting any browser-specific failure and why). - 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. - 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.
- Install
@axe-core/playwrightand add a dedicated accessibility-scan test asserting(await new AxeBuilder({ page }).analyze()).violationsis empty on the flow's main page.
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?
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?
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?
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?
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.