Testando Custom Hooks
Teste custom hooks em isolamento usando renderHook -- verifique mudanças de estado, comportamento assíncrono e dependências de contexto.
Busque em todas as páginas da documentação
Teste custom hooks em isolamento usando renderHook -- verifique mudanças de estado, comportamento assíncrono e dependências de contexto.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Cartão de receita de referência rápida -- pronto para copiar e colar.
import { renderHook, act, waitFor } from "@testing-library/react";
// Teste um hook síncrono
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
// Teste um hook assíncrono
const { result } = renderHook(() => useFetch("/api/users"));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.data).toEqual([{ id: 1, name: "Alice" }]);
});
// Teste um hook com contexto
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
);
const { result } = renderHook(() => useAuth(), { wrapper });Quando usar isso: Quando você tem um custom hook com lógica que vale a pena testar independentemente de qualquer componente específico.
// 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("retorna o valor inicial imediatamente", () => {
const { result } = renderHook(() => useDebounce("hello", 500));
expect(result.current).toBe("hello");
});
it("faz debounce nas mudanças de valor", () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: "hello", delay: 500 } }
);
// Atualiza o valor
rerender({ value: "world", delay: 500 });
expect(result.current).toBe("hello"); // ainda não atualizado
// Avança o tempo
act(() => {
vi.advanceTimersByTime(500);
});
expect(result.current).toBe("world"); // agora atualizado
});
it("reseta o timer em mudanças rápidas de valor", () => {
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));
// Apenas 200ms desde a última mudança -- ainda em debounce
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("inicia em estado de loading", () => {
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("retorna dados em caso de sucesso", 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("retorna erro em caso de falha", 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("refaz a requisição quando a URL muda", 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");
});
});O que isso demonstra:
renderHook para testar hooks fora de componentesrerender com novas props para acionar a reexecução do hookfetch para teste de hook assíncronowaitFor para esperar por atualizações de estado assíncronasrenderHook cria um componente wrapper mínimo que chama seu hook e expõe result.current -- o valor de retorno atualresult.current é um objeto semelhante a um ref -- ele sempre reflete o último valor de retorno após re-renderizaçõesact() é necessário ao acionar atualizações de estado de fora do ciclo de renderização do React (por exemplo, chamando métodos retornados por hooks)rerender() re-renderiza o componente wrapper com novas props, o que reexecuta o hook com argumentos atualizadoswrapper permite que você envolva o hook em provedores como contexto, roteadores ou clientes de consultaTestando um hook com contexto:
// src/hooks/use-theme.test.tsx
import { renderHook, act } from "@testing-library/react";
import { ThemeProvider, useTheme } from "./theme-context";
it("alterna o tema", () => {
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");
});Testando a limpeza ao desmontar:
it("cancela requisições pendentes ao desmontar", async () => {
vi.mocked(fetch).mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 5000))
);
const { unmount } = renderHook(() => useFetch("/api/slow"));
unmount();
// Nenhum erro de atualização de estado deve ocorrer após o desmontar
// A flag cancelled no hook impede setState após o desmontar
});// Tipando renderHook com initialProps
const { result, rerender } = renderHook(
({ url }: { url: string }) => useFetch<User[]>(url),
{ initialProps: { url: "/api/users" } }
);
// rerender espera o mesmo tipo de props: { url: string }
// result.current é tipado como o tipo de retorno do hook
const data: User[] | null = result.current.data;Esquecer act() para atualizações de estado síncronas -- Chamar result.current.increment() sem act() gera um aviso e pode não atualizar result.current. Correção: Envolva chamadas síncronas que acionam o estado em act().
Ler result.current obsoleto -- Desestruturar const { count } = result.current captura um snapshot. Correção: Sempre leia diretamente de result.current após mudanças de estado.
Testar hooks que só funcionam em componentes -- Hooks que usam useContext lançam erro sem um provider. Correção: Passe uma opção wrapper para renderHook.
Hooks assíncronos e waitFor ausente -- Se um hook aciona um efeito assíncrono, o teste pode ser concluído antes que o estado seja atualizado. Correção: Use waitFor ou findBy para esperar pelo estado esperado.
Timers falsos e código assíncrono -- vi.useFakeTimers() pode interferir com waitFor e Promises. Correção: Se estiver usando timers falsos com hooks assíncronos, chame vi.advanceTimersByTime() dentro de act() e certifique-se de que as Promises resolvam.
| Alternativa | Usar Quando | Não Usar Quando |
|---|---|---|
| Testar através de um componente | O hook é simples e intimamente acoplado a um componente específico | O hook é reutilizado em muitos componentes |
| Funções de play do Storybook | Você quer testar hooks através de interações de componentes em stories | Você precisa de testes unitários isolados com mocks |
| Teste de integração | O hook interage com APIs externas que você quer testar juntas | Você quer testes rápidos e isolados |
renderHook cria um componente wrapper mínimo para testar hooks em isolamento.Chamar uma função de atualização de estado como result.current.increment() fora do ciclo de renderização do React requer act() para processar as atualizações. Sem ele, result.current pode não refletir o estado mais recente.
A desestruturação captura um snapshot naquele momento:
const { count } = result.current; // obsoleto após mudanças de estadoSempre leia diretamente de result.current após mudanças de estado para obter o valor mais recente.
Passe uma opção wrapper para renderHook:
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
);
const { result } = renderHook(() => useAuth(), { wrapper });Use rerender com novas props:
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: "hello" } }
);
rerender({ value: "world" });Timers falsos congelam o intervalo de consulta usado por waitFor. Avance os timers dentro de act() antes de usar waitFor, ou use vi.useFakeTimers({ shouldAdvanceTime: true }).
vi.stubGlobal("fetch", vi.fn());
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify(data), { status: 200 })
);Use a função unmount de renderHook:
const { unmount } = renderHook(() => useFetch("/api/slow"));
unmount();
// Verifique se nenhum erro de atualização de estado ocorre após o desmontarconst { result, rerender } = renderHook(
({ url }: { url: string }) => useFetch<User[]>(url),
{ initialProps: { url: "/api/users" } }
);
// rerender espera o mesmo tipo de props: { url: string }renderHook para hooks com lógica complexa como debounce, busca de dados ou 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();Ele retorna o tipo de retorno do hook. Para useFetch<T>, seria { data: T | null; error: string | null; loading: boolean }. O TypeScript infere isso automaticamente.
Revisado por Chris St. John·Última atualização: 7 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥