Testing Logic and Hooks
Test pure functions and reducers directly, custom hooks with renderHook, and time-dependent code with Jest's fake clock.
const { result, rerender, unmount } = await renderHook(hookFn, { initialProps, wrapper });A unit test takes one piece of code, feeds it input and checks the output. No screen, no device, no network. These are the tests worth having most of, because they finish in milliseconds and tell you exactly which function broke.
Start with what is already pure
A function that takes values and returns a value needs nothing special: import it and assert. Reducers have the same shape. Give one a state and an action, then compare the returned state against a literal you wrote by hand. Do not build the expected value by calling the reducer again, or the test simply agrees with the bug.
Hooks need a component
A hook can only run during a render, so the testing library provides renderHook. It renders a throwaway component that calls your hook and hands back result.current. In version 14 renderHook is async, and so are the rerender and unmount functions it returns. Pass initialProps to supply arguments, and wrapper to provide a context the hook reads.
Time
Code with setTimeout inside it should never make the test wait. jest.useFakeTimers() freezes the clock and jest.advanceTimersByTime(ms) moves it forward. Wrap the advance in await act(...) when it causes a state update, so React flushes that update before your next assertion, and restore real timers afterwards.
Example
// __tests__/useDebouncedValue.test.tsx
import { act, renderHook } from '@testing-library/react-native';
import { useDebouncedValue } from '../hooks/useDebouncedValue';
describe('useDebouncedValue', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
// Hand the clock back, or a later test awaiting a real promise stalls.
jest.useRealTimers();
});
it('keeps the old value until the delay has passed', async () => {
const { result, rerender } = await renderHook(
({ value }: { value: string }) => useDebouncedValue(value, 300),
{ initialProps: { value: 'a' } },
);
expect(result.current).toBe('a');
// rerender replaces the props, like a parent re-rendering with new input.
await rerender({ value: 'ab' });
expect(result.current).toBe('a');
await act(async () => {
jest.advanceTimersByTime(299);
});
expect(result.current).toBe('a'); // one millisecond short
await act(async () => {
jest.advanceTimersByTime(1);
});
expect(result.current).toBe('ab');
});
});When to use it
- Pin down a currency or date formatter so a change in rounding cannot slip through.
- Prove a reducer returns a new object rather than mutating the state it was handed.
- Test a debounce or polling hook without making the suite wait for real seconds.
- Confirm a custom hook cleans up its subscription when the screen unmounts.
More examples
A reducer test that cannot flatter itself
Reach for this whenever state transitions live in a reducer rather than scattered useState calls. The third test is the one people skip and the one that catches the most bugs: React only re-renders when the state identity changes, so a reducer that mutates its argument produces a screen that does not update. Testing it costs two lines.
// __tests__/cartReducer.test.tsx
import { cartReducer, type CartState } from '../lib/cartReducer';
const empty: CartState = { lines: [], totalCents: 0 };
describe('cartReducer', () => {
it('adds a line and totals it', () => {
const next = cartReducer(empty, { type: 'add', sku: 'MUG', priceCents: 1200 });
// The expected state is written out, not produced by the reducer.
expect(next).toEqual({
lines: [{ sku: 'MUG', quantity: 1, priceCents: 1200 }],
totalCents: 1200,
});
});
it('increments the quantity rather than adding a second line', () => {
const once = cartReducer(empty, { type: 'add', sku: 'MUG', priceCents: 1200 });
const twice = cartReducer(once, { type: 'add', sku: 'MUG', priceCents: 1200 });
expect(twice.lines).toHaveLength(1);
expect(twice.lines[0].quantity).toBe(2);
expect(twice.totalCents).toBe(2400);
});
it('leaves the state it was given untouched', () => {
cartReducer(empty, { type: 'add', sku: 'MUG', priceCents: 1200 });
expect(empty.lines).toHaveLength(0);
// Output: empty.lines.length === 0
});
});Giving a hook the context it needs
Any hook that calls useContext needs a wrapper, otherwise it reads the context default and the test proves nothing. The second case is worth writing once per context: a hook that throws a named error when the provider is missing saves a future afternoon of debugging an undefined value three screens deep.
// __tests__/useTheme.test.tsx
import type { ReactNode } from 'react';
import { renderHook } from '@testing-library/react-native';
import { ThemeProvider, useTheme } from '../theme';
// wrapper must accept children and render them, nothing more.
function Wrapper({ children }: { children: ReactNode }) {
return <ThemeProvider value={{ mode: 'dark' }}>{children}</ThemeProvider>;
}
it('reads the mode from the nearest provider', async () => {
const { result } = await renderHook(() => useTheme(), { wrapper: Wrapper });
expect(result.current.mode).toBe('dark');
// Output: result.current.mode === 'dark'
});
it('throws when used outside a provider', async () => {
// No wrapper, so there is no context value to read.
await expect(renderHook(() => useTheme())).rejects.toThrow(
'useTheme must be used inside a ThemeProvider',
);
});Checking that the cleanup actually runs
A hook that sets an interval, subscribes to a listener or opens a connection must tear it down when the component goes away, or the app leaks one per navigation. unmount() is the only cheap way to assert that. The same shape works for a hook that adds an event subscription: unmount, fire the event, assert the callback was not called again.
// __tests__/useInterval.test.tsx
import { renderHook } from '@testing-library/react-native';
import { useInterval } from '../hooks/useInterval';
it('stops ticking once the component unmounts', async () => {
jest.useFakeTimers();
const tick = jest.fn();
const { unmount } = await renderHook(() => useInterval(tick, 1000));
// No act() needed: tick is a spy and changes no React state.
jest.advanceTimersByTime(3000);
expect(tick).toHaveBeenCalledTimes(3);
await unmount(); // runs the effect's cleanup function
jest.advanceTimersByTime(3000);
expect(tick).toHaveBeenCalledTimes(3); // unchanged
jest.useRealTimers();
// Output: 3 calls before unmount, 3 after
});
Discussion