Adding Async Unit Tests When Fetching Data
It’s not always clear how to test code that executes asynchronously.
Testing asynchronous code can be tricky. If a function is supposed to return data when it’s finished running but doesn’t do so synchronously, you’ll probably want to test that the result is ready and available. That’s where async unit tests come in!
But there are other use cases for async unit tests that don’t involve fetching data from an API or external server. For example, writing tests for functions that call setTimeout() or JavaScript timers can be tricky because the callback won’t run immediately.
If your code includes any asynchronous operations like these (and almost all JavaScript does), you should write at least one test with them included so you don’t forget about them later on when refactoring your app.
For example, in a component that fetches data, you might have some code like this:
if (isLoading) return <p>Loading...</p>;
it("On initial load, loader is displayed", () => {
render(<Destinations />);
const loader = screen.getByText(/loading/i);
expect(loader).toBeInTheDocument();
});
You would normally tend to do a fetch in a useEffect when the component mounts like the below example:
useEffect(() => {
// Allows us to intercept an API request so we can cancel
anytime - sending signal in fetch will destroy immediately
const controller = new AbortController();
const { signal } = controller;
const fetchData = async () => {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users",
{
signal,
}
).then((res) => res.json());
setData(response);
setIsLoading(false);
} catch (err) {
if (err instanceof Error) {
if (err.name === "AbortError") {
console.log("api request cancelled");
}
} else {
console.log("unknown error");
}
}
};
fetchData();
// cleanup abort controller
return () => {
controller.abort();
};
}, []);
The fetch
method is async.
The fetch method is async. This can be hard to wrap your head around at first, so it’s important to understand what that means. Essentially, the fetch() method returns a promise which indicates whether or not the request has been completed successfully and if so, what type of response has been received (i.e., a success or failure).
We need a better way to test errors.
One of the biggest benefits of testing async units is that you can easily test for errors.
You have several options here:
Use the waitFor
helper to wait until a promise resolves or rejects. If it rejects, you get access to an error object with helpful information about what went wrong.
Use the expect
helper to specify what kind of value you expect as the result of a promise being resolved. If this doesn’t pan out as expected, you’ll be notified right away in your tests. (If there’s any confusion over how to use these two methods together—or if the documentation is unclear—check out my article on how they work.)
Accessing properties directly on an error object will tell you exactly where things went wrong and why!
The waitFor
helper can help with this too.
React Testing Library also has a waitFor
helper that you can use to wait for changes to the DOM.
The syntax is similar to the wait
helper:
it("renders trendingDestinations list", async () => {
render(<Destinations />);
await waitFor(() => {
const trendingDestinations = screen.getAllByRole("list")[0];
expect(trendingDestinations).toBeInTheDocument();
});
});
it("renders correct flight list data", async () => {
render(<Destinations />);
await waitFor(() => {
const Gwenborough = screen.getByText(/Gwenborough/i);
expect(Gwenborough).toBeInTheDocument();
});
});
This same pattern works for testing with Axios, or any async HTTP-calling library.
In the previous section, we discussed how to use the waitFor
helper to make sure that our tests were testing what they should be. This same pattern works for testing with Axios, or any async HTTP-calling library.
The code below shows how we can create a test using waitFor
which will wait until our data has loaded before running further assertions.
There are some helpful utilities from @testing-library/react to test an async request in a component.
First, let’s take a look at the wait
and waitFor
helpers. These two should be used together in order to test an async request in a component. Here’s what that might look like:
import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import Destinations from "..";
import userEvent from "@testing-library/user-event";
Conclusion
We’ve gone over how to write tests for asynchronous code and the edge cases that can make it tricky. You might be tempted to just use a fake API and not worry about getting into the weeds of this problem, but I would caution against that. Tests are still important even if they only test a small number of cases and don’t cover every possible scenario. You should use tools like waitFor
as much as possible—it lets you focus on other parts of your app while knowing that your tests will catch any major bugs in any async code paths before they go out into production!
Check out this video that shows this example or check out the Imran Codes Youtube Channel!