Testing async behavior in React components with React Testing Library
When testing React components with async state changes, like when data fetching with useEffect
, you might get this error:
TL;DR
Issue
Warning: An update to <SomeComponent> inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...)
Solution
- When using plain
react-dom/test-utils
orreact-test-renderer
, wrap each and every state change in your component with anact()
- When using React Testing Library, use async utils like
waitFor
andfindBy...
Async example - data fetching effect in useEffect
You have a React component that fetches data with useEffect
.
Unless you're using the experimental Suspense, you have something like this:
-
Loading/placeholder view
- When data is not there yet, you may display a placeholder UI like a spinner, "Loading..." or some skeleton item.
-
Data view
- When data arrives, you set data to your state so it gets displayed in a Table, mapped into
<li>
s, or any data visualization have you.
- When data arrives, you set data to your state so it gets displayed in a Table, mapped into
import React, { useEffect, useState } from "react";
const Fetchy = () => {
const [data, setData] = useState([]);
useEffect(() => {
// simulate a fetch
setTimeout(() => {
setData([1, 2, 3]);
}, 3000);
}, []);
return (
<div>
<h2>Fetchy</h2>
<div>
{data.length ? (
<div>
<h3>Data:</h3>
{data.map((d) => (
<div key={d}>{d}</div>
))}
</div>
) : (
<div>Loading</div>
)}
</div>
</div>
);
};
export default Fetchy;
Testing a data fetch
😎 Now, you want to test this. Here, we're using React Testing Library, but the concepts apply to Enzyme as well.
Here's a good intro to React Testing Library
describe.only("Fetchy", () => {
beforeAll(() => {
jest.useFakeTimers();
})
afterAll(() => {
jest.useRealTimers()
})
it("shows Loading", async () => {
render(<Fetchy />);
screen.debug();
expect(screen.getByText("Loading")).toBeInTheDocument();
jest.advanceTimersByTime(3000);
screen.debug();
expect(screen.getByText("Data:")).toBeInTheDocument();
});
});
getByText()
finds element on the page that contains the given text. For more info on queries: RTL queries
- Render component
screen.debug()
logs the current HTML of document.body-
Assert Loading UI. It logs:
... <div>Loading</div> ...
- Simulate to the time data arrives, by fast-forwarding 3 seconds.
jest.advanceTimersByTime
lets us do this screen.debug()
-
Assert Data UI. It logs:
... <h3>Data:</h3> <div>1</div> <div>2</div> <div>3</div> ...
🕐 Note that we use
jest.advanceTimersByTime
to fake clock ticks. This is so test runner / CI don't have to actually waste time waiting. To make it work, putjest.useFakeTimers
on setup andjest.useRealTimers
on teardown
🖥 You can also put a selector here like
screen.debug(screen.getByText('test'))
. For more info: RTL screen.debug
✅ Tests pass...
😱 but we're getting some console warnings 🔴
Note that it's not the
screen.debug
since even after commenting it out, the same warning shows.
Wait, what is act()
?
Part of React DOM test utils, act()
is used to wrap renders and updates inside it, to prepare the component for assertions.
📚 Read more: act() in React docs
The error we got reminds us that all state updates must be accounted for, so that the test can "act" like it's running in the browser.
In our case, when the data arrives after 3 seconds, the data
state is updated, causing a re-render. The test has to know about these state updates, to allow us to assert the UI changes before and after the change.
Warning: An update to Fetchy inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});
/* assert on the output */
Coming back to the error message, it seems that we just have to wrap the render in act()
.
The error message even gives us a nice snippet to follow.
Wrapping state updates in act()
Wrap render in act()
it("shows Loading", async () => {
act(() => {
render(<Fetchy />);
});
...
});
😭 Oh no, we're still getting the same error...
Wrapping the render inside act
allowed us to catch the state updates on the first render, but we never caught the next update which is when data arrives after 3 seconds.
Wrap in act()
with mock timer
it("shows Loading and Data", async () => {
act(() => {
render(<Fetchy />);
});
...
act(() => {
jest.advanceTimersByTime(3000);
});
...
});
🎉 Awesome! It passes and no more errors!
Using async utils in React Testing Library
React Testing Library provides async utilities to for more declarative and idiomatic testing.
it("shows Loading and Data", async () => {
render(<Fetchy />);
expect(await screen.findByText("Loading")).toBeInTheDocument();
screen.debug();
expect(await screen.findByText("Data:")).toBeInTheDocument();
screen.debug();
});
-
Instead of wrapping the render in
act()
, we just let it render normally. Then, we catch the async state updates byawait
-ing the assertion.findBy*
queries are special, that they return a promise that resolves when the element is eventually found
- We don't even need the
advanceTimersByTime
anymore, since we can also just await the data to be loaded. screen.debug()
only after theawait
, to get the updated UI
This way, we are testing the component closer to how the user uses and sees it in the browser in the real world. No fake timers nor catching updates manually.
📚 Read more: RTL async utilities
❌😭 Oh no! Tests are failing again!
Note that if you have the jest fake timers enabled for the test where you're using async utils like
findBy*
, it will take longer to timeout, since it's a fake timer after all 🙃
Timeouts
The default timeout of findBy*
queries is 1000ms (1 sec), which means it will fail if it doesn't find the element after 1 second.
Sometimes you want it to wait longer before failing, like for our 3 second fetch.
We can add a timeout
in the third parameter object waitForOptions
.
it("shows Loading and Data", async () => {
render(<Fetchy />);
expect(await screen.findByText("Loading", {}, { timeout: 3000 })).toBeInTheDocument();
screen.debug();
expect(await screen.findByText("Data:", {}, {timeout: 3000})).toBeInTheDocument();
screen.debug();
});
✅😄 All green finally!
Other async utils
findBy*
is a combination of getBy*
and waitFor
. You can also do:
await waitFor(() => screen.getByText('Loading'), { timeout: 3000 })
📚 More details on findBy: RTL findBy
Async example 2 - an async state change
Say you have a simple checkbox that does some async calculations when clicked.
We'll simulate it here with a 2 second delay before the label
is updated:
import React, { useState } from "react";
const Checky = () => {
const [isChecked, setChecked] = useState(false);
function handleCheck() {
// simulate a delay in state change
setTimeout(() => {
setChecked((prevChecked) => !prevChecked);
}, 2000);
}
return (
<div>
<h2>Checky</h2>
<h4>async state change: 2 second delay</h4>
<input type="checkbox" onChange={handleCheck} id="checky2" />
<label htmlFor="checky2">{isChecked.toString()}</label>
</div>
);
};
export default Checky;
Wrap in act()
with mock timer
Testing with act()
can look like this:
it("updates state with delay - act() + mock timers", async () => {
act(() => {
render(<Checky />);
})
screen.debug();
let label = screen.getByLabelText("false");
expect(label).toBeInTheDocument();
act(() => {
fireEvent.click(label);
jest.advanceTimersByTime(2000);
})
screen.debug()
expect(screen.getByLabelText("true")).toBeInTheDocument();
});
- Render component, wrap in
act()
to catch the initial state -
screen.debug()
to see HTML of initial UI... <input id="checky2" type="checkbox" /> <label for="checky2">false</label> ...
- Assert initial UI: "false" label
- Click the label using
fireEvent
- Simulate to the time state is updated arrives, by fast-forwarding 2 seconds.
jest.advanceTimersByTime
screen.debug()
-
Assert updated UI with label "true"
... <input id="checky2" type="checkbox" /> <label for="checky2">true</label> ...
Using async utils in React Testing Library
Like in the first example, we can also use async utils to simplify the test.
it("updates state with delay - RTL async utils", async () => {
render(<Checky />);
let label = await screen.findByLabelText("false")
expect(label).toBeInTheDocument();
screen.debug();
fireEvent.click(label);
expect(await screen.findByLabelText("true", {}, { timeout: 2000 })).toBeInTheDocument();
// await waitFor(() => screen.getByLabelText("true"), { timeout: 2000 });
screen.debug()
});
As before, await
when the label we expect is found. Remember that we have to use findBy*
which returns a promise that we can await.
Timeout is needed here since we are not under jest's fake timers, and state change only happens after 2 seconds.
An alternative to expect(await screen.findBy...)
is await waitFor(() => screen.getBy...);
.
getBy* commands fail if not found, so waitFor
waits until getBy* succeeds.
✅ All good! Tests passes and no warnings! 😄💯
Code
https://github.com/lenmorld/react-test-library-boilerplate
Further reading
-
For a more in-depth discussion on fixing the
"not wrapped in act(...)" warning
and more examples in both Class and Function components, see this article by Kent C Dodds -
Common mistakes when using React Testing Library
-
Here's the Github issue that I found when I struggled with this error before
Conclusion
🙌 That's all for now! Hope this helps when you encounter that dreaded not wrapped in act(...)
error and gives you more confidence when testing async behavior in your React components with React Testing Library. 👍