Development

A Comprehensive Guide to Testing React Applications with React Testing Library

Umur Alpay
26 May 2023

In the realm of web development, testing is integral for maintaining a robust and reliable codebase. It ensures your application behaves as expected under different scenarios and facilitates easier debugging, making it a crucial part of the development lifecycle. When working with React, a popular library for building user interfaces, this is particularly relevant.

React Testing Library (RTL), part of the Testing Library family, has emerged as a robust tool in the developer's arsenal. It simplifies testing React components by providing a lightweight solution for testing UIs and emphasizing accessibility. React Testing Library encourages testing practices that give developers more confidence in their application's behavior, favoring tests that closely resemble how users interact with the app in the real world.

In this blog post, we'll provide a comprehensive guide on using React Testing Library to test React applications. We will cover setting up the library in your project, understanding its underlying principles, writing your first test, exploring the various APIs, and tackling more complex testing scenarios. By the end of this guide, you'll have the knowledge and confidence to ensure your React applications are thoroughly tested and ready for any situation.

Getting Started: Setting Up React Testing Library

The first step to testing your React application using React Testing Library is setting up the necessary environment. Thankfully, integrating React Testing Library into your project is a straightforward process. Let's walk through the installation and setup process.

Installation

React Testing Library is a testing utility that you can add to any JavaScript project. It is available as a package on npm, the package manager that ships with Node.js. To install it, navigate to your project directory in your terminal and run the following command:

npm install --save-dev @testing-library/react

The --save-dev flag ensures that React Testing Library is added as a development dependency, as it's not required in the production build of your application.

Setup

Once you've installed React Testing Library, it's immediately ready to be used in your tests. To start writing a test, create a new file with a .test.js extension. The exact location and naming can depend on your project's structure and your personal preference, but many developers place their test files alongside the component files they're testing.

In your test file, you can import the functions you need from React Testing Library. The most common ones you'll use are render (to render a React component) and various query functions (to inspect the rendered output).

Here's a minimal example of a test for a simple React component:

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

test('renders the correct content', () => {
  render(<MyComponent />);

  expect(screen.getByText('Hello, world!')).toBeInTheDocument();
});

In this example, we first render MyComponent using the render function from React Testing Library. Then, we use the getByText query function to find a piece of text in the rendered output. The toBeInTheDocument function comes from Jest-DOM and allows us to check if the expected text is present in the document.

With React Testing Library installed and set up, you're now ready to dive deeper into testing your React applications. In the next sections, we'll explore the philosophy behind React Testing Library, the APIs it provides, and how you can write effective tests for your React components.

Understanding the Philosophy of React Testing Library

Before we dive into the details of writing tests using React Testing Library, it's essential to understand its philosophy and what sets it apart from other testing libraries. React Testing Library operates under a central guiding principle: "The more your tests resemble the way your software is used, the more confidence they can give you."

This philosophy is based on the belief that the best way to have confidence in your tests is by writing tests that mirror how your software is used as closely as possible. This approach often includes the avoidance of implementation details, making your tests more resilient to changes in the underlying codebase. This focus helps ensure your tests provide genuine value by giving you confidence that your application will work when used by real users.

The goal of testing isn't merely to achieve 100% test coverage but to have confidence in your application's functionality and user experience. Here's how React Testing Library promotes this:

Focus on User Interaction: React Testing Library encourages tests that focus on how users interact with your application. Rather than accessing component state or instance methods, tests should interact with the rendered components using the same APIs that your users would—clicking buttons, typing into inputs, and so forth.

Accessible Queries: One of the ways React Testing Library encourages user-centric testing is through its query API. The library provides various ways to select elements from the rendered output, but it prioritizes methods that are based on accessibility. For example, you can select elements by their label text (as users see) or their ARIA roles.

Avoidance of Implementation Details: React Testing Library promotes the idea of avoiding testing implementation details. Your users don't care about how something is implemented—they care about whether it works. By avoiding implementation details, your tests become more robust, maintainable, and less prone to false positives or negatives due to internal code changes.

In the upcoming sections, we will see how these philosophies play out in practice as we learn how to write tests using React Testing Library. Understanding this philosophy will guide us in writing effective, meaningful tests that truly provide confidence in our application's functionality and usability.

Writing Your First Test with React Testing Library

Now that we've discussed the philosophy behind React Testing Library and set up our testing environment, let's write our first test. We'll start with a simple React component and write a test that checks its rendering and functionality.

Suppose we have a simple "Hello World" component, like the following:

import React from 'react';

const HelloWorld = () => <h1>Hello, World!</h1>;

export default HelloWorld;

To write a test for this HelloWorld component, we'll start by importing render and screen from React Testing Library in a new test file:

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

Next, we'll write a test that renders the HelloWorld component and checks that the expected text is displayed. We'll use the render function to render the component, and then screen.getByText to find the "Hello, World!" text in the rendered output:

test('renders hello world', () => {
  render(<HelloWorld />);
  const helloElement = screen.getByText(/hello, world/i);
  expect(helloElement).toBeInTheDocument();
});

This test uses Jest's test function to define a test. Inside the test, we first call render(<HelloWorld />) to render our component. We then call screen.getByText(/hello, world/i) to search for an element that has the text "Hello, World!". The i at the end of /hello, world/i makes our search case-insensitive.

Finally, we use Jest's expect function with the .toBeInTheDocument() matcher to assert that the expected element is found in the document. If the "Hello, World!" text is correctly displayed by our component, this assertion will pass, and our test will succeed.

Now you've written your first test using React Testing Library! This basic pattern—render a component, find an element, make an assertion—will be the basis for most of the tests you'll write. As you gain experience with React Testing Library, you'll start to use more of its features and techniques, which we'll discuss in the next sections.

Exploring the API: Queries, Events and Asynchronous Utilities

React Testing Library offers a wide range of API methods that help write tests which closely resemble how users interact with your application. We'll focus on three primary sets of these APIs: Queries, Events, and Asynchronous Utilities.

Queries

Queries allow you to select elements from the component that you've rendered. React Testing Library provides several query methods, each with a specific use case:

getBy: This is used when the element you're querying for is expected to be in the document. If the element is not found, it throws an error.

queryBy: This is used when the element you're querying for is not expected to be in the document. If the element is not found, it returns null instead of throwing an error.

findBy: This is used when you need to wait for an element to appear in the document, typically when dealing with asynchronous actions.

Each of these categories comes in several flavors that let you query by different selectors, like text content (ByRole, ByLabelText, ByPlaceholderText, ByText, ByDisplayValue, ByAltText, ByTitle).

Events

React Testing Library allows you to simulate user events, like clicks or keyboard input, using the fireEvent function:

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

test('Button click changes the text', () => {
  render(<MyButton />);
  
  const buttonElement = screen.getByRole('button', { name: /click me/i });
  fireEvent.click(buttonElement);

  expect(screen.getByText(/you clicked the button/i)).toBeInTheDocument();
});

In this example, we first render MyButton and then use getByRole to find a button with the text "Click me". We then use fireEvent.click to simulate a click event on the button. After the click, we check that the text "You clicked the button" is now displayed.

Asynchronous Utilities

When dealing with asynchronous behavior (like fetching data from an API), React Testing Library provides several utilities to help write tests, including waitFor and the findBy queries. Here's an example of how you might test a component that fetches data when a button is clicked:

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

test('Fetches data and displays it', async () => {
  render(<MyAsyncComponent />);

  const buttonElement = screen.getByRole('button', { name: /fetch data/i });
  fireEvent.click(buttonElement);

  const dataElement = await screen.findByText(/fetched data: /i);
  expect(dataElement).toBeInTheDocument();
});

In this example, we click a "Fetch data" button and then use findByText to wait for an element with the text "Fetched data: " to appear. The findBy queries return a promise that resolves when the element is found, making it perfect for waiting for asynchronous updates.

These are the basics of the React Testing Library API that you'll be using most often. However, the library provides many more utilities and options, so it's worth checking out the official React Testing Library docs for a full overview.

Integration and Component Testing in Depth

After covering the basics of writing tests using React Testing Library and exploring its API, it's time to delve into more complex testing scenarios, namely integration testing and in-depth component testing.

Integration Testing

While unit tests focus on isolated pieces of your application (like a single function or component), integration tests aim to test how different parts of your application work together. React Testing Library is particularly well-suited to integration tests, as it encourages testing the user experience rather than implementation details.

Suppose we have a form component that updates and displays the user's input. This component consists of a text input, a button, and a display element. To test this as a user would, we might simulate typing into the input, clicking the button, and then check that the display element updates with the typed text:

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

test('updates and displays input', async () => {
  render(<MyForm />);
  
  const inputElement = screen.getByLabelText('Input:');
  fireEvent.change(inputElement, { target: { value: 'Hello, World!' } });

  const buttonElement = screen.getByRole('button', { name: /submit/i });
  fireEvent.click(buttonElement);

  const displayElement = await screen.findByText(/you entered: hello, world!/i);
  expect(displayElement).toBeInTheDocument();
});

Component Testing in Depth

In-depth component testing involves testing components under various conditions and ensuring they behave as expected. This could involve testing different props, checking the component over time as it receives new props, or even testing a component's interaction with context or other parts of React's API.

For example, imagine we have a component that displays a different message based on a status prop. We might want to test this component with each possible status to ensure it behaves correctly:

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

test('displays the correct message for each status', () => {
  const statuses = ['loading', 'success', 'error'];
  const messages = [
    'Loading, please wait...',
    'Data loaded successfully!',
    'An error occurred.'
  ];

  statuses.forEach((status, index) => {
    render(<StatusMessage status={status} />);
    const messageElement = screen.getByText(new RegExp(messages[index], 'i'));
    expect(messageElement).toBeInTheDocument();
  });
});

In this example, we're testing the StatusMessage component with three different statuses. For each status, we render the component with that status and then check that the expected message is displayed.

Remember, the goal of testing is to give you confidence in your application's functionality and user experience. Integration tests and in-depth component tests are powerful tools in achieving this goal, as they let you ensure your application works as a cohesive unit and behaves correctly under various conditions.

Handling Complex Scenarios: Mocking and Server Side Rendering (SSR) Tests

As we move forward, testing in complex scenarios such as mocking external dependencies or handling server-side rendering (SSR) will become crucial. Let's discuss how to deal with these situations.

Mocking

Often while testing, you'll come across situations where you need to isolate your component from external dependencies like APIs, modules, or even complex parts of your own codebase. Mocking comes to rescue here. In Jest, you can mock modules using jest.mock():

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

// Mock the axios module
jest.mock('axios');

test('Fetches data and displays it', async () => {
  axios.get.mockResolvedValueOnce({ data: 'Hello, World!' });

  render(<FetchComponent />);
  
  const buttonElement = screen.getByRole('button', { name: /fetch data/i });
  fireEvent.click(buttonElement);

  const dataElement = await screen.findByText(/fetched data: hello, world!/i);
  expect(dataElement).toBeInTheDocument();
});

Here, we're mocking axios and manually resolving it to a response. Now when FetchComponent tries to use axios.get(), it will use our mock function instead.

Server Side Rendering (SSR) Tests

Server Side Rendering (SSR) is an essential part of many React applications, as it can improve performance and SEO. However, it also introduces a new layer of complexity when it comes to testing.

The good news is that React Testing Library works well with SSR. Since it operates on the final DOM (just like a user would), it doesn't matter whether that DOM was rendered on the client or the server. For the most part, you can write your tests the same way regardless of whether you're using SSR.

However, some aspects of your components might behave differently when rendered server-side. To test these aspects, you might need to render your components in a Node environment, or mock certain browser APIs that aren't available in Node.

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

test('renders correctly with server side rendering', () => {
  // Simulate server-side rendering environment
  global.window = undefined;

  render(<SSRComponent />);

  const element = screen.getByText(/rendered on the server/i);
  expect(element).toBeInTheDocument();
});

In this example, we're simulating a server-side rendering environment by setting global.window to undefined before rendering our component.

Testing complex scenarios may seem challenging at first, but with the right tools and techniques, it's entirely achievable. Remember, the ultimate goal is to gain confidence that your application will work correctly for your users, under any condition. Mocking and SSR testing are just two more tools in your toolbox for achieving that goal.

Continuous Integration with React Testing Library

As your project grows in size and complexity, running tests manually can become a tedious task. Thankfully, Continuous Integration (CI) can help automate this process. CI is a software development practice where developers integrate code into a shared repository frequently, preferably several times a day. Each integration is then verified by an automated build and automated tests.

Most modern CI services support running JavaScript and Node.js code, which means they can run your React Testing Library tests too. Here's a basic example of how you might set up CI with GitHub Actions:

  1. Create a new file in your repository at .github/workflows/test.yml:
name: Test
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Use Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '16'
      - name: Install dependencies
        run: npm ci
      - name: Run tests
        run: npm test

This configuration will trigger a new test run whenever code is pushed to the repository. It sets up a Node.js environment, installs your project's dependencies, and then runs your tests.

  1. Push this file to your repository. Now, whenever you push code, you'll see a new "Test" check in your GitHub pull requests. This check will run your React Testing Library tests and show whether they passed or failed.

Continuous Integration provides a safety net against potential bugs or errors that might be introduced during the development process. By integrating React Testing Library into your CI pipeline, you can ensure that your tests run automatically, helping to catch and resolve issues more quickly and efficiently.

Conclusion and Best Practices

We've walked through the journey of testing React applications with React Testing Library, from setting it up and understanding its philosophy, to writing your first test, delving into the library's API, conducting in-depth component testing, handling complex scenarios, and even integrating tests in a Continuous Integration pipeline.

As we wrap up, let's summarize some of the key best practices for testing with React Testing Library:

Test user interactions, not implementation details: Remember, React Testing Library encourages you to focus on how users interact with your application. Writing tests from this perspective helps ensure your application works for your users, not just for your tests.

Use query methods judiciously: The library offers a variety of query methods each tailored for specific use cases. Make sure you use the one that best suits your current need.

Leverage asynchronous utilities for handling async behavior: When dealing with async operations, use utilities like waitFor and findBy queries to handle asynchronicity in your tests.

Isolate external dependencies using mocks: Isolate your tests from external dependencies by using mocking. It helps make your tests more reliable and predictable.

Automate your tests with CI: Automate your testing process using a Continuous Integration service. This will ensure that your tests are run regularly, helping you catch and fix issues promptly.

Continue learning: The React Testing Library has a vast ecosystem, with a lot of resources and community support. Keep learning and exploring to make the most of it.

In conclusion, testing is an indispensable part of the development process. It provides the confidence that your application will work as expected in the hands of your users. With React Testing Library and its emphasis on user-centric testing, you're well-equipped to build robust, user-friendly React applications.


Follow me on Instagram, Facebook or Twitter if you like to keep posted about tutorials, tips and experiences from my side.

You can support me from Patreon, Github Sponsors, Ko-fi or Buy me a coffee