Testing-library: avoid these mistakes in async tests

tipsy_dev

Aleksei Tsikov

Posted on November 21, 2021

Testing-library: avoid these mistakes in async tests

Testing is a crucial part of any large application development. The more code you write, the more tests you want to add to make sure all the parts still work together as expected. Here in Revolut, a lot of things happen behind our mobile super-app. We have a lot of backoffice apps with complex logic, and need to be sure nothing is broken when new features are added.

Sometimes, tests start to unexpectedly fail even if no changes were made to the business logic. It may happen after e.g. you updated some underlying library, made changes to the network layer, etc. Good and stable tests should still reliably assert component output against the given input, no matter what happens at the lower levels. Another even worse case is when tests still pass even when the component logic got broken.

In this article, I would like to show a few common mistakes that could lead to such issues, how to fix these, and how to make your tests stable and predictable. Initially, I picked this topic for our internal Revolut knowledge share session, but I feel like it could be helpful for a broader audience.

These and a few more examples could be found in this repository.

Table of Contents

  1. Await with sync methods
  2. Async methods without await
  3. Side-effects inside waitFor

Await with sync methods

Simple asynchronous request

This is the most common mistake I'm running into while refactoring code. Let's say, you have a simple component that fetches and shows user info. For the sake of simplicity, our API will only capitalize the given user id and return it as a user name. I'm also using react-query-alike hooks, but not the library itself, to make things more transparent:

const getUser = async (id: string): Promise<string> =>
  id[0].toUpperCase().concat(id.slice(1))

const useUserQuery = (id: string | null) => {
  const [data, setData] = useState<string | null>(null)
  useEffect(() => {
    if (!id) {
      setData(null)
      return
    }
    getUser(id).then(setData)
  }, [id])
  return data
}

const UserView = ({ id }: { id: string | null }) => {
  const data = useUserQuery(id)
  if (data === null) return <div>Loading...</div>
  return <>{data}</>
}
Enter fullscreen mode Exit fullscreen mode

We want to write a test for it, so we are rendering our component with React Testing Library (RTL for short) and asserting that an expected string is visible to our user:

it('should render user info', async () => {
  await render(<UserView id="bob" />)
  expect(screen.getByText('Bob')).not.toBeNull()
})
Enter fullscreen mode Exit fullscreen mode

So far, this test works perfectly well.

Complex asyncronous request

Later, a new requirement comes in to display not only a user but also their partner name. Easy-peasy! Let's just change our fetch function a little bit, and then update an assertion.
In getUser, we will now wait for two consecutive requests and only then return the aggregated data:

const getUser = async (id: string): Promise<string> => {
  const user = await getUser(id)
  const partner = await (user[0] === 'A'
    ? getUser('charlie')
    : getUser('daisy'))
  return `${user} and ${partner}`
}
Enter fullscreen mode Exit fullscreen mode

And let's update our test as well:

it('should render user info', async () => {
  await render(<UserView id="bob" />)
  expect(screen.getByText('Alice and Charlie')).not.toBeNull()
})
Enter fullscreen mode Exit fullscreen mode

Our changes made perfect sense, but suddenly our test will start to fail with "Unable to find an element with the text: Alice and Charlie". Oh-oh! But we didn't change any representation logic, and even the query hook is the same. Also, RTL output shows "Loading..." text in our DOM, though it looks like we are awaiting for render to complete in the very first line of our test.

Explanation

Alright, let's find out what's going on here. render is a synchronous function, but await is designed to work with asynchronous ones. What's going on when render is awaited? Well, MDN is very clear about it:

If the value of the expression following the await operator is not a Promise, it's converted to a resolved Promise.

In our test, when we are calling render with await, JavaScript implicitly wraps the result into a promise and waits for it to be settled. Meanwhile, we already have another pending promise scheduled in the fetch function. By the time implicit awaited promise is resolved, our fetch is resolved as well, as it was scheduled earlier. So we have the correct output on the screen.

But after the latest changes, our fetch function waits for the two consecutive promises, thus data is not fully ready after implicit render promise is resolved. In fact, even in the first green test, react warned us about something going wrong with an "act warning", because actual update after fetch promise was resolved happened outside of RTL's act wrappers:

Warning: An update to UserAndPartnerView 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 */

This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act
    at UserAndPartnerView (./common-testing-library-mistakes/src/a-await-sync-methods/UserAndPartnerView.tsx:3:38)
Enter fullscreen mode Exit fullscreen mode

Now, that we know what exactly caused the error, let's update our test. RTL provides a set of methods that return promises and are resolved when an element is found. This is the perfect case to use one of these:

it('should render user info', async () => {
  render(<UserView id="bob" />)
  expect(await screen.findByText('Alice and Charlie')).not.toBeNull()
})
Enter fullscreen mode Exit fullscreen mode

Now, we don't care how many requests happen while the component is being rendered. findByText will wait for the given text to appear in the DOM.

Conclusion

You should never await for syncronous functions, and render in particular. Use the proper asyncronous utils instead:

expect(await screen.findByText('some text')).not.toBe(null)
// or
await waitFor(() => {
  expect(screen.getByText('some text')).not.toBe(null)
})
Enter fullscreen mode Exit fullscreen mode

Async methods without await

Positive case

Let's face the truth: JavaScript gives us hundreds of ways to shoot in a leg. And while async/await syntax is very convenient, it is very easy to write a call that returns a promise without an await in front of it.

Let's see how this could cause issues in our tests. I will be writing a test for the same UserView component we created in a previous example:

it('should render user info', async () => {
  render(<UserView id="alice" />)
  waitFor(() => {
    expect(screen.getByText('Alice')).not.toBeNull()
  })
})
Enter fullscreen mode Exit fullscreen mode

This test passes, and everything looks good. Indeed, for a user with an id "alice", our request should return the name "Alice".

False-positive case

Now, let's see if our test fails when we pass the incorrect id

it('should render user info', async () => {
  render(<UserView id="bob" />)
  waitFor(() => {
    expect(screen.getByText('Alice')).not.toBeNull()
  })
})
Enter fullscreen mode Exit fullscreen mode

Oops, it's still passing. But "bob"'s name should be Bob, not Alice.

Explanation

The reason is the missing await before asyncronous waitFor call. Asyncronous method call will always return a promise, which will not be awaited on its own. Jest simply calls this line and finishes the test. No assertions fail, so the test is green. But if we add await in front of waitFor, the test will fail as expected:

it('should render user info', async () => {
  render(<UserView id="bob" />)
  await waitFor(() => {
    expect(screen.getByText('Alice')).not.toBeNull()
  })
})
Enter fullscreen mode Exit fullscreen mode
Unable to find an element with the text: Alice.

Ignored nodes: comments, <script />, <style />
<body>
  <div>
    Bob
  </div>
</body>
Enter fullscreen mode Exit fullscreen mode

Conclusion

Never forget to await for async functions or return promises from the test (jest will wait for this promise to be resolved in this case). Otherwise, you may end up running tests that always pass.

Side-effects inside waitFor

How waitFor works

First of all, let's recall what is waitFor. It's an async RTL utility that accepts a callback and returns a promise. This promise is resolved as soon as the callback doesn't throw, or is rejected in a given timeout (one second by default). waitFor will call the callback a few times, either on DOM changes or simply with an interval.

Now, keeping all that in mind, let's see how side-effects inside waitFor could lead to unexpected test behavior.

Green test

Here, we have a component that renders a list of user transactions. Each list entry could be clicked to reveal more details.

const TransactionDetails = ({
  description,
  merchant,
}: {
  description?: string | null
  merchant?: string | null
}) => (
  <ul>
    {description && <li>Description: {description}</li>}
    {merchant && <li>Merchant: {merchant}</li>}
  </ul>
)

const Transactions = () => {
  const [selectedTransactionId, setSelectedTransactionId] = useState<
    string | null
  >(null)

  const transactions = useTransactionsQuery()
  if (transactions === null) return <div>Loading...</div>

  return (
    <ul>
      {transactions.map(tx => (
        <li
          key={tx.id}
          onClick={() =>
            setSelectedTransactionId(
              selectedTransactionId === tx.id ? null : tx.id,
            )
          }
        >
          <div>Id: {tx.id}</div>
          {selectedTransactionId === tx.id && (
            <TransactionDetails description={tx.description} />
          )}
        </li>
      ))}
    </ul>
  )
}
Enter fullscreen mode Exit fullscreen mode

And the test to cover this logic:

it('should render transaction details', async () => {
  render(<Transactions />)

  await waitFor(() => {
    fireEvent.click(screen.getByText('Id: one'))
    expect(screen.getByText('Description: Coffee')).not.toBeNull()
  })
})
Enter fullscreen mode Exit fullscreen mode

As the transactions list appears only after the request is done, we can't simply call screen.getByText('Id: one') because it will throw due to missing "Id: one" text. To avoid it, we put all the code inside waitFor which will retry on error. So we are waiting for the list entry to appear, clicking on it and asserting that description appears.

Hanging test

Now, let's add a bit more logic and fetch the transaction details as soon as it is clicked. Again, as in the very first example, we should not significantly change the test as the component basically stays the same. So we only want to add another assertion to make sure that the details were indeed fetched.

We will slightly change the component to fetch more data when one of the transactions is selected, and to pass fetched merchant name inside TransactionDetails. When nothing is selected, useTransactionDetailsQuery returns null, and the request is only triggered when an id is passed.

const TransactionsWithDetails = () => {
  // ...

  const transactions = useTransactionsQuery()
  const details = useTransactionDetailsQuery(selectedTransactionId)

  // ...
          <div>Id: {tx.id}</div>
          {selectedTransactionId === tx.id && (
            <TransactionDetails
              description={tx.description}
              merchant={details?.merchant}
            />
          )}
  // ...
}
Enter fullscreen mode Exit fullscreen mode

First, the user sees the list of transactions. Then, as soon as one is clicked, details are fetched and shown.

As was mentioned earlier, in our test we will only add another assertion to check that merchant name from the details is rendered:

it('should render transaction details', async () => {
  render(<TransactionsWithDetails />)

  await waitFor(() => {
    fireEvent.click(screen.getByText('Id: one'))
    expect(screen.getByText('Description: Coffee')).not.toBeNull()
    expect(screen.getByText('Merchant: Mega Mall one')).not.toBeNull()
  })
})
Enter fullscreen mode Exit fullscreen mode

When we run our updated test, we could notice that the test runner hangs. And while it's relatively easy to find the problem when we deal with a single test, it's a pain to find such a broken one in another few hundred.

Explanation

Let's figure out what is happenning here. waitFor is triggered multiple times because at least one of the assertions fails. Let's go through the sequence of calls, where each list entry represents the next waitFor call:

  1. fireEvent.click(screen.getByText('Id: one')) fails as transactions list is not yet fetched, and "Id: one" text is not at the screen.
  2. "Id: one" is present and clicked, but now expect(screen.getByText('Merchant: Mega Mall one')).not.toBeNull() fails as details are not yet fetched.
  3. The above successful fireEvent.click triggered a DOM mutation, so waitFor executes the callback once again. fireEvent.click is triggered once again, closing the transaction description, and expect(screen.getByText('Description: Coffee')).not.toBeNull() fails.

As at the third call fireEvent.click caused another DOM mutation, we stuck in 2-3 loop. Transaction details are being opened and closed over and over again with no chance for the details request to complete and to render all the needed info.

The fix for the issue is very straightforward: we simply need to move our side-effect (fireEvent.click) out of waitFor.

it('should render transaction details', async () => {
  render(<TransactionsWithDetails />)

  const transaction = await screen.findByText('Id: one'))
  fireEvent.click(transaction)

  await waitFor(() => {
    expect(screen.getByText('Description: Coffee')).not.toBeNull()
    expect(screen.getByText('Merchant: Mega Mall one')).not.toBeNull()
  })
})
Enter fullscreen mode Exit fullscreen mode

Conclusion

As waitFor is non-deterministic and you cannot say for sure how many times it will be called, you should never run side-effects inside it. Instead, wait for certain elements to appear on the screen, and trigger side-effects synchronously.

How to avoid these issues

The simplest way to stop making these mistakes is to add eslint-plugin-testing-library to your eslint.

The only thing it doesn't catch is await render, but works perfectly well for everything else.

Summary

Debugging asynchronous tests could be pretty difficult, but you could simply make your tests more failure-proof avoiding the mistakes I described above.

Unfortunately, most of the "common mistakes" articles only highlight bad practices, without providing a detailed explanation. I hope I closed this gap, and my post gave you enough details on why the above mistakes should be avoided.

And make sure you didn't miss rather old but still relevant Kent C. Dodds' Common mistakes with React Testing Library where more issues are described.

💖 💪 🙅 🚩
tipsy_dev
Aleksei Tsikov

Posted on November 21, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related