Testing
Set up Vitest, jsdom and React Testing Library, then write tests for the Reading List's reducer and components the way a user would use them.
So far you've checked your work by clicking around in the browser. That's fine for one change, but tomorrow you'll move the whole app to Next.js. How will you know you haven't broken adding, filtering or deleting? Clicking through everything after every change takes ages, and you'll forget something.
An automated test is a small program that uses your code and checks the result. You write it once and run it hundreds of times in a few seconds. This lesson uses three tools that work together:
- Vitest is the test runner. It finds your test files, runs them and reports what passed. It uses your Vite config, so TypeScript and JSX work without extra setup.
- jsdom is a pretend browser that runs inside Node.js, so your components have a
documentto render into. - React Testing Library renders components and finds elements on the "page" the way a person would: by their role, label or text.
These tests run on your computer with real tooling, so this lesson uses normal code blocks instead of live sandboxes. Type them into your Reading List project as you go.
Setting up
Install the packages as dev dependencies:
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event@testing-library/jest-dom adds readable checks like toBeInTheDocument() and toBeDisabled(). @testing-library/user-event simulates real typing and clicking.
Tell Vitest to use jsdom. Add a test section to your existing vite.config.ts. Keep the plugins you already have, including the compiler from the previous lesson. The first line gives TypeScript the types for the test section.
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
});Create the setup file. Vitest runs it before every test file. It loads the extra checks and clears the page after each test, so one test can't affect the next.
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
afterEach(() => {
cleanup();
});Add a script to package.json:
{
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest"
}
}Run npm test. Vitest reports that it found no test files yet. That's expected, and it means the setup works.
Testing a reducer
Start with the easiest thing to test: a pure function. booksReducer takes the current books and an action, and returns new books. No rendering, no clicking, no waiting. You give it an input and check the output.
Vitest finds files ending in .test.ts or .test.tsx. Put each test file next to the file it tests:
import { describe, expect, it } from 'vitest';
import { booksReducer, nextStatus } from './booksReducer';
import type { Book } from './types';
const hobbit: Book = { id: '1', title: 'The Hobbit', author: 'J.R.R. Tolkien', status: 'done' };
const dune: Book = { id: '2', title: 'Dune', author: 'Frank Herbert', status: 'reading' };
describe('booksReducer', () => {
it('adds a book to the end of the list', () => {
const circe: Book = { id: '3', title: 'Circe', author: 'Madeline Miller', status: 'want' };
const result = booksReducer([hobbit, dune], { type: 'added', book: circe });
expect(result).toEqual([hobbit, dune, circe]);
});
it('changes the status of one book only', () => {
const result = booksReducer([hobbit, dune], {
type: 'statusChanged',
id: '2',
status: 'done',
});
expect(result[1].status).toBe('done');
expect(result[0]).toBe(hobbit);
});
it('does not mutate the original array', () => {
const books = [hobbit, dune];
booksReducer(books, { type: 'added', book: hobbit });
expect(books).toHaveLength(2);
});
});
describe('nextStatus', () => {
it('cycles want, reading, done, then back to want', () => {
expect(nextStatus('want')).toBe('reading');
expect(nextStatus('reading')).toBe('done');
expect(nextStatus('done')).toBe('want');
});
});Each test follows the same three steps, often called Arrange, Act, Assert: set up the data, run the code, and check the result with expect.
describegroups related tests under a name.it(or its twin,test) is one test. Its name should read like a sentence about the behavior.toEqualcompares contents.toBechecks that two things are the very same value or object.expect(result[0]).toBe(hobbit)proves the reducer left the unchanged book alone instead of copying it.
Save the file. If npm test is still running, it re-runs the tests by itself:
Test Files 1 passed (1)
Tests 4 passed (4)Now try breaking the reducer on purpose, for example by making added put the book at the start of the list. The test fails and tells you exactly what was different. That's the safety net you'll rely on tomorrow.
Your reducer's file names and action shapes may differ a little from the ones here, depending on how you wrote Day 4 and Day 5. Adjust the imports and actions in the tests to match your code.
Test the remaining actions
Add two tests to booksReducer.test.ts: one checking that deleted removes the right book, and one checking that rated sets the rating on the right book.
Show solution
it('removes a book by id', () => {
const result = booksReducer([hobbit, dune], { type: 'deleted', id: '1' });
expect(result).toEqual([dune]);
});
it('rates one book', () => {
const result = booksReducer([hobbit, dune], { type: 'rated', id: '2', rating: 4 });
expect(result[1].rating).toBe(4);
expect(result[0]).toBe(hobbit);
});Put both inside the describe('booksReducer', ...) block. The second check in rates one book is worth keeping: it catches the common bug where every book gets the rating.
Testing a component
Component tests follow the same three steps, but "act" means interacting like a user. Testing Library's guiding principle is: the more your tests resemble the way your software is used, the more confidence they can give you. So you don't check a component's state or call its functions. You find a button the way a user would, click it, and check what appears on the screen.
BookCard reads dispatch from BooksContext, so the test renders it inside BooksProvider. A small helper keeps that out of each test:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { BookCard } from './BookCard';
import { BooksProvider } from './BooksContext';
import type { ListBook } from './types';
const dune: ListBook = { id: '2', title: 'Dune', author: 'Frank Herbert', status: 'reading' };
function renderCard(book: ListBook, onOpen = vi.fn()) {
render(
<BooksProvider>
<ul>
<BookCard book={book} onOpen={onOpen} />
</ul>
</BooksProvider>
);
return { onOpen };
}
describe('BookCard', () => {
it('shows the title, author and status', () => {
renderCard(dune);
expect(screen.getByRole('heading', { name: 'Dune' })).toBeInTheDocument();
expect(screen.getByText('Frank Herbert')).toBeInTheDocument();
expect(screen.getByText('Reading')).toBeInTheDocument();
});
it('opens the details when you click Details', async () => {
const user = userEvent.setup();
const { onOpen } = renderCard(dune);
await user.click(screen.getByRole('button', { name: 'Details' }));
expect(onOpen).toHaveBeenCalledTimes(1);
});
it('disables its buttons while the book is still saving', () => {
renderCard({ ...dune, pending: true });
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Next status' })).toBeDisabled();
});
});New pieces in this file:
renderputs the component into jsdom's page.screen.getByRole('button', { name: 'Details' })finds the button whose accessible name is "Details". If there's no such element, the test fails with a helpful printout of the page.userEvent.setup()creates a simulated user. Its actions are async, so youawaitthem.vi.fn()creates a mock function: a fake that records how it was called, so you can check thatonOpenran.
If your BookCard uses React Router's <Link> instead of an onOpen prop, it needs a router around it in tests too. Wrap it in <MemoryRouter> from react-router, inside the BooksProvider.
Which query is the best first choice for finding the Delete button in a test?
Testing async UI
The add-book form uses an Action, and Actions are async. After you click Add book, the error message doesn't appear in the same instant. For that, Testing Library has findBy queries. They return a Promise that resolves as soon as the element appears, and fail if it doesn't appear within about a second.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, it, vi } from 'vitest';
import { AddBookForm } from './AddBookForm';
import { BooksProvider } from './BooksContext';
it('shows an error and keeps the title when the author is missing', async () => {
const user = userEvent.setup();
render(
<BooksProvider>
<AddBookForm onOptimisticAdd={vi.fn()} />
</BooksProvider>
);
await user.type(screen.getByLabelText('Title'), 'Dune');
await user.click(screen.getByRole('button', { name: 'Add book' }));
expect(await screen.findByRole('alert')).toHaveTextContent(
'Please enter both a title and an author.'
);
expect(screen.getByLabelText('Title')).toHaveValue('Dune');
});The second check proves the defaultValue trick from the Actions lesson works: the form reset doesn't wipe what you typed.
| Query | When nothing matches | Use it for |
|---|---|---|
getBy... | Throws straight away | Things that should already be on screen |
queryBy... | Returns null | Checking that something is not there |
findBy... | Waits, then throws | Things that appear after something async |
Deep diveFaking the API in tests
The success path calls saveBook, which waits 1.2 seconds. That's longer than findBy waits by default, and you don't want slow tests anyway. vi.mock replaces a whole module with a fake for this test file:
import { vi } from 'vitest';
vi.mock('./api', () => ({
saveBook: vi.fn(async (book) => book),
}));Vitest moves vi.mock calls to the top of the file, so AddBookForm gets the fake saveBook, which resolves at once. Now a test can type a title and an author, submit, and check that onOptimisticAdd was called with a book with the right title. In tomorrow's Next.js app you'll mock the Server Function in the same way.
After clicking Submit, a success message appears once an async Action finishes. Which line checks for it correctly?
Day 6 progress
Performance and the Compiler
Find out why components re-render, see what memo, useMemo and useCallback do, let the React Compiler memoize for you, and keep Effects small with useEffectEvent.
Day 7 overview
Take your Reading List to production with Next.js, Server Components, Server Functions, an accessibility pass, and a real deploy.