JavaScript and React course Β· Module 13: Testing React

Main Project: Complete Test Suite - Cosmic Mission Control Center

10 min read
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 loadMissions function 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 validateCrewMember function (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, retry
  • useSystemHealth - reading the systems at a set interval
  • useCrewFilter - 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:

  1. Unit tests - helper functions, validation
  2. Component tests - rendering, interactions, states
  3. Async tests - data fetching, loading, errors
  4. Hook tests - custom hooks with renderHook
  5. API mocking - jest.fn() stand-ins, jest.mock, fetch mocking
  6. 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 tests

The __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

  1. Test behavior, not implementation - check what the user sees
  2. One concept per test - each test checks one thing
  3. Readable test names - it('displays error when mission not found')
  4. DRY in tests with moderation - duplication in tests is better than overly complicated abstractions
  5. Test success and error paths - the happy path and edge cases
  6. Don't test the framework - don't test whether useState works, 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 userEvent and messages with role="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?

Useful articles