ReactTDD.com

Understanding act

React’s act function is an essential part of React unit tests. Why, though? What does it do and why do we need it? This post looks at the act function from the perspective of an application developer (like me and you).

React 17 introduced act to the world as a “guard rail” to stop asynchronous affects from one test affecting subsequent tests. And React 18 simplifies how it’s used.

Ignoring testing libraries for a second, let’s say we wanted to write unit tests for this function:

import React from "react";
import ReactDOM from "react-dom/client";

const Customer = ({ name }) => <p>{name}</p>;

Here’s how you’d write a test for this, without act:

it("renders name (without act)", async () => {
  global.IS_REACT_ACT_ENVIRONMENT = false; // not always necessary, see below
  const container = document.createElement("div");
  ReactDOM.createRoot(container).render(
    <Customer name="Ashley" />
  )
  await new Promise(setTimeout);
  expect(container.textContent).toContain("Ashley");
});

What’s new in React 18 is that the render function is always asynchronous, so you have to use the call to setTimeout to ensure all asynchronous affects have finished before you invoke any expectations.

In previous versions of React, this test wouldn’t have been async and wouldn’t have needed that await line at all. However, as soon as you introduced asynchronous affects with the useEffect hook you would have needed to use this set up.

Now here’s how you’d write this test with the act function:

import { act } from "react-dom/test-utils";

it("renders name (with act)", () => {
  global.IS_REACT_ACT_ENVIRONMENT = true;
  const container = document.createElement("div");
  act(() => {
    ReactDOM.createRoot(container).render(
      <Customer name="Ashley" />
    )
  });
  expect(container.textContent).toContain("Ashley");
});

Notice that the test is no longer marked async. I can’t claim to know how act works internally, but because async has disappeared I am pretty that confident that act has changed React’s rendering “mode” and everything is now being done synchronously.

A basic lesson of unit testing is that should always strive to remove asynchronicity from your unit tests, because asynchronous behaviour makes the execution path more difficult to reason about, and can be a source of intermittent test failures. You want your unit tests to be as deterministic as possible.

So using act is, on the whole, a good thing. And React 18 improves on React 17 by basically forcing you to always use act. Also a good thing, since it means you need to think less about when act is required and when it isn’t.

What you’d then do is add the following to your package.json config for Jest so that you don’t always need to write the global.IS_REACT_ACT_ENVIRONMENT line:

"jest": {
  "globals": {
    "IS_REACT_ACT_ENVIRONMENT": true
  }
}

Now, you might not be calling React’s render function directly in your tests, but instead using a testing library. These libraries mask the use of act by wrapping render into its own thing:

// container declared elsewhere
const render = (component) =>
  act(() =>
    ReactDOM.createRoot(container).render(component)
  );

This is fine for basic tests where you’re just testing initial rendering, but what happens when your component invokes an asynchronous affect within a useEffect hook?

const Customer = ({ name }) => {
  useEffect(() => {
    const fetchData = async () => {
      // do something asynchronous
    }

    fetchData();
  }, []);
}

Remember that act triggers React to render in a synchronous mode, but React can’t do the same for your own component code. The asynchronicity persists even in the presence of the act call, so you need to use the async form of act:

// note that act's argument value is declared async
const renderAndWait = (component) =>
  act(async () =>
    ReactDOM.createRoot(container).render(component)
  );

One unfortunate thing about JavaScript is that the asynchronous behaviour is baked into the language. You can’t turn it off in your tests. Other languages have this capability, where task-based work can be directed to run inline.

(It would be lovely if JavaScript had a test runner that could do this, because we could avoid all this async complexity in our test code.)

Leaky internals

There’s one more problem with act. The act API is designed so that it should be an internal detail for your testing library but I find that in practice it’s always going to leak out into your test code.

One time that happens is when you raise a DOM event that your component handles and then performs some async action. For example, clicking a button to submit a form.

Again, your testing library can and probably does wrap all these events, but it’s unfortunate that they have to bother. It’d be great if raising the plain DOM event was enough, which not only keeps the testing libraries lean, but also means you can keep your tests coded against the stable, decades old DOM API rather than whatever testing library is in fashion this week.

If you’re interested in more examples of act and how it can be used effectively, check out the GitHub repository for the Mastering React Test-Driven Development book.

— Written by Daniel Irvine on August 22, 2022.