Skip Main Navigation
Ben IlegboduBen Ilegbodu

React Testing Library best practices

5 categories of best practices for testing components with React Testing Library based on the ESLint plugin

Sunday, November 21, 2021 ยท 7 min read

In my opinion, ESLint is one of the best ways to communicate best practices for JavaScript code because it doesn't require everyone to read and follow a document or blog post. Instead it notifies the individual developer that they have broken a rule. A rule which itself typically has docs explaining the rule and how to fix it. So without intervention from a senior developer or "expert", ESLint is able to communicate best practices. And if the best practices change, the ESLint rules is updated, a new version of the plugin is released, and the offending code starts failing.

I've been using React Testing Library for several years now. After using Enzyme for many years prior, I found RTL to be a much better approach to testing React applications. Read React Testing Library over Enzyme for more of my thoughts on the differences if you're interested. Although I've been using React Testing Library for a while, I only started recently using eslint-plugin-testing-library. It codifies a whole bunch of best practices. Some of which had changed from when I had first learned RTL.

So I want to walk through 5 groups of those best practices to help us write healthier and more resilient React tests.


1. Using user events

The core premise of React Testing Library is testing React components how users interact with them instead of how the code is implemented. The primary way that users interact with our components is through actions (clicking, typing, hovering, etc). Actions are handled in our React components by handling DOM events like onClick, onChange, onMouseOver, etc.

React Testing Library exports fireEvent for triggering DOM events, and was the original suggested approach for simulating user actions. But fireEvent was considered too low-level and the user-event library was introduced to simulate user interactions.

For example before with fireEvent, we would simulate typing in a <textarea> by triggering the onChange DOM event that the component was handling.

import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'

test('types into text box', () => {
  render(<textarea />)

  // โš ๏ธ `prefer-user-event` ESLint error
  // don't use `fireEvent`
  fireEvent.change(screen.getByRole('textbox'), {
    target: {
      value: 'Hello,\nWorld!',
    },
  })

  expect(screen.getByRole('textbox')).toHaveValue('Hello,\nWorld!')
})

The prefer-user-event ESLint rule enforces the usage of userEvent over fireEvent, so this is now an error. We should've known we weren't doing it right when we had to specify e.target.value. Definitely to low-level. Instead, we should use the type user event.

import React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

test('types into text box', () => {
  render(<textarea />)

  // ๐Ÿ‘๐Ÿพ `type` writes text inside of the `<textarea>`
  // character-by-character triggering multiple `onChange` events
  userEvent.type(screen.getByRole('textbox'), 'Hello,{enter}World!')

  expect(screen.getByRole('textbox')).toHaveValue('Hello,\nWorld!')
})

The type user event writes the specified text into the <textarea>, character by character. This actually triggers onChange events for each character typed, just like what would happen when a real user types into a text box. We even have to specify {enter} (hitting the ENTER key) instead of a line break (\n). The text box is also "clicked" before typing.


2. Avoiding the DOM

React Testing Library provides methods for semantically querying DOM elements so that we can test our page in the most accessible way. Instead of searching by class name, we find elements by role, label, display text, etc. Folks coming from Enzyme or used to using other UI testing libraries that use heavy DOM traversal to select DOM nodes may bring that into RTL testing.

import React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Example from './Example'

test('displays the content when button is clicked', () => {
  const { container } = render(<Example />)

  // โš ๏ธ `no-container` ESLint error
  // don't use `querySelector` or other DOM methods
  const button = container.querySelector('.btn-primary')

  userEvent.click(button)

  // โš ๏ธ `no-node-access` ESLint error
  // don't use `firstChild`
  const message = screen.getByTestId('foot').firstChild

  expect(message).toHaveTextContext('Loaded')
})

The no-container and no-node-access ESLint rules help guard against the non-RTL way of querying elements. This forces us to use the appropriate queries.

import React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Example from './Example'

test('displays the content when button is clicked', () => {
  render(<Example />)

  // ๐Ÿ‘๐Ÿพ use the button's implicit role instead
  const button = screen.getByRole('button')

  userEvent.click(button)

  // ๐Ÿ‘๐Ÿพ can search by `data-testid` as well
  const message = screen.getByTestId('message')

  expect(message).toHaveTextContext('Loaded')
})

3. Proper use of queries

In the beginning, when there was just React Testing Library, the suggested approach to get these queries (like getByRole) was by destructuring the object returned from calling render.

import React from 'react'
import { render } from '@testing-library/react'
import Greeting from './Greeting'

test('renders a message', () => {
  const { getByText } = render(<Greeting />)

  // โš ๏ธ `prefer-screen-queries` ESLint error
  // don't destructure, `render`, use `screen` instead
  expect(getByText('Hello, world!')).toBeInTheDocument()
})

DOM Testing Library, which React Testing Library is built on top of, now exposes a screen object which has every query built-in. The changed best practice is to always use screen object and no longer destructure the object returned by render. And the prefer-screen-queries ESLint rule ensures we follow this best practice.

import { render, screen } from '@testing-library/react'
import Greeting from './Greeting'

test('renders a message', () => {
  render(<Greeting />)

  // ๐Ÿ‘๐Ÿพ use `screen` object queries instead
  expect(screen.getByText('Hello, world!')).toBeInTheDocument()
})

The benefit of using screen is that we no longer need to keep updating the destructure of the render call as we change the queries we need. I know that I would frequently end up with unused destructured variables. And with editors like VSCode, when we type editor. we'll get autocompletion for the queries. We could have also not never destructured the object from render and get the same benefits, but ๐Ÿคท๐Ÿพโ€โ™‚๏ธ.

There are several types of queries (get*, query* & find*) and it's not always clear when to use one over the other.

import React from 'react'
import { render, screen } from '@testing-library/react'
import Greeting from './Greeting'

test('renders a message', () => {
  render(<Greeting />)

  // โš ๏ธ `prefer-presence-queries` ESLint error
  // use `getByText` when asserting presence
  expect(screen.queryByText('Hello, world!')).toBeInTheDocument()

  // โš ๏ธ `prefer-presence-queries` ESLint error
  // use `queryByRole` when asserting hidden
  expect(screen.getByRole('region')).not.toBeVisible()
})

The get* methods throw an error when the element is not found. So when we are asserting if an element is present (e.g. .toBeInTheDocument()) and it's not found, using the get* methods will offer a better error message over query* or find*. Similarly the query* methods return null instead of throwing, which is perfect when testing when an element is not present. That way the test will fail on the assertion (.not.ToBeInTheDocument()) instead of throwing an error with the get* methods.

This best practice is pretty tricky to understand, let alone get it right every single time. But thankfully the prefer-presence-queries ESLint rule has our back and will alert us when we misstep.

import React from 'react'
import { render, screen } from '@testing-library/react'
import Greeting from './Greeting'

test('renders a message', () => {
  render(<Greeting />)

  // ๐Ÿ‘๐Ÿพ use `get*` when asserting presence
  expect(screen.getByText('Hello, world!')).toBeInTheDocument()

  // ๐Ÿ‘๐Ÿพ use `query*` when asserting hidden
  expect(screen.queryByRole('region')).not.toBeVisible()
})

There are some other ESLint rules to check out to help ensure we are using the proper query methods. The prefer-find-by rule enforces using a find* query instead of waitFor + get* when waiting for elements. The prefer-query-by-disappearance rule enforces using query* queries when waiting for disappearance with waitForElementToBeRemoved.


4. Proper use of waiting

The waitFor method is a powerful asynchronous utility to enable us to make an assertion after a non-deterministic amount of time. The way waitFor works is that polls until the callback we pass stops throwing an error. So if we were to make side-effects within the callback, those side-effects could trigger a non-deterministic number of times.

import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import PlayersCombobox from './PlayersCombobox'

test('has keyboard support', async () => {
  render(<PlayersCombobox />)

  await waitFor(() => {
    // โš ๏ธ `no-wait-for-side-effects` ESLint error
    // don't call side-effects w/in `waitFor` callback
    // it could get called N number of times
    userEvent.type(screen.getByRole('input'), '{arrowdown}')

    expect(screen.getByTestId('item3')).toBeChecked()
  })
})

Instead the best practice is to move these sorts of side-effects outside of the waitFor callback, and only put assertions within it. The no-wait-for-side-effects ESLint rule ensures we adhere to this.

import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import PlayersCombobox from './PlayersCombobox'

test('has keyboard support', async () => {
  render(<PlayersCombobox />)

  // side effects go *outside* `waitFor`
  userEvent.type(screen.getByRole('input'), '{arrowdown}')

  await waitFor(() => {
    expect(screen.getByTestId('item3')).toBeChecked()
  })
})

There are a couple of other similar best practices codified by ESLint rules. The no-wait-for-empty-callback prevents us from waiting for the next tick of the event loop before continuing processing by using waitFor and waitForElementToBeRemoved with an empty callback. This isn't consistent with the philosophy of React Testing Library and that functionality very well could break in the future. The no-wait-for-multiple-assertions is kind of the opposite. It ensures that only one assertion exists within waitFor.


5. Miscellaneous

Finally, the catch-all bucket. ๐Ÿ˜„

React Testing Library provides several super useful debugging utilities to help understand what's going on in the DOM.

import React from 'react'
import { render, screen } from '@testing-library/react'
import Greeting from './Greeting'

test('renders a message', () => {
  render(<Greeting />)

  // โš ๏ธ `no-debugging-utils` ESLint error
  // prevent checking in debug code
  screen.debug()

  expect(screen.getByText('Hello, world!')).toBeInTheDocument()
})

Just like console.log() debug statements, the debugging utilities shouldn't be checked into source code The no-debugging-utils ESLint rule ensures that we don't accidentally commit debug test code. More than likely they won't break our tests, but they'll certainly clutter up the logs.

There is also no-render-in-setup that disallows the use of render in the Jest setup functions (like beforeEach()), as well as no-unnecessary-act which aims to help us avoid using act() as something we throw any and everywhere to avoid the not wrapped in act(...) warnings.


If you're writing tests with React Testing Library, please include eslint-plugin-testing-library in your ESLint configuration. It's like having Kent C. Dodds looking over your shoulder. ๐Ÿ˜‚

I'm curious if folks have best practices with RTL that aren't yet codified in ESLint rules? I'm always trying to find ways to develop and test better. Feel free to reach out to me on Twitter at @benmvp and let me know!

Keep learning my friends. ๐Ÿค“

Subscribe to the Newsletter

Get notified about new blog posts, minishops & other goodies

โ€‹
โ€‹

Hi, I'm Ben Ilegbodu. ๐Ÿ‘‹๐Ÿพ

I'm a Christian, husband, and father of 3, with 15+ years of professional experience developing user interfaces for the Web. I'm a Google Developer Expert Frontend Architect at Stitch Fix, and frontend development teacher. I love helping developers level up their frontend skills.

Discuss on Twitter // Edit on GitHub