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.
The
waitFor function repeats an assertion check until it succeeds or the timeout expires: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});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);findBy queries are a shortcut combining waitFor with getBy: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();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}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});The
act() function tells React that we're performing an operation that updates state and requires re-rendering: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});render and fireEvent in act()1test('debounced search filters results', async () => {
2 jest.useFakeTimers();
3 const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
4
5 render(<SearchBar onSearch={jest.fn()} debounceMs={300} />);
6
7 await user.type(screen.getByRole('textbox'), 'Mars');
8
9 // Before debounce expires -- no results
10 expect(screen.queryByText('Searching...')).not.toBeInTheDocument();
11
12 // After debounce expires
13 act(() => {
14 jest.advanceTimersByTime(300);
15 });
16
17 await waitFor(() => {
18 expect(screen.getByText('Searching...')).toBeInTheDocument();
19 });
20
21 jest.useRealTimers();
22});Waiting for an element to disappear (e.g., a spinner):
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});