React Unit Testing

React Unit Testing

Author: Chidume Nnamdi

Introduction

Unit testing is a crucial step in the software development process, where developers meticulously examine the smallest functional components, known as units, to ensure their proper functioning. This process involves thorough testing conducted by software developers, and occasionally by QA personnel, as an integral part of the development lifecycle.

Unit testing helps us have faith and strong faith at that, that our software works well and in the most bizarre use cases. Users would not want bug-ridden software. Once users find glitches and bugs in software, they lose hope and if the bugs and glitches are not fixed immediately they might abandon the software altogether. So we see that bugs contribute a lot to how software progress in the market.

Sometimes, it's difficult to test and cover all use cases before the software is deployed, but unit testing helps us test different independent units of our software. Making sure that they are working correctly and will stand the test of time.

Let's see an example of how unit testing is important. Let's say we have a add function in an application that we use in our calculator application. This add function accepts two parameters, and these parameters should be a number. Now, in the real world, users might input words and characters or even symbols, this will cause our application to glitch because we did not know that users might enter inputs other than numbers. But with unit testing of the add function before deployment, we would have caught this and refactored the add function to handle these scenarios.

So we see how unit testing is very crucial in the software development of any application. So in that unit testing has been a must in companies, developers must write tests in any feature they build in their application otherwise the software will be deemed a risk. Nobody wants to lose customers.

So many unit testing tools have sprung up in recent years making unit testing in the React framework very easy. In addition, almost all programming has testing frameworks or tools built for them. The popular ones are JUnit, PHUnit, Jasmine, Mocha, etc.

In this article, we will learn all about unit testing in the React.js framework. We will learn how to unit test our component down to hooks and Context.

Setting up a testing environment

Let's create a new React project.

create-react-app test-prj
cd test-prj

The testing tools that we will use are Jest and React Testing Libary.

  • Jest: This is a popular testing library built by Facebook. It has simple APIs that make testing very easy.
  • React Testing Libary: This is a testing built on-top DOM Testing Library that gives us the power to test React components. It comes with React project scaffolded with the create-react-app tool, if it is not present you can install it using npm install --save-dev @testing-library/react.

To install Jest we run the command: npm install --save-dev jest.

The next question is "Where do we write the test?"

Tests are written in js/ts files, but the .js/ts are preceded with .test so that Jest and React Testing Library can pick them up because that is the only we tell the testing libraries that those are our test files. So our test file for the add function will be add.test.js.

To run the test files, we will have to add a test section to the scripts section in our package.json.

{
  "scripts": {
    "test": "jest"
  }
}

Now, when we run the command: npm run test or yarn run test, Jest will search and collect all files with .test.*js|ts|tsx|tsx in their names and run them in a testing environment.

We can group the test files under a folder called __tests__. Jest we look for the folders in your application and run the .test.*js|ts|tsx|tsx in them.

Writing unit tests for React components

We will learn how to test a basic React component.

Let's write a component that renders "Hello World" and test that the component actually renders the popular greeting:

import React from "react";

const HelloWorld = () => {
  return <div>Hello World</div>;
};

export default HelloWorld;

Now, we will create a test file HelloWorld.test.js:

test('renders "Hello World" text', () => {
  const { getByText } = render(<HelloWorld />);
  const helloWorldElement = getByText("Hello World");
  expect(helloWorldElement).toBeInTheDocument();
});

In this test case, we render the HelloWorld component using the render function provided by React Testing Library. We then use the getByText function to retrieve the element that contains the "Hello World" text. Finally, we use the toBeInTheDocument matcher to check if the element is present in the rendered component.

The render function is used to render the component in a DOM, this is similar to the DOM in the browser. Once this is done we can then test the component using DOM API-like functions provided by the React Testing Library.

The getByText function searches the rendered DOM for the string passed to it.

The toBeInTheDocument function is used to test if the DOM object it was called exists in the DOM.

We can assign a test-id to elements in our component so we can pinpoint them directly by using the getTestById function provided by React Testing Library.

import React from "react";

const HelloWorld = () => {
  return <div data-testid="hello-world">Hello World</div>;
};

export default HelloWorld;

See the use of data-testid property in the div element there. To get the div element, we will call the getByTestId function passing in the value of the data-testid to the function: getByTestId("hello-world"). This returns the HTMLElement instance of the div element and then we can test the Hello World text node using the textContent DOM property.

import React from "react";
import { render } from "@testing-library/react";
import HelloWorld from "./HelloWorld";

test('renders "Hello World" text using getByTestId', () => {
  const { getByTestId } = render(<HelloWorld />);
  const helloWorldElement = getByTestId("hello-world");
  expect(helloWorldElement).toBeInTheDocument();
  expect(helloWorldElement.textContent).toBe("Hello World");
});

We use getByTestId('hello-world') to query the element with the data-testid attribute set to "hello-world". Then, we assert that the element is in the document and verify its textContent to be "Hello World" using the toBe matcher.

Now, let's move over to testing and firing events in components.

Test firing events

Let's say we have a Counter application that updates the DOM with the click of a button:

import React, { useState } from "react";

const Counter = ({ count }) => {
  const [increment, setIncrement] = useState(0);

  const handleIncrement = () => {
    setIncrement(increment + 1);
  };

  return (
    <div>
      <p>Increment: {increment}</p>
      <button onClick={handleIncrement}>Increment</button>
    </div>
  );
};

export default Counter;

Now, we have a state increment that holds the state of the application, and it is displayed in the DOM. The Increment button when clicked increases the state increment by one. So let's write a test for this component to make sure that the increment state is increased when the Increment button is clicked.

So, to test this we will need to somehow fire a click event on the Increment button, but how can we do this? We will see below.

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

test("increments count on button click", () => {
  const { getByText } = render(<Counter />);
  const incrementElement = getByText("Increment: 0");
  const buttonElement = getByText("Increment");

  fireEvent.click(buttonElement);

  expect(incrementElement.textContent).toBe("Increment: 1");
});

React Testing Library has a click() method set to the DOM instance of an element that simulates the click event as if it was actually clicked by a user.

See that we got the DOM instance of the button by calling this getByText('Increment'), then we called the click() method on its instance, this will fire the click event on the button causing the increment state to be increased by one, then we will thereafter to see if the state was really updated.

See in the last line, we got the text node of the div element and expect it to be Increment: 1.

Now, let's move over to testing states and props in components.

State and props of the components

This entails testing that the current value of a state is updated correctly when an action is executed.

Let's say we have a state:

import React, { useState } from "react";

const Counter = ({ count }) => {
  const [count, setCount] = useState(0);

  const increment = () => {
    setCount(count + 1);
  };

  return (
    <div>
      <p>
        Count: <span data-testid="count">{count}</span>
      </p>
      <button data-testid="button" onClick={increment}>
        Increment
      </button>
    </div>
  );
};

export default Counter;

We have a state count, and it is displayed in the DOM in the p element. The Increment button when clicked increments the state of the count by 1 and this in turn makes the component re-render and displayed the updated value of the count state.

Let's write a test that ensures that the count state is updated when the setCount is called.

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

test("increments count on button click", () => {
  const { getByText } = render(<Counter />);
  const countElement = getByTestId("count");
  const buttonElement = getByTestId("button");

  fireEvent.click(buttonElement);

  expect(incrementElement.textContent).toBe("1");
});

We are now familiar with what is being done here. We expect the last code to pass because after the button has been clicked the state count should be incremented to 1.

Let's test for props passed to a component. We know that props are attributes passed to components.

import React, { useState } from "react";

const Counter = ({ count }) => {
  return (
    <div>
      <p>
        Count: <span data-testid="count">{count}</span>
      </p>
    </div>
  );
};

export default Counter;

We have this component expecting a value to be passed to it via count attribute in its props object. So now let's write a test for it to make sure a count attribute passed to the Counter component is displayed.

import React from "react";
import { render } from "@testing-library/react";
import Counter from "./Counter";

test("increments count on button click", () => {
  const { getByText } = render(<Counter count={9} />);
  const countElement = getByTestId("count");

  expect(incrementElement.textContent).toBe("9");
});

Here, we rendered the Counter component passing a value of 9 via its count props. Then, we got hold of the p element where the props will be rendered. Then, we expect it to be the value 9.


Open-source enterprise application platform for serious web developers

refine.new enables you to create React-based, headless UI enterprise applications within your browser that you can preview, tweak and download instantly.

🚀 By visually combining options for your preferred ✨ React platform, ✨ UI framework, ✨ backend connector, and ✨ auth provider; you can create tailor-made architectures for your project in seconds. It feels like having access to thousands of project templates at your fingertips, allowing you to choose the one that best suits your needs!


Mocking function calls

During testing, we might not really want an actual function to be called based on some factors. For example, the function might have a number of calls set on it. The only way to go about this is to mock that function, ie, to create a dumb version function of that actual function. We do this in Jest by calling the fn() API. This creates a mock function and returns it.

const mockFn = jest.fn();

We can then test the number of times the function was called, and the return value of the function, and also Jest provides in an array the arguments passed to the function each time it was called.

Let's see an example:

const Test = ({ done }) => {
  return (
    <div>
      <button onClick={done}>Call DONE</button>
    </div>
  );
};

We have a simple component here, it accepts a function in its props object via done property. This done props function is called when the Call DONE button is clicked. Now, we want to test the component to see if the done props passed to the component is called when the Call DONE button is clicked. To test this we will need to pass a mock function to the done props of the component. Let's see below how we can do it:

import React from "react";
import { render } from "@testing-library/react";
import Test from "./Test";

test("test mock function props is called", () => {
  const fn = jest.fn();
  const { getByText } = render(<Test done={fn} />);
  const button = getByText("Call DONE");
  button.click();

  expect(fn.mock.calls).toBe(1);

  button.click();
  expect(fn.mock.calls).toBe(2);
});

We created a mock function fn by calling the fn() function from the jest object. We passed the mock function to the Test component in a done attribute. Next, we got the handle of the Call DONE button and fire a click event on it. This will call the mock function once, that's why in the next line we expect the mocked function to be called once. Next, we fired the click event, and this time we expected the function to be called again.

Testing React hooks

Now, we will test React custom hooks. In testing React hooks we will be verifying the behavior and state changes of a component that uses hooks. Let's say we have a custom hook called useCounter that manages a counter value and provides functions to increment and decrement it:

import { useState } from "react";

const useCounter = () => {
  const [count, setCount] = useState(0);

  const increment = () => {
    setCount((prevCount) => prevCount + 1);
  };

  const decrement = () => {
    setCount((prevCount) => prevCount - 1);
  };

  return { count, increment, decrement };
};

export default useCounter;

The above custom hook gives us the ability to handle our count state from one place. It returns the count state, the increment function, and the decrement function. We will destructure all these inside the component and use it to manage and manipulate the count state.

Now, let's write a test case for this custom hook:

import { renderHook, act } from "@testing-library/react-hooks";
import useCounter from "./useCounter";

test("should increment and decrement counter correctly", () => {
  const { result } = renderHook(() => useCounter());

  const { count, increment, decrement } = result.current;

  expect(count).toBe(0);

  act(() => {
    increment();
  });

  expect(count).toBe(1);

  act(() => {
    decrement();
  });

  expect(count).toBe(0);
});

In this test case, we use the renderHook function from the @testing-library/react-hooks package to render the useCounter hook. The result object from renderHook gives us access to the current state and functions returned by the hook. The result is an object that contains a property called current. The value of this current is an object that contains the return value of the useCounter hook. From this current object we destructured all the objects returned by the useCounter hook.

We assert that the initial value of the count state is 0. Then, we use the act function to perform actions on the hook. Within act, we call the increment function and assert that the count state is incremented to 1. Similarly, we call the decrement function and verify that the count state is decremented to 0.

The act function is used to wrap asynchronous or state-updating code that affects React components. It ensures that all state updates are properly processed and reflected in the component before making assertions.

You see how easy it is to test custom hooks using the renderHook function, we had no need of using the custom hook inside of a component just to test it.

Testing asynchronous operations

Asynchronous operations involve handling promises, asynchronous functions, and async/await syntax. The most common example of an async operation in a React component is the fetching of data over HTTP. HTTP calls are async operations in the sense that it does not stop the flow of the code execution, instead, it is executed in parallel with the main code.

Let's see a component that on render makes an HTTP call to an endpoint and then renders the result of the fetch.

import React, { useState, useEffect } from "react";

const AsyncComponent = () => {
  const [data, setData] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch("https://api.example.com/data");
      const result = await response.json();
      setData(result);
    };

    fetchData();
  }, []);

  return <div>{data ? data.message : "Loading..."}</div>;
};

export default AsyncComponent;

This component fetches data from https://api.example.com/data and renders it. Now, we want to test this component but we don't want the component to make an actual HTTP call to the endpoint. So what do we do? We have to mock the fetch call. Also, the fetch call is an async op, what do we do? The React Testing Library has a function waitFor, this function waits for an async operation to complete before the code execution flow resumes. We can pass a callback to it, that executes after the async operation completes.

Let's see below:

import React from "react";
import { render, waitFor } from "@testing-library/react";
import AsyncComponent from "./AsyncComponent";

test("renders fetched data after async call", async () => {
  const mockData = { message: "Test Message" };

  // Mock the fetch API
  jest.spyOn(window, "fetch").mockImplementation(() =>
    Promise.resolve({
      json: () => Promise.resolve(mockData),
    })
  );

  const { getByText } = render(<AsyncComponent />);

  // Assert that "Loading..." is initially rendered
  expect(getByText("Loading...")).toBeInTheDocument();

  // Wait for the async operation to complete
  await waitFor(() => {
    expect(getByText(mockData.message)).toBeInTheDocument();
  });

  // Restore the original fetch implementation
  window.fetch.mockRestore();
});

In this test case:

  • A mock data object mockData is defined to simulate the data returned from the API.
  • The fetch function is mocked using jest.spyOn and mockImplementation. It returns a resolved promise that resolves to an object with a json function that, in turn, resolves to mockData.
  • The render function is used to render the AsyncComponent.
  • The initial rendering displays "Loading...", which is verified using expect and getByText.
  • The waitFor function from @testing-library/react is used to wait for the asynchronous operation to complete. Within the callback of waitFor, we assert that the fetched data (mockData.message) is rendered in the component.
  • Finally, the original implementation of fetch is restored using window.fetch.mockRestore() to ensure other tests are not affected.

By using await and waitFor, you can test and assert the behavior of asynchronous operations in our React components. It allows us to verify that the correct data is fetched and rendered after the asynchronous operation is completed.

Testing React Context API

In testing React Context API, we will verify that the components correctly consume and provide values from the context.

Let's say we have a context called ThemeContext that provides a theme value to consuming components:

import React, { createContext, useContext } from "react";

const ThemeContext = createContext();

export const ThemeProvider = ({ children }) => {
  const theme = "light";

  return (
    <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
  );
};

export const useTheme = () => useContext(ThemeContext);

We created a global context here that passes down a theme down to its children. The ThemeProvider does that, see how encloses the children element between its tag. We created a custom hook that we can use to get the ThemeContext, see that it utilizes the useContext hook from React to consume the theme value from the ThemeContext. It returns the current value of the ThemeContext.

Now, let's write a test case for a component that consumes the ThemeContext using the useTheme hook:

import React from "react";
import { render } from "@testing-library/react";
import { ThemeProvider, useTheme } from "./ThemeContext";

const ThemeConsumer = () => {
  const theme = useTheme();
  return <div>{theme}</div>;
};

test("renders theme value from the context", () => {
  const { getByText } = render(
    <ThemeProvider>
      <ThemeConsumer />
    </ThemeProvider>
  );

  expect(getByText("light")).toBeInTheDocument();
});

Here, we rendered the ThemeConsumer component wrapped within the ThemeProvider to provide the theme value.

Next, we used the getByText function to query the DOM for the rendered text. We expect to have a text node with the value light in the DOM because the ThemeConsumer must have gotten the theme value and rendered it.

Next, let's see how we can test routing in React.

Testing React Router

Its quite tricky how this can be done but believe me it's very easy and straightforward. To test React Router, all we have to do is to load a particular URL and verify that the associated component is rendered on the DOM.

Let's say we have two routes: /home and /about in our application and /home route is mapped to Home component and the /about route is mapped to the About component.

const Home = () => {
  return <div>This is the Home component</div>;
};

export default Home;

// About.js
const About = () => {
  return <div>This is the About component</div>;
};

export default About

Let's set up the routing:

import React from "react";
import { BrowserRouter as Router, Route, Link, Switch } from "react-router-dom";
import Home from "./Home";
import About from "./About";

const App = () => {
  return (
    <Router>
      <nav>
        <ul>
          <li>
            <Link to="/home">Home</Link>
          </li>
          <li>
            <Link to="/about">About</Link>
          </li>
        </ul>
      </nav>

      <Switch>
        <Route exact path="/home" component={Home} />
        <Route path="/about" component={About} />
      </Switch>
    </Router>
  );
};

export default App;

Now, let's write the test cases:

import React from "react";
import { render, screen } from "@testing-library/react";
import { BrowserRouter as Router } from "react-router-dom";
import App from "./App";

test("renders home component when visiting the home route", () => {
  render(
    <Router initialEntries={["/home"]}>
      <App />
    </Router>
  );

  expect(screen.getByText("This is the Home component")).toBeInTheDocument();
});

test("renders about component when visiting the about route", () => {
  render(
    <Router initialEntries={["/about"]}>
      <App />
    </Router>
  );

  expect(screen.getByText("This is the About component")).toBeInTheDocument();
});

You understand the trick we did there. See that in the first render, we rendered our App component enclosed by the Router component and passed the URL /home to it. Now, according to our route mapping, this is supposed to render the Home component, and the component will render the text This is the Home component in the DOM.

So in the next line, we expect to see the text This is the Home component in the DOM. Easy.

To test for the /about route, we did the same thing, but this time we passed /about URL, so we know that the About will be rendered this time and we expect to have This is the About component text node in the DOM.

These test cases verify that the components Home and About are rendered correctly based on the route configuration.

Snapshot testing

Snapshot testing is quite different from what we have seen in this article. This type of testing is classified as output comparison testing. In the case of React component snapshot testing, the UI of the component is taken first and saved, then on subsequent testing, a current snapshot of the component is taken and compared with the previous snapshot to check for changes that may cause breaks.

Let's see an example:

import React from "react";

const Button = ({ text, onClick }) => {
  return (
    <button onClick={onClick} className="button">
      {text}
    </button>
  );
};

export default Button;

To create a snapshot test for this component, you can write a test case using the toMatchSnapshot matcher:

import React from "react";
import { render } from "@testing-library/react";
import Button from "./Button";

test("Button component matches snapshot", () => {
  const { asFragment } = render(<Button text="Click me" onClick={() => {}} />);
  expect(asFragment()).toMatchSnapshot();
});

Here, the render function renders the Button component with some props. The asFragment function is then used to retrieve the rendered component as a snapshot.

The toMatchSnapshot matcher compares the rendered output with the previously saved snapshot. If a snapshot doesn't exist, Jest creates a new one. If a snapshot exists and there are no changes, the test passes. If there are differences between the snapshot and the current output, Jest highlights the differences and the test fails.

When you run this test for the first time, Jest will create a snapshot file (e.g., Button.test.js.snap) containing the rendered output. On subsequent test runs, Jest compares the rendered output with the snapshot and reports any changes.

If you intentionally make changes to the component's output and want to update the snapshot, you can run Jest with the --updateSnapshot flag to update the snapshot file:

jest --updateSnapshot

Conclusion

We covered a lot in this article. We started with introducing unit testing in software development as a whole. Then, next, we learned how to set up a testing environment in React project, and from there we installed the testing tools and libraries.

Further down, we learned how to write tests for basic React components, how to fire events on elements in a testing env, and how to test states and props. We advanced a little, exploring deep concepts, we learned how to test React Context-powered components, Routing system, and snapshot testing.