Simple and complete React hooks testing utilities that encourage good testing practices.
—
Utilities for testing asynchronous hook behavior, including waiting for updates, value changes, and condition fulfillment. These utilities are essential for testing hooks that perform async operations or have delayed effects.
Waits for a condition to become true, with configurable timeout and polling interval. Useful for testing hooks that have async side effects or delayed updates.
/**
* Wait for a condition to become true
* @param callback - Function that returns true when condition is met, or void (treated as true)
* @param options - Configuration for timeout and polling interval
* @returns Promise that resolves when condition is met
* @throws TimeoutError if condition is not met within timeout
*/
waitFor(callback: () => boolean | void, options?: WaitForOptions): Promise<void>;
interface WaitForOptions {
/** Polling interval in milliseconds (default: 50ms, false disables polling) */
interval?: number | false;
/** Timeout in milliseconds (default: 1000ms, false disables timeout) */
timeout?: number | false;
}Usage Examples:
import { renderHook } from "@testing-library/react-hooks";
import { useState, useEffect } from "react";
function useAsyncData(url: string) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
setError(null);
fetch(url)
.then(response => response.json())
.then(data => {
setData(data);
setLoading(false);
})
.catch(error => {
setError(error);
setLoading(false);
});
}, [url]);
return { data, loading, error };
}
test("async data loading", async () => {
// Mock fetch
global.fetch = jest.fn().mockResolvedValue({
json: () => Promise.resolve({ id: 1, name: "Test Data" })
});
const { result, waitFor } = renderHook(() => useAsyncData("/api/data"));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
// Wait for loading to complete
await waitFor(() => !result.current.loading);
expect(result.current.loading).toBe(false);
expect(result.current.data).toEqual({ id: 1, name: "Test Data" });
expect(result.current.error).toBe(null);
});
// Wait for specific condition
test("wait for specific value", async () => {
const { result, waitFor } = renderHook(() => useAsyncData("/api/data"));
// Wait for data to be loaded
await waitFor(() => result.current.data !== null);
expect(result.current.data).toBeTruthy();
});
// Custom timeout and interval
test("custom wait options", async () => {
const { result, waitFor } = renderHook(() => useAsyncData("/api/slow"));
// Wait with custom timeout and interval
await waitFor(
() => !result.current.loading,
{ timeout: 5000, interval: 100 }
);
expect(result.current.loading).toBe(false);
});Waits for a selected value to change from its current value. Useful when you want to wait for a specific piece of state to update.
/**
* Wait for a selected value to change from its current value
* @param selector - Function that returns the value to monitor
* @param options - Configuration for timeout and polling interval
* @returns Promise that resolves when the value changes
* @throws TimeoutError if value doesn't change within timeout
*/
waitForValueToChange(
selector: () => unknown,
options?: WaitForValueToChangeOptions
): Promise<void>;
interface WaitForValueToChangeOptions {
/** Polling interval in milliseconds (default: 50ms, false disables polling) */
interval?: number | false;
/** Timeout in milliseconds (default: 1000ms, false disables timeout) */
timeout?: number | false;
}Usage Examples:
function useCounter() {
const [count, setCount] = useState(0);
const incrementAsync = async () => {
await new Promise(resolve => setTimeout(resolve, 100));
setCount(prev => prev + 1);
};
return { count, incrementAsync };
}
test("wait for value to change", async () => {
const { result, waitForValueToChange } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
// Start async operation
result.current.incrementAsync();
// Wait for count to change
await waitForValueToChange(() => result.current.count);
expect(result.current.count).toBe(1);
});
// Wait for nested property changes
function useUserProfile(userId: string) {
const [profile, setProfile] = useState({ name: "", email: "", loading: true });
useEffect(() => {
fetchUserProfile(userId).then(data => {
setProfile({ ...data, loading: false });
});
}, [userId]);
return profile;
}
test("wait for nested property change", async () => {
const { result, waitForValueToChange } = renderHook(() =>
useUserProfile("123")
);
expect(result.current.loading).toBe(true);
// Wait for loading state to change
await waitForValueToChange(() => result.current.loading);
expect(result.current.loading).toBe(false);
expect(result.current.name).toBeTruthy();
});Waits for the next hook update/re-render, regardless of what causes it. This is useful when you know an update will happen but don't know exactly what will change.
/**
* Wait for the next hook update/re-render
* @param options - Configuration for timeout
* @returns Promise that resolves on the next update
* @throws TimeoutError if no update occurs within timeout
*/
waitForNextUpdate(options?: WaitForNextUpdateOptions): Promise<void>;
interface WaitForNextUpdateOptions {
/** Timeout in milliseconds (default: 1000ms, false disables timeout) */
timeout?: number | false;
}Usage Examples:
function useWebSocket(url: string) {
const [data, setData] = useState(null);
const [connected, setConnected] = useState(false);
useEffect(() => {
const ws = new WebSocket(url);
ws.onopen = () => setConnected(true);
ws.onmessage = (event) => setData(JSON.parse(event.data));
ws.onclose = () => setConnected(false);
return () => ws.close();
}, [url]);
return { data, connected };
}
test("websocket updates", async () => {
const mockWebSocket = {
onopen: null,
onmessage: null,
onclose: null,
close: jest.fn()
};
global.WebSocket = jest.fn(() => mockWebSocket);
const { result, waitForNextUpdate } = renderHook(() =>
useWebSocket("ws://localhost:8080")
);
expect(result.current.connected).toBe(false);
// Simulate connection
mockWebSocket.onopen();
// Wait for next update (connection state change)
await waitForNextUpdate();
expect(result.current.connected).toBe(true);
// Simulate message
mockWebSocket.onmessage({ data: JSON.stringify({ message: "Hello" }) });
// Wait for next update (data change)
await waitForNextUpdate();
expect(result.current.data).toEqual({ message: "Hello" });
});
// With custom timeout
test("wait for update with timeout", async () => {
const { waitForNextUpdate } = renderHook(() => useWebSocket("ws://test"));
// Wait with longer timeout
await waitForNextUpdate({ timeout: 2000 });
});All async utilities can throw TimeoutError when operations don't complete within the specified timeout:
import { renderHook } from "@testing-library/react-hooks";
function useNeverUpdating() {
const [value] = useState("static");
return value;
}
test("timeout error handling", async () => {
const { result, waitForValueToChange } = renderHook(() => useNeverUpdating());
// This will throw TimeoutError after 100ms
await expect(
waitForValueToChange(() => result.current, { timeout: 100 })
).rejects.toThrow("Timed out");
});
test("no timeout with false", async () => {
const { result, waitFor } = renderHook(() => useNeverUpdating());
// This would wait forever, but we'll resolve it manually
const waitPromise = waitFor(() => false, { timeout: false });
// In real tests, you'd trigger the condition to become true
// For this example, we'll just verify the promise doesn't reject immediately
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Should not timeout")), 100)
);
// The wait should not timeout
await expect(Promise.race([waitPromise, timeoutPromise])).rejects.toThrow("Should not timeout");
});You can combine multiple async utilities for complex testing scenarios:
function useComplexAsync() {
const [step, setStep] = useState(1);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
if (step === 1) {
setTimeout(() => setStep(2), 100);
} else if (step === 2) {
fetchData()
.then(setData)
.catch(setError)
.finally(() => setStep(3));
}
}, [step]);
return { step, data, error };
}
test("complex async flow", async () => {
const { result, waitFor, waitForValueToChange, waitForNextUpdate } =
renderHook(() => useComplexAsync());
expect(result.current.step).toBe(1);
// Wait for step to change to 2
await waitForValueToChange(() => result.current.step);
expect(result.current.step).toBe(2);
// Wait for data or error
await waitFor(() =>
result.current.data !== null || result.current.error !== null
);
// Wait for final step
await waitForValueToChange(() => result.current.step);
expect(result.current.step).toBe(3);
});class TimeoutError extends Error {
constructor(util: Function, timeout: number);
}Install with Tessl CLI
npx tessl i tessl/npm-testing-library--react-hooks