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