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:

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

Additionally, for TypeScript projects, install the type definitions:

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:

npx jest --init

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

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

import React from "react";

function Button({ label, onClick }) {
  return <button onClick={onClick}>{label}</button>;
}

export default Button;

Test for Button Component

The test ensures that:

  1. The button renders correctly.
  2. It handles user interactions.
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import Button from "./Button";

test("renders Button and handles click", () => {
  const handleClick = jest.fn(); // Mock function for the click handler
  render(<Button label="Click Me" onClick={handleClick} />);

  // Assert that the button is rendered with the correct label
  const button = screen.getByRole("button", { name: /click me/i });
  expect(button).toBeInTheDocument();

  // Simulate a click and assert that the handler is called
  fireEvent.click(button);
  expect(handleClick).toHaveBeenCalledTimes(1);
});

Example 2: Testing a Form with User Interaction

Component: LoginForm

import React, { useState } from "react";

function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const handleSubmit = (e) => {
    e.preventDefault();
    onSubmit({ email, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Email:
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
      </label>
      <label>
        Password:
        <input
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </label>
      <button type="submit">Login</button>
    </form>
  );
}

export default LoginForm;

Test for LoginForm

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

test("submits login form with correct data", () => {
  const handleSubmit = jest.fn(); // Mock function for the form submit handler
  render(<LoginForm onSubmit={handleSubmit} />);

  // Fill in the form fields
  fireEvent.change(screen.getByLabelText(/email/i), {
    target: { value: "test@example.com" },
  });
  fireEvent.change(screen.getByLabelText(/password/i), {
    target: { value: "password123" },
  });

  // Submit the form
  fireEvent.click(screen.getByRole("button", { name: /login/i }));

  // Assert that the handler is called with the correct data
  expect(handleSubmit).toHaveBeenCalledWith({
    email: "test@example.com",
    password: "password123",
  });
  expect(handleSubmit).toHaveBeenCalledTimes(1);
});

Example 3: Testing Asynchronous Code

Component: FetchUser

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

function FetchUser({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    async function fetchUserData() {
      const response = await fetch(
        `https://jsonplaceholder.typicode.com/users/${userId}`
      );
      const data = await response.json();
      setUser(data);
    }
    fetchUserData();
  }, [userId]);

  if (!user) return <p>Loading...</p>;
  return <div>{user.name}</div>;
}

export default FetchUser;

Test for FetchUser

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

// Mock the fetch API
global.fetch = jest.fn(() =>
  Promise.resolve({
    json: () =>
      Promise.resolve({
        name: "John Doe",
      }),
  })
);

test("fetches and displays user data", async () => {
  render(<FetchUser userId={1} />);

  // Assert that the loading state is displayed
  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  // Wait for the user data to be rendered
  await waitFor(() =>
    expect(screen.getByText(/john doe/i)).toBeInTheDocument()
  );

  // Assert that the fetch API was called with the correct URL
  expect(fetch).toHaveBeenCalledWith(
    "https://jsonplaceholder.typicode.com/users/1"
  );
});

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:

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

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

npx playwright install

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

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

Write your test for a component:

import { expect, test, vi } from 'vitest'
import { render } from 'vitest-browser-react'
import Button from './Button';

test('renders Button and handles click', () => {
  const handleClick = vi.fn()

  const { getByText, getByRole } = render(<Button label="Click Me" onClick={handleClick} />)

  // Assert that the button is rendered with the correct label
  const button = getByRole('button', { name: /click me/i })
  await expect.element(button).toBeInTheDocument()

  // Simulate a click and assert that the handler is called
  await button.click()
  expect(handleClick).toHaveBeenCalledTimes(1);
});

Execute the test:

npm run test

Common Jest Features

Jest Expect

// 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

// 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

// 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

// 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.