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
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.
Concept 1: The RED-GREEN-REFACTOR Cycle
Test-driven development is a rhythm:
- 1RED: Write a test. It fails. (The code doesn't exist yet.)
- 2GREEN: Write the minimum code to make it pass. (Do not overthink.)
- 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
TEST 1: Component renders without crashing RED: Write test → it fails (component doesn't exist) GREEN: Export a component that returns null → test passes REFACTOR: Add structure TEST 2: Component displays a search input RED: Write test → it fails (no input in DOM) GREEN: Add `<input>` → test passes REFACTOR: Add label, accessibility attributes TEST 3: Component filters results when you type RED: Write test → it fails (no filtering logic) GREEN: Add filter logic → test passes REFACTOR: Extract filter to a helper, optimize, simplifyEach test is small. Each passing test is a working feature. The code grows incrementally, driven by tests.
Concept 2: Testing Behavior, Not Implementation
The biggest mistake in testing is this: you test the code, not the feature.
// ❌ WRONG: Testing the implementationit("calls setQuery when input value changes", () => { const setQuery = vi.fn(); render(<SearchBox query={query} setQuery={setQuery} />); fireEvent.change(screen.getByRole("textbox"), { target: { value: "react" } }); expect(setQuery).toHaveBeenCalledWith("react");});This test breaks the moment you refactor, maybe you move the state up, maybe you change how you manage query. The test is brittle.
// ✅ RIGHT: Testing the behaviorit("filters results when you type in the search box", () => { render(<SearchBox items={["React", "Vue", "Angular"]} />); const input = screen.getByRole("textbox"); userEvent.type(input, "React"); expect(screen.getByText("React")).toBeInTheDocument(); expect(screen.queryByText("Vue")).not.toBeInTheDocument();});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.
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.
it("renders an empty results list on mount", () => { render(<SearchBox items={[]} />); expect(screen.getByRole("list")).toBeEmptyDOMElement();});State 2: LOADING
Data is being fetched. Show a spinner, disable input, or communicate "hold on."
it("shows a loading indicator while fetching", async () => { render(<SearchBox isLoading={true} />); expect(screen.getByRole("status")).toHaveTextContent("Loading...");});State 3: EMPTY
The fetch succeeded but returned no results. Show "no matches" or similar.
it('shows "no results" when there are no matches', async () => { render(<SearchBox items={[]} query="xyz" />); expect(screen.getByText("No results")).toBeInTheDocument();});State 4: ERROR
The fetch failed. Show an error message, maybe a retry button.
it("shows an error message when fetch fails", async () => { render(<SearchBox error="Network failed" />); expect(screen.getByText("Network failed")).toBeInTheDocument();});If you skip any of these, you have untested code. Users will find the state you didn't cover.
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:
# Run tests oncenpm run test # Run tests in watch mode (re-run on save)npm run test:watch # Run with coveragenpm run test:coverageReact 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:
// ❌ WRONG: Querying by implementation detailwrapper.find('.search-input').simulate('change', ...);component.state.query;Correct APIs:
// ✅ RIGHT: Query by what users seescreen.getByRole("textbox");screen.getByText("No results");screen.getAllByRole("option");Common queries:
screen.getByRole('textbox'), by accessibility rolescreen.getByText('...'), by visible textscreen.getByLabelText('...'), by associated labelscreen.getByPlaceholderText('...'), by placeholderscreen.getAllByRole('...'), all matchesscreen.queryByText('...'), returns null if not found (for "should not exist" tests)
Concept 5: Anti-Patterns (What Not To Do)
Anti-Pattern 1: Over-Mocking
// ❌ WRONG: Mocking the filter functionit("filters results", () => { const mockFilter = vi.fn(() => ["React"]); render(<SearchBox filter={mockFilter} />); // ... now you're testing your mock, not your code});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.
// ✅ RIGHT: Test the real filter logicit("filters results", () => { const items = ["React", "Vue", "Angular"]; render(<SearchBox items={items} />); userEvent.type(screen.getByRole("textbox"), "React"); expect(screen.getByText("React")).toBeInTheDocument();});Anti-Pattern 2: Snapshot Tests
// ❌ WRONG: Snapshot testingit("renders correctly", () => { render(<SearchBox />); expect(container).toMatchSnapshot();});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.
// ✅ RIGHT: Behavior-driven testsit("renders a search input with a label", () => { render(<SearchBox />); expect(screen.getByLabelText("Search")).toBeInTheDocument();});Anti-Pattern 3: Testing Internals
// ❌ WRONG: Querying component internalsit("filters correctly", () => { const { container } = render(<SearchBox />); const filterFn = container.querySelector(".filter-util"); expect(filterFn).toHaveBeenCalled(); // filters don't exist in the DOM});Users don't see your filterFn helper. They see results or no results. Test what they see.
// ✅ RIGHT: Test what users seeit("filters correctly", () => { render(<SearchBox items={["React", "Vue"]} />); userEvent.type(screen.getByRole("textbox"), "React"); expect(screen.getByText("React")).toBeInTheDocument(); expect(screen.queryByText("Vue")).not.toBeInTheDocument();});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
npm install -D vitest @testing-library/react @testing-library/user-event jsdomCreate src/components/SearchBox.test.tsx:
import { describe, it, expect } from "vitest";import { render, screen } from "@testing-library/react";import userEvent from "@testing-library/user-event";import { SearchBox } from "./SearchBox"; describe("SearchBox", () => { // Tests go here});Step 2: Write Failing Tests (RED)
These tests will all fail. That's the point.
describe('SearchBox', () => { // TEST 1: Render it('renders a search input and results list', () => { render(<SearchBox items={[]} />); expect(screen.getByRole('textbox')).toBeInTheDocument(); expect(screen.getByRole('list')).toBeInTheDocument(); }); // TEST 2: Filter works it('filters results when you type', async () => { const items = ['React', 'Vue', 'Angular']; render(<SearchBox items={items} />); const input = screen.getByRole('textbox'); await userEvent.type(input, 'React'); expect(screen.getByText('React')).toBeInTheDocument(); expect(screen.queryByText('Vue')).not.toBeInTheDocument(); }); // TEST 3: Empty results it('shows "No results" when there are no matches', async () => { render(<SearchBox items={['React']} />); const input = screen.getByRole('textbox'); await userEvent.type(input, 'xyz'); expect(screen.getByText('No results')).toBeInTheDocument(); expect(screen.queryByText('React')).not.toBeInTheDocument(); }); // TEST 4: Loading state it('shows a loading message while fetching', () => { render(<SearchBox items={[]} isLoading={true} />); expect(screen.getByText('Loading...')).toBeInTheDocument(); }); // TEST 5: Error state it('shows an error message if fetch fails', () => { render(<SearchBox items={[]} error="Failed to load items" />); expect(screen.getByText('Failed to load items')).toBeInTheDocument(); });});Run npm run test:watch. All five tests fail. Good.
Step 3: Make Tests Pass (GREEN)
Now write the component. Minimum code only.
// src/components/SearchBox.tsximport { useState } from 'react'; interface SearchBoxProps { items: string[]; isLoading?: boolean; error?: string;} export function SearchBox({ items, isLoading, error }: SearchBoxProps) { const [query, setQuery] = useState(''); // Handle the four states if (isLoading) { return <div>Loading...</div>; } if (error) { return <div>{error}</div>; } // Filter items (case-insensitive) const filtered = items.filter(item => item.toLowerCase().includes(query.toLowerCase()) ); return ( <> <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." /> <ul> {filtered.length > 0 ? ( filtered.map((item) => ( <li key={item}>{item}</li> )) ) : ( <li>No results</li> )} </ul> </> );}Run tests. All five pass. You're in GREEN.
Step 4: Refactor (REFACTOR)
Now improve without breaking tests. Add accessibility, structure, clarity.
// src/components/SearchBox.tsximport { useState } from 'react'; interface SearchBoxProps { items: string[]; isLoading?: boolean; error?: string; label?: string; onItemSelect?: (item: string) => void;} export function SearchBox({ items, isLoading, error, label = 'Search', onItemSelect,}: SearchBoxProps) { const [query, setQuery] = useState(''); if (isLoading) { return ( <div role="status" aria-live="polite"> Loading... </div> ); } if (error) { return ( <div role="alert" aria-live="assertive"> {error} </div> ); } const filtered = items.filter(item => item.toLowerCase().includes(query.toLowerCase()) ); const showNoResults = query.length > 0 && filtered.length === 0; return ( <div className="search-box"> <label htmlFor="search-input" className="sr-only"> {label} </label> <input id="search-input" type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder={label} aria-describedby={showNoResults ? 'no-results' : undefined} /> <ul role="list" className="results"> {showNoResults ? ( <li id="no-results" className="no-results"> No results </li> ) : ( filtered.map((item) => ( <li key={item} className="result-item" onClick={() => onItemSelect?.(item)} role="option" > {item} </li> )) )} </ul> </div> );}Add CSS:
.search-box { display: flex; flex-direction: column; gap: 1rem;} #search-input { padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; font: inherit;} .results { list-style: none; padding: 0; margin: 0;} .result-item { padding: 0.5rem; border-bottom: 1px solid #eee; cursor: pointer; transition: background 0.2s;} .result-item:hover { background: #f5f5f5;} .no-results { padding: 0.5rem; color: #999; font-style: italic;} .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); border: 0;}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.
describe('SearchBox', () => { // ... previous tests ... // BONUS: Case-insensitive search it('filters case-insensitively', async () => { render(<SearchBox items={['React']} />); await userEvent.type(screen.getByRole('textbox'), 'react'); expect(screen.getByText('React')).toBeInTheDocument(); }); // BONUS: Clicking an item it('calls onItemSelect when you click a result', async () => { const onItemSelect = vi.fn(); render( <SearchBox items={['React']} onItemSelect={onItemSelect} /> ); await userEvent.click(screen.getByText('React')); expect(onItemSelect).toHaveBeenCalledWith('React'); }); // BONUS: Clearing the search it('shows all items when the search is cleared', async () => { const items = ['React', 'Vue', 'Angular']; render(<SearchBox items={items} />); const input = screen.getByRole('textbox'); await userEvent.type(input, 'React'); expect(screen.queryByText('Vue')).not.toBeInTheDocument(); await userEvent.clear(input); expect(screen.getByText('Vue')).toBeInTheDocument(); });});Each new test drives a tiny refinement in behavior. You're building in layers.
Checkpoint
You've completed Testing React UI when you have:
Coverage target: 100% of behavior. Every code path should be covered by at least one test.
Run:
npm run test:coverageYou 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.)
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:
// The evidence:it('calls onItemSelect when you click a result', async () => { const onItemSelect = vi.fn(); render( <SearchBox items={['React']} onItemSelect={onItemSelect} /> ); await userEvent.click(screen.getByText('React')); expect(onItemSelect).toHaveBeenCalledWith('React');});When you're tempted to say "the component handles errors gracefully," write the test:
// The evidence:it('shows an error message if fetch fails', () => { render(<SearchBox error="Failed to load" />); expect(screen.getByText('Failed to load')).toBeInTheDocument();});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.
Key Takeaways
- 1RED-GREEN-REFACTOR is the rhythm. Test fails, code passes, refactor fearlessly.
- 2Test behavior, not implementation. Users don't know about your state shape. Test what they see.
- 3The four states: idle, loading, empty, error. Cover all four.
- 4Vitest + React Testing Library are built for this. Use
screen.getByRole(), not.find(). - 5Avoid snapshots and over-mocking. They hide bugs.
- 6Write tests that read like documentation. Someone should understand the feature by reading the test.
- 7Refactor with confidence. If a test fails, you broke something. If it passes, you're safe.
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.
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-motionbehaviour every one of them should honour. - Side quest: Add a fetching example with
async/awaitand mock the API call (the only time mocking is good).