Chapter 17: Testing
You've built TaskFlow. Now make sure it stays working. Testing isn't about proving your code works today โ it's about catching when it breaks tomorrow.
๐ Where we are: TaskFlow is complete โ routing, forms, shadcn UI, theming, optimized performance (Ch 16). It works great... until someone refactors a hook and breaks task creation. This chapter adds the safety net.
๐ง Conceptsโ
1. The Testing Trophyโ
Forget the testing pyramid. In React, Kent C. Dodds' testing trophy is the mental model:
Where to focus:
- Static analysis (TypeScript + ESLint) catches typos and type errors for free
- Component tests โ render a component, interact with it, check the output
- Integration tests โ multiple components working together (forms, context, routing)
- E2E tests โ full browser tests (Playwright/Cypress) โ valuable but expensive
The sweet spot for React: component + integration tests using React Testing Library. They give the best confidence-to-effort ratio.
2. The Testing Philosophyโ
React Testing Library is opinionated and that's a feature:
"The more your tests resemble the way your software is used, the more confidence they can give you." โ Kent C. Dodds
This means:
Test user behavior, not implementation details.
// โ BAD โ testing implementation
expect(component.state.count).toBe(1);
expect(wrapper.instance().handleClick).toHaveBeenCalled();
// โ
GOOD โ testing what the user sees
expect(screen.getByText("Count: 1")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Increment" }));
expect(screen.getByText("Count: 2")).toBeInTheDocument();
Why? If you refactor useState to useReducer, or rename a handler from handleClick to increment, your implementation tests break even though nothing changed for the user. Behavior tests survive refactors.
3. The Testing Stackโ
| Tool | Purpose |
|---|---|
| Vitest | Test runner (like Jest, but Vite-native โ fast!) |
| jsdom / happy-dom | Simulated browser DOM |
| @testing-library/react | Render components, query the DOM |
| @testing-library/user-event | Simulate realistic user interactions |
| @testing-library/jest-dom | Extra DOM matchers (toBeVisible, toHaveTextContent, etc.) |
4. Setupโ
Install everything:
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
Create 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"],
css: true,
},
});
Create src/test/setup.ts:
import "@testing-library/jest-dom/vitest";
This adds matchers like toBeInTheDocument(), toBeVisible(), toHaveTextContent(), etc.
Add to package.json:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
Now npm test runs in watch mode, npm run test:run runs once.
5. Querying Elementsโ
React Testing Library gives you queries that mirror how users find elements:
Priority Order (use the highest priority that works)โ
| Priority | Query | When to use |
|---|---|---|
| 1 | getByRole | Almost always โ buttons, headings, inputs, checkboxes |
| 2 | getByLabelText | Form fields with labels |
| 3 | getByPlaceholderText | Inputs with placeholder |
| 4 | getByText | Non-interactive content (paragraphs, spans) |
| 5 | getByDisplayValue | Current value of input/select |
| 6 | getByAltText | Images |
| 7 | getByTestId | Last resort โ when nothing else works |
// โ
Best โ queries by accessible role
screen.getByRole("button", { name: "Delete Task" });
screen.getByRole("heading", { name: "TaskFlow" });
screen.getByRole("textbox", { name: "Task title" }); // input with label
screen.getByRole("checkbox", { name: "Mark as complete" });
// โ
Good โ queries by label (form fields)
screen.getByLabelText("Email address");
// โ ๏ธ Okay โ when role isn't available
screen.getByText("3 tasks remaining");
// โ Avoid โ fragile, breaks on restructuring
screen.getByTestId("task-card-123");
Why getByRole first? It tests accessibility too. If getByRole("button") can't find your button, a screen reader can't either.
Query Variantsโ
| Variant | 0 matches | 1 match | 2+ matches | Async? |
|---|---|---|---|---|
getBy | โ throws | โ returns | โ throws | No |
queryBy | โ returns null | โ returns | โ throws | No |
findBy | โ throws | โ returns | โ throws | Yes (waits) |
getAllBy | โ throws | โ array | โ array | No |
queryAllBy | โ empty array | โ array | โ array | No |
findAllBy | โ throws | โ array | โ array | Yes |
When to use which:
getByโ element should be there RIGHT NOWqueryByโ checking something is NOT there (expect(queryByText("Error")).not.toBeInTheDocument())findByโ element appears after async operation (data fetch, state update)
6. User Eventsโ
Always prefer userEvent over fireEvent. It simulates real browser behavior:
import userEvent from "@testing-library/user-event";
// Setup โ creates a user instance per test
const user = userEvent.setup();
// Clicking
await user.click(screen.getByRole("button", { name: "Submit" }));
// Typing
await user.type(screen.getByRole("textbox"), "Buy groceries");
// Clearing then typing
await user.clear(screen.getByRole("textbox"));
await user.type(screen.getByRole("textbox"), "New value");
// Keyboard
await user.keyboard("{Enter}");
await user.keyboard("{Shift>}{Tab}{/Shift}"); // Shift+Tab
// Hovering
await user.hover(screen.getByText("Hover me"));
// Selecting dropdown
await user.selectOptions(screen.getByRole("combobox"), "high");
Why userEvent over fireEvent?
fireEvent.click() dispatches a single click event. userEvent.click() fires the full sequence: pointerdown โ mousedown โ pointerup โ mouseup โ click. It catches bugs that only appear with the full event chain.
7. Testing Patternsโ
Pattern 1: Rendering and Assertingโ
import { render, screen } from "@testing-library/react";
import { TaskCard } from "./TaskCard";
describe("TaskCard", () => {
const mockTask = {
id: "1",
title: "Learn React Testing",
completed: false,
createdAt: "2026-01-15T10:00:00Z",
};
it("renders the task title", () => {
render(<TaskCard task={mockTask} onToggle={vi.fn()} onDelete={vi.fn()} />);
expect(screen.getByText("Learn React Testing")).toBeInTheDocument();
});
it("shows a checkbox that reflects completion status", () => {
render(<TaskCard task={mockTask} onToggle={vi.fn()} onDelete={vi.fn()} />);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).not.toBeChecked();
});
it("renders as completed when task.completed is true", () => {
render(
<TaskCard
task={{ ...mockTask, completed: true }}
onToggle={vi.fn()}
onDelete={vi.fn()}
/>
);
expect(screen.getByRole("checkbox")).toBeChecked();
});
});
Pattern 2: User Interactionsโ
describe("TaskCard interactions", () => {
it("calls onToggle when checkbox is clicked", async () => {
const onToggle = vi.fn();
const user = userEvent.setup();
render(<TaskCard task={mockTask} onToggle={onToggle} onDelete={vi.fn()} />);
await user.click(screen.getByRole("checkbox"));
expect(onToggle).toHaveBeenCalledWith("1");
});
it("calls onDelete when delete button is clicked", async () => {
const onDelete = vi.fn();
const user = userEvent.setup();
render(<TaskCard task={mockTask} onToggle={vi.fn()} onDelete={onDelete} />);
await user.click(screen.getByRole("button", { name: /delete/i }));
expect(onDelete).toHaveBeenCalledWith("1");
});
});
Pattern 3: Form Submissionโ
import { TaskForm } from "./TaskForm";
describe("TaskForm", () => {
it("submits the form with the entered task title", async () => {
const onAdd = vi.fn();
const user = userEvent.setup();
render(<TaskForm onAdd={onAdd} />);
const input = screen.getByRole("textbox", { name: /task/i });
await user.type(input, "Buy milk");
await user.click(screen.getByRole("button", { name: /add/i }));
expect(onAdd).toHaveBeenCalledWith("Buy milk");
});
it("clears the input after submission", async () => {
const user = userEvent.setup();
render(<TaskForm onAdd={vi.fn()} />);
const input = screen.getByRole("textbox", { name: /task/i });
await user.type(input, "Buy milk");
await user.click(screen.getByRole("button", { name: /add/i }));
expect(input).toHaveValue("");
});
it("does not submit an empty task", async () => {
const onAdd = vi.fn();
const user = userEvent.setup();
render(<TaskForm onAdd={onAdd} />);
await user.click(screen.getByRole("button", { name: /add/i }));
expect(onAdd).not.toHaveBeenCalled();
});
});
Pattern 4: Async Operationsโ
describe("TaskList with API", () => {
it("shows loading state then tasks", async () => {
render(<TaskList />);
// Initially shows loading
expect(screen.getByText("Loading...")).toBeInTheDocument();
// Wait for tasks to appear
const taskItems = await screen.findAllByRole("listitem");
expect(taskItems).toHaveLength(3);
// Loading should be gone
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});
});
Pattern 5: Testing with Context Providersโ
When components need context, create a wrapper:
import { ThemeContext } from "./ThemeContext";
import { TaskProvider } from "./TaskContext";
function renderWithProviders(
ui: React.ReactElement,
{ theme = "light", ...options } = {}
) {
const Wrapper = ({ children }: { children: React.ReactNode }) => {
return (
<ThemeContext value={theme}>
<TaskProvider>{children}</TaskProvider>
</ThemeContext>
);
}
return render(ui, { wrapper: Wrapper, ...options });
}
// Usage
it("renders in dark mode", () => {
renderWithProviders(<Header />, { theme: "dark" });
expect(screen.getByRole("banner")).toHaveClass("dark");
});
๐ React 19: Notice
<ThemeContext value={theme}>โ we use the new Context-as-provider syntax in our test wrapper too.
Pattern 6: Testing Custom Hooksโ
Use renderHook for testing hooks in isolation:
import { renderHook, act } from "@testing-library/react";
import { useToggle } from "./useToggle";
describe("useToggle", () => {
it("starts with the initial value", () => {
const { result } = renderHook(() => useToggle(false));
expect(result.current.value).toBe(false);
});
it("toggles the value", () => {
const { result } = renderHook(() => useToggle(false));
act(() => {
result.current.toggle();
});
expect(result.current.value).toBe(true);
act(() => {
result.current.toggle();
});
expect(result.current.value).toBe(false);
});
});
Testing useLocalStorage:
import { renderHook, act } from "@testing-library/react";
import { useLocalStorage } from "./useLocalStorage";
describe("useLocalStorage", () => {
beforeEach(() => {
localStorage.clear();
});
it("returns the initial value when nothing is stored", () => {
const { result } = renderHook(() => useLocalStorage("key", "default"));
expect(result.current[0]).toBe("default");
});
it("persists value to localStorage", () => {
const { result } = renderHook(() => useLocalStorage("key", "default"));
act(() => {
result.current[1]("new value");
});
expect(result.current[0]).toBe("new value");
expect(JSON.parse(localStorage.getItem("key")!)).toBe("new value");
});
it("reads existing value from localStorage", () => {
localStorage.setItem("key", JSON.stringify("stored value"));
const { result } = renderHook(() => useLocalStorage("key", "default"));
expect(result.current[0]).toBe("stored value");
});
it("handles JSON parse errors gracefully", () => {
localStorage.setItem("key", "not-valid-json");
const { result } = renderHook(() => useLocalStorage("key", "fallback"));
expect(result.current[0]).toBe("fallback");
});
});
8. What NOT to Testโ
Just as important as knowing what to test:
| Don't test | Why |
|---|---|
| Implementation details (state values, hook internals) | Breaks on refactor, doesn't test user experience |
| Third-party libraries (React Router, shadcn, etc.) | They have their own tests |
| Pure CSS/styling | Use visual regression testing if you need this |
console.log output | Not user-facing behavior |
| Every possible prop combination | Focus on meaningful scenarios, not exhaustive combos |
The golden rule: Would a user notice if this test fails? If no, you probably don't need the test.
9. Mockingโ
Sometimes you need to mock dependencies:
Mocking API callsโ
// Mock fetch globally
beforeEach(() => {
global.fetch = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("fetches and displays tasks", async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([
{ id: "1", title: "Task 1", completed: false },
]),
});
render(<TaskList />);
expect(await screen.findByText("Task 1")).toBeInTheDocument();
expect(fetch).toHaveBeenCalledWith("/api/tasks");
});
Mocking modulesโ
// Mock an entire module
vi.mock("./api", () => ({
fetchTasks: vi.fn(() =>
Promise.resolve([{ id: "1", title: "Mocked Task", completed: false }])
),
}));
When to mock vs notโ
| Mock when | Don't mock when |
|---|---|
| Network requests (APIs) | Child components โ render the real ones |
| Browser APIs not in jsdom (IntersectionObserver) | State management (Context, hooks) |
Timers (use vi.useFakeTimers()) | Your own utility functions |
| Heavy external services | Anything you can reasonably render |
Prefer integration over mocks. The more you mock, the less you're testing.
10. Test Organizationโ
src/
components/
TaskCard/
TaskCard.tsx
TaskCard.test.tsx โ co-located!
TaskForm/
TaskForm.tsx
TaskForm.test.tsx
hooks/
useLocalStorage.ts
useLocalStorage.test.ts โ co-located!
useTasks.ts
useTasks.test.ts
test/
setup.ts โ global test setup
helpers.ts โ renderWithProviders, etc.
Co-locate tests with source. The test file lives right next to the code it tests. No separate __tests__ directory needed.
๐จ Project Task: Test TaskFlowโ
Write tests for the core features of TaskFlow.
Step 1: Test TaskCardโ
Create src/components/TaskCard/TaskCard.test.tsx:
- Renders the task title
- Shows unchecked checkbox for incomplete tasks
- Shows checked checkbox for completed tasks
- Calls
onTogglewith task ID when checkbox clicked - Calls
onDeletewith task ID when delete button clicked - Applies a visual distinction for completed tasks (strikethrough, opacity, etc.)
Step 2: Test TaskFormโ
Create src/components/TaskForm/TaskForm.test.tsx:
- Submits with the entered text
- Clears input after successful submission
- Does NOT submit when input is empty
- Handles Enter key submission
- Trims whitespace before submitting
Step 3: Test useLocalStorageโ
Create src/hooks/useLocalStorage.test.ts:
- Returns initial value when key doesn't exist
- Reads existing value from localStorage
- Updates value and syncs to localStorage
- Handles invalid JSON gracefully
- Works with objects and arrays
Step 4: Test useFilteredTasks (Integration)โ
Create src/hooks/useFilteredTasks.test.ts:
- Returns all tasks when filter is "all"
- Returns only incomplete tasks when filter is "active"
- Returns only completed tasks when filter is "completed"
- Adding a task appears in the correct filter
- Toggling a task moves it between filters
- Returns correct counts
Step 5: Test a User Flow (Integration)โ
Create src/test/task-flow.test.tsx:
describe("TaskFlow: complete user flow", () => {
it("creates a task, completes it, and filters", async () => {
const user = userEvent.setup();
render(<App />);
// Add a task
await user.type(screen.getByRole("textbox"), "Write tests");
await user.click(screen.getByRole("button", { name: /add/i }));
// Task appears
expect(screen.getByText("Write tests")).toBeInTheDocument();
// Complete the task
await user.click(screen.getByRole("checkbox"));
// Filter to active โ task should disappear
await user.click(screen.getByRole("button", { name: /active/i }));
expect(screen.queryByText("Write tests")).not.toBeInTheDocument();
// Filter to completed โ task should appear
await user.click(screen.getByRole("button", { name: /completed/i }));
expect(screen.getByText("Write tests")).toBeInTheDocument();
});
});
Acceptance Criteriaโ
-
npm testruns without errors - At least 10 meaningful test cases
- Tests cover: rendering, user interactions, hooks, and one integration flow
- No tests for implementation details (no checking state directly)
- All tests use
userEventfor interactions (notfireEvent) - Tests use accessible queries (
getByRole,getByLabelText) as primary selectors
๐งช Challenge: Add Coverage Reportingโ
-
Install
@vitest/coverage-v8:npm install -D @vitest/coverage-v8 -
Run
npm run test:coverage -
Aim for:
- 80%+ coverage on your hooks
- 70%+ coverage on core components (TaskCard, TaskForm)
- Don't chase 100% โ focus on meaningful coverage
-
Look at the uncovered lines. Ask yourself: "Would a user care if this line broke?" If yes, write a test. If no, leave it.
๐ Further Readingโ
- Testing Library docs โ official docs and API reference
- Common mistakes with React Testing Library โ must-read
- Which query should I use? โ the priority guide
- Vitest docs โ test runner docs
- React docs: Testing โ official React testing guidance
๐ Congratulations! You've built a complete React application from scratch โ from mental models to tested, polished, production-grade UI with shadcn, Tailwind, and React 19.
The best way to solidify all this? Build something new. Take what you've learned and create your own project. The concepts transfer; the muscle memory comes from repetition.
Happy building! ๐