JavaScript and React course Β· Module 13: Testing React
Main Project: Complete Test Suite - Cosmic Mission Control Center
In this lesson8
The time has come to summarize everything we've learned in this module! Commander Nova is preparing the fleet for launch, and mission control is a set of panels that must work flawlessly. Every code change can quietly break the crew form or the shield alarm, and nobody clicks through the whole app before every launch. Your task is to protect this application with a complete test suite, that is, to combine all the techniques from this module in one project.
Application Description
You will find a working version of the app in the preview at the end of the lesson. The Mission Control Center consists of the following parts:
1. MissionDashboard - Mission Panel
- Loads the mission list through the
loadMissionsfunction it receives in props - Shows a loading state, an error with a retry button, and the loaded data
- Filters missions by status
- Displays a summary: the number of all and active missions
2. CrewManager - Crew Management
- A form for adding crew members with the fields Name, Role and Experience (years)
- Data validation with the
validateCrewMemberfunction (name, role, experience) - A list of members with remove buttons and a role filter
3. SystemStatus - Systems Status
- Shows the ship's system levels as bars with
role="progressbar" - Reads the sensors every 10 seconds: each reading weakens the shields by 15 points
- Raises an alarm with
role="alert"when any level drops below 30%
4. Custom Hooks
useMissions- loading missions, loading and error state, retryuseSystemHealth- reading the systems at a set intervaluseCrewFilter- filtering the crew by role
How to Work with This Project
The preview shows the working app, but it does not run Jest. The tests in this lesson are written for exactly these components, so move the code from the preview into your own project, split it into files following the structure below and run npm test. Set the project up as in the first lesson of the module: Jest with the jsdom environment, a JSX transform and @testing-library/jest-dom in the setup file.
Tasks to Complete
Your test suite should cover all the levels you have learned in the module:
- Unit tests - helper functions, validation
- Component tests - rendering, interactions, states
- Async tests - data fetching, loading, errors
- Hook tests - custom hooks with
renderHook - API mocking -
jest.fn()stand-ins,jest.mock,fetchmocking - Snapshots - key presentational components
Test Structure
Every component, hook and helper file gets its own test file, so when a test fails you know right away where to look. Jest finds them on its own, because they live in the __tests__ folder and end with .test.js:
1__tests__/
2 MissionDashboard.test.js -- mission panel tests
3 CrewManager.test.js -- crew management tests
4 SystemStatus.test.js -- system status tests
5 hooks/
6 useMissions.test.js -- missions hook tests
7 useCrewFilter.test.js -- crew filter hook tests
8 utils/
9 validators.test.js -- validation function testsThe __tests__ folder sits in src next to the components, and you keep the hooks and helpers in src/hooks and src/utils. Each of these files exports what you test, and the imports in the examples below assume exactly this layout.
Key Patterns to Apply
The patterns below cover the whole suite: from a pure function, through a hook and a component, to asynchrony, time and errors. Every example works with the code from the preview. The test names stay in English, as in the whole module.
Pattern: Pure Function Test
Validation does not need React: it receives an object and returns a list of errors, so you test it like the functions from the Jest lesson:
1import { validateCrewMember } from '../../utils/validators';
2
3test('rejects a one-letter name and an unknown role', () => {
4 const errors = validateCrewMember({ name: 'X', role: 'cook', experience: 3 });
5
6 expect(errors).toEqual(['Name must be at least 2 characters', 'Unknown role']);
7});toEqual compares the contents of the array, not the reference, so the test checks exactly the two messages the user will see. It is the cheapest and fastest test in the whole suite, the base of the test pyramid.
Pattern: Hook Test
You run the useCrewFilter hook with renderHook and wrap the filter change in act, as in the lesson on hooks:
1import { renderHook, act } from '@testing-library/react';
2import { useCrewFilter } from '../../hooks/useCrewFilter';
3
4test('filters crew by role', () => {
5 const crew = [
6 { id: 1, name: 'Astro', role: 'pilot' },
7 { id: 2, name: 'Commander Nova', role: 'captain' },
8 ];
9 const { result } = renderHook(() => useCrewFilter(crew));
10
11 act(() => result.current.setFilter('pilot'));
12
13 expect(result.current.filtered).toEqual([crew[0]]);
14});You read the result from result.current only after act, so you see the state after the filter change: only the pilot remains on the list. The hook knows nothing about the form or the buttons, which is why its test is short and resistant to changes in the look of the app.
Pattern: Arrange-Act-Assert
Every component test has three parts. First you prepare the scene (Arrange), then you perform an action like a user (Act), and finally you check the result (Assert). The comments in the test show this split:
1import { render, screen } from '@testing-library/react';
2import userEvent from '@testing-library/user-event';
3import CrewManager from '../CrewManager';
4
5test('adds new crew member', async () => {
6 // Arrange: prepare
7 const user = userEvent.setup();
8 render(<CrewManager />);
9
10 // Act: execute
11 await user.type(screen.getByLabelText('Name'), 'Ra');
12 await user.selectOptions(screen.getByLabelText('Role'), 'captain');
13 await user.click(screen.getByRole('button', { name: 'Add crew member' }));
14
15 // Assert: verify
16 expect(screen.getByText('Ra (captain, 0 years)')).toBeInTheDocument();
17});getByLabelText finds the fields because the labels are linked to them with htmlFor and id. The assertion looks for the whole list entry, not just the word "captain", because that word is also an option in the select, and the test would pass even if adding did not work. Add a second test for the error path: the name X should show a message in an element with role="alert".
Pattern: Test Isolation
Tests in one file must not depend on each other. A stand-in shared by several tests remembers the calls from the previous ones, so without cleanup the count from the first test would break the assertion in the second. That is why beforeEach clears the history before every test:
1import { render, screen } from '@testing-library/react';
2import userEvent from '@testing-library/user-event';
3import MissionDashboard from '../MissionDashboard';
4
5const missions = [{ id: 1, name: 'Apollo 11', status: 'completed', crew: 3 }];
6const loadMissions = jest.fn();
7
8beforeEach(() => {
9 // Every test starts with an empty mock call history
10 jest.clearAllMocks();
11});
12
13test('shows missions after loading', async () => {
14 loadMissions.mockResolvedValue(missions);
15 render(<MissionDashboard loadMissions={loadMissions} />);
16
17 expect(screen.getByRole('status')).toHaveTextContent('Loading missions...');
18 expect(await screen.findByText('Apollo 11')).toBeInTheDocument();
19 expect(loadMissions).toHaveBeenCalledTimes(1);
20});The panel receives the loadMissions function in props, so in the test you pass a jest.fn() stand-in instead of the real API, and in the preview the same prop gets a simulated server. First you see the loading state with role="status", and findByText waits until the data appears. The second test checks the error path and the retry:
1test('shows an error and retries', async () => {
2 const user = userEvent.setup();
3 loadMissions
4 .mockRejectedValueOnce(new Error('No connection to the mission server'))
5 .mockResolvedValueOnce(missions);
6 render(<MissionDashboard loadMissions={loadMissions} />);
7
8 expect(await screen.findByRole('alert')).toHaveTextContent('No connection');
9 await user.click(screen.getByRole('button', { name: 'Try again' }));
10
11 expect(await screen.findByText('Apollo 11')).toBeInTheDocument();
12 expect(loadMissions).toHaveBeenCalledTimes(2);
13});The two Once calls set up consecutive responses: first a failure, then data. Without jest.clearAllMocks() in beforeEach, this test would also count the call from the previous test and see 3 instead of 2. When a component calls fetch itself, also add global.fetch = jest.fn() to beforeEach, as in the lesson on mocking.
Pattern: Time Under Control
The shield alarm appears only after two sensor readings, that is, after 20 seconds. Fake timers fast-forward this time in a fraction of a second:
1import { act } from 'react';
2import { render, screen } from '@testing-library/react';
3import SystemStatus from '../SystemStatus';
4
5test('raises an alert when shields drop below 30%', () => {
6 jest.useFakeTimers();
7 render(<SystemStatus />);
8 expect(screen.queryByRole('alert')).not.toBeInTheDocument();
9
10 // Two sensor readings: 55% -> 40% -> 25%
11 act(() => {
12 jest.advanceTimersByTime(20000);
13 });
14
15 expect(screen.getByRole('progressbar', { name: 'Shields' })).toHaveAttribute('aria-valuenow', '25');
16 expect(screen.getByRole('alert')).toHaveTextContent('Shields');
17 jest.useRealTimers();
18});queryByRole at the start confirms that there is no alarm yet, because getByRole would throw an error at that point. Moving the clock changes state outside RTL's tools, which is why it sits inside act. You find the bar by the progressbar role and the name from aria-label, and you read its value from aria-valuenow.
Pattern: Error Boundary Testing
An Error Boundary is a component that catches an error thrown while its children render and shows a fallback message instead of a blank screen. React has no hook for this, so you write the boundary as a class component with a static getDerivedStateFromError method, or you take a ready one from the react-error-boundary library:
1import { Component } from 'react';
2
3class ErrorBoundary extends Component {
4 state = { hasError: false };
5
6 static getDerivedStateFromError() {
7 return { hasError: true };
8 }
9
10 render() {
11 return this.state.hasError ? this.props.fallback : this.props.children;
12 }
13}getDerivedStateFromError sets hasError, and render then shows the element from the fallback prop. The test hands the boundary a component that always throws:
1test('shows error boundary on crash', () => {
2 const ThrowError = () => {
3 throw new Error('Kaboom!');
4 };
5
6 // React 19 reports an error caught by a boundary with console.error
7 const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
8
9 render(
10 <ErrorBoundary fallback={<p>Something went wrong</p>}>
11 <ThrowError />
12 </ErrorBoundary>
13 );
14
15 expect(screen.getByText('Something went wrong')).toBeInTheDocument();
16 spy.mockRestore();
17});In React 19 an error caught by a boundary goes to console.error, so without the spy the test passes but clutters the output with a red stack trace. mockRestore() at the end gives the console its real function back, so that later tests see warnings again.
Testing Best Practices
- Test behavior, not implementation - check what the user sees
- One concept per test - each test checks one thing
- Readable test names -
it('displays error when mission not found') - DRY in tests with moderation - duplication in tests is better than overly complicated abstractions
- Test success and error paths - the happy path and edge cases
- Don't test the framework - don't test whether
useStateworks, test your logic
What You Hand In to Your Mentor
After this lesson a mentor project awaits you. Build your own React app or extend the Mission Control Center, and prepare a test suite for it that covers:
- a panel with data from an API (a stand-in instead of the real server in tests), with loading and error handling
- a form with validation, checked with
userEventand messages withrole="alert" - at least one custom hook tested with
renderHook - unit tests, integration tests of several components and a snapshot of a simple presentational component
- success paths and edge cases
Push the project with its tests to GitHub and send the repository link. Your mentor will run npm test, so make sure all tests pass on a fresh copy of the project.
Check the Preview
Below you will find the working Mission Control Center. Click through it and compare it with the tests from the lesson:
- click Simulate a server failure: the panel shows an error and a Try again button, just like in the error path test, and after you click Restore the server it loads the missions again
- add a crew member with a one-letter name and see the validation messages
- wait 20 seconds until the shields drop below 30% and the alarm appears
My advice: before you write a test, click through the scenario in the preview and write it down as three steps: Arrange, Act and Assert. Then the test almost writes itself. Remember: a good test suite is a pre-launch check that inspects the whole ship on every code change before you fly into real space.
Code for this lesson: App.jsx
1import React, { useState, useEffect, useCallback } from 'react';
2
3// ===== Simulated API =====
4// The preview has no server, so a Promise with setTimeout fakes the response.
5// In the lesson's tests MissionDashboard gets a jest.fn() mock instead.
6const MISSIONS = [
7 { id: 1, name: 'Apollo 11', status: 'completed', crew: 3 },
8 { id: 2, name: 'Artemis II', status: 'active', crew: 4 },
9 { id: 3, name: 'Europa Clipper', status: 'active', crew: 0 },
10 { id: 4, name: 'Mars Sample Return', status: 'planned', crew: 0 },
11];
12
13function fetchMissions(serverDown) {
14 return new Promise((resolve, reject) => {
15 setTimeout(() => {
16 if (serverDown) reject(new Error('No connection to the mission server'));
17 else resolve(MISSIONS);
18 }, 800);
19 });
20}
21
22// ===== Helper functions =====
23const ROLES = { captain: 'captain', pilot: 'pilot', engineer: 'engineer', scientist: 'scientist' };
24const FILTER_LABELS = { all: 'all', active: 'active', completed: 'completed', planned: 'planned' };
25const STATUS_LABELS = { active: 'active', completed: 'completed', planned: 'planned' };
26const SYSTEM_LABELS = { engine: 'Engine', shields: 'Shields', navigation: 'Navigation', lifeSupport: 'Life support' };
27
28function validateCrewMember(member) {
29 const errors = [];
30 if (!member.name || member.name.trim().length < 2) {
31 errors.push('Name must be at least 2 characters');
32 }
33 if (!Object.keys(ROLES).includes(member.role)) {
34 errors.push('Unknown role');
35 }
36 if (member.experience < 0 || member.experience > 50) {
37 errors.push('Experience must be between 0 and 50 years');
38 }
39 return errors;
40}
41
42function yearsLabel(n) {
43 return n === 1 ? '1 year' : `${n} years`;
44}
45
46// ===== Custom hooks =====
47function useMissions(loadMissions) {
48 const [missions, setMissions] = useState([]);
49 const [loading, setLoading] = useState(true);
50 const [error, setError] = useState(null);
51 const [attempt, setAttempt] = useState(0);
52
53 useEffect(() => {
54 let ignore = false;
55 loadMissions()
56 .then(data => {
57 if (!ignore) setMissions(data);
58 })
59 .catch(err => {
60 if (!ignore) setError(err.message);
61 })
62 .finally(() => {
63 if (!ignore) setLoading(false);
64 });
65 return () => {
66 ignore = true;
67 };
68 }, [loadMissions, attempt]);
69
70 // Retry: show the loading state at once and fetch again
71 const refetch = useCallback(() => {
72 setLoading(true);
73 setError(null);
74 setAttempt(a => a + 1);
75 }, []);
76 return { missions, loading, error, refetch };
77}
78
79function useCrewFilter(crew) {
80 const [filter, setFilter] = useState('all');
81 const filtered = filter === 'all' ? crew : crew.filter(member => member.role === filter);
82 return { filtered, filter, setFilter };
83}
84
85function useSystemHealth(intervalMs = 10000) {
86 const [systems, setSystems] = useState({ engine: 98, shields: 55, navigation: 100, lifeSupport: 92 });
87
88 useEffect(() => {
89 // Every sensor reading weakens the shields by 15 points
90 const id = setInterval(() => {
91 setSystems(s => ({ ...s, shields: Math.max(s.shields - 15, 0) }));
92 }, intervalMs);
93 return () => clearInterval(id);
94 }, [intervalMs]);
95
96 const repairShields = () => setSystems(s => ({ ...s, shields: 100 }));
97 return { systems, repairShields };
98}
99
100// ===== Components =====
101function MissionDashboard({ loadMissions }) {
102 const { missions, loading, error, refetch } = useMissions(loadMissions);
103 const [statusFilter, setStatusFilter] = useState('all');
104
105 if (loading) {
106 return (
107 <section className="panel">
108 <h2>Mission dashboard</h2>
109 <p className="loading" role="status">Loading missions...</p>
110 </section>
111 );
112 }
113
114 if (error) {
115 return (
116 <section className="panel">
117 <h2>Mission dashboard</h2>
118 <p className="error" role="alert">Error: {error}</p>
119 <button className="refresh-btn" onClick={refetch}>Try again</button>
120 </section>
121 );
122 }
123
124 const visible = statusFilter === 'all' ? missions : missions.filter(m => m.status === statusFilter);
125
126 return (
127 <section className="panel">
128 <h2>Mission dashboard</h2>
129 <div className="stats">
130 <div className="stat">Total: {missions.length}</div>
131 <div className="stat">Active: {missions.filter(m => m.status === 'active').length}</div>
132 </div>
133
134 <div className="filters">
135 {Object.keys(FILTER_LABELS).map(s => (
136 <button key={s} aria-pressed={statusFilter === s} onClick={() => setStatusFilter(s)}>
137 {FILTER_LABELS[s]}
138 </button>
139 ))}
140 </div>
141
142 <ul className="mission-list" aria-label="Missions">
143 {visible.map(m => (
144 <li key={m.id}>
145 <strong>{m.name}</strong>
146 <span className="badge">{STATUS_LABELS[m.status]}</span>
147 <span className="crew-count">crew: {m.crew}</span>
148 </li>
149 ))}
150 </ul>
151
152 <button className="refresh-btn" onClick={refetch}>Refresh</button>
153 </section>
154 );
155}
156
157function CrewManager() {
158 const [crew, setCrew] = useState([
159 { id: 1, name: 'Commander Nova', role: 'captain', experience: 15 },
160 { id: 2, name: 'Astro', role: 'pilot', experience: 6 },
161 { id: 3, name: 'Engineer Bolt', role: 'engineer', experience: 8 },
162 ]);
163 const [formData, setFormData] = useState({ name: '', role: 'pilot', experience: 0 });
164 const [errors, setErrors] = useState([]);
165 const { filtered, filter, setFilter } = useCrewFilter(crew);
166
167 const handleSubmit = (e) => {
168 e.preventDefault();
169 const validationErrors = validateCrewMember(formData);
170 if (validationErrors.length > 0) {
171 setErrors(validationErrors);
172 return;
173 }
174 setCrew(prev => [...prev, { ...formData, name: formData.name.trim(), id: Date.now() }]);
175 setFormData({ name: '', role: 'pilot', experience: 0 });
176 setErrors([]);
177 };
178
179 const handleRemove = (id) => {
180 setCrew(prev => prev.filter(m => m.id !== id));
181 };
182
183 return (
184 <section className="panel">
185 <h2>Crew manager</h2>
186
187 <form className="crew-form" onSubmit={handleSubmit}>
188 <div className="field">
189 <label htmlFor="crew-name">Name</label>
190 <input
191 id="crew-name"
192 value={formData.name}
193 onChange={e => setFormData(d => ({ ...d, name: e.target.value }))}
194 />
195 </div>
196 <div className="field">
197 <label htmlFor="crew-role">Role</label>
198 <select
199 id="crew-role"
200 value={formData.role}
201 onChange={e => setFormData(d => ({ ...d, role: e.target.value }))}
202 >
203 {Object.entries(ROLES).map(([value, label]) => (
204 <option key={value} value={value}>{label}</option>
205 ))}
206 </select>
207 </div>
208 <div className="field">
209 <label htmlFor="crew-exp">Experience (years)</label>
210 <input
211 id="crew-exp"
212 type="number"
213 value={formData.experience}
214 onChange={e => setFormData(d => ({ ...d, experience: Number(e.target.value) }))}
215 />
216 </div>
217 <button type="submit">Add crew member</button>
218 </form>
219
220 {errors.length > 0 && (
221 <div className="error-list" role="alert">
222 {errors.map(message => <p key={message}>{message}</p>)}
223 </div>
224 )}
225
226 <div className="filters">
227 {['all', ...Object.keys(ROLES)].map(r => (
228 <button key={r} aria-pressed={filter === r} onClick={() => setFilter(r)}>
229 {r === 'all' ? 'everyone' : ROLES[r]}
230 </button>
231 ))}
232 </div>
233
234 <ul className="crew-list" aria-label="Crew">
235 {filtered.map(m => (
236 <li key={m.id}>
237 <span>{m.name} ({ROLES[m.role]}, {yearsLabel(m.experience)})</span>
238 <button className="remove" aria-label={`Remove: ${m.name}`} onClick={() => handleRemove(m.id)}>
239 Remove
240 </button>
241 </li>
242 ))}
243 </ul>
244 </section>
245 );
246}
247
248function SystemStatus() {
249 const { systems, repairShields } = useSystemHealth(10000);
250 const critical = Object.keys(systems).filter(key => systems[key] < 30);
251
252 const getStatusColor = (level) => {
253 if (level > 80) return '#4caf50';
254 if (level > 50) return '#ff9800';
255 return '#f44336';
256 };
257
258 return (
259 <section className="panel">
260 <h2>System status</h2>
261 <p className="hint">Sensor reading every 10 s: the shields weaken by 15 points.</p>
262 <div className="system-grid">
263 {Object.entries(systems).map(([key, value]) => (
264 <div key={key} className="system-item">
265 <span className="system-name">{SYSTEM_LABELS[key]}</span>
266 <div
267 className="progress-bar"
268 role="progressbar"
269 aria-label={SYSTEM_LABELS[key]}
270 aria-valuenow={value}
271 aria-valuemin={0}
272 aria-valuemax={100}
273 >
274 <div className="progress-fill" style={{ width: `${value}%`, background: getStatusColor(value) }} />
275 </div>
276 <span className="system-value">{value}%</span>
277 </div>
278 ))}
279 </div>
280 {critical.length > 0 && (
281 <div className="system-alert">
282 <p role="alert">Critical level: {critical.map(key => SYSTEM_LABELS[key]).join(', ')}</p>
283 <button onClick={repairShields}>Repair shields</button>
284 </div>
285 )}
286 </section>
287 );
288}
289
290export default function App() {
291 const [serverDown, setServerDown] = useState(false);
292 const loadMissions = useCallback(() => fetchMissions(serverDown), [serverDown]);
293
294 return (
295 <div className="app">
296 <h1>Cosmic Mission Control Center</h1>
297 <p className="subtitle">
298 Main project: a working app that the lesson's tests check.
299 The preview does not run Jest, you run the tests in your own project.
300 </p>
301 <button className="sim-btn" onClick={() => setServerDown(d => !d)} aria-pressed={serverDown}>
302 {serverDown ? 'Restore the server' : 'Simulate a server failure'}
303 </button>
304 {/* key: after the server switch the panel starts over from the loading state */}
305 <MissionDashboard key={serverDown ? 'down' : 'up'} loadMissions={loadMissions} />
306 <CrewManager />
307 <SystemStatus />
308 </div>
309 );
310}Spotted a mistake in this lesson?