Testing asynchronous behaviour in React

Testing asynchronous behaviour in React

Here at Moxio we're using React to build web applications. We're learning a lot about how React works under the hood to write correct and efficient code.

When writing Components for a web application you're going to have to deal with asynchronousness. A common pattern is that you want to load some data. If you're writing a custom hook to load data you will want to make sure it behaves as intended.

This article covers some things we've learned about testing asynchronousness in React.

We're going to be looking at hooks mostly but the principles apply to Components as well. We're using renderHook from the react-hooks-testing-library to test our hooks. We use jest as a test runner and assert for assertions. The code examples use typescript, but hopefully nothing too scary.

A basic example

Let's say we have a hook that looks like this:


import React from "react";

const mockData = "abcdefghijklmnopqrstuvwxyz".split("");

export const useData = (): {
	data: null | string[];
	loading: boolean;
	loadMore: () => void;
} => {

	const [ data, setData ] = React.useState(null);
	const [ loading, setLoading ] = React.useState(true);

	React.useEffect(() => {
		setTimeout(() => {
			setData(mockData.slice(0, 10));
			setLoading(false);
		});
	}, []);

	const loadMore = () => {
		setLoading(true);
		setTimeout(() => {
			setData(mockData.slice(0, data ? data.length + 10 : 10));
			setLoading(false);
		});
	};

	return {
		data,
		loading,
		loadMore,
	};
};

The implementation is a mock implementation, but it has the same behaviours that a real data loader will have.

We return data: a list of strings, that is null initially. A loading flag. And a function for loading more data.

Dealing with unmount

Let's start with our first test. We want to test that loading is true initially:


it("is initially loading", () => {
	const { result } = renderHook(() => useData());
	assert.strictEqual(result.current.loading, true);
});

This test looks reasonable, and it passes, except we get a big fat warning:

Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.

What does this mean?

This warning occurs because we're updating our state after the hook (or Component) has been unmounted; in this case after our test has completed. This is because we're updating state in a setTimeout. The correct fix is to check if we're still mounted before calling the state update. For the purposes of this example I will simply add a wait to the testcase:


it("is initially loading", async () => {
	const { result, waitFor } = renderHook(() => useData());
	assert.strictEqual(result.current.loading, true);
	await waitFor(() => result.current.loading === false); // wait to avoid warning about state update after unmount
});

We're using async+await and waitFor to wait for the asynchronous state update. The test passes without any warnings.

Now we have all the pieces to add a second test:


it("initially loads the first slice", async () => {
	const { result, waitFor } = renderHook(() => useData());
	await waitFor(() => result.current.loading === false);
	assert.strictEqual(result.current.loading, false);
	assert.strictEqual(result.current.data?.length, 10);
});

We test that loading becomes false and we get 10 items. This test passes.

Using act

Let's add another test. We want to test that when we call loadMore, loading eventually becomes false again, and we get more items.


it("loads the second slice on loadMore", async () => {
	const { result, waitFor } = renderHook(() => useData());
	await waitFor(() => result.current.loading === false);
	result.current.loadMore(); // problem
	await waitFor(() => result.current.loading === false);
	assert.strictEqual(result.current.data?.length, 20);
});

This test fails. data.length is 10 instead of 20. What happened here? The short answer is that we didn't wait. We didn't wait for loading to first become true.

We could add a waitFor for loading to become true but React has a better solution. React actually wants us to use act. Wrapping actions in act allows React to guarantee that it has completed all state updates.

So if we change the line to:


act(() => result.current.loadMore());

Now the test passes.

@typescript-eslint/no-floating-promises

This is a good moment to talk about quality of life. Working with asynchronous processes can be tricky. We can forget to wait by returning too soon, or we can forget to use the result.

To avoid problems like this at Moxio we use @typescript-eslint/no-floating-promises. It will warn you when there is a Promise that isn't used in some way.

You can roughly do 4 things with a Promise:

  • return the Promise.
  • await the Promise.
  • use the result of the Promise in a .then.
  • or explicitly don't wait for the Promise.

Which of these is correct in this case? act is actually a little special. It can both return a Promise, or not. You would expect in this case that we would want to await the act call, but act knows that it's not handling an asynchronous process (loadMore doesn't return a Promise). So it will actually complain if you await it in this case:

Warning: Do not await the result of calling act(...) with sync logic, it is not a Promise.

So the solution that appeases this lint rule is to use void to indicate that we don't want to wait:


void act(() => result.current.loadMore());

How to wait

renderHook gives us 3 functions to wait:

  • waitFor: waits for a specified condition.
  • waitForNextUpdate: waits for the hook to rerender.
  • waitForValueToChange: waits for the value to change (similar to how deps work in useMemo).

I've experimented with these 3 functions. You probably only want to use waitFor. The reasoning is that the other 2 functions are not clear about what we are waiting for exactly.

You might think that it's not a problem because it doesn't matter what you're waiting for, but it's good to think ahead. Maybe in the future the code-under-test is modified and now your wait is hiding a bug. Or maybe you expect two things to happen in one re-render. It's probably a good idea to be really clear about what your expectations are.

So far I haven't found a case where waitForValueToChange or waitForNextUpdate is a better fit than waitFor.

A more complex example

Let's look at a more complex example. This tests our knowledge of how all the parts come together.

We're modifying our hook in 3 ways:

  • We're modifying the behaviour of loadMore so that it ignores additional loadMore calls when it's already loading.
  • loadMore now returns a promise.
  • We're replacing setTimeout with a fetcher Promise; mainly so we can mock it in our test. If you're using a real data loader you can likely mock it in a similar way, ie through a Context.

export const useDataWithFetcher = (fetcher: () => Promise): {
	data: null | string[];
	loading: boolean;
	loadMore: () => Promise;
} => {

	const [ data, setData ] = React.useState(null);
	const [ loading, setLoading ] = React.useState(true);

	React.useEffect(() => {
		void fetcher().then(() => {
			setData(mockData.slice(0, 10));
			setLoading(false);
		});
	}, [ fetcher ]);

	const loadMore = () => {
		if (loading === false) {
			setLoading(true);
			return fetcher().then(() => {
				setData(mockData.slice(0, data ? data.length + 10 : 10));
				setLoading(false);
			});
		}
		return Promise.resolve();
	};

	return {
		data,
		loading,
		loadMore,
	};
};

For our test we need to be able to call loadMore a second time before the first loadMore call has completed. So we need a slightly more involved setup:


const createControlledFetcher = (numPromises: number) => {
	const promises: Promise[] = [];
	const resolvers: ((value?: unknown) => void)[] = [];
	for (let i = 0; i < numPromises; i += 1) {
		promises.push(new Promise((resolve) => {
			resolvers.push(resolve);
		}));
	}
	return {
		fetcher: createFetcherForPromises(promises),
		resolvers: resolvers,
	};
};
const createFetcherForPromises = (promises: Promise[]): () => Promise => {
	let index = 0;
	return () => {
		if (index >= promises.length) {
			throw new Error("Too many requests");
		}
		index += 1;
		return promises[index - 1];
	};
};

And now we want to test the new behaviour of loadMore:


it("ignores a loadMore while already fetching", async () => {
	const controlledFetcher = createControlledFetcher(2);
	const { result, waitFor } = renderHook(() => useDataWithFetcher(controlledFetcher.fetcher));
	controlledFetcher.resolvers[0](); // resolve first fetcher promise
	await waitFor(() => result.current.loading === false);
	void act(() => void result.current.loadMore()); // first loadMore
	void act(() => void result.current.loadMore()); // second loadMore
	await waitFor(() => result.current.loading === true);
	controlledFetcher.resolvers[1](); // resolve second fetcher promise
	await waitFor(() => result.current.loading === false);
	assert.strictEqual(result.current.data?.length, 20); // expect 2 slices, not 3
});

This test passes and tests the new behaviour. Let's look at the details and see if we understand why it has to be this way.


void act(() => void result.current.loadMore()); // first loadMore

We're calling loadMore here but why are we not doing: ?


await act(() => result.current.loadMore()); // first loadMore

// or

await act(async () => {
	await result.current.loadMore();
});

// these are equivalent

The answer is that, if we use await we're going to be stuck. That promise is never going to resolve, because we were going to resolve the promise ourselves. So we can't await.

Now if we use void we get a warning because loadMore now returns a Promise:

Warning: You called act(async () => ...) without await. This could lead to unexpected testing behaviour, interleaving multiple act calls and mixing their scopes. You should - await act(async () => ...);

This is why we need to first void the loadMore Promise, and _then_ void the act call.

This may seem excessive, but it is correct. We're taking control of the asynchronous process. We resolve the Promise and then wait for the state update. Note that resolving the Promise doesn't need to be wrapped in an act call.

It's also good to understand that any warnings that React throws about unmounted state updates or missing act wraps is because it notices that 'things' happen after your test is completed. If you add the correct wait conditions you can always avoid these warnings.

Memo'ing

You might have noticed that our hook triggers more re-renders than is strictly necessary. Our loadMore function also changes every time this hook is re-rendered; which might trigger additional re-renders down the line.

It's possible to avoid this by using useMemo (or useCallback) and using references (useRef).

I would recommend that for hooks that are used a lot you should test their re-render behaviour to get the best performance.

To give you an idea of what is possible, here is the same hook fully memo'd:


export const useDataWithMemo = (fetcher: () => Promise): {
	data: null | string[];
	loading: boolean;
	loadMore: () => Promise;
} => {

	const [ internalState, setInternalState ] = React.useState<{
		data: null | string[];
		loading: boolean;
	}>({
		data: null,
		loading: true,
	});
	const internalStateRef = React.useRef(internalState);
	internalStateRef.current = internalState;
	const fetcherRef = React.useRef(fetcher);
	fetcherRef.current = fetcher;

	React.useEffect(() => {
		void fetcherRef.current().then(() => {
			// TODO check that we're still mounted
			setInternalState({
				data: mockData.slice(0, 10),
				loading: false,
			});
		});
	}, []);

	const loadMore = React.useCallback(() => {
		if (internalStateRef.current.loading === false) {
			setInternalState({
				...internalStateRef.current,
				loading: true,
			});
			return fetcherRef.current().then(() => {
				// TODO check that we're still mounted
				setInternalState({
					data: mockData.slice(0, internalStateRef.current.data ? internalStateRef.current.data.length + 10 : 10),
					loading: false,
				});
			});
		}
		return Promise.resolve();
	}, []);

	return React.useMemo(() => {
		return {
			...internalState,
			loadMore,
		};
	}, [ internalState, loadMore ]);
};

Conclusion

Hopefully this article has shown you how to write your tests in such a way that you can be confident that your code works as intended.

Frits van Campen

Frits van Campen