16.
Testing & Debugging React Applications
Written by Eli Ganim
The Learning Tracker works. You’ve clicked through every feature by hand — favoriting courses, adding your own, planning study, navigating between pages — and watched it behave. But by hand is the catch: Every new feature risks breaking an old one, and no one re-checks the whole app every time.
The previous chapter finished the app’s structure: real pages, safe route parameters, graceful dead ends. This chapter proves those behaviors stay correct. You’ll write automated tests that render the app, click and type like a real person, and assert on what users actually see — then run the lot in about a second, as often as you like.
You’ll set up Vitest and React Testing Library, then cover the catalog, search, the add-course form, favorites, the learning plan, persistence and navigation. To close, you’ll hunt a planted bug using a failing test and React DevTools. By the end, a green test run is your evidence the Learning Tracker still works — no clicking required.
The Testing Pyramid
Tests come in sizes. Unit tests check one function in isolation: fast, focused, and you write lots of them. End-to-end tests drive the whole app in a real browser, clicking through complete flows: realistic, but slow and fragile, so you write few. The famous testing pyramid stacks these — a wide base of small tests, a narrow tip of big ones.
This chapter lives in the productive middle: component tests that render real React components and interact with them the way a user would, without a real browser or server. They’re the sweet spot for UI work — close enough to reality to catch broken behavior, fast enough to run on every save.
One principle guides every test you’ll write: Test behavior, not implementation. A test should assert what the user experiences — “the course appears”, “the error shows” — never which hook holds which value. Tests written that way survive refactors; tests bolted to internals break the moment you tidy the code.
Installing the Testing Tools
Stop the dev server with Ctrl-C. Install the testing stack as dev dependencies:
npm install -D vitest jsdom \
@testing-library/react \
@testing-library/user-event \
@testing-library/jest-dom
Here’s the cast. Vitest is the test runner, sharing Vite’s config so your tests see the same setup your app does. jsdom simulates a browser’s DOM in Node, so components can render without a real window.
The rest come from Testing Library. @testing-library/react renders components and finds elements the way users do — by role and text. @testing-library/user-event simulates realistic typing and clicking. And @testing-library/jest-dom adds readable assertions like toBeInTheDocument.
Vitest needs to know it’s testing a browser-like app. Open vite.config.ts and add a test block:
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
})
The triple-slash line pulls in Vitest’s config types. environment: 'jsdom' tells Vitest to give every test a simulated DOM. And setupFiles names a file that runs before your tests — you’ll create it next. Finally, add a script to package.json so npm test runs Vitest:
"test": "vitest",
Put it alongside the existing dev, build and lint scripts. Running vitest with no arguments starts it in watch mode, re-running tests as you edit — exactly what you want while writing them.
Setting Up the Test Environment
jsdom is a lean browser stand-in, and it leaves two gaps the Learning Tracker cares about. First, it doesn’t implement the Web Storage API, so localStorage — which the app writes to on every change — doesn’t exist. Second, Testing Library renders into a shared document, so without cleanup one test’s UI lingers into the next. The setup file closes both gaps once, for every test.
Create src/test/setup.ts:
import '@testing-library/jest-dom/vitest'
import { afterEach, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
// jsdom doesn't implement the Web Storage API, so give the
// tests a small in-memory localStorage. The Learning Tracker
// persists to it on every change, so it must exist before any
// component renders.
const store = new Map<string, string>()
vi.stubGlobal('localStorage', {
get length() {
return store.size
},
clear() {
store.clear()
},
getItem(key: string) {
return store.get(key) ?? null
},
key(index: number) {
return [...store.keys()][index] ?? null
},
removeItem(key: string) {
store.delete(key)
},
setItem(key: string, value: string) {
store.set(key, value)
},
})
// Unmount rendered components and empty stored state after
// each test so nothing leaks into the next one.
afterEach(() => {
cleanup()
localStorage.clear()
})
The first import teaches Vitest’s expect the jest-dom matchers. The vi.stubGlobal call installs a localStorage backed by a plain Map — just enough of the real API for the app to read and write. Note there’s no as Storage cast forcing the shapes to line up; the object simply provides the methods the app calls, which is honest and enough.
The afterEach block is the isolation you need. cleanup() unmounts anything Testing Library rendered, and localStorage.clear() empties the store, so each test starts from a blank slate. Skip this, and tests mysteriously depend on their neighbors — one of the most confusing failures a beginner meets.
Rendering the Catalog
Time for a real test. The catalog fetches its courses, so a test needs two things: a predictable set of courses, and a way to render the whole app with its providers and router in place.
Start with the data. Create src/test/testCourses.ts — a small, typed fixture:
import type { Course } from '../types/course.ts'
export const testCourses: Course[] = [
{
id: 'react-basics',
title: 'React Basics',
description: 'Build interfaces from reusable components.',
category: 'Frontend',
level: 'Beginner',
durationHours: 6,
},
{
id: 'css-layout',
title: 'CSS Layout',
description: 'Arrange pages with flexbox and grid.',
category: 'Frontend',
level: 'Intermediate',
},
]
Typing the fixture as Course[] means it can’t drift from the real shape — drop a required field and TypeScript complains right here, in the test data. Two courses is plenty: enough to prove filtering and counting, few enough to reason about.
Now the test file. Create src/App.test.tsx with the imports, a mock and two helpers:
import { describe, expect, it, vi } from 'vitest'
import {
render,
screen,
within,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router'
import App from './App.tsx'
import { ThemeProvider } from './ThemeProvider.tsx'
import { testCourses } from './test/testCourses.ts'
vi.mock('./api/fetchCourses.ts', () => ({
fetchCourses: async () => testCourses,
}))
function renderApp(initialPath = '/') {
return render(
<ThemeProvider>
<MemoryRouter initialEntries={[initialPath]}>
<App />
</MemoryRouter>
</ThemeProvider>,
)
}
function cardFor(title: string) {
const card = screen
.getByRole('link', { name: title })
.closest('article')
if (card === null) {
throw new Error('No card found for ' + title)
}
return within(card)
}
vi.mock replaces the real fetchCourses with one that returns your fixture — no network, deterministic results. renderApp wraps App in the same ThemeProvider it has in main.tsx, plus a MemoryRouter (a router that keeps its URL in memory instead of the address bar, ideal for tests) whose initialEntries sets the starting page. Neither helper declares a return type; TypeScript infers them, so the utilities stay lean.
cardFor is a scoping helper. The catalog shows many course cards, and a later test will need to interact with one specific card rather than the whole page. This finds a course’s card by its title link, walks up to the enclosing <article>, and hands back a within query scoped to just that card. Now add the first test, below the helpers:
describe('the catalog page', () => {
it('shows courses once loading finishes', async () => {
renderApp()
expect(screen.getByRole('status')).toHaveTextContent(
'Loading courses…',
)
expect(
await screen.findByRole('link', {
name: 'React Basics',
}),
).toBeInTheDocument()
expect(
screen.getByText('Showing 2 of 2 courses'),
).toBeInTheDocument()
})
})
The test reads as a story. Render the app; immediately, the loading note is on screen, so getByRole('status') finds it. Then findByRole — the find family waits for an element to appear — pauses until the mocked fetch resolves and the “React Basics” link shows up. Once it does, the result count confirms both courses rendered. Run it:
npm test
One passing test. Notice what it never mentions: no component names, no state, no CSS classes — only what a user would see. That’s the style every test here follows.
Querying Like a User
Testing Library gives you a family of queries, and choosing well keeps tests readable and robust. Three distinctions matter:
-
getBy… / findBy… / queryBy…:
getByexpects the element to be there now and throws if not.findByreturns a Promise and retries its query until that element appears (or it times out) — use it for content that arrives asynchronously, like data finishing loading.queryByreturnsnullinstead of throwing — the only one for asserting something is absent. -
Query priority: Prefer
getByRole(role, { name }), which mirrors how assistive tech sees the page — a button, a link, a heading, each by its accessible name. It’s the most user-faithful query, and it quietly checks accessibility as a bonus. -
Form fields: Text inputs expose a
textboxrole and take their name from the associated<label>, sogetByRole('textbox', { name: 'Title' })finds them by that label.
Reach for getByText only when there’s genuinely no meaningful role — the result count, the no-results sentence. A status message isn’t one of those: It carries the status role, so getByRole('status') is the right tool, as the first test showed. Keep getByTestId and CSS-class lookups as last resorts; they test structure, not experience.
Testing Search
Search filters the catalog as the user types, so the test must type. That’s userEvent’s job. Add a second test inside the describe block:
it('narrows the list as you search', async () => {
const user = userEvent.setup()
renderApp()
await screen.findByRole('link', {
name: 'React Basics',
})
await user.type(
screen.getByRole('searchbox', {
name: 'Search courses',
}),
'css',
)
expect(
screen.queryByRole('link', { name: 'React Basics' }),
).not.toBeInTheDocument()
expect(
screen.getByRole('link', { name: 'CSS Layout' }),
).toBeInTheDocument()
expect(
screen.getByText('Showing 1 of 2 courses'),
).toBeInTheDocument()
})
userEvent.setup() creates a virtual user. After the catalog loads, user.type enters “css” into the search box — found by its searchbox role and label. The assertions then check the filter worked from the user’s side: “React Basics” is gone (note queryByRole, which returns null for the absent link so the negative assertion can run), “CSS Layout” remains, and the count reads one of two.
Note: Every
usercall isawaited, and so isfindByRole— but for different reasons. Awaiting ausercall waits for that interaction to finish: Its events fire, React re-renders, and the work those events trigger settles.findBy…waits for something else — it retries its query until the element it wants appears, which is why it fits data that loads on its own schedule.The practical rule:
awaitinteractions, and usefindBy…when you’re waiting for something to appear. Once an interaction’sawaitresolves, whatever it produced is already on screen, so a plaingetBy…reads it — exactly what these tests do right afterawait user.type(...). Forget anawaitand you’ll assert against a screen that hasn’t caught up yet.
Add one more, for the empty result:
it('explains when a search matches nothing', async () => {
const user = userEvent.setup()
renderApp()
await screen.findByRole('link', {
name: 'React Basics',
})
await user.type(
screen.getByRole('searchbox', {
name: 'Search courses',
}),
'zzz',
)
expect(
screen.getByText('No courses match "zzz".'),
).toBeInTheDocument()
})
Typing gibberish leaves nothing to show, and the app says so. This is the kind of edge case that’s tedious to click through by hand but trivial to lock down in a test. Save; in watch mode the suite reruns and all three catalog tests pass.
Testing the Add-Course Form
The form has its own rules: Valid input adds a course, empty input shows errors. Both deserve a test. Add a new describe block after the catalog one:
describe('the add-course form', () => {
it('adds a valid course to the catalog', async () => {
const user = userEvent.setup()
renderApp()
await screen.findByRole('link', {
name: 'React Basics',
})
await user.type(
screen.getByRole('textbox', { name: 'Title' }),
'GraphQL Intro',
)
await user.type(
screen.getByRole('textbox', { name: 'Description' }),
'Query your API precisely.',
)
await user.click(
screen.getByRole('button', { name: 'Add course' }),
)
expect(
screen.getByRole('link', { name: 'GraphQL Intro' }),
).toBeInTheDocument()
expect(
screen.getByText('Showing 3 of 3 courses'),
).toBeInTheDocument()
})
The virtual user fills the title and description by their labels, then clicks Add course. The assertions check the outcome a person would notice: The new course appears as a link, and the count climbs to three of three. Now the unhappy path — leave it as a second test in the same block:
it('reports errors when fields are empty', async () => {
const user = userEvent.setup()
renderApp()
await screen.findByRole('link', {
name: 'React Basics',
})
await user.click(
screen.getByRole('button', { name: 'Add course' }),
)
expect(
screen.getByText('Give the course a title.'),
).toBeInTheDocument()
expect(
screen.getByText('Describe the course in a sentence.'),
).toBeInTheDocument()
expect(
screen.getByText('Showing 2 of 2 courses'),
).toBeInTheDocument()
})
})
Submitting an empty form surfaces both validation messages, and — just as important — the count stays at two of two, proving nothing was added. Testing the guard rail matters as much as testing the happy path. Save; the watch runner reruns and the suite reaches five passing.
Testing Favorites
Favoriting toggles a button’s pressed state, and this is where cardFor earns its keep: The catalog shows many course cards, each with its own Favorite button, so “the Favorite button” alone would be ambiguous — scoping to one card’s title first resolves that. Add a describe block:
describe('favoriting a course', () => {
it('marks a course when you press Favorite', async () => {
const user = userEvent.setup()
renderApp()
await screen.findByRole('link', { name: 'CSS Layout' })
await user.click(
cardFor('CSS Layout').getByRole('button', {
name: 'Favorite',
}),
)
const card = cardFor('CSS Layout')
expect(
card.getByRole('button', {
name: 'Favorite',
pressed: true,
}),
).toBeInTheDocument()
expect(
card.getByText('One of your favorites'),
).toBeInTheDocument()
})
})
The test scopes to the CSS Layout card, clicks its Favorite button, then re-scopes and checks two results: The button now reports pressed: true — Testing Library reads that from aria-pressed, another accessibility win — and the “One of your favorites” note appeared. Because the assertions read the toggle’s accessible state rather than a class name, they’d survive any restyling of the button. Save; the runner picks up the sixth test and it passes.
Testing the Learning Plan
Planning spans two pages: Add a course from the catalog, then watch its status advance on My Learning. One test can walk the whole journey. Add a describe block:
describe('the learning plan', () => {
it('adds a course and advances its status', async () => {
const user = userEvent.setup()
renderApp()
await screen.findByRole('link', { name: 'CSS Layout' })
await user.click(
cardFor('CSS Layout').getByRole('button', {
name: 'Add to My Learning',
}),
)
await user.click(
screen.getByRole('link', { name: 'My Learning' }),
)
expect(screen.getByText('Planned')).toBeInTheDocument()
await user.click(
screen.getByRole('button', { name: 'Start course' }),
)
expect(
screen.getByText('In Progress'),
).toBeInTheDocument()
await user.click(
screen.getByRole('button', {
name: 'Mark completed',
}),
)
expect(
screen.getByText('Completed'),
).toBeInTheDocument()
})
})
The user adds CSS Layout to the plan, clicks the My Learning nav link to change pages — the MemoryRouter handles that navigation exactly as the real router would — and finds the course marked Planned. Then each button walks the status forward: Start course to In Progress, Mark completed to Completed. One test proves an entire real-world flow across a route change and a reducer. Save; seven tests pass now.
Testing Persisted State
The Learning Tracker remembers your choices across reloads by writing them to localStorage. That promise deserves a test — but at the right level. Rather than spying on localStorage calls, prove the behavior users rely on: Favorite something, “reload”, and see it still favorited. Add a describe block:
describe('persistence', () => {
it('keeps favorites after a reload', async () => {
const user = userEvent.setup()
const view = renderApp()
await screen.findByRole('link', { name: 'CSS Layout' })
await user.click(
cardFor('CSS Layout').getByRole('button', {
name: 'Favorite',
}),
)
view.unmount()
renderApp()
await screen.findByRole('link', { name: 'CSS Layout' })
expect(
cardFor('CSS Layout').getByRole('button', {
name: 'Favorite',
pressed: true,
}),
).toBeInTheDocument()
})
})
Favorite CSS Layout, then view.unmount() tears the app down — the stand-in for closing the tab. A fresh renderApp() mounts a brand-new app, which reads its initial favorites from the same in-memory localStorage the first one wrote to. The favorite survived. This tests the boundary — data in, data back out — without mocking the storage internals, so it stays true even if you change how persistence works underneath. Save; eight pass, with one journey left.
Testing Navigation
One journey remains: Clicking a course opens its details page. Add a final describe block:
describe('course navigation', () => {
it('opens a course from its catalog link', async () => {
const user = userEvent.setup()
renderApp()
await user.click(
await screen.findByRole('link', {
name: 'React Basics',
}),
)
expect(
screen.getByRole('heading', {
name: 'React Basics',
level: 2,
}),
).toBeInTheDocument()
expect(
screen.getByRole('link', {
name: 'Back to the catalog',
}),
).toBeInTheDocument()
})
})
Clicking the card’s title link navigates to /courses/react-basics. On the details page the title is a level-2 heading rather than a link, and a “Back to the catalog” link appears — both confirm the route changed and the right page rendered. Save and run the full suite:
Nine tests, each a user journey. Here’s the map of what they now protect:
Coverage isn’t a percentage to chase; it’s a set of behaviors you refuse to let break. Loading, searching, adding, validating, favoriting, planning, persisting, navigating — the Learning Tracker’s promises, each with a test standing guard.
Reading a Failing Test
Passing tests are quiet; failing ones teach. And a test is only useful if you can read its complaint. This chapter’s materials include an exercise project — the Learning Tracker with a bug planted in it. Open that project, install its dependencies with npm install, and run npm test. One test fails:
Read it top to bottom. The header names the failing test: the catalog page > narrows the list as you search. The message is the clue: Unable to find an accessible element with the role "link" and name "CSS Layout".
So after typing “css”, the CSS Layout link vanished — the opposite of what should happen. To help you diagnose, Testing Library then prints every element it could find, and CSS Layout isn’t among them.
When a failure isn’t this clear, one tool helps: screen.debug(). Drop it into a test and it prints the current DOM to the console, so you can see exactly what rendered. Use it sparingly — a flashlight while diagnosing, not a fixture you leave behind.
Here the message is clear enough: Searching “css” filtered CSS Layout out. Why would a matching search hide a course? Time to look inside.
Debugging with React DevTools
The failure tells you what broke; React DevTools shows you why. Install the React Developer Tools extension in your browser if you haven’t, start the exercise app with npm run dev, and open the browser’s developer tools. React adds two tabs — Components and Profiler.
Open Components, then type “css” into the search box in the running app and watch the tree. Select App and read its hooks in the right-hand panel: query holds "css", exactly as typed. So state is correct. Now select CatalogPage and inspect its props:
There’s the contradiction. App’s query state is right, but the visibleCourses prop flowing into CatalogPage is empty — even though “CSS Layout” plainly matches. State is fine; the value derived from it is wrong. That points straight at how visibleCourses is computed. Open src/App.tsx and find the search filter:
visibleCourses = visibleCourses.filter((course) =>
course.description.toLowerCase().includes(trimmedQuery),
)
It filters by description, not title. “css” appears in neither course’s description, so every course is filtered away. Change course.description to course.title:
visibleCourses = visibleCourses.filter((course) =>
course.title.toLowerCase().includes(trimmedQuery),
)
Save and run npm test. All nine pass — the full green run from earlier, restored. That’s the loop: The failing test localized the symptom, DevTools revealed the state-versus-derived-value split, and the fix was one word. You’ll run this loop constantly — the tools just make each lap faster.
Writing Tests That Last
Your tests read like user stories on purpose. As you write more, a few habits keep them a safety net rather than a maintenance burden:
-
Don’t query by CSS class or test id when a role works.
getByRole('button', { name: 'Favorite' })describes the user’s world;container.querySelector('.button.primary')describes markup that changes with every restyle. -
Don’t assert on internal state. Reaching for a component’s
useStatevalue couples the test to today’s implementation. Assert what renders instead — the button that’s now pressed, the message that appeared. - Don’t reach for snapshots reflexively. A giant snapshot of rendered HTML breaks on any cosmetic change and tells you nothing about intent. A focused assertion — “this text is on screen” — says what actually matters.
The through-line: Test what the user experiences, not how the code achieves it. Every test in this chapter would survive renaming a component, swapping a hook, or restyling a button — because none of them look inside.
Challenge: Cover One More Journey
One journey has no test yet: removing a personal course. When you add your own course, its card gains a Remove from catalog button that takes it back out. Write a test that proves it.
A few hints:
- Reuse the add-course steps to create a personal course first — a card needs to exist before you can remove it.
- After clicking Remove from catalog, assert the course’s link is gone with
queryByRoleand that the result count dropped back. - Scope the remove click to the right card with
cardFor, just as the favorites test scopes its Favorite click.
You’ll find a complete solution in the challenge folder of this chapter’s materials.
Key Points
- Component tests render real components and interact as a user would — the productive middle of the testing pyramid, fast enough to run on every save.
- Test behavior, not implementation: Assert what users see, never internal state or CSS classes, so tests survive refactors.
- Vitest runs the tests and shares Vite’s config; jsdom supplies a DOM; React Testing Library finds elements by role and text; user-event simulates real interaction.
- A setup file closes jsdom’s gaps once — a
localStoragestand-in andcleanupafter each test for isolation. - Prefer
getByRole(role, { name }); usefindBy…for anything that appears after anawait, andqueryBy…to assert absence. -
Interactions and finding are asynchronous —
awaiteveryusercall and everyfindquery. - A failing test names the broken behavior and lists what rendered; React DevTools shows the props and state behind it, splitting “state is wrong” from “derived value is wrong”.
Where to Go From Here?
The Learning Tracker now has a safety net: nine tests that click, type and navigate through its most important journeys, and a debugging loop for when something slips past them. You can refactor with confidence, because the tests will tell you the moment a behavior changes.
There’s more testing to explore on your own — coverage reports that reveal untested paths, mocking a failed request to exercise the error state, and the Profiler tab for performance. That last one is a fitting hand-off: The final chapter takes this tested, working app and gets it ready to ship, measuring and fixing a real performance problem before you build for production and deploy.