A Practical Guide to Testing React Applications (Unit Tests)

Victory AsokomehVictory Asokomeh
10 min read

Introduction

Test Driven Development (TDD) is a beneficial practice for engineers, but in reality, it's not always feasible.

However, the initial investment in writing automated tests is worth the effort as it saves you time in the long run.

In this article, we’ll explore how to set up automated tests for a simple React application.

The principles used here will prove relevant for more complex scenarios.

How to go about testing

You can test for many things when building, but it is important to prioritize the things with the potential to impact users the most first.

With this in mind, these questions can serve as a guide

  • What is the typical workflow for users on the application?

  • What features will cause the most disruption for the users in the event of failure?

Your priority should be to test for functionality rather than implementation details. In a React application, implementation details can refer to the inner workings of a function or component.

Tests should be deterministic, such that given the same inputs they always return the same results.

Implementation

Our demo application displays a list view of users fetched from an API, a button to toggle more details for each user, and the ability to search for a specific user.

We will explore how to test using Jest and React Testing Library.

Installation

Run the following command to install the necessary packages

npm install --save-dev @testing-library/dom @testing-library/jest-dom @testing-library/react react-test-renderer @testing-library/react-hooks
or 
yarn add --dev @testing-library/dom @testing-library/jest-dom @testing-library/react react-test-renderer @testing-library/react-hooks

GridItem.tsx

import { IUser, SelectedUser } from "../../models";

import styles from "./gridItem.module.css";

export interface IGridItem {
 user: IUser;
 handleOpen: () => void;
 setSelectedUser: (user: SelectedUser) => void;
}

function GridItem({ user, handleOpen, setSelectedUser }: IGridItem) {
 function handleClick() {
   setSelectedUser(user);
   handleOpen();
 }

 return (
   <article className={styles.container} data-testid="GridItem">
     <p>{user.name}</p>
     <button onClick={handleClick}>View User</button>
   </article>
 );
}

export default GridItem;

As we’ve established earlier we don’t test for implementation details, we only want to concern ourselves with the input and output from this component.

Here inputs are the following props:

  • user object

  • handleOpen function,

  • setSelectedUser function to set the selected user.

The outputs are

  • User name text

  • a button labeled View User that triggers a handleOpen function.

So we test that given said input, we get the same output.

//GridItem.test.tsx
import { render, fireEvent, screen } from "@testing-library/react";
import GridItem, { IGridItem } from "../../../components/GridItem";

const componentProps: IGridItem = {
 user: {
   avatarUrl: "https://i.pravatar.cc/150?img=68",
   id: 0,
   name: "John Smith",
   gender: "male",
   age: 20
 },
 handleOpen: jest.fn(),
 setSelectedUser: jest.fn()
};

test("Should render the username and the handleOpen function is called when 'view user' button is clicked ", () => {
 render(<GridItem {...componentProps} />);

 expect(screen.getByText(componentProps.user.name)).toBeInTheDocument();

 const viewButton = screen.getByText(/view user/i);

 fireEvent.click(viewButton);

 expect(componentProps.handleOpen).toHaveBeenCalled();
});

First, we define the props to be passed into the component as componentProps

We define our test using the test function from Jest. This function takes 2 parameters, a description first and a callback function containing the test logic.

We use the render, fireEvent, and screen utilities from @testing-library/react.

The render utility function mounts the component into the DOM.

The expect utility from Jest gives us access to methods called matchers we can use to check for test conditions.

We confirm the DOM contains the username we provided using the toBeInTheDocument matcher from jest.

You can negate this check by using the not modifier from Jest

expect(screen.getByText(componentProps.user.name)).not.toBeInTheDocument();

Next, we check for a view user button by doing a regex case-insensitive search for the label.

We confirm that clicking the ‘View User’ button triggers the Jest mock function assigned to ‘handleOpen’.

Grid.tsx

import React from "react";
import GridItem from "../GridItem";
import { IUser, SelectedUser } from "../../models";
import useDisclosure from "../../hooks/useDisclosure";
import Modal from "../Modal";
import UserDisplay from "../UserDisplay";

import styles from "./grid.module.css";

interface IGrid {
 users: IUser[] | undefined;
}

function Grid({ users }: IGrid) {
 const { isOpen, onOpen, onClose } = useDisclosure();
 const [selectedUser, setSelectedUser] = React.useState<SelectedUser>(
   undefined
 );

 function handleCloseModal() {
   onClose();
   setSelectedUser(undefined);
 }

 if (!users || !users.length)
   return <p className={styles.no_result}>No user found</p>;

 return (
   <>
     {isOpen ? (
       <Modal title="User Info" onClose={handleCloseModal} isOpen={isOpen}>
         <UserDisplay user={selectedUser} />
       </Modal>
     ) : null}

     <div className={styles.container}>
       {users.map((user) => (
         <GridItem
           user={user}
           key={user.id}
           handleOpen={onOpen}
           setSelectedUser={setSelectedUser}
         />
       ))}
     </div>
   </>
 );
}

export default Grid;

Here we want to assert that given a list of users, the component returns;

  • A list of users (GridItem component) equal to the number of items on the list

  • A descriptive error when no user is provided.

//Grid.test.tsx
import Grid from "../../../components/Grid";
import { IUser } from "../../../models";
import { render, screen } from "@testing-library/react";

const users: IUser[] = [
 {
   avatarUrl: "https://i.pravatar.cc/150?img=68",
   id: 0,
   name: "John Smith",
   gender: "male",
   age: 20
 },
 {
   avatarUrl: "https://i.pravatar.cc/150?img=48",
   id: 1,
   name: "Martha Liberty",
   gender: "female",
   age: 18
 }
];

test("It renders the list of user", () => {
 render(<Grid users={users} />);

 expect(screen.getAllByTestId("GridItem")).toHaveLength(users.length);
});

test("it shows descriptive text when no user is provided", () => {
 render(<Grid users={undefined} />);

 expect(screen.getByText(/No user found/i)).toBeInTheDocument();
});

First, render the Grid component with an array of users as the ‘user’ props value

Select all the instances of GridItem rendered using the testId attribute with getAllByTestId.

Assert that the occurrence of GridItem in the DOM is equal to the number of users supplied.

data-testid is a special attribute used for testing, and should be used sparingly after the other queries don't work for your use case.

We also confirm that No user found is displayed when the users prop is undefined.

UserDisplay.tsx

import { SelectedUser } from "../../models";

import styles from "./userDisplay.module.css";

function UserDisplay({ user }: { user: SelectedUser }) {
 if (!user) return null;

 return (
   <article className={styles.container}>
     {user.avatarUrl && (
       <img src={user.avatarUrl} alt={user.name} data-testid="UserImage" />
     )}

     <div>
       <h1>{user.name}</h1>
       <p>{user.gender}</p>
       <p>{user.age}</p>
     </div>
   </article>
 );
}

export default UserDisplay;

To test this component, we assert that given the right props, it renders the user details including user including the user’s image, name, gender, and age.

//UserDisplay.test.tsx
import UserDisplay from "../../../components/UserDisplay";
import { axe } from "jest-axe";
import { render, screen } from "@testing-library/react";
import { IUser } from "../../../models";

const user: IUser = {
 avatarUrl: "https://i.pravatar.cc/150?img=68",
 id: 0,
 name: "John Smith",
 gender: "male",
 age: 20
};

test("Should render the user name, gender and age", () => {
 render(<UserDisplay user={user} />);

 expect(screen.getByText(user.name)).toBeInTheDocument();
 expect(screen.getByText(user.gender)).toBeInTheDocument();
 expect(screen.getByText(user.age)).toBeInTheDocument();
 expect(screen.getByTestId("UserImage")).toBeInTheDocument();
});

First, we Render the UserDisplay component with a sample user object.

Then we write assertions for the properties of the user object by using corresponding matchers.

We can also write tests to verify that this component does not have accessibility violations (e.g. alt attribute for the user image is present).

We use the jest-axe package, which extends jest with custom matchers from axe-core, an accessibility testing engine for HTML-based user interfaces.

Installation

yarn add jest-axe @types/jest-axe 
or 
npm i jest-axe @types/jest-axe

jest-axe has a utility toHaveNoViolations which asserts that a React component does not have any accessibility violations according to the Axe rules.
In setupTests.ts import toHaveNoViolations and extend jest using the extend utility.

//setupTests.ts
import { toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);

To test for accessibility violations we define an additional test function in the UserDisplay.test.tsx file.

//UserDisplay.test.tsx
test("should not fail any accessibility tests", async () => {
 const { container } = render(<UserDisplay user={user} />);

 expect(await axe(container)).toHaveNoViolations();
});

Container here is a variable that refers to the root DOM element that contains the rendered output from the render method.

We check for accessibility compliance by calling the toHaveNoViolations matcher on the container variable.

Modal.tsx

import React from "react";

import styles from "./modal.module.css";

function disablePageScroll() {
 document.body.style.overflow = "hidden";
}

function enablePageScroll() {
 document.body.style.overflow = "unset";
}

interface IModal {
 title: string;
 onClose: () => void;
 isOpen: boolean;
 children: React.ReactNode;
}

function Modal(props: IModal) {
 const { title, onClose, isOpen, children } = props;

 React.useEffect(() => {
   if (isOpen) {
     disablePageScroll();
   }
   return () => {
     enablePageScroll();
   };
 }, [isOpen]);

 return (
   <div role="dialog" aria-modal="true">
     <div
       data-testid="modalOverlay"
       className={styles.modal_overlay}
       onClick={onClose}
     ></div>

     <div className={styles.modal_wrapper}>
       <p className={styles.modal_title}>{title}</p>
       <div className={styles.modal_content}>{children}</div>
       <button onClick={onClose}>Close modal</button>
     </div>
   </div>
 );
}

export default Modal;

The component takes the following props;

  • a title, isOpen boolean,

  • onClose function triggered when the modal overlay or Close modal button is clicked

  • children property for rendering nested items, Here we use the text React is fun

//Modal.test.tsx
import Modal from "../../../components/Modal";
import { render, fireEvent } from "@testing-library/react";

const componentProps = {
 title: "Random Title",
 onClose: jest.fn(),
 isOpen: true,
 children: <p>React is fun</p>
};

const renderComponent = (props = componentProps) =>
 render(<Modal {...props} />);

test("Modal should render title, children and close button ", () => {
 const { getByText } = renderComponent();

 expect(getByText(componentProps.title)).toBeInTheDocument();

 expect(getByText(/React is fun/i)).toBeInTheDocument();

 expect(getByText(/Close modal/i)).toBeInTheDocument();
});

test("clicking the close button or the modal overlay should call the onClose function", () => {
 const { getByText, getByTestId } = renderComponent();
 const closeButton = getByText(/Close modal/i);
 const modalOverlay = getByTestId("modalOverlay");

 fireEvent.click(closeButton);
 fireEvent.click(modalOverlay);

 expect(componentProps.onClose).toHaveBeenCalledTimes(2);
});

First, we check for the title in the DOM.

Next, check for children by searching for the text “React is fun” and a button with a close modal label is rendered.

Next, we check that the onClose function is called by either clicking the close modal button or the modal overlay.

We do this by using the toHaveBeenCalledTimes matcher from jest which takes in the number of times we expect a mock function to be called.

Testing React hooks

UseDisclosure.tsx

import React from "react";

const defaultState = { isOpen: false };

const actions = {
 ON: "ON",
 OFF: "OFF",
 TOGGLE: "TOGGLE"
} as const;

type reducerType = { type: keyof typeof actions };

function disclosureReducer(state: typeof defaultState, action: reducerType) {
 switch (action.type) {
   case actions.ON:
     return { isOpen: true };
   case actions.OFF:
     return { isOpen: false };
   case actions.TOGGLE:
     return { isOpen: !state.isOpen };

   default:
     return state;
 }
}

function useDisclosure(initialState = defaultState) {
 const [{ isOpen }, dispatch] = React.useReducer(
   disclosureReducer,
   initialState
 );

 const onOpen = () => dispatch({ type: actions.ON });
 const onClose = () => dispatch({ type: actions.OFF });
 const onToggle = () => dispatch({ type: actions.TOGGLE });

 return { isOpen, onOpen, onClose, onToggle };
}

export default useDisclosure;

This is a simple hook that allows you to handle common state open, close, and toggle scenarios.
@testing-library/react-hooks exposes a renderHook function that can render the custom hook, and it returns a result property on which we can run assertions.

//UseDisclosure.test.tsx
import useDisclosure from "../../../hooks/useDisclosure";
import { renderHook, act } from "@testing-library/react-hooks";

test("returns the correct initial values", () => {
 const { result } = renderHook(() => useDisclosure({ isOpen: true }));

 expect(result.current.isOpen).toBe(true);
 expect(typeof result.current.onOpen).toBe("function");
 expect(typeof result.current.onClose).toBe("function");
 expect(typeof result.current.onToggle).toBe("function");
});

test("toggles the state correctly", () => {
 const { result } = renderHook(() => useDisclosure());

 // Check that the initial state is false
 expect(result.current.isOpen).toBe(false);

 act(() => result.current.onOpen()); // open fn
 expect(result.current.isOpen).toBe(true);

 act(() => result.current.onClose()); // Close fn
 expect(result.current.isOpen).toBe(false);

 act(() => result.current.onToggle()); // Toggle fn
 expect(result.current.isOpen).toBe(true);

 act(() => result.current.onToggle());
 expect(result.current.isOpen).toBe(false);
});

To check that the hook returns the right values, we render the hook with an initial value of true, then write assertions to confirm that isOpen evaluates to true and onOpen, onClose, and onToggle are functions.

Next, we test that the isOpen property returns false when an initial value is absent.

@testing-library/react-hooks exposes a method called act that allows us to run updates to component state synchronously.

Using this method we can simulate the action of calling the onOpen, onClose, and onToggle functions and check that the isOpen value updates accordingly.

Finally to run the test use yarn test or npm test.

You can add the --watch flag to run this command in watch mode so the tests will automatically re-run whenever there are changes to the test file.

Conclusion

There are several other things we can explore with testing, like snapshot testing, and checking for false positives, but these are beyond the scope of this post.

You can find the full implementation in this Codesandbox

In the next one, we will look at how to incorporate integration tests in the same application.

Cover photo by Rahul Mishra on Unsplash

1
Subscribe to my newsletter

Read articles from Victory Asokomeh directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Victory Asokomeh
Victory Asokomeh

I am a skilled Frontend Engineer with experience in building innovative web applications. I am very passionate about finding ways to harness the power of technology for good. This blog is my attempt at making the journey easier for those coming after me.