loading...
Cover image for Testing async stuff in React components with Jest and react-testing-library

Testing async stuff in React components with Jest and react-testing-library

lennythedev profile image Lenmor Ld ・7 min read

Initially posted in lennythedev.com

When testing React components with async state changes, like when data fetching with useEffect, you might get this error:

not wrapped in act 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(...)
Enter fullscreen mode Exit fullscreen mode

Solution

  1. When using plain react-dom/test-utils or react-test-renderer, wrap each and every state change in your component with an act()

  2. When using React Testing Library, use async utils like waitFor and findBy...

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:

  1. Loading/placeholder view

    • When data is not there yet, you may display a placeholder UI like a spinner, "Loading..." or some skeleton item.
  2. 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.

fetch component example

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;
Enter fullscreen mode Exit fullscreen mode

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();
    });
});
Enter fullscreen mode Exit fullscreen mode

getByText() finds element on the page that contains the given text. For more info on queries: RTL queries

  1. Render component
  2. screen.debug() logs the current HTML of document.body
  3. Assert Loading UI. It logs:

    ...
    <div>Loading</div>
    ...
    
  4. Simulate to the time data arrives, by fast-forwarding 3 seconds. jest.advanceTimersByTime lets us do this

  5. screen.debug()

  6. 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, put jest.useFakeTimers on setup and jest.useRealTimers on teardown

🖥 You can also put a selector here like screen.debug(screen.getByText('test')). For more info: RTL screen.debug

✅ Tests pass...

all tests pass

😱 but we're getting some console warnings 🔴
console warnings act error

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 */
Enter fullscreen mode Exit fullscreen mode

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 />);
    });
    ...
});
Enter fullscreen mode Exit fullscreen mode

😭 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);
    });
    ...
});
Enter fullscreen mode Exit fullscreen mode

🎉 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();
});
Enter fullscreen mode Exit fullscreen mode
  1. Instead of wrapping the render in act(), we just let it render normally. Then, we catch the async state updates by await-ing the assertion.

    • findBy* queries are special, that they return a promise that resolves when the element is eventually found
  2. We don't even need the advanceTimersByTime anymore, since we can also just await the data to be loaded.

  3. screen.debug() only after the await, 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!

findBy timeout error

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.

findBy timeout options

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();
});
Enter fullscreen mode Exit fullscreen mode

findBy timeout test passes

✅😄 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 })
Enter fullscreen mode Exit fullscreen mode

📚 More details on findBy: RTL findBy

Async example 2 - an async state change

async checkbox state example

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;
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode
  1. Render component, wrap in act() to catch the initial state
  2. screen.debug() to see HTML of initial UI

    ...
    <input id="checky2" type="checkbox" />
    <label for="checky2">false</label>
    ...
    
  3. Assert initial UI: "false" label

  4. Click the label using fireEvent

  5. Simulate to the time state is updated arrives, by fast-forwarding 2 seconds. jest.advanceTimersByTime

  6. screen.debug()

  7. 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()
});
Enter fullscreen mode Exit fullscreen mode

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.

Checky all good

✅ All good! Tests passes and no warnings! 😄💯

Code

https://github.com/lenmorld/react-test-library-boilerplate

Further reading

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. 👍

Posted on by:

lennythedev profile

Lenmor Ld

@lennythedev

webdev @ Autodesk | Someone used to call me "Learn more", and I'm spending forever to live up to it. You'll find me dabbling in random stuff 👨‍💻 or missing a wide open shot in 🏀

Discussion

pic
Editor guide