When we are building an application, it's always necessary to ensure that our app is working the way it should. So, in this article, I'm going to show you how to test your React Apps in the right way with a real-world example.
The big reason
Besides making sure it's everything working well, with good tests in place, you can refactor your code like crazy and have the assurance that you didn’t break anything. Moreover, you can add new features or improve existing ones with the same level of confidence.
What's worth testing?
Probably it already happened to you, and I can say that when it comes time to actually write the tests, we frequently get stuck on it. So, what exactly should and it's worth to test? Based on the app we're going to build/test today, at least, you'll be able to...
Test if it is rendering
Test events
Test edge cases
Test states
🧪 Setting up our React App for tests
npx create-react-app testing-irl
Feel free to give a different name to the app
📐 The project structure
There are many ways to structure your files and folders for tests, but let's just focus on these ones:
- The folder
__tests__
-> This one always stays on the source folder (src
) and here you can just add your .js
files (as usual).
- The files
Otherwise, if you don't add the test files to the __tests__
folder, it's necessary to add one of these suffixes:
.spec.js
orspec.ts
.test
orspect.ts
We recommend to put the test files (or tests folders) next to the code they are testing so that relative imports appear shorter. For example, if App.test.js and App.js are in the same folder, the test only needs to import App from './App' instead of a long relative path. Collocation also helps find tests more quickly in larger projects. — Running tests, create-react-app
👩🏫 Understanding the it
, describe
, and expect
Before start building like crazy our app, it's necessary to understand the main concepts of test implementation: global functions.
The it
and describe
receive a string and function as parameters.
- Test Suite
describe('App.js', () => {})
// or
describe('Testing the App.js file', () => {})
describe
has a description of the test and the callback function which contains the implementation of each test.
Hint: Describe is useful for separating a group of tests in the archive.
- Test Case
it('should list 3 <li> elements', () => {})
it
receives the description of what will be tested and the test implementation as a second parameter.
Hint: In general, by convention, we write the descriptions in English, according to the it pronoun syntax. e.g.: it should be able to show the h1 element.
expect
And finally, this is a method to make an assertion where we compare what is expected as the result. Within the expect
function, firstly comes the expected result, then we'll be able to use some assertiveness methods (e.g.: expect().ToBe(null)
)
describe('Checkbox.js', () => {
test('true is truthy', () => {
expect(true).toBe(true);
});
test('false is falsy', () => {
expect(false).toBe(false);
});
});
🎨 Final outlook
Basically, what we're gonna build is an app where the user can add technologies and also delete them. Also, if the user tries to a user that already exists in the list, it'll not work. Although it looks pretty simple, the test's complexity can require much more. Let's start with the components!
The components
App.js
import Technologies from 'pages/Technologies';
function App() {
return (
<>
<h1>Hello, testing world</h1>
<Technologies />
</>
);
}
export default App;
Techologies.js
import { useState } from 'react';
function Technologies() {
const [technologies, setTechnologies] = useState(['React']);
const [newTech, setNewTech] = useState('');
const handleSubmit = e => {
e.preventDefault();
if (!newTech || technologies.includes(newTech)) return;
setTechnologies([...technologies, newTech]);
setNewTech('');
}
const handleDelete = (tech) => {
setTechnologies(technologies.filter((techItem) => techItem !== tech));
}
return (
<>
<ul data-testid="ul-techs">
{technologies.map(tech => (
<li data-testid={tech} key={tech}>
{tech}
{' '}
<button
disabled={tech === 'React'}
data-testid={`${tech}-btn-delete`}
type="button"
onClick={() => handleDelete(tech)}
>
❌
</button>
</li>
))}
</ul>
<form data-testid="form-add-tech" onSubmit={handleSubmit}>
<input
data-testid="input-add-tech"
type="text"
value={newTech}
onChange={(e) => setNewTech(e.target.value)}
/>
<button type="submit">Save</button>
</form>
</>
);
};
export default Technologies;
technologies
-> Which is a string array that stores the names of the technologies and that already comes with the "React" technology filled in by default.newTech
- A state that stores a string with the name of the technology that the user will type.handleSubmit
(event) - When it is fired, add the technology to thetechnologies
array and clear the field. First, it makes sure that thenewTech
is not empty and if the array already contains the technology that is being inserted. If it already exists, do not allow to continue the operation.handleDelete
(technology) - It receives a string with the technology, and using the concept of immutability, recreates an array of technologies except with the tech that was passed as a parameter.
🍏 Testing our app
App.spec.js
import { render, screen } from '@testing-library/react';
import App from 'App';
describe('App.js', () => {
it('should be able to show the h1 element', () => {
render(<App />);
const titleEl = screen.getByText(/hello testing world/i); // Hello, testing world
expect(titleEl).toBeInTheDocument();
});
});
The test on the App.spec.js
is quite simple:
- First I render the
<App />
- I assign in the constant
titleEl
the return of the query by text. - I hope that
titleEl
is in the DOM (toBeInTheDocument
). If it is true, the test succeeds. Otherwise, it fails. And also, to run the test, you just need to use this command at the root of the project:
npm run test
oryarn test
The
render
method on the@testing-library
is responsible for rendering the component. It takes the element as a parameter and returns aRenderResult
that has several utility methods and properties such as a container.The
screen
object is used to query and debug the DOM. It is more recommended and you don't need to be disrupting the render return to get every single function that makes the queries.
// ❌
const { getByRole } = render(<App />);
// ✅
render(<App />);
const errorMessage = screen.getByRole('alert');
Tecnologies.spec.js
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Technologies from 'pages/Techonologies';
fireEvent
-> It fires click events (.click
), text changes (.change
), and also form submissions.
For most cases, you will need to use the userEvent
(that was created under the fireEvent package). It provides several methods that are closer to the reality of the user's interaction with the screen. It has the type method to trigger the click
, keyDown
, keyPress
, and keyUp
events.
Hint: Whenever possible, use @testing-library/user-event
instead of fireEvent
.
- 🔬 1st test
it("should be able to add new technology", () => {
render(<Technologies />);
const input = screen.getByTestId("input-add-tech");
const form = screen.getByTestId("form-add-tech");
userEvent.type(input, "React Native");
fireEvent.submit(form);
expect(screen.getByTestId("React Native")).toBeTruthy();
});
With the input
and the form
in hands, we can perform actions on these elements via programming.
userEvent.type(input, "React Native");
The userEvent
is used to fill the input
with React Native as a value. Also, I use the fireEvent
to trigger the event and submit it to the form
.
I expect that the React Native exists, which means that the result of the query screen.getByTestId("React Native")
is true.
It'll return a string or not. If returns a string, the truthy
value of a string is true. On the other side, null, undefined are false: falsy
.
With that, we could implement:
expect(!!screen.getByTestId('React Native')).toBe(true);
The result would be the same, but the assertion with toBeTruthy()
is more semantic and improves the readability of the code ;)
- 🔬 2nd test
it("should be able to list three techs", () => {
const { getByTestId } = render(<Technologies />);
const input = getByTestId("input-add-tech");
const form = getByTestId("form-add-tech");
fireEvent.change(input, { target: { value: "React Native" } });
fireEvent.submit(form);
fireEvent.change(input, { target: { value: "Flutter" } });
fireEvent.submit(form);
const techList = getByTestId("ul-techs");
expect(techList.children.length).toBe(3);
});
In this one, I used the
render
breakdown to get thegetByTestId
method. Also,the fireEvent.change
simulates the user typing something.- Note: Just to demonstrate that it is possible and exists in the API, but
userEvent.type
is more recommended to simulate a user events.
- Note: Just to demonstrate that it is possible and exists in the API, but
Also, I expect that after adding 2 elements (the first was already added when I declared the
technologies
array with a default value: React) the list length must be 3.techList
is aul
tag, and its children, ali
.If you have 3 children, the result is true and the test passes.
🔬 3rd test
it("should be able to delete one tech", () => {
render(<Technologies />);
const input = screen.getByTestId("input-add-tech");
const form = screen.getByTestId("form-add-tech");
userEvent.type(input, "React Native");
fireEvent.submit(form);
expect(screen.getByTestId("React Native")).toBeTruthy();
const itemButton = screen.getByTestId("React Native-btn-delete");
userEvent.click(itemButton);
expect(screen.queryByTestId("React Native")).toBeNull();
});
- Render the Technologies component, take the input and use the
data-testid
. - I use
userEvent.type
to simulate the user filling the input with React Native and submit it using thefireEvent.submit
.- Also, I check if it has been added and finally assign the item deletion button in the list item to the
const itemButton
. - The event is fired with the
userEvent.click
to the button. - I expect that the return of React Native is
null
:<li data-testid = {tech} key = {tech}>
- For each item rendered, the
id
will be the name of the technology.
- Also, I check if it has been added and finally assign the item deletion button in the list item to the
Note: Ideally, it would be the technology id, but with this example, it's ok.
- 🔬 4th test
it("button delete should be disabled only for React technology", () => {
render(<Technologies />);
const button = screen.getByTestId("React-btn-delete");
expect(button).toBeDisabled();
});
And finally, last and not least, we have the test that checks whether the button with the React item is disabled.
The news here is the toBeDisabled()
method -> it checks if the button is disabled to pass the test.
Full code
🧑🎓 Conclusion
It is always good to know more about the methods, as well, the API to be able to implement them correctly. Kent Dodds, one of the maintainers of the RTL, wrote this article that talks about common mistakes when implementing and I heavily recommend to read it.