What to unit test in a React App

Alfred Yang
finnovate.io
Published in
5 min readDec 8, 2021
Photo by Matt Hudson on Unsplash

This article isn’t about how to unit test a React app, it’s about what to unit test in a React app.

I think one of the key benefits of unit testing is that it forces the developer to write clean code with proper abstraction and separation of concerns. Quite frankly, messy code isn’t unit-testable.

Keep components small to unit test properly

When advising a development team that is just starting out with unit testing in React, I would first suggest that they write the app in such a way so that there are small, individually unit-testable pieces. Keep components small by:

  • Breaking a large component down into smaller components
  • Abstracting logic that is state dependent with custom hooks
  • Abstracting logic that is NOT state dependent with pure functions
  • Manage global application state with Redux

Now let’s examine the different pieces by increasing level of test difficulty.

Level 1 — Pure functions

Pure functions are the easiest elements to test in an application. A pure function is a function that is deterministic. When given a certain input, a pure function will always return the same output every time without any side effects. Pure functions can be tested without any special library or tools.

Since pure functions are so easy to test, I often encourage developers to write as many of them as possible. In the context of a React application, developers can write pure functions to handle more complex calculations or business logic and import such functions into components.

Examples:

  • Custom functions imported into components or modules
  • Redux reducers
// Testing pure function is dead simple
// Do as many of this as possible
describe('add', () => {
it('Should return 4 given 1 and 3', () => {
expect(add(1, 3)).toBe(4);
});
});

Level 2 — Functions with side effects

Inevitably, we will have functions that return results based on more than its input parameters. In a React app, we will have functions that make API calls. Such functions can only be tests by “mocking” actual API call outs. Keep in mind that unit tests should be executed entirely in memory, meaning it should not have dependencies on external systems, or the internet for that matter.

Side effects also refer to modifications that the function can make outside of its input and output properties. For example, a function can call another function to make changes to an outside variable. In such case, we can use spying techniques — also available via mock functions — to assert the expected modifications are made.

Examples:

  • Custom React hooks

In this example, we will use the react-testing-library to render a hook, and use the “act()” function to ensure the state change is complete before assertion.

import { renderHook, act } from '@testing-library/react-hooks'
import useCounter from './useCounter'
describe("(Hook) useCounter", () => {
it('should increment counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
});
  • Functions that make API calls

In this example, we are mocking the “axios” post function to have it return a token.

import login from '../login';
import axios from 'axios';
jest.mock("axios");describe('login', () => {
it('should call POST and return token', () => {
const token = { token: "xyz" };
axios.post.mockResolvedValueOnce(token);
const result = await login();
expect(axios.post).toHaveBeenCalledWith(`${BASE_URL}/login`);
expect(result).toEqual(token);
});
});

Level 3 — Components

When unit testing React components, we should only focus on key UI elements and interactions. It is very difficult to assert the appearance of styles, so I would generally advise that we focus on functionality.

Test Initial render

The unit test suite for a component should include a test case that asserts key elements returned by the initial rendering cycle. For example, if you have a login form component, we should assert that a email address field, a password field and a submit button are rendered. For simplicity, we will assume this form component calls an onSubmit prop when the “Login” button is pressed and it makes no API call on its own.

We will use react-testing-library for for examples:

import { screen } from "@testing-library/react";// Mount the Login component and test if key elements are rendereddescribe("(Component) Login", () => {
it("Should render all key form elements", () => {
render( <LogIn /> );
const emailInput = screen.getByTestId("email-input");
const passwordInput = screen.getByTestId("password-input");
const logInButton = screen.getByTestId("log-in-button");
expect(emailInput).toBeInstanceOf(HTMLInputElement);
expect(passwordInput).toBeInstanceOf(HTMLInputElement);
expect(logInButton).toBeInstanceOf(HTMLButtonElement);
});
});

Test key component states

We should have a test case for each component state. For example, when a user enters an invalid email address, our test case should assert that the proper error message is rendered by the component.

import { fireEvent, screen } from "@testing-library/react";// Test that an error message is returned when invalid email is
// entered
describe("(Component) Login", () => {
it("Should render error message if invalid email entered", () => {
render( <LogIn /> );
const emailInput = screen.getByTestId("email-input");
const passwordInput = screen.getByTestId("password-input");
const logInButton = screen.getByTestId("log-in-button");
fireEvent.change(emailInput, {
target: { value: "invalid.email" },
});
fireEvent.change(passwordInput, {
target: { value: "password" },
});
fireEvent.click(logInButton); await screen.findByText("Invalid email address");
});
});

Test side effects & callouts

We should have a test case for each interaction the component has with the outside world. We do this by stubbing out imports or as props of the component with spying functions. For example, we can replace the imported function with a spying function to assert that the imported function is called when the component is mounted on the screen. As another example, we can feed a spying function to the component as the “onSubmit” prop to assert that “onSubmit” is called when the “Submit” button is pressed.

import { fireEvent, screen } from "@testing-library/react";// Test that onSubmit is called when form submitsdescribe("(Component) Login", () => {
it("Should call onSubmit when Login button is pressed", () => {
const onSubmit = jest.fn();
render( <LogIn onSubmit={onSubmit} /> );
const emailInput = screen.getByTestId("email-input");
const passwordInput = screen.getByTestId("password-input");
const logInButton = screen.getByTestId("log-in-button");
fireEvent.change(emailInput, {
target: { value: "a@email.com" },
});
fireEvent.change(emailInput, {
target: { value: "password" },
});
fireEvent.click(logInButton); expect(onSubmit).toHaveBeenCalledWith("a@email.com", "password");
});
});

Finnovate.io is a technology company focused on helping organizations build unique digital experiences on web, mobile and blockchain. Finnovate.io offers development services, training, consulting, as well as a platform that rapidly turns paper based content into digital interactive experiences.

--

--

Alfred Yang
finnovate.io

Alfred is the founder of https://finnovate.io, a company that focuses on helping organizations build unique digital experiences on web, mobile and blockchain.