circuit

You Probably Don't Need act() in Your React Tests

What you should do instead.




Photo by Marek Novotný on UnsplashPhoto by Marek Novotný on Unsplash

TL;DR If you find yourself using act() with RTL (react-testing-library), you should see if RTL async utilities could be used instead: waitFor , waitForElementToBeRemoved or findBy .

React wants all the test code that might cause state updates to be wrapped in act() .

But wait, doesn’t the title say we should not use act()? Well… Yes, because act() is boilerplate, which we can remove by using react-testing-library 🚀

What problem does act() solve?

Think about it this way: when something happens in a test, for instance, a button is clicked, React needs to call the event handler, update the state, and run useEffect. Since React state updates are asynchronous, React has to know when to do all of these things. That is why act() is necessary.

There is an amazing read if you want to dig deeper.

react-testing-library already wraps utilities in act()

Every time you use render() , userEvent , fireEvent they are already wrapped in act() . What does it mean, practically speaking?

It means that every time you use one of these utilities, all component’s relevant state updates are flushed. An additional synchronous act() is not going to change anything.

If you don’t believe me, take a look at this example:

// App.js
import { useState } from "react";

const App = () => {
  const [title, setTitle] = useState("");

  const handleClick = () => {
    setTitle("Clicked"); // act() in RTL render() makes this update happen
  };

  return (
    <>
      <button onClick={handleClick}>Click me</button>
      {title}
    </>
  );
};

// App.test.js
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

it("renders title when clicked", () => {
  render(<App />); // this is wrapped in act()

  userEvent.click(screen.getByRole("button"));

  expect(screen.getByText("Clicked")).toBeInTheDocument();
});

Note: all examples are based on a fresh create-react-app .

As you can see, we didn’t use act() and the new title is flushed after the click.

I mentioned synchronous act() for a reason. await act() is where things start to get tricky 🙊

😈 Asynchronous components

Whenever a state update is scheduled **asynchronously (e.g. after a promise resolves), **the test can no longer stay synchronous. Otherwise, React will warn us that state updates are not wrapped in act() .

Let me illustrate what I mean. Instead of setting the title on button click, we’ll fetch it from an API:

// App.js
import { useEffect, useState } from "react";

const App = () => {
  const [title, setTitle] = useState("");

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(
        "https://jsonplaceholder.typicode.com/posts/1"
      );
      const { title } = await response.json();

      setTitle(title); // this happens after the test is done
    };

    fetchData();
  }, []);

  return <>{title}</>;
};

// App.test.js
import { render, screen } from "@testing-library/react";

it("renders title", () => {
  jest.spyOn(window, "fetch").mockResolvedValue({
    json: async () => ({ title: "Fetched" }),
  });

  render(<App />);

  // this happens before the state update is scheduled
  expect(screen.getByText("Fetched")).toBeInTheDocument();
});

Not only does this test fail, but it also produces the infamous An update to App inside a test was not wrapped in act(...). warning.

Why does this happen? The answer has more to do with the event loop than it does with React.

You might have noticed the test does everything synchronously. It does not have await or promise chains. For that reason, setTitle(title) goes into the **task queue **(also known as message queue) **and is only executed after the call stack is clear. On the other end, expect(screen.getByText("Fetched")).toBeInTheDocument() goes into the **call stack, which means it runs before the state update is scheduled!

setTitle() goes into task queue, hence executes later than expect()setTitle() goes into task queue, hence executes later than expect()

Okay, but why the warning, though? After all, render() causes the state update and it is wrapped in act() by RTL, so we should be good, right? 😧

Not really. Since render() is a synchronous function, it only flushes synchronous state updates.

To put it shortly:

  • The test fails because the state update is scheduled after the assertion.

  • The warning is printed because the state update is scheduled after the test is done.

How can this be solved?

🔴 Let me start with an incorrect example:

it("renders title", async () => {
  jest.spyOn(window, "fetch").mockResolvedValue({
    json: async () => ({ title: "Fetched" }),
  });

  // DON'T DO THIS
  await act(async () => {
    render(<App />);
  });

  expect(screen.getByText("Fetched")).toBeInTheDocument();
});

This solves both our problems. It hides the warning and it also makes the test pass, but it brings other issues:

  • This only works because the state update happens in the next tick of the event loop.

  • act() does nothing special here, it merely hides the warning.

In fact, to show the hacky nature of this solution, take a look at another example. It also hides the warning and makes the test pass. It doesn’t look like it makes a lot of sense, right? Semantically, there is little difference between these two.

it("renders title", async () => {
  jest.spyOn(window, "fetch").mockResolvedValue({
    json: async () => ({ title: "Fetched" }),
  });

  // DON'T DO THIS
  render(<App />);
  await act(async () => {});

  expect(screen.getByText("Fetched")).toBeInTheDocument();
});

To prove my first point, This only works because the state update happens in the next tick of the event loop, consider an example in which we await for an additional promise.

const fetchData = async () => {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
  const { title } = await response.json();

  await new Promise((resolve) => setTimeout(resolve, 0));

  setTitle(title);
};

It makes the tests fail!

✅ Use RTL async utilities instead

The good news is there is no need to use act() in these scenarios. We can use functions that come with RTL: waitFor , waitForElementToBeRemoved and findBy queries.

it("renders title", async () => {
  jest.spyOn(window, "fetch").mockResolvedValue({
    json: async () => ({ title: "Fetched" }),
  });

  render(<App />);

  expect(await screen.findByText("Fetched")).toBeInTheDocument();
});

Or the waitFor variant:

it("renders title", async () => {
  jest.spyOn(window, "fetch").mockResolvedValue({
    json: async () => ({ title: "Fetched" }),
  });

  render(<App />);

  await waitFor(() => expect(screen.getByText("Fetched")).toBeInTheDocument());
});

Both variants pass and they don’t have the aforementioned issues.

What if I fetch after an event?

There is no difference if asynchronously scheduled state update happens in useEffect or in an event handler. In the tests, instead of waiting for something to happen after render() , we can wait for something to happen after the event.

Let’s revisit the very first example, but this time we will update the state asynchronously:

// App.js
import { useState } from "react";

const App = () => {
  const [title, setTitle] = useState("");

  const handleClick = async () => {
    const response = await fetch(
      "https://jsonplaceholder.typicode.com/posts/1"
    );
    const { title } = await response.json();

    setTitle(title);
  };

  return (
    <>
      <button onClick={handleClick}>Click me</button>
      {title}
    </>
  );
};

// App.test.js
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

it("renders title when clicked", async () => {
  jest.spyOn(window, "fetch").mockResolvedValue({
    json: async () => ({ title: "Fetched" }),
  });

  render(<App />);

  userEvent.click(screen.getByRole("button"));

  expect(await screen.findByText("Fetched")).toBeInTheDocument();
});

As you can see, the fact that the state update is scheduled after the user’s click, rather than in useEffect , has not changed much from the test’s perspective. Wrapping userEvent in act() is as bad as wrapping render() .

Are there cases when using act() is inevitable?

Yes, there might be certain scenarios. For instance, you might want to schedule a state update on a debounced function. In that case, using jest’s fake timers and wrapping jest.runAllTimers or jest.advanceTimersByTime in act() seems like a reasonable approach.

I suggest reading more about tricky cases here.

🚪 Conclusion

In most cases, react-testing-library makes wrapping test code in act() unnecessary. Furthermore, doing so might cause additional problems. Instead, try to make use of RTL async utilities and they should cover most of your needs.

There is already work going on to provide no-unnecessary-act rule in eslint-plugin-testing-library v4, but as of the time of writing this article, it is still a work in progress. Hopefully, it gets done soon.

I highly appreciate you taking your time reading this and I hope you’ve learned something new 🙂

Feel free to **connect **with me in LinkedIn, GitHub, Twitter

Resources




Continue Learning