Module 10 · Testing · Drills

Drills: Testing Frontend & E2E

Reading test code is not writing it. Type every one of these yourself, run it, watch it go red then green — that feedback loop is where the skill actually forms.

How to use this page Each drill is a small testing task against a tiny component. Attempt it first — write the test, run vitest (or npx playwright test), then click "Show solution" to compare. If yours queries differently but still asserts on user-visible behaviour, that's exactly right. Tick each box as you go; progress is saved in this browser.

A · Warm-up reps Basic

Drill 1 render + getByRole

A component renders <h1>DocChat</h1> and a <button>Send</button>. Render it and assert both exist — finding each by its role, not its text.

Show solution
import { render, screen } from "@testing-library/react"
import { Header } from "./Header"

it("renders the title and send button", () => {
  render(<Header />)
  expect(
    screen.getByRole("heading", { name: "DocChat" })
  ).toBeInTheDocument()
  expect(
    screen.getByRole("button", { name: "Send" })
  ).toBeInTheDocument()
})

Drill 2 userEvent click

A <Counter /> shows "Count: 0" and a button labelled "Increment". Click it once with userEvent and assert it now reads "Count: 1".

Show solution
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { Counter } from "./Counter"

it("increments on click", async () => {
  const user = userEvent.setup()
  render(<Counter />)
  await user.click(screen.getByRole("button", { name: "Increment" }))
  expect(screen.getByText("Count: 1")).toBeInTheDocument()
})

Always await userEvent and call userEvent.setup() once at the top — it's async because it mimics the full event sequence a real user fires.

Drill 3 getByLabelText

A form has <label>Ask a question</label> tied to a text input. Grab the input by its label, type into it, and assert its value.

Show solution
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { AskForm } from "./AskForm"

it("accepts typed input", async () => {
  const user = userEvent.setup()
  render(<AskForm />)
  const input = screen.getByLabelText("Ask a question")
  await user.type(input, "refund policy?")
  expect(input).toHaveValue("refund policy?")
})

B · Stretch Intermediate

Drill 4 async findBy

A <Loader /> shows "Loading…" then, after a delay, "Done". Assert that "Done" eventually appears — using the query that waits.

Show solution
import { render, screen } from "@testing-library/react"
import { Loader } from "./Loader"

it("shows Done after loading", async () => {
  render(<Loader />)
  // present immediately
  expect(screen.getByText("Loading…")).toBeInTheDocument()
  // appears later — findBy polls until it exists
  expect(await screen.findByText("Done")).toBeInTheDocument()
})

Use getBy* for things present now, findBy* for things that appear later, and queryBy* when you need to assert something is absent.

Drill 5 MSW handler

Write an MSW v2 handler for GET /api/documents that returns a JSON array of two documents. Then override it inside one test to return a 500.

Show solution
import { http, HttpResponse } from "msw"

// default handler (in handlers.ts)
export const handlers = [
  http.get("/api/documents", () =>
    HttpResponse.json([
      { id: "1", name: "policy.pdf" },
      { id: "2", name: "report.pdf" },
    ])
  ),
]

// per-test override (inside an it block)
server.use(
  http.get("/api/documents", () => new HttpResponse(null, { status: 500 }))
)

In v2 it's http.get / http.post (not the old rest.*), and you return an HttpResponse. server.use() overrides for the current test; afterEach(() => server.resetHandlers()) wipes it.

Drill 6 Playwright locator

Write a Playwright test that visits /, fills the "Ask a question" textbox, clicks "Send", and asserts an element containing "refund" becomes visible.

Show solution
import { test, expect } from "@playwright/test"

test("asking shows an answer", 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(/refund/i)).toBeVisible()
})

The await expect(locator).toBeVisible() assertion retries on its own until the element shows up — no manual sleeps. Note Playwright reuses the same accessible getByRole locators you learned in RTL.

C · Build challenge Build

Mini-project Test DocChat's <DocumentList /> component through its three states — loading → loaded → empty — driving each with an MSW handler. This is the exact shape of component testing you'll do on the job: one component, every state it can be in, the network mocked.

Build · document list states

The component fetches GET /api/documents on mount. While the request is in flight it shows "Loading documents…". On success it renders a list; if the array is empty it shows "No documents yet." Write three tests.

Show solution
import { render, screen } from "@testing-library/react"
import { http, HttpResponse, delay } from "msw"
import { server } from "./test/setup"
import { DocumentList } from "./DocumentList"

it("shows a loading state first", () => {
  server.use(
    http.get("/api/documents", async () => {
      await delay()          // keep the request pending
      return HttpResponse.json([])
    })
  )
  render(<DocumentList />)
  expect(screen.getByText("Loading documents…")).toBeInTheDocument()
})

it("renders the loaded documents", async () => {
  server.use(
    http.get("/api/documents", () =>
      HttpResponse.json([
        { id: "1", name: "policy.pdf" },
        { id: "2", name: "report.pdf" },
      ])
    )
  )
  render(<DocumentList />)
  // findBy waits for the fetch to resolve and the list to render
  expect(await screen.findByText("policy.pdf")).toBeInTheDocument()
  expect(screen.getByText("report.pdf")).toBeInTheDocument()
  // loading text is gone
  expect(screen.queryByText("Loading documents…")).not.toBeInTheDocument()
})

it("shows an empty state when there are none", async () => {
  server.use(
    http.get("/api/documents", () => HttpResponse.json([]))
  )
  render(<DocumentList />)
  expect(await screen.findByText("No documents yet.")).toBeInTheDocument()
})

Three handlers, three states, zero real network. Notice the pattern: getBy* for what's present immediately, findBy* for what arrives after the fetch, queryBy* + not.toBeInTheDocument() to prove the loading text disappeared. Add a fourth test that overrides the handler to return a 500 and asserts an error message — same shape.

D · Rapid recall Flashcards

Click a card to flip it. Say the answer out loud before you flip — that's the rep that builds storage strength.

RTL's one guiding principle?
Test the behaviour the user sees, not the implementation details.
click to flip
Default query to find elements?
getByRole(role, { name }) — mirrors how users and screen readers find things.
click to flip
get vs query vs find?
get throws if absent · query returns null (assert absence) · find is async, retries (appears later).
click to flip
Why MSW over stubbing fetch?
Intercepts at the network layer, client-agnostic, tests your real request code.
click to flip
Test a custom hook with…?
renderHook; read result.current and wrap updates in act().
click to flip
Playwright's anti-flake trio?
Lazy locators, auto-waiting before actions, and retrying web-first assertions.
click to flip

E · Self-check before moving on

Tick each only if you can do it without looking:

Next All ticked? You can now prove your frontend works at every level of the pyramid — that's a hireable, portfolio-worthy skill. Next we wire intelligence into DocChat: Module 11 — AI Agents & Tools.