Writing unit tests

Chapter Outline

Writing unit tests with Jest and React Testing Library

Testing is an integral part of modern web development. Unit testing ensures that individual components and functions work as expected, providing confidence in code stability and maintainability. In this article we’ll focus on writing unit tests using Jest and React Testing Library (RTL).

Jest is a popular JavaScript testing framework, while React Testing Library complements it by providing utilities to test React components in a way that mimics user interactions. Together, they form a powerful combination for writing robust unit tests in React.

Setting Up Jest and React Testing Library

Before we dive into writing tests, let’s set up a React project with Jest and React Testing Library.

Installation

If you're using a project created with create-react-app, Jest and React Testing Library come pre-installed. However, if you’re starting from scratch or using a custom setup, you can install the necessary packages:

bash
npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-event

Additionally, for TypeScript projects, install the type definitions:

bash
npm install --save-dev @types/jest @types/react @types/react-dom

Configuring Jest

If your project doesn’t already have a Jest configuration, you can initialize it with:

bash
npx jest --init

You’ll also need to set up a jest.config.js file if additional configuration is required, for example:

javascript
module.exports = {
testEnvironment: "jsdom", // Required for React Testing Library
setupFilesAfterEnv: ["<rootDir>/jest.setup.js"], // Custom setup file
};

Writing Unit Tests with Jest and React Testing Library

Let’s explore how to write tests for React components and functions.

Example 1: Testing a Simple Component

Component: Button

jsx
1import React from "react";
2
3function Button({ label, onClick }) {
4 return <button onClick={onClick}>{label}</button>;
5}
6
7export default Button;

Test for Button Component

The test ensures that:

  1. The button renders correctly.
  2. It handles user interactions.
jsx
1import React from "react";
2import { render, screen, fireEvent } from "@testing-library/react";
3import Button from "./Button";
4
5test("renders Button and handles click", () => {
6 const handleClick = jest.fn(); // Mock function for the click handler
7 render(<Button label="Click Me" onClick={handleClick} />);
8
9 // Assert that the button is rendered with the correct label
10 const button = screen.getByRole("button", { name: /click me/i });
11 expect(button).toBeInTheDocument();
12
13 // Simulate a click and assert that the handler is called
14 fireEvent.click(button);
15 expect(handleClick).toHaveBeenCalledTimes(1);
16});

Example 2: Testing a Form with User Interaction

Component: LoginForm

jsx
1import React, { useState } from "react";
2
3function LoginForm({ onSubmit }) {
4 const [email, setEmail] = useState("");
5 const [password, setPassword] = useState("");
6
7 const handleSubmit = (e) => {
8 e.preventDefault();
9 onSubmit({ email, password });
10 };
11
12 return (
13 <form onSubmit={handleSubmit}>
14 <label>
15 Email:
16 <input
17 type="email"
18 value={email}
19 onChange={(e) => setEmail(e.target.value)}
20 />
21 </label>
22 <label>
23 Password:
24 <input
25 type="password"
26 value={password}
27 onChange={(e) => setPassword(e.target.value)}
28 />
29 </label>
30 <button type="submit">Login</button>
31 </form>
32 );
33}
34
35export default LoginForm;

Test for LoginForm

jsx
1import React from "react";
2import { render, screen, fireEvent } from "@testing-library/react";
3import LoginForm from "./LoginForm";
4
5test("submits login form with correct data", () => {
6 const handleSubmit = jest.fn(); // Mock function for the form submit handler
7 render(<LoginForm onSubmit={handleSubmit} />);
8
9 // Fill in the form fields
10 fireEvent.change(screen.getByLabelText(/email/i), {
11 target: { value: "test@example.com" },
12 });
13 fireEvent.change(screen.getByLabelText(/password/i), {
14 target: { value: "password123" },
15 });
16
17 // Submit the form
18 fireEvent.click(screen.getByRole("button", { name: /login/i }));
19
20 // Assert that the handler is called with the correct data
21 expect(handleSubmit).toHaveBeenCalledWith({
22 email: "test@example.com",
23 password: "password123",
24 });
25 expect(handleSubmit).toHaveBeenCalledTimes(1);
26});

Example 3: Testing Asynchronous Code

Component: FetchUser

jsx
1import React, { useEffect, useState } from "react";
2
3function FetchUser({ userId }) {
4 const [user, setUser] = useState(null);
5
6 useEffect(() => {
7 async function fetchUserData() {
8 const response = await fetch(
9 `https://jsonplaceholder.typicode.com/users/${userId}`
10 );
11 const data = await response.json();
12 setUser(data);
13 }
14 fetchUserData();
15 }, [userId]);
16
17 if (!user) return <p>Loading...</p>;
18 return <div>{user.name}</div>;
19}
20
21export default FetchUser;

Test for FetchUser

jsx
1import React from "react";
2import { render, screen, waitFor } from "@testing-library/react";
3import FetchUser from "./FetchUser";
4
5// Mock the fetch API
6global.fetch = jest.fn(() =>
7 Promise.resolve({
8 json: () =>
9 Promise.resolve({
10 name: "John Doe",
11 }),
12 })
13);
14
15test("fetches and displays user data", async () => {
16 render(<FetchUser userId={1} />);
17
18 // Assert that the loading state is displayed
19 expect(screen.getByText(/loading/i)).toBeInTheDocument();
20
21 // Wait for the user data to be rendered
22 await waitFor(() =>
23 expect(screen.getByText(/john doe/i)).toBeInTheDocument()
24 );
25
26 // Assert that the fetch API was called with the correct URL
27 expect(fetch).toHaveBeenCalledWith(
28 "https://jsonplaceholder.typicode.com/users/1"
29 );
30});

Testing components within Vite

Jest is not fully supported by vite due to how the plugin system from vite works. However, a suitable alternative to Jest is Vitest. It is a Vite native testing framework.

To add Vitest to your project, run the following:

bash
npm install -D vitest vitest-browser-react @vitest/browser playwright

You may need to download the latest Chromium browser to run Playright:

bash
npx playwright install

In order to execute the test, add the following section to your package.json:

json
{
"scripts": {
"test": "vitest"
}
}

Write your test for a component:

jsx
1import { expect, test, vi } from 'vitest'
2import { render } from 'vitest-browser-react'
3import Button from './Button';
4
5test('renders Button and handles click', () => {
6 const handleClick = vi.fn()
7
8 const { getByText, getByRole } = render(<Button label="Click Me" onClick={handleClick} />)
9
10 // Assert that the button is rendered with the correct label
11 const button = getByRole('button', { name: /click me/i })
12 await expect.element(button).toBeInTheDocument()
13
14 // Simulate a click and assert that the handler is called
15 await button.click()
16 expect(handleClick).toHaveBeenCalledTimes(1);
17});

Execute the test:

bash
npm run test

Common Jest Features

Jest Expect

javascript
// Basic expectations
expect(value)
.not
.toBe(value)
.toEqual(value)
.toBeTruthy()
// Boolean
expect(value)
.toBeFalse()
.toBeNull()
.toBeTruthy()
.toBeUndefined()
.toBeDefined()
// Object
expect(value)
.toContain(item)
.toContainEqual(item)
.toHaveLength(number)
.toBeInstanceOf(Class)
.toMatchObject(object)
.toHaveProperty(keyPath, value)
// Numbers
expect(value)
.toBeCloseTo(number,numDigits)
.toBeGreaterThan(number)
.toBeGreaterThanOrEqual(number)
.toBeLessThan(number)
.toBeLessThanOrEqual(number)
// Strings
expect(value)
.toMatch(regexpOrString)
// Errors
expect(value)
.toThrow(error)
.toThrowErrorMatchingSnapshot()

Jest Mock functions

javascript
// Mock functions
const fn = jest.fn()
const fn = jest.fn(n => n * n)
// Calls
const fn = jest.fn()
fn(123)
fn(456)
fn.mock.calls.length // → 2
fn.mock.calls[0][0] // → 123
fn.mock.calls[1][0] // → 456
// Return values
const fn = jest.fn(() => 'hello')
jest.fn().mockReturnValue('hello')
jest.fn().mockReturnValueOnce('hello')
// Instances
const Fn = jest.fn()
a = new Fn()
b = new Fn()
Fn.mock.instances
// → [a, b]
// Mock implements
const fn = jest.fn()
.mockImplementationOnce(() => 1)
.mockImplementationOnce(() => 2)
fn() // → 1
fn() // → 2
// Assertions
expect(fn)
.toHaveBeenCalled()
.toHaveBeenCalledTimes(number)
.toHaveBeenCalledWith(arg1, arg2, ...)
.toHaveBeenLastCalledWith(arg1, arg2, ...)
.toHaveBeenCalledWith(expect.anything())
.toHaveBeenCalledWith(expect.any(constructor))
.toHaveBeenCalledWith(expect.arrayContaining([ values ]))
.toHaveBeenCalledWith(expect.objectContaining({ props }))
.toHaveBeenCalledWith(expect.stringContaining(string))
.toHaveBeenCalledWith(expect.stringMatching(regexp))

Common Vitest Features

Vitest Expect

javascript
// Basic expectations
expect(value)
.not
.toBe(value)
.toEqual(value)
.toBeTruthy()
// Boolean
expect(value)
.toBeFalse()
.toBeNull()
.toBeTruthy()
.toBeFalsy()
.toBeUndefined()
.toBeDefined()
// Object
expect(value)
.toContain(item)
.toContainEqual(item)
.toHaveLength(number)
.toBeInstanceOf(Class)
.toMatchObject(object)
.toHaveProperty(keyPath, value)
// Numbers
expect(value)
.toBeCloseTo(number,numDigits)
.toBeGreaterThan(number)
.toBeGreaterThanOrEqual(number)
.toBeLessThan(number)
.toBeLessThanOrEqual(number)
// Strings
expect(value)
.toMatch(regexpOrString)
// Errors
expect(value)
.toThrow(error)
.toThrowErrorMatchingSnapshot()
// Arrays
expect(['Alice', 'Bob', 'Eve'])
.toHaveLength(number)
.toContain('Alice')
// Exception
expect(fn)
.toThrowError(error)

Vitest Mock functions

javascript
// Mock functions
const fn = vi.fn()
const fn = vi.fn(n => n * n)
// Calls
const fn = vi.fn()
fn(123)
fn(456)
fn.mock.calls.length // → 2
fn.mock.calls[0][0] // → 123
fn.mock.calls[1][0] // → 456
// Return values
const fn = vi.fn(() => 'hello')
vi.fn().mockReturnValue('hello')
vi.fn().mockReturnValueOnce('hello')
// Assertions
expect(fn)
.toHaveBeenCalled()
.toHaveBeenCalledTimes(number)
.toHaveBeenCalledWith(arg1, arg2, ...)
.toHaveBeenLastCalledWith(arg1, arg2, ...)
.toHaveBeenCalledWith(expect.anything())
.toHaveBeenCalledWith(expect.any(constructor))
.toHaveBeenCalledWith(expect.arrayContaining([ values ]))
.toHaveBeenCalledWith(expect.objectContaining({ props }))
.toHaveBeenCalledWith(expect.stringContaining(string))
.toHaveBeenCalledWith(expect.stringMatching(regexp))

Best Practices for Testing React Applications

  • Test User Behavior, Not Implementation Details:
    • Use React Testing Library’s utilities to mimic real user interactions.
    • Avoid testing internal implementation, focusing instead on the rendered output and behavior.
  • Mock External Dependencies:
    • Use Jest’s mocking features to simulate API calls, timers, and other dependencies.
  • Measure Test Coverage:
    • Use Jest’s built-in coverage tool to identify untested parts of your code.
  • Organize Tests:
    • Place test files alongside their corresponding components using the .test.tsx or .spec.tsx naming convention.

Feedback