JavaScript and React course Β· Module 13: Testing React
Testing Asynchronous Components - Signals from Deep Space
In this lesson5
Many operations in React applications work asynchronously - fetching data from an API, loading resources, delayed updates. It's like receiving signals from deep space - the response doesn't come immediately, you have to wait.
A plain test checks the screen right after render. If the data arrives 50 ms later, the assertion has long since run and the test fails, even though the component works. We need tools that wait for the signal, but not forever.
waitFor - Waiting for the Signal
The waitFor function repeats an assertion check until it succeeds or the timeout expires. The callback you pass may be called many times, so it should contain only assertions:
1import { render, screen, waitFor } from '@testing-library/react';
2
3test('loads mission data from API', async () => {
4 render(<MissionDetails missionId="apollo-13" />);
5
6 // Spinner is visible immediately
7 expect(screen.getByText('Loading...')).toBeInTheDocument();
8
9 // Wait for data
10 await waitFor(() => {
11 expect(screen.getByText('Apollo 13')).toBeInTheDocument();
12 });
13
14 // Spinner should be gone
15 expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
16});The order of assertions mirrors the component's life: first the spinner, then the data, and finally the spinner disappears. The last check uses queryByText, because getByText would throw once the element is gone.
waitFor Configuration
You can change the default timings with the second argument:
1await waitFor(
2 () => {
3 expect(screen.getByText('Data loaded')).toBeInTheDocument();
4 },
5 {
6 timeout: 3000, // Maximum wait time (default 1000ms)
7 interval: 100, // How often to check (default 50ms)
8 }
9);Increase timeout only when an operation really takes longer, because long waits slow down the whole test suite.
findBy Queries - Shortcut for waitFor
findBy queries are a shortcut combining waitFor with getBy. They return a Promise that resolves once the element appears:
1// These two are equivalent:
2
3// Method 1: waitFor + getBy
4await waitFor(() => {
5 expect(screen.getByText('Mission loaded')).toBeInTheDocument();
6});
7
8// Method 2: findBy (recommended!)
9const element = await screen.findByText('Mission loaded');
10expect(element).toBeInTheDocument();Both versions do the same thing, but findBy is shorter and states more clearly what we are waiting for. Keep waitFor for cases where you wait for something other than an element appearing, such as a mock being called.
Example with a Data-Fetching Component
Here is a typical component that fetches a crew list after mounting. It has three states: loading, error and data:
1function CrewList({ shipId }) {
2 const [crew, setCrew] = useState([]);
3 const [loading, setLoading] = useState(true);
4 const [error, setError] = useState(null);
5
6 useEffect(() => {
7 fetch(`/api/ships/${shipId}/crew`)
8 .then(res => res.json())
9 .then(data => {
10 setCrew(data);
11 setLoading(false);
12 })
13 .catch(err => {
14 setError(err.message);
15 setLoading(false);
16 });
17 }, [shipId]);
18
19 if (loading) return <p>Loading crew...</p>;
20 if (error) return <p>Error: {error}</p>;
21
22 return (
23 <ul>
24 {crew.map(member => (
25 <li key={member.id}>{member.name}</li>
26 ))}
27 </ul>
28 );
29}The effect with fetch runs after the first render, so "Loading crew..." always shows first. That transitional state is exactly what we will check.
Testing This Component
In a test we do not want the real network. We replace global.fetch with a jest.fn() mock and decide what it returns:
1// Mock fetch
2beforeEach(() => {
3 global.fetch = jest.fn();
4});
5
6test('displays crew members after loading', async () => {
7 fetch.mockResolvedValueOnce({
8 json: () => Promise.resolve([
9 { id: 1, name: 'Captain Nova' },
10 { id: 2, name: 'Engineer Bolt' },
11 ])
12 });
13
14 render(<CrewList shipId="enterprise" />);
15
16 // Loading state
17 expect(screen.getByText('Loading crew...')).toBeInTheDocument();
18
19 // Wait for data
20 expect(await screen.findByText('Captain Nova')).toBeInTheDocument();
21 expect(screen.getByText('Engineer Bolt')).toBeInTheDocument();
22 expect(screen.queryByText('Loading crew...')).not.toBeInTheDocument();
23});
24
25test('displays error when fetch fails', async () => {
26 fetch.mockRejectedValueOnce(new Error('Network error'));
27
28 render(<CrewList shipId="enterprise" />);
29
30 expect(await screen.findByText('Error: Network error')).toBeInTheDocument();
31});mockResolvedValueOnce returns an object that imitates a response with a json method, while mockRejectedValueOnce simulates a network failure. So we cover both paths, success and error, without a single real request.
When every fetch call should get the same response, one line with mockResolvedValue, without Once, is enough:
1const data = [{ id: 1, name: 'Captain Nova' }];
2global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve(data) });mockResolvedValue answers the same way on every call, while mockResolvedValueOnce answers only the next one. Choose the Once version when consecutive calls should get different responses, for example an error first and then data.
act() - Synchronization with React
The act() function tells React that we're performing an operation that updates state and requires re-rendering. Let's see it on a launch countdown. The component decreases the counter every second with setInterval:
1function CountdownTimer({ seconds }) {
2 const [remaining, setRemaining] = useState(seconds);
3
4 useEffect(() => {
5 const id = setInterval(() => {
6 setRemaining(r => (r > 0 ? r - 1 : 0));
7 }, 1000);
8 return () => clearInterval(id);
9 }, []);
10
11 return <p>{remaining}</p>;
12}Two details matter in tests. The functional update setRemaining(r => ...) always receives the latest value, even though the function inside the interval was created during the first render and on its own would only see the initial remaining. The clearInterval cleanup stops the clock when the component disappears, and RTL unmounts the component after every test, so the interval does not keep ticking in the background of the next test.
Now the test. We import act from the react package, because the old act from react-dom/test-utils is deprecated in React 19:
1import { act } from 'react';
2
3test('updates timer display', () => {
4 jest.useFakeTimers();
5 render(<CountdownTimer seconds={10} />);
6
7 expect(screen.getByText('10')).toBeInTheDocument();
8
9 // Fast-forward time by 3 seconds
10 act(() => {
11 jest.advanceTimersByTime(3000);
12 });
13
14 expect(screen.getByText('7')).toBeInTheDocument();
15
16 jest.useRealTimers();
17});jest.useFakeTimers() replaces the clock, and advanceTimersByTime(3000) moves it forward three seconds without waiting. Advancing the clock changes state outside RTL, which is why we wrap it in act. At the end we restore real timers so we do not disturb other tests.
When is act() Needed?
- React Testing Library automatically wraps
render,fireEventanduserEventevents, and the waiting inwaitForandfindBy, inact() - You need to use it manually when updating state outside RTL's tools (e.g., fake timers, direct callback calls)
If you see a "not wrapped in act(...)" warning in the console, it signals that some state update happened after the test finished or outside RTL's tools.
Testing Debounced and Throttled Operations
A debounced search box does not send a request after every keystroke; it waits until the user stops typing. Here is such a component. Every new character changes query, and the effect cleanup cancels the previous setTimeout, so onSearch fires only after 300 ms of silence:
1function SearchBar({ onSearch, debounceMs = 300 }) {
2 const [query, setQuery] = useState('');
3
4 useEffect(() => {
5 if (!query) return;
6 const id = setTimeout(() => onSearch(query), debounceMs);
7 return () => clearTimeout(id);
8 }, [query, debounceMs, onSearch]);
9
10 return (
11 <input
12 aria-label="Search missions"
13 value={query}
14 onChange={e => setQuery(e.target.value)}
15 />
16 );
17}The test combines fake timers with userEvent. The advanceTimers option tells the library how to move the replaced clock:
1test('calls onSearch once, after the user stops typing', async () => {
2 jest.useFakeTimers();
3 const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
4 const onSearch = jest.fn();
5
6 render(<SearchBar onSearch={onSearch} debounceMs={300} />);
7
8 await user.type(screen.getByRole('textbox'), 'Mars');
9
10 // Before the debounce expires: no request yet
11 expect(onSearch).not.toHaveBeenCalled();
12
13 // After the debounce expires
14 act(() => {
15 jest.advanceTimersByTime(300);
16 });
17
18 expect(onSearch).toHaveBeenCalledTimes(1);
19 expect(onSearch).toHaveBeenCalledWith('Mars');
20
21 jest.useRealTimers();
22});Without the advanceTimers option, user.type would wait for real delays between keystrokes and the test would hang. After typing, we move time forward by 300 ms, exactly the debounce length. Four keystrokes, yet one call with the full word Mars: that is exactly the promise of debounce that this test checks. Throttling is tested the same way, except you expect at most one call per time window.
waitForElementToBeRemoved
Waiting for an element to disappear (e.g., a spinner). You import the function from @testing-library/react, just like waitFor:
1test('hides loading spinner after data loads', async () => {
2 render(<DataPanel />);
3
4 // Spinner is visible
5 const spinner = screen.getByText('Loading...');
6
7 // Wait for it to disappear
8 await waitForElementToBeRemoved(spinner);
9
10 // Data should be visible
11 expect(screen.getByText('Data ready')).toBeInTheDocument();
12});The element has to be found before it disappears, which is why getByText comes before waitForElementToBeRemoved. The function waits for the DOM node itself to be removed. If the component only swaps the text inside the same tag, for example the same <p> first shows "Loading..." and then "Data ready", the node stays in place and the test ends with a timeout. In that case wait for the new text with findByText.
My advice: choose findBy when you wait for an element, and waitFor only when you wait for something else. In the next lesson you will learn to mock whole modules and APIs. Remember: an async test is a patient radar that listens for a signal, but with a fixed time limit.
Code for this lesson: App.jsx
1import React, { useState, useEffect } from 'react';
2
3// Simulated API: the preview has no server, so the response comes
4// from a Promise and setTimeout, just like the mock in the lesson's tests
5const MISSIONS = [
6 { id: 1, name: 'Apollo 11', status: 'completed' },
7 { id: 2, name: 'Artemis I', status: 'active' },
8 { id: 3, name: 'Mars 2030', status: 'planned' },
9];
10
11const STATUS_LABELS = { completed: 'completed', active: 'active', planned: 'planned' };
12
13function fakeApi(endpoint, delay = 1500) {
14 return new Promise((resolve, reject) => {
15 setTimeout(() => {
16 if (endpoint === '/api/missions') {
17 resolve(MISSIONS);
18 } else {
19 reject(new Error('lost contact with mission control'));
20 }
21 }, delay);
22 });
23}
24
25// Asynchronous component: loading, then data or an error
26function MissionLoader({ endpoint }) {
27 const [data, setData] = useState(null);
28 const [error, setError] = useState(null);
29 const [attempt, setAttempt] = useState(0);
30
31 useEffect(() => {
32 let ignore = false;
33 fakeApi(endpoint)
34 .then(result => {
35 if (!ignore) setData(result);
36 })
37 .catch(err => {
38 if (!ignore) setError(err.message);
39 });
40 return () => {
41 ignore = true;
42 };
43 }, [endpoint, attempt]);
44
45 if (error) {
46 return (
47 <div className="error">
48 <p role="alert">Signal lost: {error}</p>
49 <button
50 onClick={() => {
51 setError(null);
52 setAttempt(a => a + 1);
53 }}
54 >
55 Reconnect
56 </button>
57 </div>
58 );
59 }
60
61 if (!data) {
62 return (
63 <div className="loading" role="status">
64 <div className="spinner" />
65 <p>Receiving a signal from deep space...</p>
66 </div>
67 );
68 }
69
70 return (
71 <div className="mission-data">
72 <h3>Missions received ({data.length})</h3>
73 <ul>
74 {data.map(mission => (
75 <li key={mission.id} className={`mission-${mission.status}`}>
76 <strong>{mission.name}</strong>
77 <span className="status-badge">{STATUS_LABELS[mission.status]}</span>
78 </li>
79 ))}
80 </ul>
81 </div>
82 );
83}
84
85// Debounced search: the request goes out only 300 ms after the last keystroke
86function DebouncedSearch({ debounceMs = 300 }) {
87 const [query, setQuery] = useState('');
88 const [sent, setSent] = useState([]);
89
90 useEffect(() => {
91 if (!query) return;
92 const id = setTimeout(() => {
93 setSent(prev => [...prev, query]);
94 }, debounceMs);
95 return () => clearTimeout(id);
96 }, [query, debounceMs]);
97
98 return (
99 <div className="search">
100 <h3>Debounced search</h3>
101 <label htmlFor="mission-search">Search missions</label>
102 <input
103 id="mission-search"
104 value={query}
105 onChange={e => setQuery(e.target.value)}
106 placeholder="Type fast: Mars"
107 />
108 <p>Requests sent: {sent.length}</p>
109 {sent.length > 0 && <p className="last-query">Last request: {sent[sent.length - 1]}</p>}
110 </div>
111 );
112}
113
114// Countdown: setInterval, a functional update and cleanup with clearInterval
115function CountdownTimer({ seconds }) {
116 const [remaining, setRemaining] = useState(seconds);
117 const [running, setRunning] = useState(false);
118 const counting = running && remaining > 0;
119
120 useEffect(() => {
121 if (!counting) return;
122 const id = setInterval(() => {
123 setRemaining(r => Math.max(r - 1, 0));
124 }, 1000);
125 return () => clearInterval(id);
126 }, [counting]);
127
128 return (
129 <div className="countdown">
130 <h3>Launch countdown</h3>
131 <div className="timer-display">
132 {remaining > 0 ? (
133 <span className="time">{remaining}</span>
134 ) : (
135 <span className="launched">Liftoff!</span>
136 )}
137 </div>
138 <div className="btn-row">
139 <button onClick={() => setRunning(true)} disabled={counting || remaining === 0}>
140 {counting ? 'Counting down...' : 'Start countdown'}
141 </button>
142 <button
143 onClick={() => {
144 setRunning(false);
145 setRemaining(seconds);
146 }}
147 >
148 Reset
149 </button>
150 </div>
151 </div>
152 );
153}
154
155export default function App() {
156 const [endpoint, setEndpoint] = useState('/api/missions');
157
158 return (
159 <div className="app">
160 <h1>Testing Async - Signals from Space</h1>
161 <p className="note">
162 The preview shows the working components from the lesson. You run the tests
163 with fake timers and findBy in Jest, in your own project.
164 </p>
165
166 <div className="controls">
167 <button
168 onClick={() => setEndpoint('/api/missions')}
169 className={endpoint === '/api/missions' ? 'active' : ''}
170 aria-pressed={endpoint === '/api/missions'}
171 >
172 Load missions
173 </button>
174 <button
175 onClick={() => setEndpoint('/api/error')}
176 className={endpoint === '/api/error' ? 'active' : ''}
177 aria-pressed={endpoint === '/api/error'}
178 >
179 Simulate error
180 </button>
181 </div>
182
183 {/* key: when the source changes, the component starts over from the loading state */}
184 <MissionLoader key={endpoint} endpoint={endpoint} />
185 <DebouncedSearch />
186 <CountdownTimer seconds={10} />
187 </div>
188 );
189}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What does the waitFor function do in React Testing Library?
2. When do you need to use act() by hand in React tests?
Hands-on tasks in the game
- Code editor
Finish the SearchFilter search in the space object atlas. ___BLANK1___: an object matches the search when its name contains the typed text, where neither letter case nor spaces at the start and end of the typed text matter (empty text matches everything). ___BLANK2___: an object matches the category when All is selected (the value 'all') or when its category equals the selected one. ___BLANK3___: when no object is left after filtering, a message with role="status" is shown instead of the list. The test types text into the search field (the searchbox role), picks a category from the list and checks the list items.
- Horizontal ordering
Arrange the elements of the fetch mocking syntax using jest.fn() in the correct order:
- Vertical ordering
Arrange the stages of testing an asynchronous component in the correct order:
- Code editor
Finish CountdownTimer, which counts down the seconds to the rocket launch after you click Start. ___BLANK1___: every second the updater function lowers the current value by 1 (use the current parameter, not the left variable, because the interval is created once and would see an old value). ___BLANK2___: the effect returns a cleanup function that calls clearInterval(id), so that Pause, Reset and unmounting stop the interval. ___BLANK3___: elapsed is the number of seconds that have already passed (seconds minus left); the progress bar shows it in aria-valuenow. The test turns on fake timers and moves time forward second by second.