March 8, 2021

Uncovering and conquering async bugs: A trick to testing undesired effects

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:

/**
 * 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:

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:

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:

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 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.