How to Test React Hooks (The Async Ones)

An adventure in patience

Luke Ghenco
Oct 16 · 6 min read

As I have started writing new code using React hooks, I have noticed some occasional issues when testing components with async calls using the React.useEffect. I wanted to share some examples of some tricks I have learned and implemented in my own code.

I’ve created a repo @ https://github.com/Lukeghenco/async-hook-testing that you can follow along and code with. There is also a completed branch @ https://github.com/Lukeghenco/async-hook-testing/tree/complete if you just want to see the finished code. The repo already has React, React Testing Library, and Axios (async API calls) installed for the sake of brevity.

Please note this article assumes that we are using at least React 16.9. You will want to implement this workaround mentioned here if using React 16.7–16.8.

Testing React.useEffect

I will be using a component with a React.useEffect hook (alongside a React.useState hook) to run an async API call. This will be very similar to how componentDidMount works within React class components.

I will start with a simple component that renders the top 3 trending GIFs from the Giphy API.

Let’s create a file called src/GifGenerator/GifGenerator.js that will hold our GifGenerator component and our API service code.

// src/GifGenerator/GifGenerator.js
import React from ‘react’
import axios from ‘axios’
function useGipyhAPI() {
const [gifs, setGifs] = React.useState(null)
React.useEffect(() => {
// Basic implementation to handle race conditions
// When component might unmount before API call finishes
// https://overreacted.io/a-complete-guide-to-useeffect/#speaking-of-race-conditions
let isStopped = false
if (!isStopped) {
const getGifs = async () => {
try {
const { data: { data: gifs } } = await axios.get(`http://api.giphy.com/v1/gifs/trending?api_key=${process.env.REACT_APP_GIPHY_API_KEY}&limit=3`)

if (!isStopped && gifs) {
setGifs(gifs)
}
} catch (error) {
console.error(error)
}
}
getGifs()
}
return () => {
isStopped = true
}
}, [])
return [gifs]
}
export default function GifGenerator() {
const [gifs] = useGipyhAPI()

return (
<>
{
gifs ? gifs.map(gif => (
<img
key={gif.id}
src={gif.images.fixed_width.url}
alt={gif.title}
/>
)) : <p>…Loading</p>
}
</>
)
}

Essentially we have a component that fetches GIFs from Giphy’s trending GIFs API with a limit of three while showing a “…Loading” screen and then renders the GIFs with <img> tags.

Writing the tests

The first test we want to do is check that it’s rendering the “…Loading” text while fetching the GIFs. We will need to setup an API mock and create some fake GIF data for the first test.

// src/GifGenerator/GifGenerator.test.js
import React from ‘react’
import { render, cleanup } from ‘@testing-library/react’
import axios from ‘axios’
import uuid from ‘uuid/v4’
import GifGenerator from ‘./GifGenerator’
// Define a factory function to simplify
// stubbing out Gif data in the test
function Gif({ title, imageURL }) {
return {
id: uuid(),
images: {
fixed_width: {
url: imageURL
}
},
title
}
}
const stubbedGifs = [
Gif({
title: ‘Sad Gif’,
imageURL: ‘//media2.giphy.com/media/sad.gif’
}),
Gif({
title: ‘Funny Gif’,
imageURL: ‘//media2.giphy.com/media/funny.gif’
}),
Gif({
title: ‘Animated GIF’,
imageURL: ‘//media2.giphy.com/media/animated.gif’
})
]
beforeEach(() => {
axios.get = jest.fn(() => Promise.resolve({ data: { data: stubbedGifs }}))
})
afterEach(cleanup)describe(‘GifGenerator’, () => {
it(‘displays text “…Loading” while fetching gifs’, () => {
const { getByText } = render(<GifGenerator />)

getByText(‘…Loading’)
})
})

If we run the test, you should see a warning that states: “Warning: An update to GifGenerator inside a test was not wrapped in act(…).

Whenever you see this warning, you can fix it by wrapping the body of your test in an act function provided by testing library (a wrapper around the react testing tools act function) that will verify that all state updates will be complete and then flushed before another test runs.

Change the test code to look like this:

import { act, render, cleanup } from ‘@testing-library/react’it(‘displays text “…Loading” while fetching gifs’, async () => {   
await act(async () => {
const { getByText } = render(<GifGenerator />)
getByText(‘…Loading’)
})
})

This should now silence the test warning. The act here is necessary when you are not testing all state changes, but only portions of it. We are also adding the async/await code to verify that the test will be waiting for the state changes to be finished before attempting to close out the test. To see what I am talking about, let’s test 2 scenarios: 1) the “…Loading” test will disappear when GIFs are rendered, and 2) the GIFs are rendering.

To test that “…Loading” disappears, we can write a simple test waiting for the text element to be removed using the waitForElementToBeRemoved function:

it(‘removes text “…Loading” after displaying gifs’, async () => {
const { getByText } = render(<GifGenerator />)

await waitForElementToBeRemoved(() => getByText(‘…Loading’))
})

After running the tests, we should see 2 passing tests and no warnings. So why didn’t we need to use act here? From what we have learned so far, we know that the removal of the “…Loading” text is the final state for the component. Using the await in conjunction with waitForElementToBeRemoved has verified that all state updates have been completed. This could also be re-written using act, though if you do not want to hold to the implementation of this component so strictly:

it(‘removes text “…Loading” after displaying gifs’, async () => {.  
await act(async () => {
const { getByText } = render(<GifGenerator />)

await waitForElementToBeRemoved(() => getByText(‘…Loading’))
})
})

We will still be passing the tests either way in this example. However, keep in mind, the latter is probably the better option for test maintainability.

The final test is checking for the GIFs to render:

it(‘displays the trending gifs received from Giphy API’, async () => {
await act(async() => {
const { getByAltText } = render(<GifGenerator />)
await waitForElement(() => getByAltText(stubbedGifs[0].title))
getByAltText(stubbedGifs[1].title)
getByAltText(stubbedGifs[2].title)
})
})

Notice I’m wrapping this in the act function again? I could have omitted the act here, as well, since it is the final state update of the component like so:

it(‘displays the trending gifs received from Giphy API’, async () => {
const { getByAltText } = render(<GifGenerator />)

await waitForElement(() => getByAltText(stubbedGifs[0].title))
getByAltText(stubbedGifs[1].title)
getByAltText(stubbedGifs[2].title)
})

The final style is up to you, but just know that opting in to the latter option here might make it harder for others in your team to debug if or when the final upstate state of the component changes, and they are left with a failing test. You will have some security knowing that the test should log a “Warning: An update to GifGenerator inside a test was not wrapped in act(…).” message. That does leave you with some protections in place.

Conclusion

Testing hooks at a component level can be a bit challenging, especially async scenarios like the one above. I hope this is insightful for you and saves you hours of frustration. Testing new library APIs and features can be challenging, but rest assured that the act and waitForElementToBeRemoved functions are here to offer support.

Check out my other React posts, to more tips and tricks around testing or state management:


Thanks for reading! Want to work on a mission-driven team that loves well-tested JavaScript and well-written tests? We’re hiring!


To learn more about Flatiron School, visit the website, follow us on Facebook and Twitter, and visit us at upcoming events near you.

Flatiron School is a proud member of the WeWork family. Check out our sister technology blogs WeWork Technology and Making Meetup.

Flatiron Labs

We're the technology team at The Flatiron School (a WeWork company). Together, we're building a global campus for lifelong learners focused on positive impact.

Luke Ghenco

Written by

Flatiron Labs

We're the technology team at The Flatiron School (a WeWork company). Together, we're building a global campus for lifelong learners focused on positive impact.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade