Contents
29 Chapter

Component testing — Vitest + Testing Library

Testing components and hooks with Vitest + React Testing Library. render · userEvent · mocking patterns, things to watch in a Next.js setting, and CI integration.

Part 4 wraps up at Chapter 28. This chapter opens Part 5 (Operations · Testing · Deploy). The five chapters of Part 5 are the bridge from “I can build React” to “I work with React.”

This chapter is the first board on that bridge: testing. The components built across Parts 1 ~ 4 of this book rendered on screen, and we verified their behavior by hand. Once a project grows, though, checking every behavior by hand every time is no longer possible. Automated tests become the safety net at that point. This chapter covers automated tests at the component and hook level; the next chapter (30) covers E2E tests at the user-flow level.

Why Vitest #

Jest was the standard in the React world for a long time. The following shifts brought Vitest up as the new standard:

  • Aligned with Vite: it reuses the configuration of a Vite project (the environment built in Chapter 2) as is. There is no separate babel config or transformer.
  • ESM native: as modern libraries trend toward ESM-only, Jest is tricky to configure. Vitest handles ESM by default.
  • Fast start and watch: it uses Vite’s HMR directly, so tests rerun almost instantly.
  • Jest-compatible API: the describe / it / expect signatures are nearly identical, so Jest material applies as is.

Vitest also works for Next.js projects. Unless you have a large codebase already on Jest, starting new projects with Vitest is the standard.

The first principle of testing — verify behavior, not implementation #

Before installing Vitest, there is one thing to settle first: what to test.

A common trap is verifying the internal state or props flow of a component as is.

🚫 testing the implementation — fragile
test('the counter raises state', () => {
  const wrapper = mount(<Counter />);
  expect(wrapper.state('count')).toBe(0);
  wrapper.instance().increment();
  expect(wrapper.state('count')).toBe(1);
});

A test like this breaks the moment the implementation changes a little (state variable name, internal function name). The user does not care about a state variable. What the user sees is the screen and the interaction.

The starting point of a good component test:

verify what the user sees on the screen and what change the user sees in response to their action.

This is the philosophy React Testing Library enforces from the start, and every component test in this book takes the same view as its starting point.

Vitest setup #

Add the testing tools to the Vite + React + TypeScript project built in Chapter 2.

install packages
pnpm add -D vitest @vitest/ui jsdom \
  @testing-library/react @testing-library/jest-dom @testing-library/user-event

The role of each package:

  • vitest: the test runner itself
  • @vitest/ui: a browser-based test UI (optional)
  • jsdom: simulates a browser DOM inside Node
  • @testing-library/react: component rendering and queries
  • @testing-library/jest-dom: DOM matchers like toBeInTheDocument
  • @testing-library/user-event: simulates real user interactions like keyboard and click

Create vitest.config.ts.

vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test-setup.ts'],
  },
});

src/test-setup.ts.

src/test-setup.ts
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';

afterEach(() => {
  cleanup();
});

Adding vitest/globals to types in tsconfig.json lets you use describe / it / expect without imports.

tsconfig.json (excerpt)
{
  "compilerOptions": {
    "types": ["vitest/globals", "@testing-library/jest-dom"]
  }
}

Scripts in package.json.

package.json (excerpt)
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:ui": "vitest --ui"
  }
}

pnpm test enters watch mode; pnpm test:run runs once and exits, the mode used in CI.

First component test #

Bring back the Counter component from Chapter 5.

src/Counter.tsx
import { useState } from 'react';

type Props = {
  initial?: number;
};

export default function Counter({ initial = 0 }: Props) {
  const [count, setCount] = useState(initial);
  return (
    <div>
      <p>Current value: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      <button onClick={() => setCount(initial)}>Reset</button>
    </div>
  );
}

The test.

src/Counter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';

describe('Counter', () => {
  it('displays the initial value', () => {
    render(<Counter initial={5} />);
    expect(screen.getByText('Current value: 5')).toBeInTheDocument();
  });

  it('increments by 1 when the +1 button is clicked', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByRole('button', { name: '+1' }));
    await user.click(screen.getByRole('button', { name: '+1' }));

    expect(screen.getByText('Current value: 2')).toBeInTheDocument();
  });

  it('returns to initial when the reset button is clicked', async () => {
    const user = userEvent.setup();
    render(<Counter initial={10} />);

    await user.click(screen.getByRole('button', { name: '+1' }));
    await user.click(screen.getByRole('button', { name: 'Reset' }));

    expect(screen.getByText('Current value: 10')).toBeInTheDocument();
  });
});

What this test verifies:

  • The text shown on screen (screen.getByText)
  • The user’s click (user.click)
  • The change on screen after the click

Nothing about how count state is managed, whether it uses useState or useReducer, or what the internal function names are — none of that is checked. It verifies from the user’s point of view.

queryBy / findBy / getBy — the three apart #

Testing Library’s query functions have three prefixes, each used differently.

prefixIf not foundAsync waitMain use
getBythrowsnoan element that must be present right now
queryByreturns nullnoasserting “not present”
findBythrowsyesan element about to appear asynchronously

getBy asserts an element that must already be on screen. When it is missing, it throws and prints the rendered DOM helpfully.

queryBy is used to verify that an element that “should not exist” is absent.

queryBy example
expect(screen.queryByText('Error')).not.toBeInTheDocument();

findBy waits for an element that appears after asynchronous work. Internally, Testing Library’s waitFor is at work.

findBy example
await user.click(screen.getByRole('button', { name: 'Log in' }));
expect(await screen.findByText('Welcome')).toBeInTheDocument();

Order matters:

  1. Try getBy* first — synchronous, fast, and the message is clear.
  2. For elements that appear asynchronously, findBy*.
  3. queryBy* only when asserting absence.

Interaction tests — using userEvent #

userEvent simulates keyboard input and clicks like a real user. It is closer to reality than the plain fireEvent (for example, type enters one character at a time and fires every keystroke event).

form input test
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

it('handles user input and submission in the login form', async () => {
  const user = userEvent.setup();
  const handleSubmit = vi.fn();

  render(<LoginForm onSubmit={handleSubmit} />);

  await user.type(screen.getByLabelText('Email'), 'me@example.com');
  await user.type(screen.getByLabelText('Password'), 'secret123');
  await user.click(screen.getByRole('button', { name: 'Log in' }));

  expect(handleSubmit).toHaveBeenCalledWith({
    email: 'me@example.com',
    password: 'secret123',
  });
});

getByLabelText finds the input that is connected through <label>. It pairs naturally with accessibility markup; the label the user sees becomes the selector directly.

getByRole queries by ARIA role. 'button' / 'textbox' / 'heading' are common. Preferring accessibility-friendly selectors has the side effect of your tests checking a11y naturally.

Mocking — vi.mock #

Use vi.mock when mocking an external module (for example, a fetch wrapper or an API client).

module mocking example
import { render, screen } from '@testing-library/react';
import { vi } from 'vitest';
import PostList from './PostList';

vi.mock('./api', () => ({
  fetchPosts: vi.fn().mockResolvedValue([
    { id: '1', title: 'First post' },
    { id: '2', title: 'Second post' },
  ]),
}));

it('renders the post list', async () => {
  render(<PostList />);
  expect(await screen.findByText('First post')).toBeInTheDocument();
  expect(await screen.findByText('Second post')).toBeInTheDocument();
});

The first argument to vi.mock is the module path; the second is a factory function that returns the mock shape.

When MSW is the right fit #

If many tests mock the same API, or you want to intercept the network layer itself, MSW (Mock Service Worker) is the better choice.

MSW example (concept)
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
  http.get('/api/posts', () =>
    HttpResponse.json([{ id: '1', title: 'First post' }])
  ),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

MSW intercepts fetch / XHR and responds at the network level. The same handlers can be reused in Chapter 30 (Playwright), so the same mock can be shared across testing levels, which is a significant advantage.

Hook tests — renderHook #

Use renderHook when you want to verify a hook on its own. Custom hooks like the useToggle from Chapter 13 are the target.

hook test
import { renderHook, act } from '@testing-library/react';
import useToggle from './useToggle';

it('toggles a boolean with useToggle', () => {
  const { result } = renderHook(() => useToggle(false));

  expect(result.current[0]).toBe(false);

  act(() => result.current[1]());

  expect(result.current[0]).toBe(true);
});

act wraps state updates inside React’s render cycle. Without it, React warns.

Unit-testing a hook vs integration-testing through a component #

If the hook is not complex and is only used inside a single component anyway, a behavior test on that component verifies the hook as well. There is no need to reach for renderHook again.

When renderHook is especially useful:

  • A hook used across multiple components, library-like code.
  • A hook with many branches inside, where wiring up each case through a component is tedious.
  • When you are separating responsibilities between component and hook as you build.

Most application-level hooks are fine with integration tests.

Next.js component tests — things to watch #

There are limits to be aware of when using Vitest in a Next.js project.

Testing Server Components directly #

A Server Component runs once on the server and emits the result as HTML. Rendering it directly inside Vitest’s jsdom environment works only partially. In particular, async function components or code that uses RSC-only APIs (headers, cookies, etc.) is hard to cover with unit tests.

Principle: it is more natural to delegate Server Component behavior verification to E2E tests in Chapter 30 (Playwright). Keep component-level tests focused on Client Components and pure functions.

Client Components work fine #

Components marked 'use client' are treated as ordinary React components and work well in Vitest. Code that imports a Server Action is typically intercepted with a mock.

Server Action mocking
vi.mock('./actions', () => ({
  postMessage: vi.fn().mockResolvedValue({ success: true }),
}));

Mocking next/navigation in App Router #

Components that use hooks like useRouter or useSearchParams need mocking.

next/navigation mocking
vi.mock('next/navigation', () => ({
  useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
  useSearchParams: () => new URLSearchParams(),
}));

CI integration — GitHub Actions #

A test suite that only runs locally is half-effective. Running on every PR in CI is what makes it a real safety net.

.github/workflows/test.yml:

.github/workflows/test.yml
name: test

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm test:run
      - run: pnpm test:run -- --coverage

pnpm test:run is the once-and-done CI mode (not watch). Measuring coverage on every run can be costly, so splitting it into a separate job is a common pattern.

Measuring coverage #

vitest.config.ts (coverage added)
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      exclude: ['**/*.test.{ts,tsx}', 'src/test-setup.ts'],
    },
  },
});

The v8 provider uses Node’s built-in coverage without an extra package. It is fast and low-config.

Do not enforce a target coverage number. Writing code that is easy to test and writing meaningful tests come first; the number is only a result indicator.

How testing levels divide the work #

As the first chapter of Part 5, let us draw the big picture once. This is the split with Chapter 30 (Playwright).

LevelToolWhat it verifiesSpeedWhere it shines
UnitVitestPure functions, hooks, small componentsvery fastalgorithms, branches, edge cases
IntegrationVitest + jsdomSeveral components cooperatingfastform flows, lifting state up, Context
E2EPlaywright (Chapter 30)Whole user scenariosslowfull flows like sign-up → login → add Todo → complete

Not every level is required. A small project is fine with Vitest units plus one or two E2E for the core flow. A large project keeps the three levels in balance.

Keep the pyramid shape in mind. Most unit tests, fewest E2E tests. Flip it upside down and CI gets slow, PRs pile up, and eventually tests get turned off.

Try it yourself — adding tests to the Chapter 27 guestbook #

Let us add tests to the MessageForm from the Chapter 27 guestbook.

  1. Server Action mocking: mock postMessage with vi.mock('./actions', ...). Prepare two cases: a success case ({ success: true }) and a validation failure case ({ error: 'Please enter your name' }).
  2. Empty-input validation: verify that when the submit button is clicked with name / message empty, the error message appears on screen. Use screen.findByText to wait for the asynchronous appearance.
  3. Success case: verify that after filling name and message and submitting, the form resets. Check all the way to the value of screen.getByLabelText('Name') returning to an empty string.
  4. SubmitButton pending state: verify how the disabled state of the SubmitButton (separated as its own component) changes during submission. useFormStatus may need a polyfill or mock.

After writing these scenarios, the core pattern of React Testing Library — render + userEvent + findBy + Server Action mock — settles into your hands.

Exercises #

  1. Picking getBy / findBy / queryBy. Answer which prefix is appropriate in each of these three situations, and why. (a) Right after page load, confirm that a welcome message exists. (b) After clicking the login button, confirm that the welcome message appears. (c) Confirm that no error message is present. Compare your answers against the table in the chapter.
  2. Identifying implementation vs behavior tests. Suppose the Chapter 5 (useState) example has the following two tests: (a) “when the button is pressed, the count state goes up by 1”, (b) “when the button is pressed, the on-screen text ‘Current value’ increases by 1”. Explain which is more stable and which still passes if the component is refactored to useReducer.
  3. Applying the testing pyramid. Design where to place which tests for the Part 6 / Chapter 34 fullstack Todo app. (a) The Todo sort function, (b) the validation flow of MessageForm, (c) the full scenario sign-up → login → add Todo. Classify each as unit / integration / E2E and write one line of reasoning each.

In one line: Vitest + React Testing Library is the standard pairing for testing that “verifies behavior, not implementation.” Learn the uses of getBy / findBy / queryBy, drive the UI like a user with userEvent, and cut external dependencies with vi.mock. Next.js Server Components have limits at the unit-test level, so delegating them to E2E in Chapter 30 (Playwright) reads naturally. Keep the pyramid of unit → integration → E2E in mind, and prefer meaningful tests to coverage numbers.

Next chapter #

The next chapter, Chapter 30 E2E Testing — Playwright, covers the user-scenario-level automated tests this chapter does not. Where Vitest verified components inside jsdom, Playwright launches a real browser and automates full flows like sign-up → login → add Todo. We will also connect naturally with this chapter’s mock patterns by showing how tools like MSW can be shared between the two levels.

X