Introduction to Testing React Components with Vite, Vitest and React Testing Library

Originally published on my blog: 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 and find query functions

  • The three As 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?

Asserting that the button works as expected

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 As of testing

When writing tests, a good framework to follow is the three As 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 renders 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 fireEvents 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 As 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.