The wonders of react testing library

Kathrin Holzmann
6 min readOct 11, 2022
A huge library shelf with machinery around standing in a foggy fantasy like nature scenery. There is also another, smaller machine like object that seems to also contain books.
Image created with Midjourney

How switching from enzyme to react testing library made not only my tests better but also my code.

Since I started working with react in 2016 I always used enzyme with jest as my test environment. My applications relied on a lot of heavy big class components. Testing with enzyme and shallow rendering was a quick and easy possibility to write tests for them.

In early 2019 the new concept of hooks got released and in 2020 the company I was working for at that time migrated from heavy class components with redux to now smaller functional components. Many of our enzyme tests broke and were not able to be revived by that time. So I started to look more into react-testing-library. Also because it was now part of the create-react-app package. Many people were already fans — but, I was sceptical. Is it just another shiny new framework to learn?

Now two years later I am a huge fan of testing library react and don’t write enzyme anymore. By using react testing library, not only did my tests become better but also my code. Here is how and why.

Changing Perspektive

The biggest change between using enzyme and testing library for me was the shift in perspective.

Enzyme focuses a lot on the state of a component by offering methods like setState to manipulate the state from outside the component, as well as calling functions directly on a component's instance. This teaches you to write your tests from a developer's view. You call a function or change the state programmatically and then look at the expected return value. In an extreme case, it means that all your tests can succeed but in the browser, it can be fully broken.

React-testing-library does not offer you to call a function directly or manipulate the state programmatically. Once you rendered your component you can only access what the user sees. As a helper, it offers you the screen object. Events are at the core of your test case to trigger functions and state changes. Just how a user would interact with your component in real life. Your testing from your users' view.

Event-driven tests

Events are the core interaction of every testing-library test. It offers two different types of events.

fireEvent:

this triggers an isolated singular DOM event. Which means it calls the function that is bound to the event handler of a component. It is a synchronous interaction and a quick way to do a more unit-test-like function testing. fireEvent.click(getByRole(button, {name: 'Submit'}))

userEvent:

this should be your most used event inside your tests. userEvent simulates the whole browser interaction stack. This means that if you are changing the value of a text input all interactions from focus to keypress and blur will be simulated in order to execute the event. The return is always a promise. By simulating the whole interaction userEvent can not only determine if a component is disabled but also actuallyya visible to the user. It also propagates the event to all bound components. So clicking a label will focus the bound input field. Using userEvent over fireEvent elevates your test as close to an e2e as possible, but runs much faster and needs way less setup than an actual end-to-end test.

About Queries

Before you can trigger any event you have to find the element you want to interact with first. Testing library offers you a variety of shortcut helpers to query for components.

Each query consists of three parts, the method, how many elements to expect and the attribute to base your search on.

a react testing library quers: queryAllByDisplayValue. Each part is highlighted in a different color and with a additional label text. Method: query; Amount: All; Element Attribute: byDisplayValue
The different parts of a react testing library query

Query Methods: testing library offers three query methods.

  • get: synchronous method, expects the component to be in the document otherwise will throw an error.
  • query: synchronous method, will return null if the component is not in the document
  • find: asynchronous method, will throw an error if the component can not be found.

Amount of elements: you can specify if you expect more than one element to be found via the keyword All. It will then return all found elements in an array. If you did not specify the attribute but the query finds more than one element, it will throw an error.

Accessible Attributes: Testing library offers a wide range of helpers to search for elements. They spread from the Developers' viewing perspective to the Users viewing perspective.

  • byRole: this is the selector you should use most often. It is able to find any accessible element. You can find a full list here. const submitButton = screen.getByRole(button, {name: 'Submit'})
  • byText: this is another selector you might find yourself using very often. But I want to emphasise here that if you are using the text as your matcher, you should ensure to only match for texts that are relevant. It is not very helpful to find an element based on a long, rather random copy. Better use a regular expression to focus on the important part of your text. const aboutAnchorNode = screen.getByText(/about/, {exact:false})
  • byLabelText: will return the form label or the associated input element const inputNode = screen.getByLabelText('Username', {selector: 'input'})
  • byPlaceholderText: will find an input element by its placeholder, a good alternative if no label is present: const inputNode = screen.getByPlaceholderText('Username')
  • byAltText: is able to find images based on their alt attribute, a very good way to ensure that all used images are accessible for screenreader users. const decorativeImage = screen.getByAltText(/beautiful flower/)
  • byTitle: Simlar to byAltText, ByTitle can be used to find SVG icons. const warningIcon = screen.getByTitle(/warning icon/)`
  • byDisplayValue: is able to return an input based on its current display value. const lastNameInput = screen.getByDisplayValue('Simpson')
  • byTestId: this selector should be used only rarely as it will not guarantee that the element is visible to the user. const myComponent = getByTestId('my-custom-component')

Conclusion

By using semantic query selectors like getByRole you can implicitly already test for the accessibility of your components. You will also notice regression errors when, for example, the component library you are using, switches from an accessible HTML element to one that is not.

The core belief of react-testing-library is that all functionality should be triggered through events and not programmatically. This led me to often reconsider my decision about where to put a heavy piece of logic. While at the beginning of react the idea was to have everything concerning a component in one single class, I now tend to write much smaller and more generic react components. Putting the heavy logic pieces more into utils or context files which I am then covering with pure jest unit tests to cover all corner cases. While for the react component itself I write the more complex user interaction which will lead to the function call. In the end I am now writing more tests for smaller, more modular parts of my application.

Writing these full integration-like tests using the userEvent functionality also protects me from simple propagation bugs as well as other forms of regression errors, as the full browser interaction is simulated and executed.

Overall I would say using react testing library changed my tests from being very state-focused to being now more behaviour-focused and therefore gives me confidence in an actual working user interface.

This article was written based on a talk I originally gave at ReactBerlin meetup. You can find the slides here at slideshare.

--

--