Uncovering and conquering async bugs: A trick to testing undesired effects
Tags: react, javascript, testing, programming
Published: 2023-01-05 22:29:20

Imagine you are coding a new React component to upload a file. The component will receive a callback to notify the app if the upload has been or has not been successful. You may end with something similar to:

```tsx
/**
 * We will use the `executor` function to simulate different
 * scenarios from our tests.
 */
function UploadButton({ executor, onSuccess }) {
  return (
    <button
      type="button"
      onClick={async () => {
        await executor();

        onSuccess();
      }}
    >
      Upload
    </button>
  );
}
```

To test the most simple scenario, that the `<UploadButton />` component uses the callback once the upload is completed, we can rely on the standard approach:

```tsx
it("uses the callback to notify that the upload completed", async () => {
  const spy = jest.fn();

  render(<UploadButton executor={success} onSuccess={spy} />);

  user.click(screen.getByText("Upload"));

  await waitFor(() => {
    expect(spy).toHaveBeenCalledTimes(1);
  });
});
```

But things are more complicated if we want to ensure that the `onSuccess` callback is not executed if something unexpected happens and the process fails. The initial and most naive implementation might be to assure that the spy is never called when using the failing executor:

```tsx
it("does not use the callback if something goes wrong", async () => {
  const spy = jest.fn();

  render(<UploadButton executor={fail} onSuccess={spy} />);

  user.click(screen.getByText("Upload"));

  await waitFor(() => {
    expect(spy).toHaveBeenCalledTimes(0);
  });
});
```

**Even if it passes, the test is not checking that the spy never gets called**. Since the executor is an asynchronous process, and the spy starts with zero calls, the expectation is valid immediately. You can spot this by replacing `executor={fail}` with `executor={success}` --that’s the kind of change that should make the test have a different result, but it continues passing!

To test this scenario, we need to come up with a different approach:

```tsx
it("does not use the callback if something goes wrong", async () => {
  const asyncRender = new Promise<undefined>((resolve, reject) => {
    render(<UploadButton executor={fail} onSuccess={reject} />);

    user.click(screen.getByText("Upload"));

    setTimeout(resolve, 0);
  });

  await expect(asyncRender).resolves.toBeUndefined();
});
```

By embracing the asynchronous nature of this problem, we can wrap the rendering within a promise and use its reject function as the `onSuccess` callback. Remember that promises execute immediately but return a delayed response. To give some room for the component to fail, I use the [setTimeout](https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop#zero_delays) trick to force the process to complete pending tasks from the queue.

Now, `asyncRender` should always resolve (with an `undefined` value). If it does not, our component is having issues handling the `onSuccess` callback and calling it even when the upload fails (since it is what the failing executor does). If you change `executor={fail}` to `executor={success}`, you may see the result keeps consistent, and the test fails.

Ready to keep the conversation going? [Let's connect on Twitter](https://twitter.com/codecoolture) and continue the discussion!

Happy hacking!