Chapter 13 - Unlock the Power of React Testing Library: Write Tests Users Love

React Testing Library tests components like users interact, focusing on behavior over implementation. It encourages accessible, robust tests by simulating user actions and checking outputs, leading to more maintainable and user-centric code.

Chapter 13 - Unlock the Power of React Testing Library: Write Tests Users Love

React Testing Library has revolutionized how we write tests for React components. It’s all about testing your app the way a user would interact with it, focusing on behavior rather than implementation details. This approach leads to more robust and maintainable tests.

Getting started with React Testing Library is a breeze. First, you’ll need to install it along with jest-dom for additional matchers. Just run npm install --save-dev @testing-library/react @testing-library/jest-dom in your project directory. Once that’s done, you’re ready to start writing tests.

Let’s dive into a simple example. Say we have a component that displays a greeting:

function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}

To test this component, we might write something like this:

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

test('displays a greeting', () => {
  render(<Greeting name="Alice" />);
  expect(screen.getByText('Hello, Alice!')).toBeInTheDocument();
});

This test renders the Greeting component, then uses the getByText query to find the greeting text and assert that it’s in the document. Simple, right?

But React Testing Library really shines when testing more complex interactions. Let’s say we have a counter component:

function Counter() {
  const [count, setCount] = React.useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

We can test this component like this:

import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';

test('increments count when button is clicked', () => {
  render(<Counter />);
  const button = screen.getByText('Increment');
  expect(screen.getByText('Count: 0')).toBeInTheDocument();
  fireEvent.click(button);
  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

Here, we’re not just checking what’s rendered, but also simulating user interactions and verifying the component responds correctly. This is the essence of React Testing Library - testing from the user’s perspective.

One of the great things about React Testing Library is how it encourages accessible code. The queries it provides, like getByRole, getByLabelText, and getByAltText, mirror the ways users (including those using assistive technologies) find elements on a page. This means that if your tests are easy to write, your app is likely to be more accessible.

When writing tests, it’s important to think about what you’re actually trying to verify. Are you testing that a certain element is rendered? That a state update occurs correctly? That an API call is made? React Testing Library encourages you to focus on behaviors, not implementation details.

For example, let’s say we have a component that fetches and displays user data:

function UserProfile({ userId }) {
  const [user, setUser] = React.useState(null);

  React.useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(response => response.json())
      .then(data => setUser(data));
  }, [userId]);

  if (!user) return <p>Loading...</p>;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
    </div>
  );
}

We might test this component like this:

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

test('displays user data when loaded', async () => {
  const fakeUser = { name: 'John Doe', email: '[email protected]' };
  jest.spyOn(global, 'fetch').mockImplementation(() =>
    Promise.resolve({
      json: () => Promise.resolve(fakeUser)
    })
  );

  render(<UserProfile userId="123" />);

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

  await waitFor(() => {
    expect(screen.getByText('John Doe')).toBeInTheDocument();
    expect(screen.getByText('Email: [email protected]')).toBeInTheDocument();
  });

  expect(global.fetch).toHaveBeenCalledWith('/api/users/123');

  global.fetch.mockRestore();
});

This test covers several behaviors: the initial loading state, the API call being made with the correct URL, and the user data being displayed once loaded. We’re using jest.spyOn to mock the fetch function, allowing us to control the response and verify it was called correctly.

One common pitfall when testing React components is testing implementation details. For example, you might be tempted to test that a certain function was called or that a component’s state changed in a particular way. But these kinds of tests are often fragile and can break when you refactor your code, even if the behavior hasn’t changed.

Instead, focus on the output - what the user sees and interacts with. This leads to more robust tests that give you confidence your app is working correctly from the user’s perspective.

Another important aspect of writing good tests is handling asynchronous operations. React Testing Library provides several utilities for this, including waitFor and findBy queries. These allow you to write tests that wait for certain conditions to be met, which is crucial when testing components that fetch data or have delayed renders.

Let’s look at another example. Say we have a component that loads and displays a list of todos:

function TodoList() {
  const [todos, setTodos] = React.useState([]);
  const [loading, setLoading] = React.useState(true);

  React.useEffect(() => {
    fetch('/api/todos')
      .then(response => response.json())
      .then(data => {
        setTodos(data);
        setLoading(false);
      });
  }, []);

  if (loading) return <p>Loading todos...</p>;

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

We could test this component like this:

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

test('loads and displays todos', async () => {
  const fakeTodos = [
    { id: 1, title: 'Buy milk' },
    { id: 2, title: 'Walk the dog' },
  ];
  jest.spyOn(global, 'fetch').mockImplementation(() =>
    Promise.resolve({
      json: () => Promise.resolve(fakeTodos)
    })
  );

  render(<TodoList />);

  expect(screen.getByText('Loading todos...')).toBeInTheDocument();

  const todoItems = await screen.findAllByRole('listitem');
  expect(todoItems).toHaveLength(2);
  expect(todoItems[0]).toHaveTextContent('Buy milk');
  expect(todoItems[1]).toHaveTextContent('Walk the dog');

  global.fetch.mockRestore();
});

Here, we’re using the findAllByRole query, which returns a promise that resolves when the elements are found. This allows us to wait for the todos to be loaded and rendered without explicitly using waitFor.

When writing tests, it’s also important to consider edge cases and error states. What happens if the API returns an error? What if the data is in an unexpected format? Testing these scenarios can help you catch and handle errors before they affect your users.

For example, let’s modify our TodoList component to handle errors:

function TodoList() {
  const [todos, setTodos] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState(null);

  React.useEffect(() => {
    fetch('/api/todos')
      .then(response => {
        if (!response.ok) throw new Error('Failed to fetch');
        return response.json();
      })
      .then(data => {
        setTodos(data);
        setLoading(false);
      })
      .catch(error => {
        setError(error.message);
        setLoading(false);
      });
  }, []);

  if (loading) return <p>Loading todos...</p>;
  if (error) return <p>Error: {error}</p>;

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

Now we can add a test for the error case:

test('handles error when fetching todos fails', async () => {
  jest.spyOn(global, 'fetch').mockImplementation(() =>
    Promise.resolve({
      ok: false,
      status: 500
    })
  );

  render(<TodoList />);

  expect(screen.getByText('Loading todos...')).toBeInTheDocument();

  const errorMessage = await screen.findByText(/Error:/);
  expect(errorMessage).toBeInTheDocument();

  global.fetch.mockRestore();
});

This test ensures that our component correctly displays an error message when the API request fails.

As your test suite grows, you might find yourself repeating certain setup steps in multiple tests. This is where custom render functions can be helpful. You can create a function that wraps React Testing Library’s render function with any providers or other setup your components need.

For example, if your app uses React Router, you might create a custom render function like this:

import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';

function renderWithRouter(ui, { route = '/' } = {}) {
  window.history.pushState({}, 'Test page', route);

  return render(ui, { wrapper: BrowserRouter });
}

Then you can use this in your tests:

test('navigates to about page', () => {
  renderWithRouter(<App />);
  const aboutLink = screen.getByText('About');
  fireEvent.click(aboutLink);
  expect(screen.getByText('About Us')).toBeInTheDocument();
});

This approach can make your tests cleaner and easier to maintain, especially for larger applications with complex setups.

When it comes to testing forms, React Testing Library really shines. It provides utilities for interacting with form elements in a way that closely mimics user behavior. Let’s look at an example of testing a simple login form:

function LoginForm({ onSubmit }) {
  const [username, setUsername] = React.useState('');
  const [password, setPassword] = React.useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    onSubmit({ username, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="username">Username:</label>
      <input
        id="username"
        value={username}
        onChange={(e) => setUsername(e.target.value)}
      />
      <label htmlFor="password">Password:</label>
      <input
        id="password"
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit">Log In</button>
    </form>
  );
}

We can test this form like this:

import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import LoginForm from './LoginForm';

test('submits username and password', () => {
  const handleSubmit = jest.fn();
  render(<LoginForm onSubmit={handleSubmit} />);

  fireEvent.change(screen.getByLabelText('Username:'), {
    target: { value: 'testuser' },
  });