Module 10 · Testing · Deep Dive

Testing Frontend & E2E

From "click around and hope" to a test suite that proves your UI works — Vitest, React Testing Library, MSW for network mocking, and Playwright driving a real browser through the whole flow.

BasicIntermediateBuild

Why this matters The backend tests from Lesson 10.1 only prove your API is correct. They say nothing about whether a user can actually upload a document and get an answer in DocChat. Frontend tests close that gap. In your jQuery days you "tested" by clicking around the page — that doesn't scale and it doesn't catch regressions. Here you'll learn to assert on what the user sees, mock the network so tests are fast and deterministic, and run a real browser end-to-end. This is the difference between "it worked on my machine" and a green CI badge you can trust.
In this lesson
  1. The testing pyramid
  2. Vitest setup & jsdom
  3. React Testing Library philosophy
  4. Queries: roles, labels, test-ids
  5. userEvent & async UI
  6. Mocking the network with MSW
  7. Testing a custom hook
  8. Playwright E2E & CI
  9. Build: testing DocChat's chat
  10. Check yourself

1 · The testing pyramid

Not all tests cost the same or catch the same bugs. The classic mental model is a pyramid: many cheap tests at the bottom, a few expensive ones at the top.

LevelWhat it testsSpeedTool
UnitOne function, one hook, one component in isolationMillisecondsVitest + RTL
IntegrationA few components working together, with the network mockedFastVitest + RTL + MSW
E2EThe real app in a real browser, real (or test) backendSecondsPlaywright

The shape is a guideline, not a law. Write many fast unit/integration tests because they pinpoint bugs and run in seconds. Write a handful of E2E tests for the critical journeys — for DocChat that's "upload a document, ask a question, see an answer." E2E tests are slow and occasionally flaky, so you guard your most important flows with them and let the lower levels cover the details.

A useful heuristic Ask "if this test breaks, will I know what broke?" Unit tests answer precisely ("the formatter mangles dates"). E2E tests answer broadly ("checkout is down"). You want both kinds of signal — the cheap precise one constantly, the expensive broad one for the journeys that earn revenue.
jQuery bridge: back then "the test" was you, manually, in Chrome. The pyramid replaces you with code at every level except the very top — and even there, Playwright is just an automated, tireless version of you clicking through.

2 · Vitest setup & jsdom

Vitest is the test runner for the Vite/React world. It's fast, ships with TypeScript and ESM support out of the box, and its API mirrors Jest (describe, it, expect) so existing knowledge transfers. Jest still works fine and you'll see it in older codebases — but for a new React 19 / Vite project in 2026, Vitest is the default.

npm install -D vitest @testing-library/react @testing-library/user-event \
  @testing-library/jest-dom jsdom

Tests run in Node, which has no DOM. jsdom is a pure-JavaScript implementation of the browser DOM so render() has something to render into. Configure it in vite.config.ts:

vite.config.ts
/// <reference types="vitest/config" />
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    globals: true,                 // use describe/it/expect without imports
    setupFiles: "./src/test/setup.ts",
  },
})

The setup file runs once before your tests. Use it to extend expect with the jest-dom matchers (toBeInTheDocument, toBeDisabled, …) and to wire up MSW later:

src/test/setup.ts
import "@testing-library/jest-dom/vitest"

Add a script to package.json"test": "vitest" for watch mode while you code, "test:run": "vitest run" for a single pass in CI.

3 · React Testing Library philosophy

React Testing Library (RTL) has one guiding principle, and internalising it will make every test you write better:

The golden rule Test the behaviour the user experiences, not the implementation. The more your tests resemble the way your software is used, the more confidence they give you.

What does that mean in practice? You don't reach into a component's state, you don't check that a particular hook was called, you don't assert on CSS class names. Instead you find elements the way a user (or a screen reader) would — "the button labelled Send", "the textbox named Ask a question" — interact with them, and assert on what appears.

Why this matters: a test coupled to implementation breaks every time you refactor, even when nothing the user sees has changed. A test written against behaviour survives refactors and only fails when you've actually broken something real. That's the entire value proposition — tests that fail for good reasons and pass for good reasons.

import { render, screen } from "@testing-library/react"
import { Greeting } from "./Greeting"

it("greets the user by name", () => {
  render(<Greeting name="Sam" />)
  expect(screen.getByText("Hi, Sam")).toBeInTheDocument()
})

render mounts the component into jsdom. screen is your handle to query the rendered output — you'll use it constantly. Note there's nothing here about props internals or state: just "render it, then look for what the user would see."

The Enzyme trap (history) Older React codebases used Enzyme, which let you reach into component.state() and shallow-render. It's deprecated and has no React 18+ adapter — do not start a new project with it. If you inherit an Enzyme suite, migrating to RTL is a known, well-documented path. The philosophy shift (behaviour over internals) is the whole point of the move.

4 · Queries: roles, labels, test-ids

RTL gives you a ranked menu of ways to find elements. The ranking is the advice — prefer the ones near the top, because they mirror how real users and assistive technology perceive the page.

QueryFinds byUse when
getByRoleARIA role + accessible nameAlmost always — this is the default
getByLabelTextA form field's labelInputs, selects, textareas
getByTextVisible text contentNon-interactive copy, messages
getByTestIdA data-testid attributeLast resort only

getByRole is the workhorse. Every meaningful element has a role: a <button> is button, an <input type="text"> is textbox, an <h2> is heading, a <ul> is list. You pair the role with the accessible name:

// the <button>Send</button>
screen.getByRole("button", { name: "Send" })

// an <input> tied to a <label>Ask a question</label>
screen.getByRole("textbox", { name: "Ask a question" })

// the <h1>DocChat</h1>
screen.getByRole("heading", { name: "DocChat" })

The quiet superpower: if getByRole can find your button by its accessible name, then a screen-reader user can find it too. Your tests double as an accessibility audit. When a query can't find an element by role, that's often a real a11y bug — a button with no text, an input with no label.

getByLabelText is the cleanest way to grab form fields and it forces you to actually label them. getByTestId is the escape hatch — reach for it only when there's genuinely no accessible way to identify an element (a purely visual wrapper, say). Every data-testid is a small admission that the element isn't user-perceivable, so keep them rare.

get vs query vs find Three prefixes, three behaviours. getBy* throws if not found (use when it must exist). queryBy* returns null if not found (use to assert something is absent). findBy* returns a promise and retries (use for things that appear later). Picking the right one expresses your intent precisely.

5 · userEvent & async UI

To simulate interaction, use @testing-library/user-event, not the older low-level fireEvent. userEvent dispatches the full sequence a real user triggers — focus, keydown, keypress, input, keyup — so it catches bugs fireEvent would miss. It's async, so you always await it.

import userEvent from "@testing-library/user-event"

it("submits the typed question", async () => {
  const user = userEvent.setup()   // call once at the top of each test
  render(<ChatBox />)

  const input = screen.getByRole("textbox", { name: "Ask a question" })
  await user.type(input, "What is the refund policy?")
  await user.click(screen.getByRole("button", { name: "Send" }))

  expect(input).toHaveValue("")   // cleared after submit
})

Now the async part. After a user clicks "Send", DocChat calls the API and the answer arrives later. The element you want to assert on doesn't exist yet at the moment of assertion — so you wait for it with findBy*, which polls the DOM until the element appears (or times out):

// findBy* returns a promise — await it
const answer = await screen.findByText(/refunds are processed/i)
expect(answer).toBeInTheDocument()

For assertions that aren't about a single element appearing — say "the spinner has gone away" — use waitFor, which retries an arbitrary callback until it stops throwing:

import { waitFor } from "@testing-library/react"

await waitFor(() => {
  expect(screen.queryByText("Thinking…")).not.toBeInTheDocument()
})
jQuery bridge: you used to write setTimeout(() => expect(...), 500) and pray the timing held. findBy* and waitFor retry on a tight loop and resolve the instant the condition is met — faster and not flaky. Never hardcode a sleep in a test again.

6 · Mocking the network with MSW

Your component calls fetch("/api/ask"). In a test you don't want a real backend — it'd be slow, require a running server, and give different answers each run. You need to intercept that request and return a canned response. The 2026 standard for this is MSW (Mock Service Worker) v2.

The old approach was to monkey-patch global.fetch with a stub. That's brittle: you're faking the function, so your test passes even if you call the wrong URL, send the wrong body, or your real code switches from fetch to axios. MSW intercepts at the network layer — it doesn't care which client you use, and it matches on the actual URL, method, and body. Your application code runs completely unchanged; it genuinely makes a request, MSW just answers it.

Why MSW beats stubbing fetch You define request handlers — "when a POST hits /api/ask, respond with this JSON." The same handlers work in tests, in Storybook, and even in the browser during local development against a not-yet-built backend. One source of truth for mock behaviour, and it tests your real request code instead of replacing it.
src/test/handlers.ts
import { http, HttpResponse } from "msw"

export const handlers = [
  http.post("/api/ask", async ({ request }) => {
    const body = await request.json() as { question: string }
    return HttpResponse.json({
      answer: f`Refunds are processed within 14 days.`,
      sources: [{ doc: "policy.pdf", page: 3 }],
    })
  }),
]

Wire a Node server into your setup file so every test gets the handlers automatically, and reset between tests so one test can't leak into another:

src/test/setup.ts
import "@testing-library/jest-dom/vitest"
import { afterAll, afterEach, beforeAll } from "vitest"
import { setupServer } from "msw/node"
import { handlers } from "./handlers"

export const server = setupServer(...handlers)

beforeAll(() => server.listen({ onUnhandledRequest: "error" }))
afterEach(() => server.resetHandlers())   // undo per-test overrides
afterAll(() => server.close())

Inside a single test you can override a handler — to simulate an error, say — and resetHandlers() wipes it afterwards:

import { http, HttpResponse } from "msw"
import { server } from "./test/setup"

it("shows an error when the API fails", async () => {
  server.use(
    http.post("/api/ask", () => new HttpResponse(null, { status: 500 }))
  )
  // …render, ask, then assert the error message appears
})
Interview hook: "How do you mock APIs in your tests?" The strong answer names MSW and explains why — network-layer interception tests your real request code and is client-agnostic, unlike stubbing fetch. Saying that puts you ahead of most candidates.

7 · Testing a custom hook

Most logic in a modern React app lives in custom hooks. You could test a hook only through a component that uses it — and often that's fine — but when a hook is gnarly enough to deserve focused tests, RTL gives you renderHook. It mounts the hook in a throwaway component and hands you back its return value.

useAsk.test.ts
import { renderHook, waitFor } from "@testing-library/react"
import { act } from "react"
import { useAsk } from "./useAsk"

it("loads an answer for a question", async () => {
  const { result } = renderHook(() => useAsk())

  expect(result.current.answer).toBeNull()

  await act(async () => {
    await result.current.ask("What is the refund policy?")
  })

  await waitFor(() =>
    expect(result.current.answer).toMatch(/refunds/i)
  )
})

result.current always points at the hook's latest return value — so after an update you re-read result.current, you don't hold a stale reference. Wrap any call that triggers a state update in act() so React flushes the change before you assert. MSW intercepts the hook's fetch exactly as it did for components — the mock layer doesn't care whether the request came from a component or a hook.

8 · Playwright E2E & CI

Everything so far runs in jsdom — a fake DOM, no real rendering engine, no real network. For the top of the pyramid you want a real browser running your real app. Playwright is the 2026 standard: it drives Chromium, Firefox, and WebKit, and it's built to defeat the historical curse of E2E testing — flakiness.

npm init playwright@latest   # scaffolds config, installs browsers

A Playwright test reads almost like prose. You navigate, locate, act, and assert:

e2e/ask.spec.ts
import { test, expect } from "@playwright/test"

test("a user can ask a question", async ({ page }) => {
  await page.goto("/")
  await page.getByRole("textbox", { name: "Ask a question" })
    .fill("What is the refund policy?")
  await page.getByRole("button", { name: "Send" }).click()
  await expect(page.getByText(/refunds are processed/i)).toBeVisible()
})

Notice the same getByRole philosophy carries over — Playwright deliberately mirrors RTL's accessible locators so the skill transfers. A few ideas that make Playwright reliable where older tools (Selenium, the old Cypress habits) were flaky:

FeatureWhat it gives you
Locatorspage.getByRole(...) is lazy — it re-finds the element each time you use it, so it never goes stale.
Auto-waitingBefore clicking, Playwright waits for the element to be visible, attached, and stable. No manual sleeps.
Web-first assertionsawait expect(locator).toBeVisible() retries until true or it times out — the assertion itself waits.
FixturesThe { page } argument is a fixture — a fresh, isolated browser context per test. You can author your own fixtures for logged-in pages, seeded data, etc.
Trace viewerOn failure, a recorded trace shows every step with DOM snapshots, network, and console — time-travel debugging for CI failures.

Saving auth / storage state. Logging in through the UI before every test is slow. Instead, log in once, save the cookies and localStorage to a file, and reuse it. This is the single biggest E2E speed win:

e2e/auth.setup.ts
import { test as setup } from "@playwright/test"

setup("authenticate", async ({ page }) => {
  await page.goto("/login")
  await page.getByLabel("Email").fill("test@docchat.ae")
  await page.getByLabel("Password").fill("secret")
  await page.getByRole("button", { name: "Sign in" }).click()
  await page.waitForURL("/")
  // persist cookies + localStorage to disk
  await page.context().storageState({ path: "e2e/.auth/user.json" })
})

Then in playwright.config.ts point your project at that stored state with use: { storageState: "e2e/.auth/user.json" }, and every test starts already logged in.

The trace viewer. Configure use: { trace: "on-first-retry" }. When a test fails in CI, download the trace artifact and open it with npx playwright show-trace trace.zip. You'll see a filmstrip of the run, every action, network call, and a DOM snapshot at each step — debugging a CI-only failure goes from guesswork to obvious.

Running tests in CI In a GitHub Actions job: install deps, then npx playwright install --with-deps to pull browsers + OS libraries, then run vitest run (unit/integration) and npx playwright test (E2E). Playwright's config webServer option can boot your Next.js app (npm run start) and wait for it before tests begin. Upload the HTML report and traces as artifacts so failures are debuggable without re-running. Keep unit tests as a required check on every PR; you may run the slower E2E suite on merge to main or nightly.

9 · Build: testing DocChat's chat

Your tangible win Write a real RTL integration test for DocChat's chat input — type a question, submit, assert the answer renders — with MSW mocking the API. Then write a Playwright happy-path test for the full upload → ask flow. Together they cover the same feature at two levels of the pyramid.

First the integration test. It exercises the component and its request code, with the network deterministically mocked:

ChatBox.test.tsx
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { http, HttpResponse } from "msw"
import { server } from "./test/setup"
import { ChatBox } from "./ChatBox"

it("answers a typed question", async () => {
  server.use(
    http.post("/api/ask", () =>
      HttpResponse.json({ answer: "Refunds are processed within 14 days." })
    )
  )
  const user = userEvent.setup()
  render(<ChatBox />)

  await user.type(
    screen.getByRole("textbox", { name: "Ask a question" }),
    "What is the refund policy?",
  )
  await user.click(screen.getByRole("button", { name: "Send" }))

  // the answer arrives asynchronously — findBy waits for it
  expect(
    await screen.findByText(/refunds are processed within 14 days/i)
  ).toBeInTheDocument()
})

Read what this test does and does not do. It never inspects component state, never checks a class name, never asserts that fetch was called. It does exactly what a user does — type, click, read the answer — so it'll survive any refactor that keeps that behaviour intact, and fail only when a user would actually be let down.

Now the Playwright happy-path, exercising the real upload form, the document list, and the chat box together in a browser:

e2e/upload-and-ask.spec.ts
import { test, expect } from "@playwright/test"
import path from "node:path"

test("upload a document then ask about it", async ({ page }) => {
  await page.goto("/")

  // 1 · upload via the file input
  await page.getByLabel("Upload a document")
    .setInputFiles(path.join(__dirname, "fixtures/policy.pdf"))

  // 2 · it appears in the document list (auto-waits)
  await expect(
    page.getByRole("list", { name: "Documents" })
  ).toContainText("policy.pdf")

  // 3 · ask a question about it
  await page.getByRole("textbox", { name: "Ask a question" })
    .fill("What is the refund policy?")
  await page.getByRole("button", { name: "Send" }).click()

  // 4 · the answer renders (assertion retries until visible)
  await expect(page.getByText(/refund/i)).toBeVisible()
})

This single test, slow as it is, proves the whole feature is wired together: the upload endpoint, the list re-render, the ask endpoint, the RAG pipeline, the chat rendering. That's the broad confidence the integration tests can't give you — and why one good E2E test per critical journey earns its keep.

10 · Check yourself

Answer from memory — retrieval is what moves this from "I read it" to "I know it".

Recall quiz

Why should getByRole be your default query?

Which query waits for an element that appears later?

Why does MSW beat stubbing the fetch function?

What sits at the top of the testing pyramid?

Why save Playwright storage state to a file?

Primary source ⭐ React Testing Library docs — the canonical reference, especially the "Guiding Principles" and the query priority list. Then Playwright docs for E2E and MSW v2 docs for request mocking. Kent C. Dodds' essay "Write tests. Not too many. Mostly integration." is the philosophy in one line.