Testing React and React Native Components with React Testing Library
— jest, react-testing-library, react — 7 min read
React Testing Library is a renowned library that empowers developers to write tests for React components. Its primary goal is to enable component testing in a manner that mirrors actual user interactions.
Resources
Set Up
- Begin by setting up Jest for TypeScript.
- Install the
jest-dom
package to gain access to supplementary test helper functions. - Use the
import '@testing-library/jest-dom/extend-expect'
to utilize functions liketoBeInTheDocument()
.
yarn add -D @testing-library/jest-dom
Incorporate the import into your Jest configuration:
// jest.config.jsmodule.exports = { // ... other configurations ... setupFilesAfterEnv: ['<rootDir>/jest.setup.js'], // ... other configurations ...}
// jest.setup.jsimport '@testing-library/jest-dom/extend-expect'
A sample test can be written as:
import * as React from 'react'import { render, screen } from '@testing-library/react'import List from '../src/components/List'import '@testing-library/jest-dom/extend-expect'
describe('List', () => { const milk = 'Milk' const eggs = 'Eggs' const items = [ { name: milk, done: false }, { name: eggs, done: true }, ]
it('renders a list of items', () => { render(<List items={items} toggle={jest.fn()} />)
expect(screen.getByText(milk)).toBeTruthy() expect(screen.getByRole('checkbox', { name: milk })).not.toBeChecked()
expect(screen.getByText(eggs)).toBeTruthy() })})
User Interactions
https://github.com/testing-library/user-event is a complementary library for the React Testing Library. It simulates user events by dispatching browser-like interactions. Remember that these functions return promises; thus, always use await
.
yarn add -D @testing-library/user-event
Interactions include:
- type
- click
clear
: select the text and delete it
And a sample usage:
import * as React from 'react'import { render, screen } from "@testing-library/react"import Add from '../src/components/Add'import userEvent from '@testing-library/user-event'
describe('Add', () => { const user = userEvent.setup() const addToItems = jest.fn() const item = 'Milk'
it('calls addToItems when the button is clicked', async () => { render(<Add addToItems={addToItems} />)
await user.clear(screen.getByRole('textbox') await user.type(screen.getByRole('textbox'), item) await user.click(screen.getByRole('button'))
expect(addToItems).toHaveBeenCalledWith({ name: item, done: false }) })})
await userEvent.click(screen.getByRole('radio', { name: 'Yes' }));await userEvent.selectOptions( screen.getByRole('combobox'), 'Indication or potential indication of significant harm');await userEvent.type( screen.getByRole('textbox', { name: 'Agreed action' }), agreedActionDescription);await userEvent.click(screen.getByRole('checkbox'));await userEvent.click(screen.getByRole('button', { name: /next/i }));
Selectors
getByRole
textbox
: For input, textarea elements.checkbox
: Specifically for checkboxes.radio
: For radio buttons.button
: For buttons.combobox
: For<select />
elements.
Do note that name
doesn't refer to the element's name attribute but rather the associated label text.
// select a button with the labelconst submitButton = screen.getByRole('button', { name: /ACTION/i })
it('save button should be enabled when all required inputs are done', async () => { await act(async () => { renderWithKompass(<AdminNoteForm formMode={FORM.CREATE} onSubmit={jest.fn()} />); await userEvent.type( screen.getByRole('textbox', { name: 'Note description' }), 'Note description' );
expect(screen.getByRole('button', { name: 'SAVE' })).toHaveProperty('disabled', false); });});
[expect(screen.getByRole('textbox', { name: 'Concern' })).toHaveValue('Concern description')](/b574656a518e4d608780613bb9cee7a0)expect(screen.getByRole('textbox', { name: 'Agreed action' })).toHaveValue( 'Agreed action description');expect(screen.getByRole('radio', { name: 'Yes' })).toBeInTheDocument();expect(screen.getByRole('radio', { name: 'Yes' })).toBeChecked();expect(screen.getByRole('combobox', { name: 'Primary reason for review' })).toHaveValue( 'Indication or potential indication of significant harm');
within
it('should display the name of estimated symptom field', async () => { render(<JobDetails />, {wrapper})
await waitFor(() => { screen.getByText('Job Details') const { getByText } = within(screen.getByTestId('estimated-symptom')) expect(getByText('Error Code')).toBeTruthy() }) })
getByTestId
A shortcut to container.querySelector(
[data-testid="test-id"])
<div data-testid="custom-element" />
import {screen} from '@testing-library/dom'const element = screen.getByTestId('custom-element')
Assertions
Various assertions can be used to validate test conditions. Some examples include:
toBeDefined()
: Ensures a variable isn't undefined.
it('should render the label text', () => { renderWithTheme(<ConfirmationBox {...props} />); expect(screen.getByText('I confirm that I have followed the')).toBeDefined();});
toBeInTheDocument()
: Confirms an element is in the document.
import * as React from 'react'import { render, screen } from "@testing-library/react"import Add from '../src/components/Add'import userEvent from '@testing-library/user-event'
describe('Add', () => { const user = userEvent.setup() const addToItems = jest.fn() const item = 'Milk'
it('renders an input and a button', () => { render(<Add addToItems={addToItems} />)
expect(screen.getByRole('textbox')).toBeInTheDocument() expect(screen.getByRole('button')).toBeInTheDocument() })
it('displays an error message when the item is empty', async () => { render(<Add addToItems={addToItems} />)
await user.click(screen.getByRole('button'))
expect(screen.getByText('The item cannot be empty.')).toBeInTheDocument() })})
toHaveBeenCalledWith()
: Checks if a mocked function was invoked with specific arguments.
import * as React from 'react'import { render, screen } from "@testing-library/react"import Add from '../src/components/Add'import userEvent from '@testing-library/user-event'
describe('Add', () => { const user = userEvent.setup() const addToItems = jest.fn() const item = 'Milk'
it('calls addToItems when the button is clicked', async () => { render(<Add addToItems={addToItems} />)
await user.type(screen.getByRole('textbox'), item) await user.click(screen.getByRole('button'))
expect(addToItems).toHaveBeenCalledWith({ name: item, done: false }) })
it('does not call addToItems when the button is clicked but the item is empty', async () => { render(<Add addToItems={addToItems} />)
await user.click(screen.getByRole('button'))
expect(addToItems).not.toHaveBeenCalled() })})
toBeChecked()
: Validates if a radio button or checkbox is checked.
<input type="radio" name="requireReview" value="true"> <label>Yes</label></input>
expect(screen.getByRole('radio', { name: 'Yes' })).toBeChecked();
waitFor()
: A utility to await certain conditions or events.
await waitFor(() => expect(screen.getByRole('textbox', { name: 'Concern' })) .toHaveValue('Concern description') );
Appearance and Disappearance
If you need to wait for an element to appear, the async wait utilities allow you to wait for an assertion to be satisfied before proceeding. The wait utilities retry until the query passes or times out. The async methods return a Promise, so you must always use await
or .then(done)
when calling them.
test('movie title appears',async () => {// element is initially not present...// wait for appearance and return the elementconst movie =await findByText('the lion king')})
test('movie title appears',async () => {// element is initially not present...
// wait for appearance inside an assertionawait waitFor(() => { expect(getByText('the lion king')).toBeInTheDocument() })})
The waitForElementToBeRemoved
async helper function uses a callback to query for the element on each DOM mutation and resolves to true
when the element is removed.
test('movie title no longer present in DOM',async () => {// element is removedawait waitForElementToBeRemoved(() => queryByText('the mummy'))})
Using MutationObserver
is more efficient than polling the DOM at regular intervals with waitFor
.
The waitFor
async helper function retries until the wrapped function stops throwing an error. This can be used to assert that an element disappears from the page.
test('movie title goes away',async () => {// element is initially present...// note use of queryBy instead of getBy to return null// instead of throwing in the query itselfawait waitFor(() => { expect(queryByText('i, robot')).not.toBeInTheDocument() })})
Set up Github actions
name: Run Tests
on: push: workflow_dispatch:
jobs: test: name: Run Test runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: 18 - name: test run: | yarn yarn test
Common Issues
react-script test cannot detect any tests
Ensure your test files have the .test.(j|t)s
or .spec.(j|t)s
extensions and are within the src
directory or its subdirectories.
Input Element Value Doesn't Update:
Always re-query the input element instead of relying on a previously stored reference, as the input value might not be updated in the reference
it('clears the input when the button is clicked', async () => { render( <MockedProvider mocks={mocks} addTypename={false}> <Add /> </MockedProvider> )
await user.type(screen.getByRole('textbox'), item) await user.click(screen.getByRole('button'))
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('')) // do screen.getByRole('textbox') again. })
unstable_batchedUpdates
error with expo, react native, jest, and react query
This is due to the 5 minute default timeout of react query. use jest.fakeTimers()
jest.useFakeTimers()
it('should display the scheduled date', async () => {
render(<JobEdit />, { wrapper })
await waitFor(() => { expect(screen.getByText('Scheduled Visit Date')).toBeTruthy() })
const inputElement = screen.getByTestId('scheduled-visit-date') expect(inputElement.props.value).toBe('28/09/2023') fireEvent.changeText(screen.getByTestId('scheduled-visit-date'), '30/09/2023') fireEvent.press(screen.getByTestId('job-save-button'))
await waitFor(() => expect(apis.updateJob).toHaveBeenCalledWith({ ...mockJob, scheduledVisit: { date: '2023-09-28', time: 'AM', }, } as Job)) })
[RNC] Async Storage is null
Async Storage module is tightly coupled with its NativeModule
part. In order to use it in tests, you have to provide its separate implementation.
- In your project root directory, create
__mocks__/@react-native-async-storage
directory. - Inside that folder, create
async-storage.js
file. - Inside that file, export
Async Storage
mock.
export * from '@react-native-async-storage/async-storage/jest/async-storage-mock';
"ResizeObserver is not defined” error
Use polypill to handle the event in jest
tests
yarn add -D resize-observer-polyfill
global.ResizeObserver = require('resize-observer-polyfill')