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…", () => {
…
})
})
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>}
</>
);
};
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();
});
});
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:
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
:
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 thedisabled
props - Test 2: rendering the
BaseComponent
passing thedisabled
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();
});
});
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);
});
});
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;
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");
});
});
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
Top comments (4)
I always appreciate these write ups for unit tests concerning frontend.
I always struggle to weigh the pros and cons when it comes to implementing these as I consult with many higher level front end experts who recommend against front end testing due to how sensitive these can be when developing new features.
Between directly creating unit tests to potential testing like Selenium, I have not landed on anything specifically that I feel strongly about to implement in my professional job.
I do however l need to just create some side projects and play with this first-hand to get a better understanding.
Great job!!!
Thanks, chrischism8063!!
I find the idea of side projects for better understanding quite interesting. Currently, I use a personal project to apply everything I want to delve into (jest, typescript, storybook...), and it has been quite helpful.
I consider testing an important part aiming for greater security and reliability in the development process. Regarding the type of testing, in case of having to choose one (e2e or unit), two important factors I see are the type of project and the cost of executing these tests.
Currently, I'm working on a personal learning project, which is a component library, where unit tests play a significant role in ensuring that whoever uses it, the components will perform as expected
nice, good job with the article.
Thanks Ricardo!!