1. Vitest Setup
Vitest is a test runner built for Vite projects — same configuration, same fast dev-server-style re-runs, and a Jest-compatible API if you've used Jest before.
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom', // simulates a browser DOM in Node
setupFiles: './src/test-setup.ts',
globals: true,
},
});
import '@testing-library/jest-dom'; // adds matchers like .toBeInTheDocument()
import { describe, it, expect } from 'vitest';
function sum(a: number, b: number) {
return a + b;
}
describe('sum', () => {
it('adds two numbers', () => {
expect(sum(2, 3)).toBe(5);
});
});
jsdom is what makes browser-like testing possible in Node at all — it
simulates a DOM well enough that React can render into it and your test can query the
result, without ever opening a real browser.
2. React Testing Library's Philosophy
React Testing Library (RTL) is built around one guiding principle, printed on its own homepage: "the more your tests resemble the way your software is used, the more confidence they can give you." In practice, that means testing what a user sees and does — visible text, buttons, form fields — rather than a component's internal state or implementation details.
// Fragile: breaks if you rename the internal state variable,
// even if the component's actual behavior is unchanged.
expect(wrapper.state('isOpen')).toBe(true);
expect(wrapper.find('.internal-class-name')).toHaveLength(1);
// Resilient: this passes as long as the user-visible outcome is correct,
// regardless of how the component achieves it internally.
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Settings saved')).toBeInTheDocument();
This philosophy is why RTL deliberately has no API for reaching into a component's
props or internal state — it's a constraint, not a missing feature. A test written
this way keeps passing through a component being refactored from
useReducer to useState, or from a class to a function,
because none of that changes what the user actually sees or can click.
3. Queries & User Events
RTL's queries (getByRole, getByLabelText,
getByText...) find elements the way an assistive technology or a real
user would identify them — which is also why writing tests this way tends to surface
accessibility problems (Week 21) for free.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import Counter from './Counter';
describe('Counter', () => {
it('increments when the button is clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
const button = screen.getByRole('button', { name: /increment/i });
await user.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
});
getByRole('button', {'{ name: /increment/i }'}) finds the button the
same way a screen reader would announce it — by its accessible role and name — not
by a CSS class or test-specific attribute. @testing-library/user-event
(not the lower-level fireEvent) simulates a real click as a full sequence
of browser events (pointer down, focus, pointer up, click), catching bugs a
simplified synthetic event would miss.
it('shows a validation error for an invalid email', async () => {
const user = userEvent.setup();
render(<SignupForm />);
await user.type(screen.getByLabelText('Email'), 'not-an-email');
await user.click(screen.getByRole('button', { name: /sign up/i }));
expect(await screen.findByText(/enter a valid email/i)).toBeInTheDocument();
});
findByText (rather than getByText) returns a promise and
retries until the element appears or a timeout elapses — necessary here because
React Hook Form's validation (Week 5) runs asynchronously after submission, so the
error message isn't in the DOM the instant click resolves.
4. Mocking Network Requests with MSW
A component test shouldn't depend on a real backend being available and returning
consistent data. Mock Service Worker (MSW) intercepts actual
fetch calls at the network level and returns data you control — your
component code doesn't know it's being tested at all.
npm install -D msw
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({ id: Number(params.id), name: 'Ada Lovelace' });
}),
];
import '@testing-library/jest-dom';
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it('shows the fetched user name', async () => {
render(<UserProfile userId={5} />);
expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument();
});
This is a meaningfully better approach than mocking your fetchUser
function directly — UserProfile runs its actual, real fetch call, and
only the network layer underneath it is faked. The test verifies your real request
logic and real response handling, not just that a mocked function was called with
the right arguments.
5. Testing Custom Hooks & Coverage
A custom hook (Week 4) is just a function, but it can't be called directly in a test
the way a plain function can — it uses useState/useEffect
internally, which only work inside React's render cycle. Testing
Library's renderHook solves this by mounting the hook inside a
minimal invisible component for you.
import { useState, useCallback } from 'react';
export function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = useCallback(() => setCount((c) => c + 1), []);
const reset = useCallback(() => setCount(initial), [initial]);
return { count, increment, reset };
}
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('increments and resets', () => {
const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
act(() => result.current.increment());
expect(result.current.count).toBe(6);
act(() => result.current.reset());
expect(result.current.count).toBe(5);
});
});
act() wraps any code that triggers a state update outside of an event
RTL already wraps for you (like calling result.current.increment()
directly) — it flushes React's pending updates before the next assertion runs, the
same guarantee userEvent gives you for free in component tests.
npx vitest run --coverage
A coverage percentage tells you which lines executed during your test run —
it says nothing about whether the right assertions ran against them.
100% coverage with weak assertions (expect(result).toBeTruthy()
everywhere) is worse than 70% coverage of your app's actual critical paths — signup,
checkout, anything money or auth touches — tested thoroughly.
A CI coverage threshold (vitest run --coverage --coverage.thresholds.lines=80) is useful for catching an untested file slipping in unnoticed — treat it as a minimum bar, not a goal to chase for its own sake. Week 15 picks this thread back up at a larger scale: integration and end-to-end tests that verify these unit-tested pieces actually work together.
6. Hands-on Exercise
Write a full unit test suite for Week 5's signup form
Cover the form with component tests, then test one custom hook it depends on in isolation — then add the two kinds of tests most suites skip.
Part 1 — Core suite:
- Set up Vitest + React Testing Library +
jest-domin a project containing Week 5's signup form. - Write a test confirming the form renders all expected fields with correct labels (
getByLabelTextfor each). - Write a test confirming a validation error appears when submitting with a mismatched
password/confirmPassword, usinguserEvent.typeandfindByText. - Write a test confirming a successful submission (valid data) shows a success message — mock the "submit" network call with MSW rather than testing against a real endpoint.
- If the form uses a custom hook (e.g. a
useFormStatusor field-validation hook), test it directly withrenderHook, independent of the component that consumes it. - Run
vitest run --coverageand confirm the form and its hook are both meaningfully covered — not just executed, but covered by assertions that would actually fail on a real regression.
If getByLabelText can't find a field, it's almost always because the <label>'s htmlFor doesn't match the input's id — the same accessibility wiring from Week 5's exercise. A failing test here is often catching a real a11y bug, not just a test-configuration problem.
Part 2 — Accessibility regressions, async hooks, and enforced coverage:
- Install
vitest-axe(orjest-axe) and write a test asserting the form has zero automated accessibility violations both on initial render and after triggering a validation error — a11y regressions often appear only once error markup is present, not on a clean form. - Pick an async custom hook from an earlier week (Week 4's
useTodoswith itslocalStoragepersistence is a good candidate) and write arenderHooktest that useswaitForto assert the hook's state settles correctly after an async action, not just immediately after calling it. - Write one deliberately tricky test: use
userEvent.typeto type into thenamefield character-by-character, then assert the field's final rendered value matches exactly what was typed, in order — a real regression class where a brokenonChangehandler (e.g. reading stale state) silently drops or reorders characters. - Add coverage thresholds to
vitest.config.ts(branches,functions,lines,statementsall at 80%), then delete one of your Part 1 tests temporarily and confirmvitest run --coveragenow exits with a non-zero status — proof the threshold is actually enforced, not just displayed.
expect(await axe(container)).toHaveNoViolations() needs the actual rendered DOM container from RTL's render() result, not a re-created element — pass render(...).container straight into axe(), and remember to re-run it after the interaction that produces the error state, not only on the initial render.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does React Testing Library deliberately not provide a way to read a component's internal state directly?
Why does React Testing Library deliberately not provide a way to read a component's internal state directly?
Its guiding principle is testing behavior the way a real user experiences it, not implementation details. Tests that check internal state break whenever that implementation changes — even if the user-visible behavior is identical — while tests written against visible text and roles keep passing through refactors, because they verify the same thing a user would actually notice.
Q2
Why prefer getByRole('button', {'{ name: ... }'}) over something like getByClassName or a test ID?
Why prefer getByRole('button', {'{ name: ... }'}) over something like getByClassName or a test ID?
It finds the element the same way an assistive technology would identify it — by its accessible role and name — which both survives CSS/styling refactors that would break a class-name query, and doubles as an accessibility check: if getByRole can't find your button, a screen reader user likely can't either.
Q3
What does MSW actually intercept, and why is that better than mocking your fetchUser function directly?
What does MSW actually intercept, and why is that better than mocking your fetchUser function directly?
It intercepts the actual network request at the network layer, letting your component's real fetchUser code run exactly as it would in production — only the response is faked. Mocking fetchUser itself skips testing that function's real logic entirely, so a bug inside it (a wrong URL, a wrong header) would go completely undetected.
Q4
Why does testing a custom hook require renderHook instead of just calling the hook function directly in a test?
Why does testing a custom hook require renderHook instead of just calling the hook function directly in a test?
Hooks like useState and useEffect only work inside React's render cycle — calling a custom hook as a plain function outside a component throws, because there's no fiber for React to associate the state with. renderHook mounts the hook inside a minimal invisible test component so it behaves exactly as it would in real usage, while still giving the test direct access to its return value via result.current.