Testing Asynchronous Code in React

Loading

Testing asynchronous code is essential in modern React applications, especially when dealing with API calls, state updates, or other side effects. React components often need to handle asynchronous operations, such as fetching data from an API or waiting for user input. In these cases, you must ensure that your tests wait for the asynchronous operations to complete before asserting results.

Here’s a guide on how to test asynchronous code in React applications using Jest and React Testing Library.

1. Understanding Asynchronous Testing in React

In React, asynchronous operations often involve:

  • API requests (e.g., fetching data).
  • Event handlers (e.g., submitting a form or button click).
  • Timers (e.g., setTimeout or setInterval).
  • State updates that happen asynchronously (e.g., through useEffect).

When writing tests for asynchronous behavior, you need to make sure your tests wait for these operations to finish before making assertions.

2. Testing Asynchronous Code with React Testing Library

React Testing Library works seamlessly with Jest’s async utilities to test asynchronous code. The primary methods used are:

  • findBy* queries (e.g., findByText, findByRole) that wait for elements to appear asynchronously.
  • waitFor to wait for conditions to be met in your component.

3. Test Example: Fetching Data from an API

Consider a React component that fetches user data from an API and displays it. This component makes an asynchronous API call when it mounts.

Example Component

// UserProfile.js
import React, { useState, useEffect } from 'react';

const UserProfile = () => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const fetchUser = async () => {
      const response = await fetch('https://api.example.com/user');
      const data = await response.json();
      setUser(data);
      setLoading(false);
    };
    fetchUser();
  }, []);

  if (loading) return <h1>Loading...</h1>;
  return <h1>{user?.name}</h1>;
};

export default UserProfile;

This component will display “Loading…” while the data is being fetched and show the user’s name once the data is loaded.

4. Testing the Asynchronous Behavior

To test this component, we need to:

  1. Mock the API call to control the data returned.
  2. Render the component.
  3. Assert that the loading state is displayed first.
  4. Wait for the component to update with the fetched data.

Example Test for Asynchronous Data Fetching

import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';

// Mock the global fetch function
global.fetch = jest.fn();

describe('UserProfile', () => {
  it('fetches and displays user data', async () => {
    // Mock the API response
    const mockUserData = { name: 'John Doe' };
    fetch.mockResolvedValueOnce({
      json: jest.fn().mockResolvedValueOnce(mockUserData),
    });

    // Render the component
    render(<UserProfile />);

    // Assert that "Loading..." is initially displayed
    expect(screen.getByText('Loading...')).toBeInTheDocument();

    // Wait for the user data to be displayed
    await waitFor(() => screen.getByText('John Doe'));

    // Assert that the user's name is displayed
    expect(screen.getByText('John Doe')).toBeInTheDocument();
  });
});

5. Key Concepts in Testing Asynchronous Code

a) Mocking Fetch Requests

In this example, we mock the fetch API using jest.fn(). This ensures that we don’t make real API calls during tests, which is important for fast and reliable testing.

global.fetch = jest.fn();

We use mockResolvedValueOnce to simulate a successful API response with the desired data.

b) Waiting for Changes with waitFor

waitFor is a utility that allows you to wait for a specific condition to be true. It waits until the DOM is updated with the correct state, such as when data has been fetched and displayed.

await waitFor(() => screen.getByText('John Doe'));

In this case, waitFor ensures that the test doesn’t fail before the asynchronous operation completes.

c) Assertions Before and After the Async Operation

You can assert that certain elements are displayed while the component is still in its loading state ("Loading...") and once the data has been fetched and the component is updated with the new state (John Doe).

expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(screen.getByText('John Doe')).toBeInTheDocument();

6. Testing Asynchronous Code with findBy* Queries

You can also use findBy* queries, which are asynchronous by default. These queries will automatically wait for the element to appear before continuing the test.

Example Using findByText

it('fetches and displays user data with findByText', async () => {
  const mockUserData = { name: 'John Doe' };
  fetch.mockResolvedValueOnce({
    json: jest.fn().mockResolvedValueOnce(mockUserData),
  });

  render(<UserProfile />);

  // Assert that "Loading..." is displayed initially
  expect(screen.getByText('Loading...')).toBeInTheDocument();

  // Wait for the user data to appear
  const userName = await screen.findByText('John Doe');

  // Assert that the user's name is displayed
  expect(userName).toBeInTheDocument();
});

Here, findByText waits for the element to appear, removing the need for waitFor.

7. Testing Asynchronous Code with Timers

Sometimes your components depend on timers (e.g., setTimeout or setInterval). Jest provides timer mocks to control the timing in your tests.

Example with setTimeout

// DelayedMessage.js
import React, { useState, useEffect } from 'react';

const DelayedMessage = () => {
  const [message, setMessage] = useState('');

  useEffect(() => {
    const timer = setTimeout(() => {
      setMessage('Hello, World!');
    }, 2000);

    return () => clearTimeout(timer); // Cleanup the timer
  }, []);

  return <div>{message}</div>;
};

export default DelayedMessage;

Test for Delayed Message with Timer

To test this, we need to mock the timers using jest.useFakeTimers and simulate the passage of time.

import { render, screen, act } from '@testing-library/react';
import DelayedMessage from './DelayedMessage';

jest.useFakeTimers();

describe('DelayedMessage', () => {
  it('displays message after 2 seconds', () => {
    render(<DelayedMessage />);

    // Assert that no message is initially shown
    expect(screen.queryByText('Hello, World!')).toBeNull();

    // Simulate the passage of 2 seconds
    act(() => {
      jest.advanceTimersByTime(2000); // Advance the timers by 2 seconds
    });

    // Assert that the message appears after the delay
    expect(screen.getByText('Hello, World!')).toBeInTheDocument();
  });
});

8. Using await with act

When testing asynchronous updates (like state changes after API calls), you might need to use act() in some cases to ensure all state updates are flushed before assertions. This is especially true when dealing with timers or useEffect.

import { act } from 'react-dom/test-utils';

await act(async () => {
  jest.advanceTimersByTime(2000); // Simulate 2 seconds passing
});

Leave a Reply

Your email address will not be published. Required fields are marked *