Lightweight Client-Side Tests with React-Testing-Library
I have a confession to make: I usually dread writing tests. I especially dread writing them early on in a project, when components are still subject to rapid change and iteration. A few weeks ago, I found myself in that position—I had written new components that needed test coverage, but I knew there were several iterations of both designs and features ahead of us.
On this project we were using React-Testing-Library, a project by Kent C. Dodds that I had heard about, but hadn’t yet tried for myself. As I dove into the documentation, one thing in particular stood out to me: the philosophy of “The more your tests resemble the way your software is used, the more confidence they can give you.” React-Testing-Library allows you to write tests in a way that emulates how a user interacts with your app as it exists on the DOM level. This is in contrast to testing libraries like Enzyme, which focus more on testing components' implementation details.
This means no mocking, no mounting, just testing whether interactions with your components lead to the results in the DOM that you’d want and expect a user to get (note: you can do mocking with Jest or another framework if truly needed; see the react-testing-library docs for more details). Once I adjusted to this new mindset around testing, it felt so freeing and straightforward compared to traditional testing frameworks. Frequently, all I really care about is making sure what I expect the user to see, given some interaction, is what the user actually sees, so having a testing library built around that concept makes a ton of sense.
The syntax feels very intuitive as well—the way you’re testing client-side interactions is very similar to how a user would experience them, or how you, as a developer, wrote those interactions in the first place. The following set of code snippets shows how you’d test that a message with a count variable updates correctly after a user clicks a button that increases the count by one:
// messageComponent.js import React, { useState } from 'react' const MessageComponent = () => { const [count, updateCount] = useState(1) return ( <div> <p data-testid='countMessage'>{`The count is ${count}`}</p> <button onClick={() => updateCount(count + 1)}>Add 1 to Count</button> </div> ) } export default MessageComponent
// messageComponent.test.js import React from 'react' import { render, fireEvent } from 'react-testing-library' import MessageComponent from './MessageComponent' describe('<MessageComponent />', () => { /* note - you could also use this in conjunction with a Jest snapshot and compare the rendered "container" to said snapshot */ test('Component renders with a count of 1', () => { const { getByTestId } = render(<MessageComponent />) // use getByTestId here for component with dynamic text const countMessage = getByTestId('countMessage').textContent expect(countMessage).toEqual('The count is 1') }) test('Clicking the button adds 1 to the count', () => { const { getByText } = render(<MessageComponent />) // methods like getByText are preferred when possible const button = getByText(/add/i) fireEvent.click(button) const countMessage = getByTestId('countMessage').textContent expect(countMessage).toEqual('The count is 2') }) })
This felt much closer to writing in plain English than a lot of other testing syntax that I've used in the past, and I loved the simplicity of it.
A lot of the things that change over time in a component, turns out, are implementation details—things like state shape, props (especially names!)—the stuff that often becomes tedious to continually update over time as the app and the components change. By contrast, I've found that intended outcomes of specific user interactions don’t often change in a way that drastically affects your test harness—an additional outcome might be expected, or perhaps an interaction gets removed altogether, but those changes would require additional tests regardless. I also believe that this makes tests more effective at, well, testing—if you don't have to update the tests every time something small changes anyway, it's clearer whether a change breaks the core intended outcome.
I know that I’ll be reaching for React-Testing-Library the next time I’m choosing a library for client-side test coverage, and I recommend that you check it out if you haven’t already.