Basic Recipes for React Testing Library
Last year I attended a conference called Clojure South where we had a lot of cool tech talks about functional programming and the programming language Clojure.
One of the talks I really liked was about Clojure Spec & Generative Tests by David Chelimsky. He was part of the Rspec team and he works in the clojure.spec team.
He started his talk asking
Why do you make tests?
"Confidence" said someone in the audience. Confidence that your software works as expected.
But tests only give confidence if we add tests that make sense. So it's important to have best practices and adjust the mindset to test properly.
Side note: In my opinion, having bad tests is worse than having any tests at all. Without tests, you are aware that you need tests in the future or that you will need to manually test your software before shipping to production. With bad tests, you think the tests cover good parts of your product, but they actually don't.
Thinking about this, I wanted to play around with React Testing Library to provide good examples of what and how to test properly. So I created a playground to, well, play with the Testing Library in some different contexts.
So, for this post, we will talk about theses topics:
- How to query
- How to verify content
- How to handle forms
- How to handle loading and fetching/async operations
So let's get started!
How to query
We have three main ways to get elements from the DOM: getBy
, queryBy
, and findBy
.
All of these queries have different variants. For example, the getBy
query has some variants like getByRole
and getByLabelText
. Depending on the context, you better use one over the others. But to simplify this process, the Testing Library docs has a section called Which query should I use? that explains the priority of one query over the others thinking about accessibility and semantics.
getBy
Before start using the getBy
query, let's see what we are testing.
import React from 'react';
const Queries = () => <h1>Title</h1>;
export default Queries;
This a simple component called Queries
that renders a title with a text Title
.
In this first test, we want to know if it renders the proper text. To query the element (<h1>
), we'll be using the getBy
. More specifically, we'll try the getByText
.
import React from 'react';
import Queries from '../Queries';
import { render, screen } from '@testing-library/react';
describe('getBy', () => {
it('title is in the document', () => {
render(<Queries />);
const title = screen.getByText(/title/i);
expect(title).toBeInTheDocument();
});
});
So here it is the first test. As simple as it looks.
- We use the
render
function from the testing library to render the component we want to test - Query by the text
title
together with thescreen
function from the testing library. - In the query, we use the regular expression to not have the problem of upper or lower case.
- And finally we expect to have this element
title
in the DOM.
We'll talk about this toBeInTheDocument
method later. But we basically want to use this matcher to expect that title is in the DOM.
One specific "feature" the getBy
has is to throw an error if it doesn't find the element in the rendered component. Let's see it in action.
it('verify no element', () => {
render(<Queries />);
try {
screen.getByText(/subtitle/i);
} catch (e) {
console.log('Did not find nonexistent element');
}
});
If you run this test, the subtitle
text doesn't exist and it will throw an error. As we use the try-catch
, when we run the test, it will log the text Did not find nonexistent element
in your terminal.
queryBy
queryBy
has the same feature as the getBy
. The main difference is that the queryBy
doesn't throw an error if it doesn't find a given element in the DOM. Let's do the same implementation we did with getBy
, but now with queryBy
.
import React from 'react';
import Queries from '../Queries';
import { render, screen } from '@testing-library/react';
describe('queryBy', () => {
it('title is in the document', () => {
render(<Queries />);
const title = screen.queryByText(/title/i);
expect(title).toBeInTheDocument();
});
});
The happy path works the same way. Here we use the queryByText
to get the title element and expect it to be in the document.
But now when it doesn't find an element:
it('verify no element', () => {
render(<Queries />);
const subtitle = screen.queryByText(/subtitle/i);
expect(subtitle).not.toBeInTheDocument();
});
It won't throw an error. Here we can use the queryByText
without the try-catch
and try to get the subtitle element.
We use the .not
together with the toBeInTheDocument
matcher to expect that the subtitle is not in the DOM.
The queryBy
is very useful when we want to make sure that the page is not showing an element.
findBy
The findBy
always returns a promise. And the promise resolves when it finds the element we are searching for.
It's very useful when the element is not the DOM. But the element appears after a specific event. Let's see an example:
import React, { useState } from 'react';
const Queries = () => {
const [isParagraphVisible, setIsParagraphVisible] = useState(false);
return (
<>
<button onClick={() => setIsParagraphVisible(true)}>
Show paragraph
</button>
{isParagraphVisible ? <p>A paragraph</p> : null}
</>
);
};
export default Queries;
So here it is a simple component where we have a button and a logic to show or not a paragraph.
At the top of the component, we use a useState
hook to just manage the state to show or hide the paragraph. If the user click the button, it will show the paragraph. This is the "feature".
Now we can use the findBy
to query a paragraph, after clicking in the button.
import React from 'react';
import Queries from '../Queries';
import { render, screen, fireEvent } from '@testing-library/react';
describe('findBy', () => {
it('paragraph is in the document', async () => {
render(<Queries />);
const button = screen.getByRole('button', { name: /show paragraph/i });
fireEvent.click(button);
const paragraph = await screen.findByText(/a paragraph/i);
expect(paragraph).toBeInTheDocument();
});
});
- First we render the
Queries
component. - Get the button with the
getByRole
query. - We use the
fireEvent
function from the testing library to mimic the click in the button. - And now we finally try to get the paragraph with the
findByText
. - After getting the paragraph, we make sure it is in the document.
- As the
findBy
query returns a promise, we can use the.then
method or theawait
to get the real element. As we used theawait
in this example, we need to add anasync
statement in theit
block.
A common example to use findBy
is when we do an asynchronous operation like fetching an API for some data, so we need to wait until we have the data rendered in the dom to get the elements we want to verify that are there.
The findBy
also throws an error when it doesn't find an element like the getBy
.
it('verify no other item', async () => {
render(<Queries />);
const button = screen.getByRole('button', { name: /show paragraph/i });
fireEvent.click(button);
try {
await screen.findByText(/another paragraph/i);
} catch (e) {
console.log('Did not find nonexistent element');
}
});
In this test, I added a try-catch
to see that the findByText
really throws an error.
How to expect content
There are many ways to verify the correct content in the page or component. But one I like the most is the matcher .toBeInTheDocument
.
The .toBeInTheDocument
is matcher from the jest-dom library from the testing library org. There other custom jest matchers we can use too.
We use this matcher in all the queries examples. But one thing that is cool to mention is that we don't need to import the matchers if we import the jest-dom
in the setupTests.js
like this:
// setupTests.js
import '@testing-library/jest-dom/extend-expect';
So now the expect
function from jest
is extended to use all the matchers from the jest-dom
.
How to handle forms
We can also handle forms with the testing library. The best practice is to use the @testing-library/user-event library. This library simulates the real interactions between users and browsers.
Before starting to test, let's the simple form we want to test.
import React from 'react';
export const Form = () => (
<>
<h1>Form</h1>
<form>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" />
</div>
<div>
<label htmlFor="age">Age</label>
<input id="age" name="age" type="number" />
</div>
<div>
<label htmlFor="birthday">Birthday</label>
<input id="birthday" name="birthday" type="date" />
</div>
<div>
Hobbies:
<input type="checkbox" name="coding" id="coding" value="coding" />
<label htmlFor="coding">Coding</label>
<input type="checkbox" name="reading" id="reading" value="reading" />
<label htmlFor="reading">Reading</label>
<input type="checkbox" name="writing" id="writing" value="writing" />
<label htmlFor="writing">Writing</label>
</div>
<div>
OS:
<input type="radio" name="windows" id="windows" value="windows" />
<label htmlFor="windows">Windows</label>
<input type="radio" name="mac" id="mac" value="mac" />
<label htmlFor="mac">Mac</label>
<input type="radio" name="linux" id="linux" value="linux" />
<label htmlFor="linux">Linux</label>
</div>
<div>
<label>Favorite city?</label>
<select id="favcity" name="favcity" data-testid="favcity">
<option value="1">Amsterdam</option>
<option value="2">Hong Kong</option>
<option value="3">London</option>
<option value="4">New York</option>
<option value="5">Sao Paulo</option>
<option value="6">Tokyo</option>
</select>
</div>
<div>
<label>Countries you want to visit</label>
<div>
<select
id="countries"
name="countries"
data-testid="countries"
multiple
>
<option value="Brazil">Brazil</option>
<option value="Japan">Japan</option>
<option value="New Zealand">Italy</option>
<option value="Germany">Germany</option>
<option value="India">India</option>
<option value="Netherlands">Netherlands</option>
</select>
</div>
</div>
<button type="submit">Submit</button>
</form>
</>
);
export default Form;
Wow! A lot of stuff here. But let's break down each part of the form (and see that it's not that scary). We want to test a lot of different kind of elements (input, select, multi-select, radio button, checkbox, etc):
- A text input for the name
- A number input for the age
- A date input for the birthdate
- A checkbox for hobbies
- A radio button for operating system
- A select for favorite city
- A multi-select for countries I want to visit
That's it! We want to fill, select, and check all these elements with the testing library, and finally click the submit button.
First thing we always do: render the component.
render(<Form />);
And then we use the screen
together with the getBy
query to get the form fields.
const nameInput = screen.getByLabelText(/name/i);
const ageInput = screen.getByLabelText(/age/i);
const birthdayInput = screen.getByLabelText(/birthday/i);
const codingCheckbox = screen.getByLabelText(/coding/i);
const readingCheckbox = screen.getByLabelText(/reading/i);
const writingCheckbox = screen.getByLabelText(/writing/i);
const osRadio = screen.getByLabelText(/mac/i);
const favCitySelect = screen.getByTestId(/favcity/i);
const tokyoOption = screen.getByText(/tokyo/i);
const countriesMultiSelect = screen.getByTestId(/countries/i);
const japanOption = screen.getByText(/japan/i);
const germanyOption = screen.getByText(/germany/i);
const netherlandsOption = screen.getByText(/netherlands/i);
const button = screen.getByRole('button', { name: /submit/i });
Text, number, and date inputs
And finally we fill the input fields with the type
function:
userEvent.type(nameInput, 'TK');
userEvent.type(ageInput, '24');
userEvent.type(birthdayInput, '01/01/1996');
Checkboxes
Check the checkboxes with the click
function:
userEvent.click(codingCheckbox);
userEvent.click(readingCheckbox);
userEvent.click(writingCheckbox);
Radio buttons
Choose an option from the radio button with the click
function:
userEvent.click(osRadio);
Selects
Select the favorite city with the selectOptions
function:
userEvent.selectOptions(favCitySelect, [tokyoOption]);osRadio);
Multi-selects
Select countries I want visit (again) with the selectOptions
userEvent.selectOptions(countriesMultiSelect, [
japanOption,
germanyOption,
netherlandsOption,
]);
And after filling all the form, we are able to click the submit button:
userEvent.click(button);
How does all come together?
import React from 'react';
import { Form } from '../Form';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('Form', () => {
it('fills form', () => {
render(<Form />);
const nameInput = screen.getByLabelText(/name/i);
const ageInput = screen.getByLabelText(/age/i);
const birthdayInput = screen.getByLabelText(/birthday/i);
const codingCheckbox = screen.getByLabelText(/coding/i);
const readingCheckbox = screen.getByLabelText(/reading/i);
const writingCheckbox = screen.getByLabelText(/writing/i);
const osRadio = screen.getByLabelText(/mac/i);
const favCitySelect = screen.getByTestId(/favcity/i);
const tokyoOption = screen.getByText(/tokyo/i);
const countriesMultiSelect = screen.getByTestId(/countries/i);
const japanOption = screen.getByText(/japan/i);
const germanyOption = screen.getByText(/germany/i);
const netherlandsOption = screen.getByText(/netherlands/i);
const button = screen.getByRole('button', { name: /submit/i });
userEvent.type(nameInput, 'TK');
userEvent.type(ageInput, '24');
userEvent.type(birthdayInput, '01/01/1996');
userEvent.click(codingCheckbox);
userEvent.click(readingCheckbox);
userEvent.click(writingCheckbox);
userEvent.click(osRadio);
userEvent.selectOptions(favCitySelect, [tokyoOption]);
userEvent.selectOptions(countriesMultiSelect, [
japanOption,
germanyOption,
netherlandsOption,
]);
userEvent.click(button);
});
});
This is a simple form, but if we want to add some validation, we can think of how a user would use the form.
Have each form field a validation of presence that show a text below each field? We could, for example, click the submit buttons and verify which fields have the validation text until it doesn't have any error anymore.
How to handle fetching and loading
Now we will see an example of fetching within a component and test the loading state and then the data rendered in the DOM.
import React from 'react';
import { useFetchAPI } from './useFetchAPI';
const Loading = ({ pokemon }) => {
const { hasError, isLoading, isResolved, data } = useFetchAPI(
`https://pokeapi.co/api/v2/pokemon/${pokemon}`,
);
if (hasError) {
return <p>Error!</p>;
}
if (isLoading) {
return <p>Loading...</p>;
}
if (!isResolved) {
return null;
}
const { name, types } = data;
const skills = types.map((type) => type.type.name).join(', ');
return (
<>
<h1>Name: {name}</h1>
<p>Skills: {skills}</p>
</>
);
};
export default Loading;
This is a very simple component that we'll use to test the loading and the error state and the data rendered.
Here we have:
- A custom hook for fetching: it uses the
fetch
function to request data from any API. - The custom hook receives a url that it will request and returns 4 values:
hasError
,isLoading
,isResolved
, and thedata
to render. - Below we have three if statements to handle the error, the loading, and when the request didn't finish.
- And then we have the data we want to render: the name and skills of a pokemon.
Let's first test the error state when the API responds with an error.
it('renders the error', async () => {
fetch.mockReject(() => Promise.reject('API is down'));
render(<Loading pokemon="charmander" />);
const error = await screen.findByText('Error!');
expect(error).toBeInTheDocument();
});
- The first thing I did was to mock the
fetch
function to simulate that the API will respond with an error. - Then we render the
Loading
component with a prop calledpokemon
and a valuecharmander
. - We try to find the text
Error!
. - And verify if it is actually in the document.
We use the findByText
because the error state will appear only after the fetching finishes, so it's a asynchronous operation. This is why we need to have an async-await
in the test.
Now for the happy path: we fetch the pokemon, the loading state appears, and then the pokemon data renders in the DOM.
it('renders the loading and then the pokemon info', async () => {
const data = { name: 'charmander', types: [{ type: { name: 'fire' } }] };
fetch.once(JSON.stringify(data));
render(<Loading pokemon="charmander" />);
const loading = screen.getByText('Loading...');
expect(loading).toBeInTheDocument();
const charmander = await screen.findByText('Name: charmander');
const charmanderSkills = await screen.findByText('Skills: fire');
expect(charmander).toBeInTheDocument();
expect(charmanderSkills).toBeInTheDocument();
});
- The first two lines we define the data and mock the fetch to return this value for us.
- We fetch the
Loading
component passingcharmander
- Then we first want to see the
Loading...
text in the document. - And then verify if the pokemon name and skills are in the document.
Now everything together:
import React from 'react';
import Loading from '../Loading';
import { render, screen } from '@testing-library/react';
describe('Loading', () => {
it('renders the loading and then the pokemon info', async () => {
const data = { name: 'charmander', types: [{ type: { name: 'fire' } }] };
fetch.once(JSON.stringify(data));
render(<Loading pokemon="charmander" />);
const loading = screen.getByText('Loading...');
expect(loading).toBeInTheDocument();
const charmander = await screen.findByText('Name: charmander');
const charmanderSkills = await screen.findByText('Skills: fire');
expect(charmander).toBeInTheDocument();
expect(charmanderSkills).toBeInTheDocument();
});
it('renders the error', async () => {
fetch.mockReject(() => Promise.reject('API is down'));
render(<Loading pokemon="charmander" />);
const error = await screen.findByText('Error!');
expect(error).toBeInTheDocument();
});
});
Final words
So here we finish this first conversation about tests in frontend. We've learned a lot of things:
- How to: query elements in the DOM
- How to: verify content in the document
- How to: test forms
- How to: handle fetching and loading
I wanted to cover as much as possible, but I think this is a good start to understand how use this tool to test our applications and gain more confidence when shipping features or refactorings to production. I hope these recipes can help you understand more about testing in general and improve the quality of your software.
Until the next time! Keep learning!