React Testing Library patterns

React Testing Library(RTL) is a light-weight solution for testing React components without relying on implementation details. This approach resembles the way our software is used and makes our tests maintainable.

In this demo, we will be exploring some common React Testing Library patterns by testing a password validator component.

Edit ivstudio/password-validator-demo


Render

The render method renders a React element into the DOM and returns an object with all the queries from DOM Testing Library. These queries help us find elements to make our test assertions.

import React from 'react'; import { render } from '@testing-library/react'; import PassWordField from '../PasswordField'; /* Description of our test */ test('Renders a password input', () => { /* Render PassWordField and destructuring */ const { getByLabelText } = render(<PassWordField />); /* Search label text with case insensitive regEx */ const input = getByLabelText(/password/i); /* Assertion */ expect(input).toHaveAttribute('type', 'password'); });

Queries

Queries help us find elements to make our test assertions. Choosing a query can be overwhelming at first. Queries have subtle differences that make one better than another for a particular use case. View suggested query guide.

The screen object from RTL has every query from the DOM Testing Library pre-bound to the document.body. The screen object is the preferred approach to query.

In the below example, we're asserting the visibility of the helper text and success banner initial state. After a validated password is entered, we test that the helper text is hidden and the success banner is shown.

import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import PassWordField from '../PasswordField'; test('Helper text and success banner visibility', () => { /* Render component */ render(<PassWordField />); /* Use screen object to access getByLabelText */ const input = screen.getByLabelText(/password/i); /* Assert helper text is render */ expect(screen.getByRole('alert')).toBeInTheDocument(); /* Assert that banner is NOT rendered */ expect(screen.queryByRole('banner')).toBeNull(); /* Trigger change event with fireEvent with new value */ fireEvent.change(input, { target: { value: 'Lx=1pwsd' } }); /* Make assertion after value changed with valid password */ expect(screen.queryByRole('alert')).toBeNull(); expect(screen.getByRole('banner')).toBeInTheDocument(); /* Debugging using screen: screen.debug(); screen.debug(input); */ });

Queries with getBy* will throw an error if an element is not found. We want to use them whenever possible because they offer great feedback.

If our intention is to test that an element is not rendered we can use queryBy*. These queries will return null instead of throwing an error.

The testing playground is a great tool to get started, make sure to take a look.


Assertions

Test assertions are boolean expressions that encapsulate the specified testing logic. The @testing-library/jest-dom provides a set of declarative custom matchers to use. We can also extend Jest matchers for our assertions. Lets take a look.

test('Enable submit with valid password', () => { render(<PassWordField />); const input = screen.getByLabelText(/password/i); const button = screen.getByText(/submit/i); /* Expects button to be disabled on initial state */ expect(button).toBeDisabled(); /* Trigger change with invalid password */ fireEvent.change(input, { target: { value: '1234' } }); /* Expects button to be disabled with invalid password */ expect(button).toBeDisabled(); /* Trigger change with valid password */ fireEvent.change(input, { target: { value: 'Lx=1pwsd' } }); /* Expects submit button to be enable if valid password */ expect(button).not.toBeDisabled(); });

Event handlers

React Testing Library offers fireEvent and user-event to simulate browser events. For most cases, user-event is the recommended method because it tries to simulate the user interaction more closely.

For example, in the context of an input field interaction. The user-event will trigger keyDown, keyPress and keyUp events. While the fireEvent method will trigger only single event to a given node. View supported events.

fireEvent

The fireEvent object is being re-exported from the DOM Testing Library with React specific utils and it's automatically wrapped in ReactTestUtils act() function.

fireEvent.change(input, { target: { value: 'Lx=1pwsd' } });

user-event (recommended)

The @testing-library/user-event is built on top of fireEvent, it provides additional methods to resemble the browser interactions more realistically.

import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import PassWordField from '../PasswordField'; test('Enable submit with valid password', async () => { render(<PassWordField />); const input = screen.getByLabelText(/password/i); const button = screen.getByText(/submit/i); /* Expects submit button to be disabled on load */ expect(button).toBeDisabled(); /* Type invalid password */ await userEvent.type(input, '1234'); /* Expects submit button to stay disabled */ expect(button).toBeDisabled(); /* Type valid password */ await userEvent.type(input, 'Lx=1pwsd'); /* Expects submit button to be enabled */ expect(button).not.toBeDisabled(); });

The userEvent always returns a promise.

In the above example, we use async/await to validate the password after the type event, then we make the assertion.

Let's look at another event handler example using userEvent.click.

test('Toggle password visibility', () => { render(<PassWordField />); const input = screen.getByLabelText(/password/i); const button = screen.getByText(/show/i); /* Expects password to be hidden */ expect(input).toHaveAttribute('type', 'password'); expect(button).toHaveTextContent(/show/i); /* Trigger click event */ userEvent.click(button); /* Expects password to be shown */ expect(input).toHaveAttribute('type', 'text'); expect(button).toHaveTextContent(/hide/i); });

Clean up

For each test, we normally render our React tree to a DOM element that is attached to the document. When the test ends, it's important to clean up the mounted react trees to run each test in isolation and avoid unexpected behaviors. Jest helper functions; beforeEach and afterEach can help us run tasks for many tests repeatedly.

The example below shows how we can clean up after each test. However, RTL unmounts react trees mounted with render automatically.

import { cleanup } from '@testing-library/react'; afterEach(cleanup);

RTL unmounts React trees mounted with render automatically.


Testing custom hooks

It's recommended to test custom hooks in isolation only when they're used across your application or it's difficult to test through interactions.

The @testing-library/react-hooks provides utility functions to easily update inputs and retrieve outputs. This library aims to provide a testing experience very close to natively using the hook within a real component.

Let's take a look at our custom hook.

tests/useValidator.js

import { renderHook, act } from "@testing-library/react-hooks"; import useValidator from "../useValidator"; test("Validator returns correct output", () => { /* The renderHook function help us render our custom hook. */ const { result } = renderHook(() => useValidator("")); /* The result.current property help us interact with whatever returns from our custom hook. Our custom hook returns a valid property. We asserted to be falsy. */ expect(result.current.valid).toBeFalsy(); /* Assert helperText validations */ expect(result.current.helperText[0].valid).toBeFalsy(); expect(result.current.helperText[1].valid).toBeFalsy(); expect(result.current.helperText[2].valid).toBeFalsy(); expect(result.current.helperText[3].valid).toBeFalsy(); expect(result.current.helperText[4].valid).toBeFalsy(); /* The Act() function help us perform updates, to simulate browser interaction. The setPassword is exported from our hook to update the password state. */ act(() => result.current.setPassword("aB}")); expect(result.current.valid).toBeFalsy(); expect(result.current.helperText[0].valid).toBeTruthy(); expect(result.current.helperText[1].valid).toBeTruthy(); expect(result.current.helperText[2].valid).toBeFalsy(); expect(result.current.helperText[3].valid).toBeTruthy(); expect(result.current.helperText[4].valid).toBeFalsy(); /* Perform another state change and assert validation */ act(() => result.current.setPassword("aB1=qxyz")); expect(result.current.valid).toBeTruthy(); expect(result.current.helperText[0].valid).toBeTruthy(); expect(result.current.helperText[1].valid).toBeTruthy(); expect(result.current.helperText[2].valid).toBeTruthy(); expect(result.current.helperText[3].valid).toBeTruthy(); expect(result.current.helperText[4].valid).toBeTruthy();

Edit ivstudio/password-validator-demo