Interaction Testing with React Testing Library

A window into React component testing

By Michael Chang

September 7th, 2020

image

My Journey

Testing is complicated. I've certainly never been good at it. For the longest time, I've only been focused on basic function input-output unit tests. Why? Because they were easy — you didn't need to render HTML, you didn't need to query DOM elements, you didn't need to interact with said DOM elements. But of course, React component testing is a necessity for any mature codebase. And it finally came time for me to sit down and figure it out.

That's when I discovered React Testing Library. And suddenly, everything seemingly became much simpler. All the complexities that I've encountered, but not understood, that made me put off React component testing disappeared. Hopefully, the same will happen for you.

Interaction Testing

As the name suggests, interaction testing tests interactions with React components. It can be thought of as unit testing for React components. Your tests will pretend to be the user — interacting with the component by typing stuff, clicking buttons, etc — and check that whatever should happen, happens.

Take this simple Counter component as an example.

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
      {
        count > 0 && (
          <button onClick={() => setCount(0)}>
            Decrement
          </button>
        )
      }
    </div>
  );
}

The user can perform two interactions with this component — increment the counter or decrement the counter (if count is greater than 0). Once the respective buttons are clicked, the component will display the new value.

We'll use this example component to go through how you can use React Testing Library for interaction testing.

Rendering

So, let's begin understanding the tools that React Testing Library provides. The first fundamental to React component testing is rendering. This is as simple as calling the render function included in React Testing Library.

import { render } from '[@testing](http://twitter.com/testing)-library/react';

test('render', () => {
  render(<Counter />);
});

And voilà! That's literally it. Seriously. Who knew?

Querying

Okay, the first hurdle is over. Now that we have rendered our component, we have to get the HTML element(s) to interact with. Fortunately for us, React Testing Library provides a lot of simple and neat queries via the render function response. In our test, getByText will help query the button for us to click.

test('queries existence', () => {
  const { getByText } = render(<Counter />);
  const increment = getByText('Increment');
});

A similar thing can be done to get the decrement button if rendered. If the not (when the count is 0),getByText('Decrement') will throw an error causing the test to automatically fail, even though we're not testing anything yet! When this is the case, we can use queryByText to try and query the button. If the element can't be found, queryByText will return null.

test('queries non-existence', () => {
  const { queryByText } = render(<Counter />);
  const decrement = queryByText('Decrement');
});

Firing Events

Time to interact! React Testing Library provides a fireEvent function that includes support for almost all DOM events — keyboard, mouse, animation, etc. Since we have defined onClick for our increment and decrement buttons, we'll use the click event.

import { fireEvent, render} from '[@testing](http://twitter.com/testing)-library/react';

test('fireEvent', () => {
  const { getByText } = render(<Counter />);
  const increment = getByText('Increment');
  fireEvent.click(increment);
});

It's important to fire the correct event — otherwise, your expected interaction will not happen. The event fired must be the same type as the event listener attached to the element. If not, the event listener will not be triggered. In our Counter component, the buttons have the onClick event listener attached and therefore will only be triggered with click events. This is different from browser implementations where click events also trigger mousedown, mouseup, and other events.

Validating

Congratulations, you're now an expert in React Testing Library! Rendering, querying, and firing events is pretty much all the specific React Testing Library tooling that you really need to know.

Wait but our test is not done! Yes, but now you have learned all the tools you need to complete our test.

Let's revisit the interactions that we need to handle:

  • Click the increment button and our counter will increase

  • Click the decrement button and our counter will decrease

We now know how to fire the click event on the buttons but how do we validate whether our counterchanged as expected? More queries!

test('validation existence', () => {
  const { getByText } = render(<Counter />);
  const increment = getByText('Increment');
  fireEvent.click(increment);
  expect(getByText('Count: 1')).toBeTruthy();
  const decrement = getByText('Decrement');
  fireEvent.click(decrement);
  expect(getByText('Count: 0')).toBeTruthy();
});

Just like how we queried for the button, we can query the component to see whether our expected update occurred. After we click the increment button, our component is updated to display the new count. By querying for what we expect the new count value to be, we can check its existence to determine whether the Counter behaves as expected.

Great! We just tested to make sure the buttons work as expected. But, for full test coverage of our Counter component, we also need to make sure that the decrement button is rendered only when count is greater than 0. Using the same technique but with queryByText, we can test for this.

test('validation non-existence', () => {
  const { getByText, queryByText } = render(<Counter />);
  expect(queryByText('Decrement')).toBeNull()
  const increment = getByText('Increment');
  fireEvent.click(increment);
  expect(queryByText('Decrement')).toBeTruthy()
});

We first make sure the decrement button doesn't exist by validating that the queryByText response is null. Then we increment the counter and validate that the decrement button exists.

And that's it! Our Counter component is fully tested and we can be very confident that it works exactly as expected.

Screen Logging

Oh wait! There's still one more valuable tool that React Testing Library provides: screen.debug. This function will log the DOM structure. By default, it will log document.body.

import { render, screen } from '[@testing](http://twitter.com/testing)-library/react';

test('debug default', () => {
  render(<Counter />);
  screen.debug();
  // output:
  //   <body>
  //     <div>
  //       <div>
  //         <p>
  //           Count:
  //           0
  //         </p>
  //         <button>
  //           Increment
  //         </button>
  //       </div>
  //     </div>
  //   </body>
});

You can also pass in a DOM element to specifically log that.

import { fireEvent, render, screen } from '[@testing](http://twitter.com/testing)-library/react';

test('debug element', () => {
  const { getByText } = render(<Counter />);
  const increment = getByText('Increment');
  screen.debug(increment);
  // output:
  //   <button>
  //     Increment
  //   </button>
});

With this tool, you can visually inspect and understand the DOM structure of your component so you can formulate the queries that are needed to test the component.

Final Thoughts

React Testing Library vastly simplifies and, for lack of a better term, dumbs it down to something you and I can understand and work with. The four tools it provides — rendering, querying, firing events, and screen logging — covers the basics of interaction testing. Now that you've learned this, go out and make sure your React components are bug-free!

Resources



Continue Learning