Pruebas de hooks personalizados
Prueba hooks personalizados de forma aislada usando renderHook -- verifica cambios de state, comportamiento asíncrono y dependencias de context.
Receta
Tarjeta de referencia rápida — lista para copiar y pegar.
import { renderHook, act, waitFor } from "@testing-library/react";
// Test a synchronous hook
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
// Test an async hook
const { result } = renderHook(() => useFetch("/api/users"));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.data).toEqual([{ id: 1, name: "Alice" }]);
});
// Test a hook with context
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
);
const { result } = renderHook(() => useAuth(), { wrapper });Cuándo usarlo: Cuando tienes un hook personalizado con lógica que merece probarse de forma independiente de cualquier componente en particular.
Ejemplo funcional
// src/hooks/use-debounce.ts
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}// src/hooks/use-debounce.test.ts
import { renderHook, act } from "@testing-library/react";
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import { useDebounce } from "./use-debounce";
describe("useDebounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("returns the initial value immediately", () => {
const { result } = renderHook(() => useDebounce("hello", 500));
expect(result.current).toBe("hello");
});
it("debounces value changes", () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: "hello", delay: 500 } }
);
// Update the value
rerender({ value: "world", delay: 500 });
expect(result.current).toBe("hello"); // not yet updated
// Fast-forward time
act(() => {
vi.advanceTimersByTime(500);
});
expect(result.current).toBe("world"); // now updated
});
it("resets timer on rapid value changes", () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: "a", delay: 300 } }
);
rerender({ value: "ab", delay: 300 });
act(() => vi.advanceTimersByTime(200));
rerender({ value: "abc", delay: 300 });
act(() => vi.advanceTimersByTime(200));
// Only 200ms since last change -- still debouncing
expect(result.current).toBe("a");
act(() => vi.advanceTimersByTime(100));
expect(result.current).toBe("abc");
});
});// src/hooks/use-fetch.ts
import { useState, useEffect } from "react";
interface UseFetchResult<T> {
data: T | null;
error: string | null;
loading: boolean;
}
export function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function fetchData() {
setLoading(true);
setError(null);
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!cancelled) setData(json);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : "Unknown error");
} finally {
if (!cancelled) setLoading(false);
}
}
fetchData();
return () => { cancelled = true; };
}, [url]);
return { data, error, loading };
}// src/hooks/use-fetch.test.ts
import { renderHook, waitFor } from "@testing-library/react";
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import { useFetch } from "./use-fetch";
describe("useFetch", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
it("starts in loading state", () => {
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify([]), { status: 200 })
);
const { result } = renderHook(() => useFetch("/api/users"));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBeNull();
expect(result.current.error).toBeNull();
});
it("returns data on success", async () => {
const users = [{ id: 1, name: "Alice" }];
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify(users), { status: 200 })
);
const { result } = renderHook(() => useFetch("/api/users"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(users);
expect(result.current.error).toBeNull();
});
it("returns error on failure", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(null, { status: 500 })
);
const { result } = renderHook(() => useFetch("/api/users"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBe("HTTP 500");
expect(result.current.data).toBeNull();
});
it("refetches when URL changes", async () => {
const mockFetch = vi.mocked(fetch);
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ id: 1 }), { status: 200 })
);
const { result, rerender } = renderHook(
({ url }) => useFetch(url),
{ initialProps: { url: "/api/users/1" } }
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(mockFetch).toHaveBeenCalledWith("/api/users/1");
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ id: 2 }), { status: 200 })
);
rerender({ url: "/api/users/2" });
await waitFor(() => expect(result.current.data).toEqual({ id: 2 }));
expect(mockFetch).toHaveBeenCalledWith("/api/users/2");
});
});Lo que demuestra esto:
renderHookpara probar hooks fuera de componentesrerendercon nuevas props para provocar la reejecución del hook- Fake timers para pruebas de debounce
- Simulación de
fetchpara pruebas de hooks asíncronos waitForpara esperar actualizaciones de state asíncronas
Análisis profundo
Cómo funciona
renderHookcrea un componente wrapper mínimo que llama a tu hook y exponeresult.current-- el valor de retorno actualresult.currentes un objeto similar a una ref -- siempre refleja el valor de retorno más reciente después de los re-renderizadosact()es necesario cuando provocas actualizaciones de state desde fuera del ciclo de renderizado de React (por ejemplo, llamando a métodos devueltos por hooks)rerender()vuelve a renderizar el componente wrapper con nuevas props, lo que vuelve a ejecutar el hook con argumentos actualizados- La opción
wrapperte permite envolver el hook en proveedores como context, routers o query clients
Variaciones
Probar un hook con context:
// src/hooks/use-theme.test.tsx
import { renderHook, act } from "@testing-library/react";
import { ThemeProvider, useTheme } from "./theme-context";
it("toggles theme", () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current.theme).toBe("light");
act(() => {
result.current.toggleTheme();
});
expect(result.current.theme).toBe("dark");
});Probar la limpieza al desmontar:
it("cancels pending requests on unmount", async () => {
vi.mocked(fetch).mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 5000))
);
const { unmount } = renderHook(() => useFetch("/api/slow"));
unmount();
// No state update errors should occur after unmount
// The cancelled flag in the hook prevents setState after unmount
});Notas de TypeScript
// Typing renderHook with initialProps
const { result, rerender } = renderHook(
({ url }: { url: string }) => useFetch<User[]>(url),
{ initialProps: { url: "/api/users" } }
);
// rerender expects the same props type: { url: string }
// result.current is typed as the hook's return type
const data: User[] | null = result.current.data;Errores comunes
-
Olvidar
act()para actualizaciones síncronas de state -- Llamar aresult.current.increment()sinact()provoca una advertencia y puede no actualizarresult.current. Solución: Envuelve las llamadas síncronas que provocan actualizaciones de state enact(). -
Leer un
result.currentobsoleto -- Desestructurarconst { count } = result.currentcaptura una instantánea. Solución: Lee siempre directamente desderesult.currentdespués de los cambios de state. -
Probar hooks que solo funcionan en componentes -- Los hooks que usan
useContextlanzan un error sin un proveedor. Solución: Pasa una opciónwrapperarenderHook. -
Hooks asíncronos sin
waitFor-- Si un hook provoca un efecto asíncrono, la prueba puede terminar antes de que se actualice el state. Solución: UsawaitForofindBypara esperar el state esperado. -
Fake timers y código asíncrono --
vi.useFakeTimers()puede interferir conwaitFory las Promises. Solución: Si usas fake timers con hooks asíncronos, llama avi.advanceTimersByTime()dentro deact()y asegúrate de que las Promises se resuelvan.
Alternativas
| Alternativa | Úsala cuando | No la uses cuando |
|---|---|---|
| Probar a través de un componente | El hook es simple y está estrechamente acoplado a un componente específico | El hook se reutiliza en muchos componentes |
| Funciones play de Storybook | Quieres probar hooks mediante interacciones de componentes en stories | Necesitas pruebas unitarias aisladas con mocks |
| Prueba de integración | El hook interactúa con APIs externas que quieres probar en conjunto | Quieres pruebas rápidas y aisladas |
Preguntas frecuentes
¿Qué es renderHook y cuándo debería usarlo?
renderHookcrea un componente wrapper mínimo para probar hooks de forma aislada.- Úsalo cuando un hook tiene lógica que merece probarse de forma independiente de cualquier componente en particular.
- Para hooks simples estrechamente acoplados a un componente, prueba a través del componente en su lugar.
¿Por qué necesito act() al probar actualizaciones síncronas de state en hooks?
Llamar a una función que actualiza el state como result.current.increment() fuera del ciclo de renderizado de React requiere act() para aplicar las actualizaciones. Sin él, result.current puede no reflejar el state más reciente.
Error común: ¿Qué pasa si desestructuro result.current?
La desestructuración captura una instantánea en ese momento:
const { count } = result.current; // stale after state changesLee siempre directamente desde result.current después de los cambios de state para obtener el valor más reciente.
¿Cómo pruebo un hook que requiere un proveedor de context?
Pasa una opción wrapper a renderHook:
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
);
const { result } = renderHook(() => useAuth(), { wrapper });¿Cómo pruebo un hook con argumentos que cambian?
Usa rerender con nuevas props:
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: "hello" } }
);
rerender({ value: "world" });Error común: ¿Por qué vi.useFakeTimers() hace que waitFor se quede colgado?
Los fake timers congelan el intervalo de sondeo que usa waitFor. Avanza los temporizadores dentro de act() antes de usar waitFor, o usa vi.useFakeTimers({ shouldAdvanceTime: true }).
¿Cómo simulo fetch para probar un hook asíncrono?
vi.stubGlobal("fetch", vi.fn());
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify(data), { status: 200 })
);¿Cómo pruebo el comportamiento de limpieza cuando un hook se desmonta?
Usa la función unmount de renderHook:
const { unmount } = renderHook(() => useFetch("/api/slow"));
unmount();
// Verify no state update errors occur after unmount¿Cómo tipar renderHook con initialProps en TypeScript?
const { result, rerender } = renderHook(
({ url }: { url: string }) => useFetch<User[]>(url),
{ initialProps: { url: "/api/users" } }
);
// rerender expects the same props type: { url: string }¿Cuándo debería probar un hook de forma aislada frente a través de un componente?
- Prueba de forma aislada cuando el hook se reutiliza en muchos componentes.
- Prueba a través de un componente cuando el hook es simple y está estrechamente acoplado.
- Usa
renderHookpara hooks con lógica compleja como debounce, obtención de datos o máquinas de estado.
¿Cómo uso fake timers para probar un hook de debounce?
vi.useFakeTimers();
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: "a" } }
);
rerender({ value: "b" });
act(() => vi.advanceTimersByTime(500));
expect(result.current).toBe("b");
vi.useRealTimers();¿Qué devuelve result.current para un hook como useFetch?
Devuelve el tipo de retorno del hook. Para useFetch<T>, sería { data: T | null; error: string | null; loading: boolean }. TypeScript lo infiere automáticamente.
Relacionado
- Fundamentos de React Testing Library — conceptos básicos de renderizado y consultas
- Pruebas de comportamiento asíncrono — waitFor, temporizadores y suspense
- Pruebas de context y gestión de state — probar hooks con context y stores
- Simulación en pruebas — simular fetch y otras dependencias