Simple and complete React Native testing utilities that encourage good testing practices
—
Specialized utilities for testing React hooks in isolation with proper act wrapping, lifecycle management, and both synchronous and asynchronous rendering support.
Test React hooks in isolation without needing to create wrapper components.
/**
* Render a hook for testing in isolation
* @param hook - Hook function to test
* @param options - Hook rendering options
* @returns RenderHookResult with hook result and utilities
*/
function renderHook<Result, Props>(
hook: (props: Props) => Result,
options?: RenderHookOptions<Props>
): RenderHookResult<Result, Props>;
interface RenderHookOptions<Props> {
/** Initial props to pass to the hook */
initialProps?: Props;
/** React component wrapper (for providers) */
wrapper?: React.ComponentType<any>;
/** Enable/disable concurrent rendering */
concurrentRoot?: boolean;
}
interface RenderHookResult<Result, Props> {
/** Current hook result in a ref object */
result: { current: Result };
/** Re-render hook with new props */
rerender: (props?: Props) => void;
/** Unmount the hook */
unmount: () => void;
}Usage Examples:
import { renderHook } from "@testing-library/react-native";
test("testing useState hook", () => {
const useCounter = (initialValue = 0) => {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(prev => prev + 1);
const decrement = () => setCount(prev => prev - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
};
const { result } = renderHook(useCounter, {
initialProps: 5
});
// Initial state
expect(result.current.count).toBe(5);
// Test increment
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(6);
// Test decrement
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(5);
// Test reset
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(5);
});
test("testing useEffect hook", () => {
const useDocumentTitle = (title) => {
const [currentTitle, setCurrentTitle] = useState(title);
useEffect(() => {
setCurrentTitle(title);
}, [title]);
return currentTitle;
};
const { result, rerender } = renderHook(useDocumentTitle, {
initialProps: "Initial Title"
});
// Initial title
expect(result.current).toBe("Initial Title");
// Update title
rerender("Updated Title");
expect(result.current).toBe("Updated Title");
});Test hooks with asynchronous behavior using renderHookAsync and proper async handling.
/**
* Async version of renderHook for testing hooks with async behavior
* @param hook - Hook function to test
* @param options - Async hook rendering options
* @returns Promise resolving to RenderHookAsyncResult
*/
function renderHookAsync<Result, Props>(
hook: (props: Props) => Result,
options?: RenderHookOptions<Props>
): Promise<RenderHookAsyncResult<Result, Props>>;
interface RenderHookAsyncResult<Result, Props> {
/** Current hook result in a ref object */
result: { current: Result };
/** Re-render hook with new props asynchronously */
rerenderAsync: (props?: Props) => Promise<void>;
/** Unmount the hook asynchronously */
unmountAsync: () => Promise<void>;
}Usage Examples:
test("async hook testing", async () => {
const useAsyncData = (url) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 100));
if (url === "/error") {
throw new Error("API Error");
}
setData(`Data from ${url}`);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchData();
}, [url]);
return { data, loading, error };
};
const { result } = await renderHookAsync(useAsyncData, {
initialProps: "/api/users"
});
// Initially loading
expect(result.current.loading).toBe(true);
expect(result.current.data).toBeNull();
expect(result.current.error).toBeNull();
// Wait for data to load
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBe("Data from /api/users");
expect(result.current.error).toBeNull();
});
test("async hook error handling", async () => {
const useAsyncData = (url) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
await new Promise(resolve => setTimeout(resolve, 50));
if (url === "/error") {
throw new Error("Network error");
}
setData(`Success: ${url}`);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchData();
}, [url]);
return { data, loading, error };
};
const { result, rerenderAsync } = await renderHookAsync(useAsyncData, {
initialProps: "/api/success"
});
// Wait for successful load
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBe("Success: /api/success");
expect(result.current.error).toBeNull();
// Test error case
await rerenderAsync("/error");
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeNull();
expect(result.current.error).toBe("Network error");
});Test hooks that depend on React context by providing wrapper components.
/**
* Wrapper component type for providing context to hooks
*/
type WrapperComponent<Props = {}> = React.ComponentType<Props & { children: React.ReactNode }>;Usage Examples:
// Context setup
const ThemeContext = React.createContext({
theme: "light",
toggleTheme: () => {}
});
const ThemeProvider = ({ children, initialTheme = "light" }) => {
const [theme, setTheme] = useState(initialTheme);
const toggleTheme = () => {
setTheme(prev => prev === "light" ? "dark" : "light");
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
// Hook that uses context
const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within ThemeProvider");
}
return context;
};
test("hook with context provider", () => {
const { result } = renderHook(useTheme, {
wrapper: ({ children }) => (
<ThemeProvider initialTheme="dark">
{children}
</ThemeProvider>
)
});
// Initial theme from provider
expect(result.current.theme).toBe("dark");
// Test theme toggle
act(() => {
result.current.toggleTheme();
});
expect(result.current.theme).toBe("light");
// Toggle again
act(() => {
result.current.toggleTheme();
});
expect(result.current.theme).toBe("dark");
});
test("hook without context throws error", () => {
expect(() => {
renderHook(useTheme); // No wrapper provided
}).toThrow("useTheme must be used within ThemeProvider");
});
test("hook with multiple providers", () => {
const UserContext = React.createContext({ user: null });
const UserProvider = ({ children, user }) => (
<UserContext.Provider value={{ user }}>
{children}
</UserContext.Provider>
);
const useUserTheme = () => {
const { theme } = useTheme();
const { user } = useContext(UserContext);
return {
theme,
userTheme: user?.preferredTheme || theme,
user
};
};
const MultiProvider = ({ children }) => (
<ThemeProvider initialTheme="light">
<UserProvider user={{ name: "John", preferredTheme: "dark" }}>
{children}
</UserProvider>
</ThemeProvider>
);
const { result } = renderHook(useUserTheme, {
wrapper: MultiProvider
});
expect(result.current.theme).toBe("light");
expect(result.current.userTheme).toBe("dark");
expect(result.current.user.name).toBe("John");
});Advanced patterns for testing complex custom hooks.
/**
* Test utilities for complex hook scenarios
*/Usage Examples:
test("custom hook with dependencies", () => {
const useDebounce = (value, delay) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
};
const { result, rerender } = renderHook(useDebounce, {
initialProps: { value: "initial", delay: 500 }
});
// Initial value is set immediately
expect(result.current).toBe("initial");
// Change value
rerender({ value: "updated", delay: 500 });
// Value should still be "initial" until debounce delay
expect(result.current).toBe("initial");
// Fast-forward time
act(() => {
jest.advanceTimersByTime(500);
});
// Now value should be updated
expect(result.current).toBe("updated");
// Change delay and value again
rerender({ value: "final", delay: 200 });
expect(result.current).toBe("updated"); // Still old value
act(() => {
jest.advanceTimersByTime(200);
});
expect(result.current).toBe("final");
});
test("hook with cleanup", () => {
const useEventListener = (eventName, handler) => {
const savedHandler = useRef(handler);
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
const eventListener = (event) => savedHandler.current(event);
// Add event listener (mocked for testing)
window.addEventListener?.(eventName, eventListener);
return () => {
window.removeEventListener?.(eventName, eventListener);
};
}, [eventName]);
};
const mockAddEventListener = jest.spyOn(window, 'addEventListener').mockImplementation();
const mockRemoveEventListener = jest.spyOn(window, 'removeEventListener').mockImplementation();
const handler = jest.fn();
const { unmount, rerender } = renderHook(useEventListener, {
initialProps: { eventName: "resize", handler }
});
// Event listener should be added
expect(mockAddEventListener).toHaveBeenCalledWith("resize", expect.any(Function));
// Change event name
rerender({ eventName: "scroll", handler });
// Should remove old listener and add new one
expect(mockRemoveEventListener).toHaveBeenCalledWith("resize", expect.any(Function));
expect(mockAddEventListener).toHaveBeenCalledWith("scroll", expect.any(Function));
// Unmount should cleanup
unmount();
expect(mockRemoveEventListener).toHaveBeenCalledWith("scroll", expect.any(Function));
mockAddEventListener.mockRestore();
mockRemoveEventListener.mockRestore();
});
test("hook with reducer pattern", () => {
const useCounter = () => {
const [state, dispatch] = useReducer((state, action) => {
switch (action.type) {
case "increment":
return { count: state.count + (action.payload || 1) };
case "decrement":
return { count: state.count - (action.payload || 1) };
case "reset":
return { count: action.payload || 0 };
default:
return state;
}
}, { count: 0 });
const increment = (amount) => dispatch({ type: "increment", payload: amount });
const decrement = (amount) => dispatch({ type: "decrement", payload: amount });
const reset = (value) => dispatch({ type: "reset", payload: value });
return {
count: state.count,
increment,
decrement,
reset
};
};
const { result } = renderHook(useCounter);
// Initial state
expect(result.current.count).toBe(0);
// Increment by 1 (default)
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
// Increment by custom amount
act(() => {
result.current.increment(5);
});
expect(result.current.count).toBe(6);
// Decrement
act(() => {
result.current.decrement(2);
});
expect(result.current.count).toBe(4);
// Reset to custom value
act(() => {
result.current.reset(10);
});
expect(result.current.count).toBe(10);
// Reset to default (0)
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(0);
});Guidelines and patterns for effective hook testing.
/**
* Best practices for hook testing
*/Usage Examples:
test("testing hook with external dependencies", () => {
// Mock external dependencies
const mockFetch = jest.fn();
global.fetch = mockFetch;
const useApi = (url) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
const result = await response.json();
setData(result);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
};
// Mock successful response
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve({ id: 1, name: "Test" })
});
const { result } = renderHook(useApi, {
initialProps: "/api/test"
});
expect(result.current.loading).toBe(true);
// Wait for fetch to complete
return waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.data).toEqual({ id: 1, name: "Test" });
expect(result.current.error).toBeNull();
});
});
test("testing hook error boundaries", () => {
const useRiskyHook = (shouldThrow) => {
const [value, setValue] = useState("safe");
useEffect(() => {
if (shouldThrow) {
throw new Error("Hook error");
}
}, [shouldThrow]);
return value;
};
// Test safe usage
const { result, rerender } = renderHook(useRiskyHook, {
initialProps: false
});
expect(result.current).toBe("safe");
// Test error case - should be caught by error boundary
expect(() => {
rerender(true);
}).toThrow("Hook error");
});
test("testing hook performance", () => {
const useExpensiveCalculation = (input) => {
const expensiveFunction = useCallback((n) => {
// Simulate expensive calculation
let result = 0;
for (let i = 0; i < n; i++) {
result += i;
}
return result;
}, []);
const memoizedResult = useMemo(() => {
return expensiveFunction(input);
}, [input, expensiveFunction]);
return memoizedResult;
};
const expensiveFunctionSpy = jest.fn((n) => {
let result = 0;
for (let i = 0; i < n; i++) {
result += i;
}
return result;
});
// Test with memoization
const { result, rerender } = renderHook(useExpensiveCalculation, {
initialProps: 1000
});
const firstResult = result.current;
expect(typeof firstResult).toBe("number");
// Re-render with same props - should not recalculate
rerender(1000);
expect(result.current).toBe(firstResult);
// Re-render with different props - should recalculate
rerender(2000);
expect(result.current).not.toBe(firstResult);
});Testing hooks in concurrent mode and with Suspense boundaries.
/**
* Hook testing with concurrent features
*/Usage Examples:
test("hook with concurrent rendering", async () => {
const useConcurrentData = (query) => {
const [data, setData] = useState(null);
// Use transition for non-urgent updates
const [isPending, startTransition] = useTransition();
const updateData = (newQuery) => {
startTransition(() => {
// Expensive state update
setData(`Result for ${newQuery}`);
});
};
useEffect(() => {
updateData(query);
}, [query]);
return { data, isPending, updateData };
};
const { result } = renderHook(useConcurrentData, {
initialProps: "initial query",
concurrentRoot: true
});
await waitFor(() => {
expect(result.current.data).toBe("Result for initial query");
});
expect(result.current.isPending).toBe(false);
// Test pending state during transition
act(() => {
result.current.updateData("new query");
});
// Might be pending briefly
if (result.current.isPending) {
await waitFor(() => {
expect(result.current.isPending).toBe(false);
});
}
expect(result.current.data).toBe("Result for new query");
});
test("hook with Suspense", async () => {
const cache = new Map();
const useSuspenseData = (key) => {
if (!cache.has(key)) {
const promise = new Promise(resolve => {
setTimeout(() => resolve(`Data for ${key}`), 100);
});
cache.set(key, promise);
throw promise;
}
const data = cache.get(key);
if (data instanceof Promise) {
throw data;
}
return data;
};
const SuspenseWrapper = ({ children }) => (
<Suspense fallback={<Text>Loading...</Text>}>
{children}
</Suspense>
);
// This will initially throw and trigger Suspense
const hookPromise = renderHookAsync(useSuspenseData, {
initialProps: "test-key",
wrapper: SuspenseWrapper
});
// Wait for Suspense to resolve
const { result } = await hookPromise;
await waitFor(() => {
expect(result.current).toBe("Data for test-key");
});
});Install with Tessl CLI
npx tessl i tessl/npm-testing-library--react-native