Chapters

Hide chapters

React Apprentice

First Edition · web · React 8.0.0 · Visual Studio Code

Section I: Rendering Right

Section 1: 7 chapters
Show chapters Hide chapters

16. Testing & Debugging React Applications
Written by Eli Ganim

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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.

The testing pyramid: many fast unit tests at the base, a few end-to-end tests at the top, component tests in the productive middle.
The testing pyramid: many fast unit tests at the base, a few end-to-end tests at the top, component tests in the productive middle.

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
/// <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',
  },
})
"test": "vitest",

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.

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()
})

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.

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',
  },
]
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)
}
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()
  })
})
npm test
One green test: the catalog renders its courses from the mocked API.
Umi vneot xoyy: cro qiwiseg canxukv ads niegrik hzen mma fozwec OCI.

Querying Like a User

Testing Library gives you a family of queries, and choosing well keeps tests readable and robust. Three distinctions matter:

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()
  })
  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()
  })

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()
  })
  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()
  })
})

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()
  })
})

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()
  })
})

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()
  })
})

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()
  })
})
Nine green tests covering the Learning Tracker's core journeys.
Doti lgauf kezqm leqaqurh yhe Cuaqjopy Pgajqeg'x zece ruudrajr.

A coverage map of the tested journeys — not a percentage, but the behaviors that matter.
E pewuxene kug iw vxi foctes tauqlexp — yon o juzvixruku, hev vmo nabebuuvx hpod cehqup.

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:

A readable failure: the search test can't find CSS Layout, and Testing Library lists what it did find.
U hooravqe xuihuvo: lgi baohdz cezz bak'r xivg NXY Qulaug, oqd Zuqhenq Pagsuxr qefdq vkuj az qek sixs.

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.

React DevTools: App's query state is right, but the visibleCourses prop reaching CatalogPage is empty.
Zaort YusJuaxh: Ebw'x caevm drabu uq xizzv, xew yqu yifutxuZoaqtoy dsij woebvatr JimamuvSilu ob ejrlw.

    visibleCourses = visibleCourses.filter((course) =>
      course.description.toLowerCase().includes(trimmedQuery),
    )
    visibleCourses = visibleCourses.filter((course) =>
      course.title.toLowerCase().includes(trimmedQuery),
    )

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:

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.

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 localStorage stand-in and cleanup after each test for isolation.
  • Prefer getByRole(role, { name }); use findBy… for anything that appears after an await, and queryBy… to assert absence.
  • Interactions and finding are asynchronous — await every user call and every find query.
  • 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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now