Curriculum1 of 12
+ Module 10

Testing React UI

Write the test before the component, then refactor without fear of breaking it.

Phase 1 · JavaScript and React · 12 sections · about 9 minutes

+ 01 / 12

Module Intro

A test is insurance. The moment you write it, you stop guessing about whether your code works. The moment you run it again six months later, you know whether your refactor broke something, before your user does.

This module flips the script. Instead of writing code and then bolting tests onto it, you'll write the test first. You'll watch it fail. Then you'll write just enough code to make it pass. Finally, you'll refactor with confidence, knowing the tests have your back.

The tool is Vitest + React Testing Library. They're built for this: Vitest is fast, React Testing Library forces you to test what users see, not component guts.

By the end, you'll have a search component with bulletproof tests that cover all four states (idle, loading, empty, error), and tests that read like documentation.


+ 02 / 12

Concept 1: The RED-GREEN-REFACTOR Cycle

Test-driven development is a rhythm:

  1. 1RED: Write a test. It fails. (The code doesn't exist yet.)
  2. 2GREEN: Write the minimum code to make it pass. (Do not overthink.)
  3. 3REFACTOR: Improve the code without changing behavior. Tests still pass.

Why This Order?

If you write code first, tests become an afterthought, a checkbox. You test what the code does, not what it should do. You miss edge cases. You write overly tight tests that break when you refactor.

If you write tests first, they define the contract. The code is just the thing that satisfies the contract. You catch impossible requirements before you build them. You refactor fearlessly.

Example: The Rhythm in Practice

+ JavaScript
1TEST 1: Component renders without crashing
2 RED: Write test → it fails (component doesn't exist)
3 GREEN: Export a component that returns null → test passes
4 REFACTOR: Add structure
5 
6TEST 2: Component displays a search input
7 RED: Write test → it fails (no input in DOM)
8 GREEN: Add `<input>` → test passes
9 REFACTOR: Add label, accessibility attributes
10 
11TEST 3: Component filters results when you type
12 RED: Write test → it fails (no filtering logic)
13 GREEN: Add filter logic → test passes
14 REFACTOR: Extract filter to a helper, optimize, simplify

Each test is small. Each passing test is a working feature. The code grows incrementally, driven by tests.


+ 03 / 12

Concept 2: Testing Behavior, Not Implementation

The biggest mistake in testing is this: you test the code, not the feature.

+ JSX
1// ❌ WRONG: Testing the implementation
2it("calls setQuery when input value changes", () => {
3 const setQuery = vi.fn();
4 render(<SearchBox query={query} setQuery={setQuery} />);
5 fireEvent.change(screen.getByRole("textbox"), { target: { value: "react" } });
6 expect(setQuery).toHaveBeenCalledWith("react");
7});

This test breaks the moment you refactor, maybe you move the state up, maybe you change how you manage query. The test is brittle.

+ JSX
1// ✅ RIGHT: Testing the behavior
2it("filters results when you type in the search box", () => {
3 render(<SearchBox items={["React", "Vue", "Angular"]} />);
4 const input = screen.getByRole("textbox");
5 
6 userEvent.type(input, "React");
7 
8 expect(screen.getByText("React")).toBeInTheDocument();
9 expect(screen.queryByText("Vue")).not.toBeInTheDocument();
10});

This test focuses on what the user sees. It doesn't care if you use useState, Zustand, or a state machine under the hood. Refactor all you want, the behavior stays the same, the test passes.

The rule: Test inputs and outputs. Never test state, implementation details, or internal function calls.


+ 04 / 12

Concept 3: The Four States (and Four Tests)

Every component that fetches data or processes input has four states. Each one needs a test.

State 1: IDLE

The component has loaded. No data is being fetched. Nothing is filtered yet.

+ JSX
1it("renders an empty results list on mount", () => {
2 render(<SearchBox items={[]} />);
3 expect(screen.getByRole("list")).toBeEmptyDOMElement();
4});

State 2: LOADING

Data is being fetched. Show a spinner, disable input, or communicate "hold on."

+ JSX
1it("shows a loading indicator while fetching", async () => {
2 render(<SearchBox isLoading={true} />);
3 expect(screen.getByRole("status")).toHaveTextContent("Loading...");
4});

State 3: EMPTY

The fetch succeeded but returned no results. Show "no matches" or similar.

+ JSX
1it('shows "no results" when there are no matches', async () => {
2 render(<SearchBox items={[]} query="xyz" />);
3 expect(screen.getByText("No results")).toBeInTheDocument();
4});

State 4: ERROR

The fetch failed. Show an error message, maybe a retry button.

+ JSX
1it("shows an error message when fetch fails", async () => {
2 render(<SearchBox error="Network failed" />);
3 expect(screen.getByText("Network failed")).toBeInTheDocument();
4});

If you skip any of these, you have untested code. Users will find the state you didn't cover.


+ 05 / 12

Concept 4: Tools, Vitest & React Testing Library

Vitest

Vitest is a test runner, like Jest, but faster. It's built for Vite and modern JavaScript.

Key commands:

+ Terminal
1# Run tests once
2npm run test
3 
4# Run tests in watch mode (re-run on save)
5npm run test:watch
6 
7# Run with coverage
8npm run test:coverage

React Testing Library

React Testing Library renders your component in a browser-like environment and lets you query it the way a user would.

Forbidden APIs:

+ JSX
1// ❌ WRONG: Querying by implementation detail
2wrapper.find('.search-input').simulate('change', ...);
3component.state.query;

Correct APIs:

+ JSX
1// ✅ RIGHT: Query by what users see
2screen.getByRole("textbox");
3screen.getByText("No results");
4screen.getAllByRole("option");

Common queries:

  • screen.getByRole('textbox'), by accessibility role
  • screen.getByText('...'), by visible text
  • screen.getByLabelText('...'), by associated label
  • screen.getByPlaceholderText('...'), by placeholder
  • screen.getAllByRole('...'), all matches
  • screen.queryByText('...'), returns null if not found (for "should not exist" tests)

+ 06 / 12

Concept 5: Anti-Patterns (What Not To Do)

Anti-Pattern 1: Over-Mocking

+ JSX
1// ❌ WRONG: Mocking the filter function
2it("filters results", () => {
3 const mockFilter = vi.fn(() => ["React"]);
4 render(<SearchBox filter={mockFilter} />);
5 // ... now you're testing your mock, not your code
6});

Mocks hide bugs. If your mock is broken, your tests pass but your code fails. Mock only external dependencies (API calls, date, random). Never mock your own code.

+ JSX
1// ✅ RIGHT: Test the real filter logic
2it("filters results", () => {
3 const items = ["React", "Vue", "Angular"];
4 render(<SearchBox items={items} />);
5 userEvent.type(screen.getByRole("textbox"), "React");
6 expect(screen.getByText("React")).toBeInTheDocument();
7});

Anti-Pattern 2: Snapshot Tests

+ JSX
1// ❌ WRONG: Snapshot testing
2it("renders correctly", () => {
3 render(<SearchBox />);
4 expect(container).toMatchSnapshot();
5});

Snapshots are lazy. When the snapshot breaks, you update it without thinking, npm test -- -u. You're not verifying behavior, you're just copying whatever the code outputs. Snapshots are brittle and hide intent.

+ JSX
1// ✅ RIGHT: Behavior-driven tests
2it("renders a search input with a label", () => {
3 render(<SearchBox />);
4 expect(screen.getByLabelText("Search")).toBeInTheDocument();
5});

Anti-Pattern 3: Testing Internals

+ JSX
1// ❌ WRONG: Querying component internals
2it("filters correctly", () => {
3 const { container } = render(<SearchBox />);
4 const filterFn = container.querySelector(".filter-util");
5 expect(filterFn).toHaveBeenCalled(); // filters don't exist in the DOM
6});

Users don't see your filterFn helper. They see results or no results. Test what they see.

+ JSX
1// ✅ RIGHT: Test what users see
2it("filters correctly", () => {
3 render(<SearchBox items={["React", "Vue"]} />);
4 userEvent.type(screen.getByRole("textbox"), "React");
5 expect(screen.getByText("React")).toBeInTheDocument();
6 expect(screen.queryByText("Vue")).not.toBeInTheDocument();
7});

+ 07 / 12

Build Exercise: Test-First Search Component

You're going to rewrite the React Fundamentals search component, but this time, tests first.

The Component (Spec)

A search box that:

  • Renders an input and results list
  • Filters items as you type (case-insensitive)
  • Shows "No results" when there are no matches
  • Shows a loading state while fetching
  • Shows an error message if fetch fails

Step 1: Set Up

+ Terminal
1npm install -D vitest @testing-library/react @testing-library/user-event jsdom

Create src/components/SearchBox.test.tsx:

+ TypeScript
1import { describe, it, expect } from "vitest";
2import { render, screen } from "@testing-library/react";
3import userEvent from "@testing-library/user-event";
4import { SearchBox } from "./SearchBox";
5 
6describe("SearchBox", () => {
7 // Tests go here
8});

Step 2: Write Failing Tests (RED)

These tests will all fail. That's the point.

+ TypeScript
1describe('SearchBox', () => {
2 // TEST 1: Render
3 it('renders a search input and results list', () => {
4 render(<SearchBox items={[]} />);
5 expect(screen.getByRole('textbox')).toBeInTheDocument();
6 expect(screen.getByRole('list')).toBeInTheDocument();
7 });
8 
9 // TEST 2: Filter works
10 it('filters results when you type', async () => {
11 const items = ['React', 'Vue', 'Angular'];
12 render(<SearchBox items={items} />);
13 
14 const input = screen.getByRole('textbox');
15 await userEvent.type(input, 'React');
16 
17 expect(screen.getByText('React')).toBeInTheDocument();
18 expect(screen.queryByText('Vue')).not.toBeInTheDocument();
19 });
20 
21 // TEST 3: Empty results
22 it('shows "No results" when there are no matches', async () => {
23 render(<SearchBox items={['React']} />);
24 
25 const input = screen.getByRole('textbox');
26 await userEvent.type(input, 'xyz');
27 
28 expect(screen.getByText('No results')).toBeInTheDocument();
29 expect(screen.queryByText('React')).not.toBeInTheDocument();
30 });
31 
32 // TEST 4: Loading state
33 it('shows a loading message while fetching', () => {
34 render(<SearchBox items={[]} isLoading={true} />);
35 expect(screen.getByText('Loading...')).toBeInTheDocument();
36 });
37 
38 // TEST 5: Error state
39 it('shows an error message if fetch fails', () => {
40 render(<SearchBox items={[]} error="Failed to load items" />);
41 expect(screen.getByText('Failed to load items')).toBeInTheDocument();
42 });
43});

Run npm run test:watch. All five tests fail. Good.

Step 3: Make Tests Pass (GREEN)

Now write the component. Minimum code only.

+ TypeScript
1// src/components/SearchBox.tsx
2import { useState } from 'react';
3 
4interface SearchBoxProps {
5 items: string[];
6 isLoading?: boolean;
7 error?: string;
8}
9 
10export function SearchBox({ items, isLoading, error }: SearchBoxProps) {
11 const [query, setQuery] = useState('');
12 
13 // Handle the four states
14 if (isLoading) {
15 return <div>Loading...</div>;
16 }
17 
18 if (error) {
19 return <div>{error}</div>;
20 }
21 
22 // Filter items (case-insensitive)
23 const filtered = items.filter(item =>
24 item.toLowerCase().includes(query.toLowerCase())
25 );
26 
27 return (
28 <>
29 <input
30 type="text"
31 value={query}
32 onChange={(e) => setQuery(e.target.value)}
33 placeholder="Search..."
34 />
35 <ul>
36 {filtered.length > 0 ? (
37 filtered.map((item) => (
38 <li key={item}>{item}</li>
39 ))
40 ) : (
41 <li>No results</li>
42 )}
43 </ul>
44 </>
45 );
46}

Run tests. All five pass. You're in GREEN.

Step 4: Refactor (REFACTOR)

Now improve without breaking tests. Add accessibility, structure, clarity.

+ TypeScript
1// src/components/SearchBox.tsx
2import { useState } from 'react';
3 
4interface SearchBoxProps {
5 items: string[];
6 isLoading?: boolean;
7 error?: string;
8 label?: string;
9 onItemSelect?: (item: string) => void;
10}
11 
12export function SearchBox({
13 items,
14 isLoading,
15 error,
16 label = 'Search',
17 onItemSelect,
18}: SearchBoxProps) {
19 const [query, setQuery] = useState('');
20 
21 if (isLoading) {
22 return (
23 <div role="status" aria-live="polite">
24 Loading...
25 </div>
26 );
27 }
28 
29 if (error) {
30 return (
31 <div role="alert" aria-live="assertive">
32 {error}
33 </div>
34 );
35 }
36 
37 const filtered = items.filter(item =>
38 item.toLowerCase().includes(query.toLowerCase())
39 );
40 
41 const showNoResults = query.length > 0 && filtered.length === 0;
42 
43 return (
44 <div className="search-box">
45 <label htmlFor="search-input" className="sr-only">
46 {label}
47 </label>
48 <input
49 id="search-input"
50 type="text"
51 value={query}
52 onChange={(e) => setQuery(e.target.value)}
53 placeholder={label}
54 aria-describedby={showNoResults ? 'no-results' : undefined}
55 />
56 <ul role="list" className="results">
57 {showNoResults ? (
58 <li id="no-results" className="no-results">
59 No results
60 </li>
61 ) : (
62 filtered.map((item) => (
63 <li
64 key={item}
65 className="result-item"
66 onClick={() => onItemSelect?.(item)}
67 role="option"
68 >
69 {item}
70 </li>
71 ))
72 )}
73 </ul>
74 </div>
75 );
76}

Add CSS:

+ CSS
1.search-box {
2 display: flex;
3 flex-direction: column;
4 gap: 1rem;
5}
6 
7#search-input {
8 padding: 0.5rem;
9 border: 1px solid #ccc;
10 border-radius: 4px;
11 font: inherit;
12}
13 
14.results {
15 list-style: none;
16 padding: 0;
17 margin: 0;
18}
19 
20.result-item {
21 padding: 0.5rem;
22 border-bottom: 1px solid #eee;
23 cursor: pointer;
24 transition: background 0.2s;
25}
26 
27.result-item:hover {
28 background: #f5f5f5;
29}
30 
31.no-results {
32 padding: 0.5rem;
33 color: #999;
34 font-style: italic;
35}
36 
37.sr-only {
38 position: absolute;
39 width: 1px;
40 height: 1px;
41 padding: 0;
42 margin: -1px;
43 overflow: hidden;
44 clip: rect(0, 0, 0, 0);
45 border: 0;
46}

Run tests. All five still pass. Your refactor didn't break anything. This is the confidence you get from test-first development.

Step 5: Add Bonus Tests

Now that the basics work, add edge cases.

+ TypeScript
1describe('SearchBox', () => {
2 // ... previous tests ...
3 
4 // BONUS: Case-insensitive search
5 it('filters case-insensitively', async () => {
6 render(<SearchBox items={['React']} />);
7 await userEvent.type(screen.getByRole('textbox'), 'react');
8 expect(screen.getByText('React')).toBeInTheDocument();
9 });
10 
11 // BONUS: Clicking an item
12 it('calls onItemSelect when you click a result', async () => {
13 const onItemSelect = vi.fn();
14 render(
15 <SearchBox items={['React']} onItemSelect={onItemSelect} />
16 );
17 
18 await userEvent.click(screen.getByText('React'));
19 expect(onItemSelect).toHaveBeenCalledWith('React');
20 });
21 
22 // BONUS: Clearing the search
23 it('shows all items when the search is cleared', async () => {
24 const items = ['React', 'Vue', 'Angular'];
25 render(<SearchBox items={items} />);
26 
27 const input = screen.getByRole('textbox');
28 await userEvent.type(input, 'React');
29 expect(screen.queryByText('Vue')).not.toBeInTheDocument();
30 
31 await userEvent.clear(input);
32 expect(screen.getByText('Vue')).toBeInTheDocument();
33 });
34});

Each new test drives a tiny refinement in behavior. You're building in layers.


+ 08 / 12

Checkpoint

You've completed Testing React UI when you have:

+ Checklist0 / 6

Coverage target: 100% of behavior. Every code path should be covered by at least one test.

Run:

+ Terminal
1npm run test:coverage

You should see 100% line and branch coverage on SearchBox.

Refactor checkpoint: Can you rename SearchBox to ProductFilter without changing any tests? The tests should still pass. (They will, because you tested behavior, not names.)


+ 09 / 12

Evidence-Over-Claims

The principle for this module: every claim about behaviour should be proven by a test.

When you say "clicking a result calls the callback," you should point to the test that proves it:

+ TypeScript
1// The evidence:
2it('calls onItemSelect when you click a result', async () => {
3 const onItemSelect = vi.fn();
4 render(
5 <SearchBox items={['React']} onItemSelect={onItemSelect} />
6 );
7 
8 await userEvent.click(screen.getByText('React'));
9 expect(onItemSelect).toHaveBeenCalledWith('React');
10});

When you're tempted to say "the component handles errors gracefully," write the test:

+ TypeScript
1// The evidence:
2it('shows an error message if fetch fails', () => {
3 render(<SearchBox error="Failed to load" />);
4 expect(screen.getByText('Failed to load')).toBeInTheDocument();
5});

This is the opposite of "we should test this at some point." It's "here's the proof it works."

Going forward, in Spark modules and your own code, claim less. Test more. Let the tests speak.


+ 10 / 12

Key Takeaways

  1. 1RED-GREEN-REFACTOR is the rhythm. Test fails, code passes, refactor fearlessly.
  2. 2Test behavior, not implementation. Users don't know about your state shape. Test what they see.
  3. 3The four states: idle, loading, empty, error. Cover all four.
  4. 4Vitest + React Testing Library are built for this. Use screen.getByRole(), not .find().
  5. 5Avoid snapshots and over-mocking. They hide bugs.
  6. 6Write tests that read like documentation. Someone should understand the feature by reading the test.
  7. 7Refactor with confidence. If a test fails, you broke something. If it passes, you're safe.

+ 11 / 12

Practising this on your own

Nobody is going to check that you did this in the right order, which is exactly why it is worth knowing what each step buys you.

Watch the test fail first. The RED phase is the one people skip and the one that matters most. A test you have never seen fail might be passing because the assertion is wrong, because the runner never picked the file up, or because it asserts something that was already true. Seeing it go red once proves the test is genuinely wired to the thing you think it is testing. Skip that and you have a test you cannot trust, which is worse than having no test at all, because you will trust it anyway.

Then write the least code that turns it green. Not the version you expect to need eventually. The minimum. If that feels like cheating, it is not. The next failing test is what tells you the real shape, and writing ahead of your tests is how you end up with code that nothing covers.

Then refactor and watch the tests stay green. This is the payoff for the whole exercise. Change the internals, rename things, pull out a helper, restructure the state. If the tests stay green you know the behaviour survived, and you did not have to click through the interface to find out. A suite that goes red when you refactor without changing behaviour is testing implementation rather than behaviour, which is the mistake Concept 2 describes.

One exercise is worth doing once, and it takes about ten minutes. Write the same assertion twice, first as a snapshot test and then with screen.getByRole(). Break the component on purpose by changing the button label, and run both. The snapshot tells you that something changed. The role query tells you what broke and where. That difference is the entire argument against snapshots, and reading it is not the same as watching it happen.

The temptation, when you are working alone, is to write the component first and add the tests afterwards. Everyone feels it and there is nothing stopping you. The cost is specific rather than moral: a test written after the code tends to assert what the code happens to do, bugs included, because you write it while looking at the implementation. Written first, the test says what the component is supposed to do, and that is the only moment you get to describe the behaviour before you know how it will work.


+ 12 / 12

Next Steps

  • Module 11 puts the components you just tested into a design system: tokens, theming, and component APIs that survive contact with a real product.
  • Module 12 adds motion to those components, including the prefers-reduced-motion behaviour every one of them should honour.
  • Side quest: Add a fetching example with async/await and mock the API call (the only time mocking is good).
+ Up nextDesign Systems in CodePreviouslyReact Fundamentals