Testing Hooks and Async Code

renderHook() renders a throwaway component that calls a hook and exposes its latest return value as result.current. BookNest's data hooks are TanStack Query 79,069 calls (Query and Zustand), which need a QueryClient and a network; the test supplies a fresh client in a wrapper and a jest.spyOn() stand-in for fetch:

__tests__/books-api.test.tsx (excerpt)JavaScript
// A fresh client per test: no retries, and no garbage-collection timer left running
function freshClient() {
  const client = new QueryClient({
    defaultOptions: { queries: { retry: false, gcTime: Infinity } } });
  return ({ children }: { children: ReactNode }) =>
    <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
const reply = (body: unknown, status = 200) => jest.spyOn(globalThis, 'fetch')
  .mockResolvedValue(new Response(JSON.stringify(body), { status }));
const useBook = (id: number) => useQuery({ queryKey: keys.book(id), queryFn: () => getBook(id) });
afterEach(() => jest.restoreAllMocks());
test('useQuery goes from pending to the book', async () => {
  const fetchMock = reply({ ...data.books[2], favorite: false });
  const { result } = await renderHook(() => useBook(3), { wrapper: freshClient() });
  expect(result.current.isPending).toBe(true);
  await waitFor(() => expect(result.current.isSuccess).toBe(true));
  expect(result.current.data?.title).toBe('Salt and Saffron');
  expect(fetchMock.mock.calls[0][0]).toMatch(/\/books\/3$/);
});

waitFor() re-runs its callback every 50 ms until it passes or a second is up, and findByText() is getByText() inside waitFor(); the file's third test uses it to wait for The Quiet Harbor in the whole catalog tab. A second test replies with status 500 and expects GET /books/3 failed: 500, which needs retry: false: TanStack Query otherwise retries three times, and the error arrives after waitFor() gives up. Without gcTime: Infinity, runs ended with Jest 62,362 did not exit one second after the test run has completed, because each client left a five-minute garbage-collection timer running. For code built on timers, use jest.useFakeTimers() and jest.advanceTimersByTime() instead of waiting.