Originally published on my blog: https://bogr.dev/blog/react-testing-intro/
This article assumes a certain level of agreement between me and you that testing the code we write as developers, is important. And also, that the best way to ensure our code has no bugs, is to write tests and run them as often as possible during the development lifecycle. I'll leave the discussion about the pros and cons of writing tests (and the different approaches in that matter) for another article.
In this article I want to walk you through the setup and writing of your very first test in React. Also, to provide some basic theory around the testing terminology and best practices. Meaning that this article is targeting mostly beginner JS/React developers, but could be a good starting point for any developer at any level of experience.
I hate long intros, so why don't we just dive right in.
What you're going to learn
- How to setup a React project with Vite, Vitest and React Testing Library
- How to implement a very basic quotes app, which shows a quote from an array and the user is able to navigate go to the next/previous quotes using previous and next buttons.
- What's the difference between Unit and Integration tests.
- What are some of the most common react-testing-library APIs (
render
,screen
,query functions
) - What's the difference between
get
,query
andfind
query functions - The three
A
s testing framework - Some best practices for writing React tests
- I'll leave some of the tests for you to figure out by yourself. Remember - it's crucial apply the knowledge if you want it to stick.
Resources
- GitHub repository with the code of our project. Feel free to clone it and experiment with it.
Project setup
Vite
First things first, let's setup a react project using Vite.
But what's vite, you may ask?
From vite's website:
Vite (French word for "quick", pronounced /vit/, like "veet") is a build tool that aims to provide a faster and leaner development experience.
We'll use Vite's superpowers to run a simple dev server for our React app while developing. Also, we'll use it to bundle the application later when we're ready to deploy (won't be covered in this article).
Create a React project with Vite
Let's start with creating a folder for the app. I'm naming my app rtl-vite
.
mkdir rtl-vite && cd rtl-vite
Let's now create the React app with Vite. Run this in your terminal:
npm create vite@latest .
You're going to be asked what project scaffold do you want for your new Vite app.
For the sake of simplicity, I've picked the React with JavaScript template, but feel free to select the React with TypeScript one if you want to.
Installing dependencies
In order to test React apps, we need to bring in couple more libraries to our project.
Vitest and React Testing Library
Vitest is a next generation testing framework powered by Vite.
npm install -D vitest
The React Testing Library is a very light-weight solution for testing React components. It provides light utility functions on top of react-dom and react-dom/test-utils, in a way that encourages better testing practices.
The RTL is the primary tool we're going to use and interact with in this tutorial.
All the other tools are in a way "the infrastructure" around the testing itself.
So let's install that as well:
npm install -D @testing-library/react
Note: The -D
flag installs the deps as dev dependencies
.
Setting up the test globals and jsdom env
In order to save us some import statements in our test files and tell vitest that we want to use jsdom
, update the exported config in your vite.config.js
to the following:
src/vite.config.js
export default defineConfig({
plugins: [react()],
test: {
// <--- Add this object
globals: true,
environment: "jsdom",
},
});
Setting up a test script
We have the needed libraries, but how do we run the tests?
Let's add a test
command to our package.json's scripts section:
package.json
"scripts": {
...
"test": "vitest"
},
That's it.
We're now ready to roll.
The app
We need something to test, so let's create a very simple app.
Here's what we're going to build now:
A minimalist quotes application featuring a streamlined interface with two primary controls: a 'Previous' button and a 'Next' button. The application's central feature is a designated display area where quotes are presented. Upon the user's interaction with the 'Next' button, the application will display a new quote. When the 'Previous' button is clicked, the application will revert to showing the immediately preceding quote.
Let's go.
The App
component
So, here's our App
component.
src/App.jsx
import { useState } from "react";
import Quote from "./components/Quote";
import Previous from "./components/Previous";
import Next from "./components/Next";
import "./App.css";
export default function App({ quotes }) {
const [currentIndex, setCurrentIndex] = useState(0);
const navigate = (direction) => () => {
let nextIndex = direction === "prev" ? currentIndex - 1 : currentIndex + 1;
if (nextIndex >= quotes.length) {
nextIndex = 0;
}
if (nextIndex < 0) {
nextIndex = quotes.length - 1;
}
setCurrentIndex(nextIndex);
};
return (
<div className="App">
<div className="App__quoteContainer">
<Quote quote={quotes[currentIndex]} />
</div>
<div className="App__navigation">
<Previous onClick={navigate("prev")} />
<Next onClick={navigate("next")} />
</div>
</div>
);
}
To keep things simple, our quotes data is going to be an array with a few items.
The App
component expects a list of quotes to be passed through its props
.
Notice also that it keeps a reference to an internal state value called currentIndex
, which is initially 0
.
The navigate
function is the meat of our app's functionality. That function gets called when the user navigates back and forth between the quotes.
In there we determine the next index, ensuring it's within the bounds of the quotes array.
The UI consists of the core component (<Quote />
), followed by the two controls, wrapped in a div that lays them out on the x axis.
The quotes list
Ideally, our app would interact with some sort of an API to get a list of quotes.
But for the purposes of our exercise, we're going to keep things as simple as possible and define a simple array with a bunch of quotes.
src/quotes.js
export const quotes = [
`“What greater gift than the love of a cat.” — Charles Dickens`,
`“One of the ways in which cats show happiness is by sleeping.” —Cleveland Amory`,
`“A cat will be your friend, but never your slave.” —Theophile Gautier`,
`“No home is complete without the pitter-patter of kitty feet.” —Unknown`,
`“As every cat owner knows, nobody owns a cat.” —Ellen Perry Berkeley`,
];
We're then passing the data to the App
component like this:
src/main.jsx
...
<React.StrictMode>
<App quotes={quotes} />
</React.StrictMode>
...
The Quote
component
The Quote component is a pure functional component, which gets a quote and displays it. That's all.
src/components/Quote.jsx
import "./Quote.css";
export default function Quote({ quote }) {
return <blockquote className="Quote">{quote}</blockquote>;
}
The navigation controls
For the navigation controls, we also have two functional components.
src/components/Previous.jsx
export default function Previous({ onClick }) {
return (
<button type="button" onClick={onClick}>
Previous
</button>
);
}
src/components/Next.jsx
export default function Next({ onClick }) {
return (
<button type="button" onClick={onClick}>
Next
</button>
);
}
Testing
Since we already have a simple, yet functional app, let's see what we can do to ensure any further development of features in the app goes smoothly and doesn't break any of the existing functionalities.
To do this, we're going to unit test the Quote
, Previous
and Next
components, and also integration test the App
component.
But before that, some theory.
Unit tests
In programming, a unit test is a piece of code that is written to test a single application module of a software system.
By module, we usually mean a tiny piece of functionality - a unit - that the program composes with other such pieces in order to do some task.
Just like lego blocks, where each block is a unit/module.
In the React world, you can think a React component as such a module. Thus, a unit test in React world is designed to test the behavior of a single React component.
Such components in our application are the Quote
, Previous
and Next
. They are a single-purpose, pure functional components.
We're going to unit test them in a second, but before that, let's quickly remind ourselves what's integration testing.
Integration tests
Integration tests, are designed to verify that the interactions between two or more single-purpose modules in a software system run smoothly, without any bugs.
In other words, we test whether a group of units work together (interact) as expected.
In our application we have the App
component which composes the rest of the components and that's a perfect candidate for an integration test.
Let's start with the simpler ones - the unit tests.
Testing the Quote
component
Our Quote
component is quite dumb and not much could break there. So it'd be enough to make sure it just renders properly as our first step.
Create a Quote.test.jsx
file under src/components/
and paste that in:
import { render } from "@testing-library/react";
import Quote from "./Quote";
describe("Quote", () => {
it("should render properly", () => {
render(<Quote quote="What a nice day." />);
});
});
So what's actually going on here?
The describe
and it
functions
Vitest, our testing framework, exposes couple of useful functions which we use to build our tests.
The first one is describe
. Think of it as a way to group couple of tests that are related in some way or another into a single describe block
.
Since we're going to write couple of tests that are testing our Quote
component, I'm going to group them in a single describe block.
You can have as many describe blocks as you wish. Also, you can nest them.
My advice would be to keep things simple. Usually, a single describe block is more than enough.
As you can see from the code above, we put our tests inside of it
functions. There's also a test
function, which is basically the same - they're interchangeable.
it
functions is where you put the actual test code.
Are they global?
Recall that in the beginning we added this to our vite.config.js
file:
test: {
globals: true,
environment: 'jsdom',
},
What that does is to expose the describe
and it
functions on the global object. That means that we can now use those (and couple of other functions) right away, without importing them.
render
is the first step
Take a look at our test again.
it("should render properly", () => {
render(<Quote quote="What a nice day." />);
});
All it does is to render our component.
But where does it render it?
Well, the cool thing is it does it behind the scenes and you don't see it. Think of it as an invisible browser.
The render
function call is the first step of a react test. You pass it the component you want to test, it does it's magic behind the scenes to generate the DOM for that component and after that you can interact with the rendered DOM, as if it was rendered in a browser.
That's the magic of react-testing-library
.
Does it pass?
A test in Vitest
passes if it doesn't throw an error.
In our case, we never return from our test, so if everything works, it should pass successfully.
Let's run it:
npm run test
And yes, as expected, our first test is successful:
Testing the Previous
and Next
components
Now things get a bit more interesting.
Our navigation components take a single prop
. A function (onClick
), which we run on button click.
What a good test for those components would be?
A good one would be to test whether or not the function gets called when a button is clicked, right?
Let's do this.
src/components/Previous.test.jsx
import { expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import Previous from "./Previous";
const onClick = vi.fn();
describe("Previous", () => {
it('should call "onClick" when a button is clicked', () => {
render(<Previous onClick={onClick} />);
const button = screen.getByRole("button");
fireEvent.click(button);
expect(onClick).toBeCalledTimes(1);
});
});
Lots of things are happening there. Let's go through each of them one step at a time.
Mocking the onClick
function
Just to remind you, our Previous
and Next
components get a callback function called onClick
through their props
. The the button is clicked, the function is called.
For the purposes of our test, we don't actually care what happens when the button is clicked. We care whether or not the function gets called.
A common approach when you want to check if a function/object is being properly used by the system under test is to create a mock function/object and check that expected calls are made to the mock function/object.
In vitest, you can mock a function, by creating a void one using vi.fn()
.
const onClick = vi.fn();
Arrange the stage
The first line of our test should be already familiar:
render(<Previous onClick={onClick} />);
We render the component and pass our mock onClick
function to it.
But let's examine what happens on the next line:
const button = screen.getByRole("button");
It's pretty self-explanatory what that line does. But what's the deal with that screen
and what's getByRole
?
The screen
object is a very important one. You're going to use it in most of your tests. It's always used in conjunction with render
and both of these objects come from the react-testing-library
package.
Think of the screen
object as the "invisible browser", through which you can interact with the rendered DOM inside of the test. That object exposes a bunch of useful methods, which makes selecting elements on the DOM a trivial task.
In our example, we call the getByRole("button")
to find the button in the rendered DOM.
Here's a list of the most commonly used methods that the screen
exposes:
// getBy*
getByRole
getByLabelText
getByTestId
...
// getAllBy*
getAllByRole
getAllByLabelText
getAllByTestId
...
// queryBy*
queryByRole
queryByLabelText
queryByTestId
// queryBy*
queryAllByRole
queryAllByLabelText
queryAllByTestId
// findBy*
findByRole
findByLabelText
findByTestId
...
// findAllBy*
findAllByRole
findAllByLabelText
findAllByTestId
...
What's the difference between getBy
, getAllBy
, queryBy
, queryAllBy
, findBy
and findAllBy
in react-testing-library
We can group all six of these so called query functions in three groups: get
, query
and find
query functions.
We use all of them to find elements on the rendered DOM.
getBy
/getAllBy
The getBy
and getAllBy
functions do a synchroneus query on the DOM and throw an error if no elements are found, thus failing the test right away. As the name implies, the getBy
is used to get a single element and the getAllBy
is used for selecting multiple elements that match the given query.
queryBy
/queryAllBy
The queryBy
and queryAllBy
do exactly the same, but they don't throw an error if no elements are found. That's useful when you need to do some conditional logic inside of your tests, based on the presence of an element, and not fail the test immediately.
findBy
/findAllBy
We use the findBy
and findAllBy
when dealing with some sort of asynchrony in the component we're testing.
For example, when the component does a fetch
request to some API and waits for the result to come in order to render a DOM element.
In that scenario, we can use find
functions to "tell" react-testing-library that the DOM element we're looking for is going to be rendered after some delay (caused by the fetch
in this case). So, the react-testing-library is going to wait for a certain amount of time for the element to appear and will throw after that time expires and no element is found.
Keep in mind that you need to await
find operations, which makes the whole test async
.
Example:
it("some async test", async () => { // <--- Notice the async
render(<SomeComponent />)
const el = await findByRole("button"); // <--- Notice the await
...
})
Act
OK, that's the theory around react-testing-library's query functions
.
Back to our test.
After we have properly set the "stage" (rendered the component and got a reference to the button), we can proceed to doing the actual click.
For that we're going to need another function from the react-testing-library
- fireEvent
.
Again, as the name implies, that's how you trigger an event on a given DOM element.
fireEvent.click(button);
Simple, right?
Assert
ing that the button works as expect
ed
Now to the meat of our test - making sure that what we've done in the first 4 lines actually worked.
That's where the expect
function comes in handy.
The expect function works like this:
You pass it an object and it gives you back a wrapper around that object, exposing a bunch of useful functions, such as toBe
or toEqual
and so on.
In our test, we pass it the onClick
mock function and assert
that it's being called once (toBeCalledTimes(1)
).
expect(onClick).toBeCalledTimes(1);
That completes our test.
Now, try to write the test for the Next
component by yourself. I won't give you the code here, but it should be identical to the one we wrote for the Previous
component.
The best way to learn thins is to get your hands dirty, so go ahead and try.
The three A
s of testing
When writing tests, a good framework to follow is the three A
s framework.
That stands for Arrange, Act, Assert.
Let's take a look at our test for the Previous
component once again and notice the framework in action:
describe("Previous", () => {
it('should call "onClick" when a button is clicked', () => {
// Arrange (the stage)
render(<Previous onClick={onClick} />);
const button = screen.getByRole("button");
// Act
fireEvent.click(button);
// Assert
expect(onClick).toBeCalledTimes(1);
});
});
Notice the comments. Those are the three main "blocks" in our test code.
The Arrange block is where you do the rendering and querying of elements. Basically, preparing the "stage" for action. Usually, you're going to see render
s and screen
operations in that block.
In the Act block goes all the code that does something on the stage. Usually, you're going to see some fireEvent
s here.
The Assert is where we validate our assumptions. We do expects
here.
Some tests may have only Arrange and Assert, some may have only Assert, and others may have all three of them.
This is a universally applicable framework. You can think of your tests in three A
s regardless of the technology you're writing your tests on.
Now, let's write an integration test.
Integration testing the App
component
It's time to make sure that the components that we compose inside of the App
component interact as expected.
Here's our App.test.jsx
file. Try to understand what's going on by yourself. In the next few sections we're going to break down each of the three integration tests.
src/App.test.jsx
import { render, fireEvent, screen } from "@testing-library/react";
import { quotes } from "./quotes";
import App from "./App";
describe("App", () => {
it("shows first quote on app load", () => {
render(<App quotes={quotes} />);
const firstQuote = quotes[0];
const quote = screen.getByTestId("quote");
expect(quote.textContent).toBe(firstQuote);
});
it("shows next quote on `Next` button click", () => {
render(<App quotes={quotes} />);
const secondQuote = quotes[1];
const quote = screen.getByTestId("quote");
const nextButton = screen.getByTestId("next-button");
fireEvent.click(nextButton);
expect(quote.textContent).toBe(secondQuote);
});
it("shows previous quote on `Previous` button click", () => {
render(<App quotes={quotes} />);
const firstQuote = quotes[0];
const quote = screen.getByTestId("quote");
const nextButton = screen.getByTestId("next-button");
const prevButton = screen.getByTestId("prev-button");
fireEvent.click(nextButton);
fireEvent.click(prevButton);
expect(quote.textContent).toBe(firstQuote);
});
});
Verify the App
renders the first quote
The first test is going to verify that the App
component properly initializes the Quote
component with the first quote from the quotes
array.
it("shows first quote on app load", () => {
render(<App quotes={quotes} />);
const firstQuote = quotes[0];
const quote = screen.getByTestId("quote");
expect(quote.textContent).toBe(firstQuote);
});
As you can see, we don't have an Act step here and that's totally fine. We just need to verify the text content of the quote
element is the proper one.
Did you notice the getByTestId
call?
What is a testId
?
A testId
is an easy way to get a handle of a given element in the DOM.
Think of it as an id
, which you attach to the DOM element.
To make things simple for me, I have just attached data-testid="quote"
test id to the Quote element, just like this:
src/components/Quote.jsx
...
<blockquote className="Quote" data-testid="quote">
{quote}
</blockquote>
...
}
I also added test ids to the Previous
and Next
button components so that it's easier to query them in my tests:
src/components/Previous.jsx
export default function Previous({ onClick }) {
return (
<button type="button" onClick={onClick} data-testid="prev-button">
Previous
</button>
);
}
src/components/Next.jsx
export default function Next({ onClick }) {
return (
<button type="button" onClick={onClick} data-testid="next-button">
Next
</button>
);
}
Verify the quote changes to the next one on Next
click
it("shows next quote on `Next` button click", () => {
render(<App quotes={quotes} />);
const secondQuote = quotes[1];
const quote = screen.getByTestId("quote");
const nextButton = screen.getByTestId("next-button");
fireEvent.click(nextButton);
expect(quote.textContent).toBe(secondQuote);
});
What do we have here?
In our Arrange block, we get a reference to the secondQuote
, which we're going to use in our assert block.
Then, we query the quote
and the nextButton
elements, again by using getByTestId
functions.
In the Act and Assert blocks we trigger a click event on the nextButton
and then verify that the contents of the quote
is the same as the secondQuote
.
Easy peasy, lemon squeezy.
Verify the quote changes to the previous one on Previous
click
it("shows previous quote on `Previous` button click", () => {
render(<App quotes={quotes} />);
const firstQuote = quotes[0];
const quote = screen.getByTestId("quote");
const nextButton = screen.getByTestId("next-button");
const prevButton = screen.getByTestId("prev-button");
fireEvent.click(nextButton);
fireEvent.click(prevButton);
expect(quote.textContent).toBe(firstQuote);
});
This time, in our Act stage, we do a next
and then previous
click and verify that the quote is back to the original one.
Pretty straight forward.
Interactions are crucial
Notice that in our integrations tests we test interactions between 2 or more components.
That's what makes a test an integration one.
Now it's your turn
I have intentionally left the tests for our array bounds logic. I'll leave that to you to figure out.
The general idea is as follows:
1. Test that when the app shows the first quote and the user clicks the "Previous" button, the next quote that's being shown is the last one in the data array.
2. Test that when the app shows the last quote and the user clicks the "Next" button, the next quote that's being shown is the first one in the data array.
Got it? Now write the tests. :)
Conclusion
I hope you had fun reading this article. It came out much longer than expected, but I think I've managed to show you the 80% of what testing a React application looks like.
Of course, we never touched asynchrony, testing hooks and what not, but that was not the idea of this article.
I'll do my best to write such guide on the topics I couldn't cover here, so you can subscribe using the form bellow to get notified when I do.
If you had any questions or comments, feel free to reach out. I'd be happy to discuss.
Take care.
Top comments (0)