Unit tests in React with Jest and Testing Library

griseduardo

Eduardo Henrique Gris

Posted on April 29, 2024

Unit tests in React with Jest and Testing Library

Introduction

In the article from March, I wrote about how to set up Jest, Babel, and Testing Library to perform unit tests in React. The idea now is to show how the testing process works, with some concepts and examples.

Jest Testing Structure

The testing structure will follow:

  • describe: represents the block of tests to be executed, which could be a block of tests related to a specific component, for example
  • it: represents the test to be executed, where the component will be rendered, will be searched an HTML element inside it, and will be simulated an user interaction
  • expect: performs test validation, comparing the expected result with the test result


describe("<Component />", () => {
  it("should…", () => {
    render component
    search component element that will be tested
    user interaction

    expect().matcher()
  })

  it("should…", () => {
      …
  })
})


Enter fullscreen mode Exit fullscreen mode

testing-library/react

The library that will allow component rendering in tests and HTML element search after rendering the component.

import { render, screen } from "@testing-library/react"

  • render: render the component
  • screen: allow search element after component render

The element search is done using queries. Below are the available types:

Type 0 matches 1 match Multiples matches Retry
getBy Returns error Returns element Returns error No
queryBy Returns null Returns element Returns error No
findBy Returns error Returns element Returns error Yes
getAllBy Returns error Returns array Returns array No
queryAllBy Returns [ ] Returns array Returns array No
findAllBy Returns error Returns array Returns array Yes
  • Returns error: causes the test to fail at the element search stage (does not proceed with the test)
  • Returns element: returns the element that satisfied the search
  • Returns null: returns null if no element satisfied the search (does not break the test, allows do an validation based on this information)
  • Returns array: returns an array with all elements that satisfy the search
  • Returns [ ]: returns an empty array if no element satisfied the search (does not break the test, allows do an validation based on this information)

Here are some examples of search using getBy as base:

getBy Search Code
getByRole by what represents getByRole(searchedRole, {name: name})
getByText by text getByText(text)
getByTestId by test id getByTestId(testId)
getByLabelText by label text getByLabelText(labelText, selector)

Jest matchers

The Jest provides some matchers for test validation. I'll list some below based on the examples of tests that will be performed:

Matcher Validation
toBe(value) value
toHaveLength(number) array or string length
toHaveBeenCalledTimes(number) number of calls

testing-library/jest-dom

Provides additional matchers in addition to those already present in Jest:

import "@testing-library/jest-dom"

Folow some examples:

Matcher Validation
toBeInTheDocument() element presence
toBeDisabled() disabled element
toHaveTextContent(text) text content

Initial tests examples

In this first example, we'll have a button component that, through the clickedNumber constant, records the number of clicks on it via the onClick function. If the number of clicks is greater than zero, it displays the click count on the screen. Additionally, it accepts a disabled props, which, once passed, disables the button:



import React, { useState } from "react";

const BaseComponent = ({ disabled }) => {
  const [clickedNumber, setClickedNumber] = useState(0);

  const onClick = () => {
    setClickedNumber(clickedNumber + 1);
  };

  return (
    <>
      <button 
        data-testid="baseButton" 
        onClick={onClick} 
        disabled={disabled}
      >
        Activations
      </button>
      {clickedNumber > 0 
        && <p data-testid="baseParagraph">{clickedNumber}</p>}
    </>
  );
};



Enter fullscreen mode Exit fullscreen mode

In the test block below, two things will be validated:

  • Test 1: if the button is present after rendering the component
  • Test 2: if the paragraph displaying the click count is not present (since the button has not been clicked)


import React from "react";
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";

import BaseComponent from "./BaseComponent";

describe("<BaseComponent />", () => {
  beforeEach(() => {
    render(<BaseComponent />);
  });

  it("should bring button element", () => {
    const element = screen.getByRole("button", { name: "Activations" });
    // const element = screen.getByText("Activations");
    // const element = screen.getByTestId("baseButton");

    expect(element).toBeInTheDocument();
  });

  it("should not bring paragraph element", () => {
    const element = screen.queryByTestId("baseParagraph");

    expect(element).not.toBeInTheDocument();
  });
});



Enter fullscreen mode Exit fullscreen mode

In both tests, the component will be rendered in the same way, so a beforeEach is placed before them with the rendering.

In the first test, three different ways of finding the button are showed: by its role (searching for the role button and its name text Activations), by its text directly and by its test ID (corresponding to the data-testid present in the component). Finally, it's validated if the button is present using the toBeInTheDocument() matcher.

The second test checks for the absence of the paragraph since the button hasn't been clicked. In this case, instead of using getBy, queryBy is used because getBy would break the test if it couldn't find the element. However, the purpose of the test is to verify the absence of the element. Using queryBy, the test doesn't break (as it returns null for the search) and the absence can be verified by negating the toBeInTheDocument() matcher.

After executing the tests, they pass successfully:

Image description

From top to bottom, it is possible to observe:

  • file that was executed: src/BaseComponent.test.js
  • block that was executed: <BaseComponent />
  • tests that were executed: both tests with their descriptions and a positive checkmark on the left indicating they passed
  • Test Suites: the number of test blocks executed and how many passed
  • Tests: the number of tests executed and how many passed

To illustrate a failure result, the search for the paragraph in the second test was modified to use getBy:

Image description

In addition to the information provided above, in case of failure:

  • The failed test is indicated with an x to the left of it
  • General information about the failed test is displayed in red, including the description of the block and test that failed. Below that, how the rendered component was at the time of failure, followed by the line of code where the test failed
  • Test Suites: Out of a total of one block, it indicates that one failed. This is because a test block fails if any test inside it fails
  • Tests: Out of two tests, one passed and one failed

Now, to test if the button will be disabled or not, two tests will be conducted by rendering the component in two different ways: passing or not passing the disabled props:

  • Test 1: rendering the BaseComponent without passing the disabled props
  • Test 2: rendering the BaseComponent passing the disabled props


import React from "react";
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";

import BaseComponent from "./BaseComponent";

describe("<BaseComponent />", () => {
  it("should bring button element enabled", () => {
    render(<BaseComponent />);

    const element = screen.getByRole("button", { name: "Activations" });

    expect(element).not.toBeDisabled();
  });

  it("should bring button element disabled with disabled props", () => {
    render(<BaseComponent disabled />);

    const element = screen.getByRole("button", { name: "Activations" });

    expect(element).toBeDisabled();
  });
});



Enter fullscreen mode Exit fullscreen mode

In both tests, the toBeDisabled() matcher was used. The first test aimed to validate that the button remains enabled since the disabled props was not passed, negating the matcher in the validation. The second test aimed to validate that the button disables when the disabled props was passed to the component.

testing-library/user-event

The library that will allows simulate the user's interaction with the component.

import userEvent from "@testing-library/user-event"

User event Action
click(), dblClick() click, double click
selectOptions() option selection
paste() text paste
type() text write
upload() file upload

Interaction test example

In this example, the same component from the initial examples above will be used, but with another validations:

  • Test 1: validate the appearance of the paragraph displaying the click count after one button click, ensuring it displays the value 1
  • Test 2: validate the appearance of the paragraph displaying the click count after a double click on the button, ensuring it displays the value 2


import React from "react";
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import BaseComponent from "./BaseComponent";

describe("<BaseComponent />", () => {
  beforeEach(() => {
    render(<BaseComponent />);
  });

  it("should bring paragraph with clicked quantity after button click", () => {
    const buttonElement = screen.getByRole("button", { name: "Activations" });

    userEvent.click(buttonElement);

    const paragraphElement = screen.queryByTestId("baseParagraph");

    expect(paragraphElement).toBeInTheDocument();
    expect(paragraphElement).toHaveTextContent(1);
  });

  it("should bring paragraph with clicked quantity after double button click", () => {
    const buttonElement = screen.getByRole("button", { name: "Activations" });

    userEvent.dblClick(buttonElement);

    const paragraphElement = screen.queryByTestId("baseParagraph");

    expect(paragraphElement).toBeInTheDocument();
    expect(paragraphElement).toHaveTextContent(2);
  });
});



Enter fullscreen mode Exit fullscreen mode

In the first test, one button click is simulated using the user event click. After this click, validation is performed to ensure the appearance of the paragraph using the toBeInTheDocument() matcher, and the displayed click count is validated using the toHaveTextContent() matcher. In the second test, similar validations are performed using the same matchers, but with the expected click count value inside the toHaveTextContent() matcher. To simulate the double click, the user event dblClick is used.

Functions mock

It allows mocking functions present inside the component, setState(), requests.

const mockFunction = jest.fn()

Analysis Code
Function calls mockFunction.mock.calls
Variables passed in calls mockFunction.mock.calls[0][0]
Call result mockFunction.mock.results[0].value
Clear all mocks jest.clearAllMocks()
  • jest.clearAllMocks(): it is used to clear the mock function between tests because the function calls are not automatically cleared (they accumulate throughout the tests if not cleaned)
  • mockFunction.mock.calls: the first [ ] corresponds to which call of the function is being analyzed, and the second [ ] corresponds to which variable of that call is being analyzed

For example, considering a function f(x, y) that was called twice during the tests:

  • mockFunction.mock.calls[0][0]: value of x in the first call
  • mockFunction.mock.calls[0][1]: value of y in the first call
  • mockFunction.mock.calls[1][0]: value of x in the second call
  • mockFunction.mock.calls[1][1]: value of y in the second call

Mock test example

To perform mock tests, a new component will be used. It's an input field with a label Value:, which receives a setState via props called setValue. Every time the text inside the input is modified, it triggers a function handleChange, which calls setValue passing the current value present in the input field:



import React from "react";

const BaseComponent = ({ setValue }) => {
  const handleChange = (e) => {
    setValue(e.target.value);
  };

  return (
    <label>
      Value:
      <input type="text" onChange={(e) => handleChange(e)} />
    </label>
  );
};

export default BaseComponent;



Enter fullscreen mode Exit fullscreen mode

Two tests will be performed, mocking the setValue to validate how many times it's called and the value passed to it:

  • Test 1: 10 will be typed into the input field using the userEvent type, which corresponds to two changes in the input field (since typing 10 involves typing 1 and then 0)
  • Test 2: 10 will be pasted into the input field using the userEvent paste, which corresponds to one change in the input field


import React from "react";
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import BaseComponent from "./BaseComponent";

const setValue = jest.fn();

describe("<BaseComponent />", () => {
  beforeEach(() => {
    render(<BaseComponent setValue={setValue} />);
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  it("should call setValue after type on input", () => {
    const element = screen.getByLabelText("Value:", { selector: "input" });

    userEvent.type(element, "10");

    expect(setValue).toHaveBeenCalledTimes(2);
    // expect(setValue.mock.calls).toHaveLength(2);
    expect(setValue.mock.calls[0][0]).toBe("1");
    expect(setValue.mock.calls[1][0]).toBe("10");
  });

  it("should call setValue after paste on input", () => {
    const element = screen.getByLabelText("Value:", { selector: "input" });

    userEvent.paste(element, "10");

    expect(setValue).toHaveBeenCalledTimes(1);
    expect(setValue.mock.calls[0][0]).toBe("10");
  });
});



Enter fullscreen mode Exit fullscreen mode

At the beginning of the test, setValue is mocked using jest.fn(). Since both tests render the component in the same way, rendering is placed inside a beforeEach. Due to the mock function accumulating calls made to it and not being automatically cleared between tests, calls are cleared after each test execution with jest.clearAllMocks() in the afterEach.
The input field is seached by its label Value: and specifying the selector type input.
In the first test, which involves typing, the user event type is used to input the value 10. Two ways are used to validate the number of calls to setValue: directly checking the number of times the mock function was called using the matcher toHaveBeenCalledTimes(), and checking the length of the array recording the calls (setValue.mock.calls) using the matcher toHaveLength(). Finally, the value passed to setValue is checked using setValue.mock.calls[0][0] for the first call and setValue.mock.calls[1][0] for the second call.
The second test performs the same validations, but instead of typing 10, the number is pasted directly using the user event paste.

Conclusion

The idea was to provide a general overview of how unit tests work using Jest with testing library, covering test structure, component rendering, element search inside the rendered component, user interaction simulation, function mocking and test validations. However, the use of these libraries allows various other types of tests to be conducted, which is why I'm providing the main links separated by themes for those who want to delve deeper.

Links

Tests structure
Queries
Roles
User events
Matchers Jest
Matchers testing-library
Mock functions

💖 💪 🙅 🚩
griseduardo
Eduardo Henrique Gris

Posted on April 29, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related