React-Testing-Library — Pro tips

Jero
10 min readFeb 14, 2020

--

In this post, we will see a set of tips and recommendations about the use of the React testing library that I grabbed from the amazing repository of Kent Dodds https://github.com/kentcdodds/react-testing-library-course/tree/tjs.

Extends expect of testing library

Adding the following attribute, we gain a lot of assertions which help us to improve the error messages in our assertion library:

import '@testing-library/jest-dom/extend-expect'

For the complete list, you can take a look at https://github.com/testing-library/jest-dom#table-of-contents

Testing Input and Label

In the following example, you will see, how we find our component from a regex query like /favorite number/i . In that way, we avoid the problem with uppercase or lowercase.

// The React component:function FavoriteNumber({min = 1, max = 9}) {
const [number, setNumber] = React.useState(0)
const [numberEntered, setNumberEntered] = React.useState(false)

function handleChange(event) {
setNumber(Number(event.target.value))
setNumberEntered(true)
}

const isValid = !numberEntered || (number >= min && number <= max)
return (
<div>
<label htmlFor="favorite-number">Favorite Number</label>
<input
id="favorite-number"
type="number"
value={number}
onChange={handleChange}
/>
{isValid ? null : <div role="alert">The number is invalid</div>}
</div>
)
}

Tests:

test('renders a number input with a label "favorite number"', () => {
const div = document.createElement('div')
ReactDOM.render(<FavoriteNumber />, div)
const {getByLabelText} = getQueriesForElement(div)

const input = getByLabelText(/favorite number/i) // throw an error when it can find any element with the label that you provide, so extra expect is needed, also using regex `i` ignore case.

expect(input).toHaveAttribute('type', 'number')
})

Debug the DOM element

It’s really really useful, during you are building your test use this function. It will print our current state of the component, in that way we will know for sure, how is look like our component at HTML level.

const {getByLabelText, debug} = render(<FavoriteNumber />)
debug()

NOTE: Also, it works if you pass an element by params you will see the HTML which you pass by parameter.

Test React Component Event Handlers

In the following example, we will see how to simulate the change event in FavoriteNumber component.

test('entering an invalid value shows an error message', () => {
const {getByLabelText} = render(<FavoriteNumber />)
const input = getByLabelText(/favorite number/i)
fireEvent.change(input, {target: {value: '10'}})

expect(getByRole('alert')).toHaveTextContent(/the number is invalid/i)
})

Improve Test Confidence with the User Event

In this example, the difference than the previous example it is we are using a more realistic scenario, because user.type will trigger all the kind of event when the end-user interacts with our component. So, in that way our test will simulate the keydown , focusin, etc.

test('entering an invalid value shows an error message', () => {
const {getByLabelText, getByRole} = render(<FavoriteNumber />)
const input = getByLabelText(/favorite number/i)
user.type(input, '10') expect(getByRole('alert')).toHaveTextContent(/the number is invalid/i)
})

getByRole vs queryByRole

If you are testing that some element doesn’t exist, we should be using `queryByRole`. In the other hand, if you are checking that the element exists should be using `getByRole`, that's all!

Testing accessibility of the component with jest-axe

import 'jest-axe/extend-expect'
import {axe} from 'jest-axe'

test('the form is accessible', () => {
const {container} = render(<Form />)
const results = await axe(container)
expect(results).toHaveNoViolations()
})

Will show print the error message showing the accessibility issues.

Mock HTTP request with jest.mock

In this example, we will see an example of how to use jest.mock to mock our loadGreeting API call.

import {loadGreeting as mockLoadGreeting} from '../api'jest.mock('../api') // this will enter to API and replace all export modules to mock functions.test('loads greetings on click', async () => {
const testGreeting = 'TEST_GREETING'
mockLoadGreeting.mockResolvedValueOnce({data: {greeting: 'testGreeting'}})
const {getByLabelText, getByText} = render(<GreetingLoader />)
const nameInput = getByLabelText(/name/i)
const loadButton = getByText(/load/i)
nameInput.value = 'Mary'

fireEvent.click(loadButton)

expect(mockLoadGreeting).toHaveBeenCalledWith('Mary')
expect(mockLoadGreeting).toHaveBeenCalledTimes(1)
wait(() => expect(getByLabelText(/greeting/i)).toHaveTextContent('testGreeting'))
})

Mock HTTP request with jest.fn

It is a similar approach, the difference is sometimes we use an environment like storybooks which we can’t use jest.mock, in that way we need to pass by props the loading function.

import {* as api} from './api'

function GreetingLoader({loadGreeting = api.loadGreeting}) {
...
}
test('loads greetings on click', async () => {
const mockLoadGreeting = jest.fn()
const testGreeting = 'TEST_GREETING'
mockLoadGreeting.mockResolvedValueOnce({data: {greeting: 'testGreeting'}})
const {getByLabelText, getByText} = render(<GreetingLoader loadGreeting={mockLoadGreeting}/>)
const nameInput = getByLabelText(/name/i)
const loadButton = getByText(/load/i)
nameInput.value = 'Mary'

fireEvent.click(loadButton)

expect(mockLoadGreeting).toHaveBeenCalledWith('Mary')
expect(mockLoadGreeting).toHaveBeenCalledTimes(1)
wait(() => expect(getByLabelText(/greeting/i)).toHaveTextContent('testGreeting'))
})

Testing react transition component

For this example, we are testing our components that use transitions. For that, we will need to mock react-testing-group in order to generate a resilient test.

import {render, fireEvent} from '@testing-library/react'
jest.mock('react-transition-group', () => {
return {
CSSTransition: props => (props.in ? props.children : null),
}
})
test('shows hidden message when toggle is clicked', () => {
const myMessage = 'hello world'
const {getByText, queryByText} = render(
<HiddenMessage>{myMessage}</HiddenMessage>,
)
const toggleButton = getByText(/toggle/i)
expect(queryByText(myMessage)).not.toBeInTheDocument()
fireEvent.click(toggleButton)
expect(getByText(myMessage)).toBeInTheDocument()
fireEvent.click(toggleButton)
expect(queryByText(myMessage)).not.toBeInTheDocument()
})

Testing Error boundaries component

The error boundaries component is very important and have a high impact at the UX level on our application. Next will see an example of how to test it:

import React from 'react'
import {render} from '@testing-library/react'
import {reportError as mockReportError} from '../api'
import {ErrorBoundary} from '../error-boundary'
jest.mock('../api')beforeAll(() => {
jest.spyOn(console, 'error').mockImplementation(() => {})
})
afterAll(() => {
console.error.mockRestore()
})
afterEach(() => {
jest.clearAllMocks()
})
function Bomb({shouldThrow}) {
if (shouldThrow) {
throw new Error('💣')
} else {
return null
}
}
test('calls reportError and renders that there was a problem', () => {mockReportError.mockResolvedValueOnce({success: true})const {rerender} = render(
<ErrorBoundary>
<Bomb />
</ErrorBoundary>,
)
rerender(
<ErrorBoundary>
<Bomb shouldThrow={true} />
</ErrorBoundary>,
)
const error = expect.any(Error)
const info = {componentStack: expect.stringContaining('Bomb')}
expect(mockReportError).toHaveBeenCalledWith(error, info)
expect(mockReportError).toHaveBeenCalledTimes(1)
expect(console.error).toHaveBeenCalledTimes(2) // twice because once is called by js-dom and other by react-dom
})
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
console.error.mockRestore()
})

If you want to take a look at a complete example: https://github.com/kentcdodds/react-testing-library-course/tree/tjs/src/__tests__/error-boundary-03.js

Creating a Form with TDD without event

Using React Testing Library it’s really easy to do TDD, please take a look at the next example of how to test a simple form:

test('renders a form with title, content, tags, and a submit button', () => {
const {getBYLabelText, getByText} = render(<Editor />)
getByLabelText(/title/i)
getByLabelText(/content/i)
getByLabelText(/tags/i)
getByText(/submit/i)
})
function Editor() {
return (
<form>
<label htmlFor="title-input">Title</label>
<input id="title-input" />

<label htmlFor="content-input">Content</label>
<textarea id="content-input" />

<label htmlFor="tags-input">Tags</label>
<textarea id="tags-input" />

<button type="submit">Submit</button>
</form>
)
}

Adding events to our Form TDD

Here we have a more realistic scenario adding events to our form component:

test('renders a form with title, content, tags, and a submit button', () => {const {getByLabelText, getByText} = render(<Editor />)  getByLabelText(/title/i)  
getByLabelText(/content/i)
getByLabelText(/tags/i)
const submitButton = getByText(/submit/i)
fireEvent.click(submitButton)
expect(submitButton).toBeDisabled()}function Editor() {
const [isSaving, setIsSaving] = React.useState(false)
function handleSubmit(e) {
e.preventDefault()
setIsSaving(true)
}
return (
<form onSubmit={handleSubmit}>
...
)
}
<button type="submit" disabled={isSaving}>Submit</button>

Adding mock to form to API

Let’s add a mock API to avoid perform an unwanted operations against real API.

import React from 'react'
import {render, fireEvent} from '@testing-library/react'
import {savePost as mockSavePost} from '../api'
import {Editor} from '../post-editor-03-api'
jest.mock('../api')afterEach(() => {
jest.clearAllMocks()
})
test('renders a form with title, content, tags, and a submit button', () => {
mockSavePost.mockResolvedValueOnce()
const fakeUser = {id: 'user-1'}
const {getByLabelText, getByText} = render(<Editor user={fakeUser} />)
const fakePost = {
title: 'Test Title',
content: 'Test content',
tags: ['tag1', 'tag2'],
}
getByLabelText(/title/i).value = fakePost.title
getByLabelText(/content/i).value = fakePost.content
getByLabelText(/tags/i).value = fakePost.tags.join(', ')
const submitButton = getByText(/submit/i)
fireEvent.click(submitButton)expect(submitButton).toBeDisabled()expect(mockSavePost).toHaveBeenCalledWith({
...fakePost,
authorId: fakeUser.id,
})
expect(mockSavePost).toHaveBeenCalledTimes(1)
})
function Editor() {
const [isSaving, setIsSaving] = React.useState(false)
function handleSubmit(e) {
e.preventDefault()
setIsSaving(true)
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="title-input">Title</label>
<input id="title-input" />

<label htmlFor="content-input">Content</label>
<textarea id="content-input" />

<label htmlFor="tags-input">Tags</label>
<input id="tags-input" />

<button type="submit" disabled={isSaving}>
Submit
</button>
</form>
)
}

NOTE: If you take a look other examples to test redirection after submit form was performed, you should take a look https://github.com/kentcdodds/react-testing-library-course/blob/tjs/src/__tests__/tdd-04-router-redirect.js

Improving testing intention reveal

At the moment to create a test, it’s very important to differentiate between what is relevant for our test and what is not.

If we are working with fixture data, and we want to communicate through the test that the data fixture is not important, we could use test-data-bot to implement random text data for us.

BEFORE:

const fakePost = {
title: 'Test Title',
content: 'Test content',
tags: ['tag1', 'tag2'],
}

AFTER:

import {build, fake, sequence} from 'test-data-bot'const postBuilder = build('Post').fields({
title: fake(f => f.lorem.words()),
content: fake(f => f.lorem.paragraphs().replace(/\r/g, '')),
tags: fake(f => [f.lorem.word(), f.lorem.word(), f.lorem.word()]),
})

Testing that API throws an exception

In the following example we will reproduce an API exception:

test('renders an error message from the server', async () => {  const testError = 'test error'  mockSavePost.mockRejectedValueOnce({data: {error: testError}})  const fakeUser = userBuilder()  const {getByText, findByRole} = render(<Editor user={fakeUser} />) 
const submitButton = getByText(/submit/i)
fireEvent.click(submitButton) const postError = await findByRole('alert') expect(postError).toHaveTextContent(testError) expect(submitButton).not.toBeDisabled()})}

Testing routing transitions

In the following example, we will test the transition between pages, that is important because we are testing actually that the routes (which the user know and also maybe have their own bookmark for that) be really that we want to.

import React from 'react'
import {Router} from 'react-router-dom'
import {createMemoryHistory} from 'history'
import {render, fireEvent} from '@testing-library/react'
import {Main} from '../main'
test('main renders about and home and I can navigate to those pages', () => {
const history = createMemoryHistory({initialEntries: ['/']})
const {getByRole, getByText} = render(
<Router history={history}>
<Main />
</Router>,
)
expect(getByRole('heading')).toHaveTextContent(/home/i)
fireEvent.click(getByText(/about/i))
expect(getByRole('heading')).toHaveTextContent(/about/i)
})

Also, we cant test the 404 pages in a similar way:

test('landing on a bad page shows no match component', () => {
const history = createMemoryHistory({
initialEntries: ['/something-that-does-not-match'],
})
const {getByRole} = render(
<Router history={history}>
<Main />
</Router>,
)
expect(getByRole('heading')).toHaveTextContent(/404/i)
})

NOTE: A really useful helper function to eliminate the boilerplate code of creating history, and declare router, etc is the following: https://github.com/kentcdodds/react-testing-library-course/blob/tjs/src/__tests__/react-router-03.js

Testing Redux store

The following example shows how to test the simple state store of our redux app in an integral way, without the need to test actions and reducers separately.

import React from 'react'
import {Provider} from 'react-redux'
import {render, fireEvent} from '@testing-library/react'
import {Counter} from '../redux-counter'
import {store} from '../redux-store'
test('can render with redux with defaults', () => {
const {getByLabelText, getByText} = render(
<Provider store={store}>
<Counter />
</Provider>,
)
fireEvent.click(getByText('+'))
expect(getByLabelText(/count/i)).toHaveTextContent('1')
})

NOTE: A really useful helper function to eliminate the boilerplate code of creating a store, the provider and all redux stuff are in the following link: https://github.com/kentcdodds/react-testing-library-course/blob/tjs/src/__tests__/redux-03.js

Testing custom hooks

In the following example, we will test out custom useCounter hook.

function useCounter({initialCount = 0, step = 1} = {}) {
const [count, setCount] = React.useState(initialCount)
const increment = () => setCount(c => c + step)
const decrement = () => setCount(c => c - step)
return {count, increment, decrement}
}

It’s important to point out that in order to test our hooks we need to test inside a TestComponent like we will see in the following example. Another important thing here is the need to use act . We need that, in order to give time to React to finish the reflected in the DOM the queue work that has before the expect of our test. If you want a more detail explanation, please take a look at this: https://github.com/threepointone/react-act-examples/blob/master/sync.md

import {render, act} from '@testing-library/react'
import {renderHook, act} from '@testing-library/react-hooks'
test('exposes the count and increment/decrement functions', () => {

const {result} = renderHook(useCounter, {initialProps: {step: 2}})

expect(result.current.count).toBe(0)
act(() => result.current.increment())
expect(result.current.count).toBe(2)
act(() => result.current.decrement())
expect(result.current.count).toBe(0)

})

That’s all, our custom hook is done and tested!

NOTE: there is not required to pass initialProps object.

Testing an unmounting of our react component

In the following example, we will test how to test the unmounting of our countdown component:

function Countdown() {
const [remainingTime, setRemainingTime] = React.useState(10000)
const end = React.useRef(new Date().getTime() + remainingTime)
React.useEffect(() => {
const interval = setInterval(() => {
const newRemainingTime = end.current - new Date().getTime()
if (newRemainingTime <= 0) {
clearInterval(interval)
setRemainingTime(0)
} else {
setRemainingTime(newRemainingTime)
}
})
return () => clearInterval(interval)
}, [])
return remainingTime
}

We will take a look that in our component, it’s important to test if the interval it’s properly called in the unmounted peace. For that we will need again the utility act that we saw in the previous examples, and we will need util for our example jest.runOnlyPendingTimers .

beforeAll(() => {
jest.spyOn(console, 'error').mockImplementation(() => {})
})
afterAll(() => {
console.error.mockRestore()
})
afterEach(() => {
jest.clearAllMocks()
jest.useRealTimers()
})
test('does not attempt to set state when unmounted (to prevent memory leaks)', () => {
jest.useFakeTimers()
const {unmount} = render(<Countdown />)
unmount()
act(() => jest.runOnlyPendingTimers())
expect(console.error).not.toHaveBeenCalled()
})

E2E testing example in a resilient way

In the following example, we will take a look an example of e2e testing using user-event which as we already saw, it produces a more realistic scenario than fireEvent . Also, we are using findByLabel instead of getByLabel , it makes our test much resilient, because findByLabel the operator works in an async way.

import React from 'react'
import {render} from '@testing-library/react'
import user from '@testing-library/user-event'
import {submitForm as mockSubmitForm} from '../api'
import App from '../app'

jest.mock('../api')

test('Can fill out a form across multiple pages', async () => {
mockSubmitForm.mockResolvedValueOnce({success: true})
const testData = {food: 'test food', drink: 'test drink'}
const {findByLabelText, findByText} = render(<App />)

user.click(await findByText(/fill.*form/i))

user.type(await findByLabelText(/food/i), testData.food)
user.click(await findByText(/next/i))

user.type(await findByLabelText(/drink/i), testData.drink)
user.click(await findByText(/review/i))

expect(await findByLabelText(/food/i)).toHaveTextContent(testData.food)
expect(await findByLabelText(/drink/i)).toHaveTextContent(testData.drink)

user.click(await findByText(/confirm/i, {selector: 'button'}))

expect(mockSubmitForm).toHaveBeenCalledWith(testData)
expect(mockSubmitForm).toHaveBeenCalledTimes(1)

user.click(await findByText(/home/i))

expect(await findByText(/welcome home/i)).toBeInTheDocument()
})

If you want to take a look at the APP example, you can check it out here: https://github.com/kentcdodds/react-testing-library-course/blob/tjs/src/app.js

Please give me your thought about this short article, I love to share ideas, learn from others and I hope this article may be helpful for someone out there!

Also, you can be following me on Twitter, or contact me by Linkedin.

--

--