Pruebas de hooks personalizados
Prueba hooks personalizados de forma aislada usando renderHook -- verifica cambios de state, comportamiento asíncrono y dependencias de context.
Busca en todas las páginas de la documentación
Prueba hooks personalizados de forma aislada usando renderHook -- verifica cambios de state, comportamiento asíncrono y dependencias de context.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
// 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:
renderHook para probar hooks fuera de componentesrerender con nuevas props para provocar la reejecución del hookfetch para pruebas de hooks asíncronoswaitFor para esperar actualizaciones de state asíncronasrenderHook crea un componente wrapper mínimo que llama a tu hook y expone result.current -- el valor de retorno actualresult.current es 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 actualizadoswrapper te permite envolver el hook en proveedores como context, routers o query clientsProbar 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
});// 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;Olvidar act() para actualizaciones síncronas de state -- Llamar a result.current.increment() sin act() provoca una advertencia y puede no actualizar result.current. Solución: Envuelve las llamadas síncronas que provocan actualizaciones de state en act().
Leer un result.current obsoleto -- Desestructurar const { count } = result.current captura una instantánea. Solución: Lee siempre directamente desde result.current después de los cambios de state.
Probar hooks que solo funcionan en componentes -- Los hooks que usan useContext lanzan un error sin un proveedor. Solución: Pasa una opción wrapper a renderHook.
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: Usa waitFor o findBy para esperar el state esperado.
Fake timers y código asíncrono -- vi.useFakeTimers() puede interferir con waitFor y las Promises. Solución: Si usas fake timers con hooks asíncronos, llama a vi.advanceTimersByTime() dentro de act() y asegúrate de que las Promises se resuelvan.
| 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 |
renderHook crea un componente wrapper mínimo para probar hooks de forma aislada.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.
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.
Pasa una opción wrapper a renderHook:
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
);
const { result } = renderHook(() => useAuth(), { wrapper });Usa rerender con nuevas props:
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: "hello" } }
);
rerender({ value: "world" });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 }).
vi.stubGlobal("fetch", vi.fn());
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify(data), { status: 200 })
);Usa la función unmount de renderHook:
const { unmount } = renderHook(() => useFetch("/api/slow"));
unmount();
// Verify no state update errors occur after unmountconst { result, rerender } = renderHook(
({ url }: { url: string }) => useFetch<User[]>(url),
{ initialProps: { url: "/api/users" } }
);
// rerender expects the same props type: { url: string }renderHook para hooks con lógica compleja como debounce, obtención de datos o máquinas de estado.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();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.
Revisado por Chris St. John·Última actualización: 7 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥