UX in network apps: how not to fail
UX in network apps: how not to fail
Close

22 June 2022

Software Development

Why should you be grateful for act() warnings in React tests

11 minutes reading

Why should you be grateful for act() warnings in React tests

If you have ever written a test using the React Testing Library there is a good chance you have encountered the notorious act() warning.

act() warning in react

Fig.1 act() warning

Usually, a solution can be found through intuition and/or a lucky guess, but not this time, though. Welcome! Let’s dive deeper into act() warnings to really understand them. 

Newsletter app

I have created an uncomplicated newsletter app to act as a simple example. Below, there is a quick overview:

function NewsletterForm({ updateNewsletter }) {
  const [signedUp, setSignedUp] = useState(false);
  const [email, setEmail] = useState("");

  const header = signedUp ? "You're signed up!" : "Sign up for our newsletter!";

  const handleSubmit = async (e) => {
    e.preventDefault();
    await updateNewsletter(email);
    setSignedUp(true);
  };

  return (
    <form onSubmit={handleSubmit} className="newsletter">
      <span>{header}</span>
      <label>Email address</label>
      <input
        data-testid="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <button type="submit" data-testid="submit" disabled={!email}>
        Sign up
      </button>
    </form>
  );
}

The main point to notice here is the behavior on clicking the submit button:

  1. Firstly, call the passed-in function updateNewsletter. 
  2. Secondly, await that function and then update the signedUp state. 

Simple, how about writing a test for our form?

Let’s write a test

test("User can signup for the newsletter", async () => {
  // given
  const updateNewsletter = jest.fn();
  render(<NewsletterForm updateNewsletter={updateNewsletter} />);
  const email = "bob.bobberson@gmail.com";

  // when
  const emailInput = screen.getByTestId("email");
  const submitButton = screen.getByTestId("submit");
  userEvent.type(emailInput, email);
  userEvent.click(submitButton);

  // then
  expect(updateNewsletter).toHaveBeenCalledWith(email);
});

In short:

  1. Enter email.
  2. Click the submit button.
  3. Expect the passed-in function to be called with the entered email.

Sounds reasonable, right? And much to your dismay, you get the warning mentioned at the beginning.

Not wrapped? act(...)? What are you even saying?

I have to admit, this warning probably does not give you as much information as you would have wished. What is an act()?

From React docs:

To prepare a component for assertions, wrap the code rendering it and performing updates inside an act() call.

Let’s break it down using our test! I wrote mine using Given-When-Then (GWT), which is a standard structured way of writing tests.

Test breakdown

Fig. 2 Test breakdown

  1. "To prepare a component for assertions" – preparing a component are the Given and When phases.

  2. "Wrap the code rendering it and performing updates inside an act() call",

    • "the code rendering it" – render() function,
    • "the code performing updates" – for example, clicking the submit button - I explain updates in-depth in the next section.

Wait, but where are those act() calls?

Thanks to the React Testing Library (RTL), you do not have to use those act() calls yourself. But trust me – you are using them every time you write a test! 

RTL is merely a library that wraps its methods in act(). This warning has to do with React itself. This is a key distinction – act() is a React warning!

Please note that there are some rare cases when you need act(). However, they are not relevant to the scope of this article.

Let’s dive deeper

There is a second explanation in React docs that explains act() in more depth.

When writing UI tests, tasks like rendering, user events, or data fetching can be considered as “units” of interaction with a user interface. react-dom/test-utils provides a helper called act() that makes sure all updates related to these “units” have been processed and applied to the DOM before you make any assertions. [...] This helps make your tests run closer to what real users would experience when using your application.

Take a look at the following diagram:

act() as a boundary

Fig. 3 act() as a boundary

  1. Think of the interactions as anything that "changes" something in the UI.
  2. act() acts as a boundary around updates related to those units of interaction mentioned above.
  3. There might be multiple updates related to a single unit of interaction - for example, data fetch, user interactions, etc.

In short, React makes sure that we are testing an updated UI, as it states in its documents above. Yay, React!

OK, cool. How do I fix my test, though?

Think about the behavior of our newsletter form in the test, line by line:

// given
const updateNewsletter = jest.fn();
render(<NewsletterForm updateNewsletter={updateNewsletter} />);
const email = "bob.bobberson@gmail.com";
  1. Create a Jest mock function and assign it to const updateNewsletter – nothing to do with the state of our component.
  2. Render the NewsletterForm component and pass it to updateNewsletter mock – we have just learned that RTL provides us helpers wrapped with act().
  3. Create email const - again, nothing to do with the state of our component.
// when
const emailInput = screen.getByTestId("email");
const submitButton = screen.getByTestId("submit");
userEvent.type(emailInput, email);
userEvent.click(submitButton);
  1. Use the RTL-provided functions to find some elements; they do not update the state.
  2. Use type and click methods on the user object – again, we already know they are wrapped with the act().
// then
expect(updateNewsletter).toHaveBeenCalledWith(email);
  1. Make the assertion, not modifying the state.

That is where our test ends, and it makes no sense at all! We have everything wrapped in act because of RTL functions, yet we get this error.

Wait, hold on. Remember this cool feature to change the header when a user submits the form? When does it happen in our test?

Well, maybe remove it and check, I guess?

When you remove it, suddenly the output gets much nicer:

const handleSubmit = async (e) => {
  e.preventDefault();
  await updateNewsletter(email);
  // setSignedUp(true);
};

The test passes and there are no warnings! However, I doubt a client would share our happiness, sadly.

Wait, let me see the warning again

act() warning in react

Fig.4 act() warning

Hopefully, that makes a lot more sense now. We had an update to NewsletterForm inside (header change), which we did not wrap in act(). Additionally, it most definitely did cause a state update.

The solution is surprisingly simple:

// then
expect(updateNewsletter).toHaveBeenCalledWith(email);
await waitForElementToBeRemoved(() =>
  screen.queryByText("Sign up for our newsletter!")
);

The test passes, the client is happy, and so are we!

Please do not focus on this particular solution but rather on the big picture. What exactly happened here?

Thank you, act()?

The act() warning tells you that something is happening in your test without your knowledge. In our case, it was a text change in the header.

State change after the finished test

Fig. 5 State change after the finished test

The main point to note here is that usually, when you get an act() warning, you are probably not accounting for a state change of some kind. Most of the time, it happens after your test ends.

After we added waitForElementToBeRemoved this graphic changes to:

Passing test

Fig. 6 Passing test

Case study conclusion

From a React point of view, something unexpected is happening. In other words, we are not testing everything that our component is doing. That does not seem like a good test.

We should make complete, thorough tests. Those in which we miss some updates will result in an act() warning. Thorough testing helps to avoid bugs like the one described in our example Newsletter App.

Bonus: you do not need act()

Well, usually. As I mentioned earlier, there are some special cases. Below, you will find one of them.

The client requests that we keep track of the number of sign-ups for our newsletter. We create a simple useCount hook for that purpose:

const useCount = () => {
  const [count, setCount] = useState(0);

  const increment = () => setCount((v) => v + 1);
  const decrement = () => setCount((v) => v - 1);

  return { count, increment, decrement };
};

Let's write a test for that hook. We will use the renderHook wrapper. It allows us to render a hook within a test React component without having to create that component ourselves.

test("useCount hook correctly increments and decrements", async () => {
  const { result } = renderHook(() => useCount());
  expect(result.current.count).toBe(0);

  result.current.increment();
  expect(result.current.count).toBe(1);

  result.current.decrement();
  expect(result.current.count).toBe(0);
});

Now, what happens when we run this test? Or rather, what happens when we call the increment or decrement function returned from our custom hook? The answer is...

act() warning in react

Fig.7 act() warning
The above code results in a warning – for these calls, we need act(). The below test passes and does not result in any warnings.

test("useCount hook correctly increments and decrements", async () => {
  const { result } = renderHook(() => useCount());
  expect(result.current.count).toBe(0);

  act(() => result.current.increment());
  expect(result.current.count).toBe(1);

  act(() => result.current.decrement());
  expect(result.current.count).toBe(0);
});

Usually, a state update is an effect of some click, input, etc. In our first example, we click a button, and the header changes. There is a middle layer. We use RTL, hence the whole interaction is wrapped in act().

Now, what about this particular test? We directly want to update the state of our component!

Please keep in mind that this is a special case. You probably will never run into it. It is here merely to show you that there exist cases when you need act(). Hopefully, you also noticed that they are quite bizarre and rare.

Note: there are different act() warnings

ERROR: ‘Warning: You seem to have overlapping act() calls, this is not supported. Be sure to await previous act() calls before making a new one.'

This warning is usually caused by not using await on a promise. In my experience, this is true in 99% of cases.

A significantly worse option is that something in your code broke. That might not be the most detailed description, but it is all you know. Something in your code broke and is causing an act to hang. Good luck!

Conclusion

Below, as a quick sum-up I have noted down the most crucial points: 

  • do not use act() explicitly - use RTL methods that wrap it,
  • act() acts as a boundary around updates related to "units" of interaction,
  • act() warning tells you that something is happening in your test without your knowledge,
  • usually, when you get an act() warning, you are not accounting for some state change, and most of the time, that happens after your test ends,
  • there are different act() warnings, do not confuse them!
Michał

Michał Kuliński

Frontend Engineer