Skip to main content

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โ€‹

ToolPurpose
VitestTest runner (like Jest, but Vite-native โ€” fast!)
jsdom / happy-domSimulated browser DOM
@testing-library/reactRender components, query the DOM
@testing-library/user-eventSimulate realistic user interactions
@testing-library/jest-domExtra 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)โ€‹

PriorityQueryWhen to use
1getByRoleAlmost always โ€” buttons, headings, inputs, checkboxes
2getByLabelTextForm fields with labels
3getByPlaceholderTextInputs with placeholder
4getByTextNon-interactive content (paragraphs, spans)
5getByDisplayValueCurrent value of input/select
6getByAltTextImages
7getByTestIdLast 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โ€‹

Variant0 matches1 match2+ matchesAsync?
getByโŒ throwsโœ… returnsโŒ throwsNo
queryByโœ… returns nullโœ… returnsโŒ throwsNo
findByโŒ throwsโœ… returnsโŒ throwsYes (waits)
getAllByโŒ throwsโœ… arrayโœ… arrayNo
queryAllByโœ… empty arrayโœ… arrayโœ… arrayNo
findAllByโŒ throwsโœ… arrayโœ… arrayYes

When to use which:

  • getBy โ€” element should be there RIGHT NOW
  • queryBy โ€” 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 testWhy
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/stylingUse visual regression testing if you need this
console.log outputNot user-facing behavior
Every possible prop combinationFocus 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 whenDon'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 servicesAnything 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 onToggle with task ID when checkbox clicked
  • Calls onDelete with 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 test runs 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 userEvent for interactions (not fireEvent)
  • Tests use accessible queries (getByRole, getByLabelText) as primary selectors

๐Ÿงช Challenge: Add Coverage Reportingโ€‹

  1. Install @vitest/coverage-v8:

    npm install -D @vitest/coverage-v8
  2. Run npm run test:coverage

  3. Aim for:

    • 80%+ coverage on your hooks
    • 70%+ coverage on core components (TaskCard, TaskForm)
    • Don't chase 100% โ€” focus on meaningful coverage
  4. 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โ€‹


๐ŸŽ‰ 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! ๐Ÿš€