A Practical Guide to Testing React Applications (Unit Tests)
Victory Asokomeh
Posted on February 26, 2024
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 ahandleOpen
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 listA 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 clickedchildren
property for rendering nested items, Here we use the textReact 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
Posted on February 26, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.