Como Usar Testes de Hooks Customizados com renderHook e act em Produção Já leu

Por que Testar Hooks Customizados? Hooks customizados são um dos pilares da arquitetura moderna em React. Eles encapsulam lógica complexa de estado, efeitos colaterais e comportamentos reutilizáveis, permitindo que você compartilhe esse código entre componentes sem duplicação. No entanto, muitos desenvolvedores cometem o erro de testar hooks apenas através dos componentes que os utilizam, o que resulta em testes frágeis, lentos e difíceis de manter. Testar hooks customizados de forma isolada oferece vários benefícios concretos: você consegue validar a lógica do hook independentemente de qualquer interface visual, os testes executam mais rápido, e você obtém feedback mais claro quando algo quebra. A biblioteca fornece duas ferramentas específicas para isso: e . Essas ferramentas não apenas facilitam o teste, como também o tornam mais intuitivo e alinhado com como os hooks realmente funcionam. Entendendo renderHook e act O que é renderHook? é uma função utilitária que permite você renderizar um hook customizado em um ambiente de teste isolado. Diferente de renderizar

Por que Testar Hooks Customizados?

Hooks customizados são um dos pilares da arquitetura moderna em React. Eles encapsulam lógica complexa de estado, efeitos colaterais e comportamentos reutilizáveis, permitindo que você compartilhe esse código entre componentes sem duplicação. No entanto, muitos desenvolvedores cometem o erro de testar hooks apenas através dos componentes que os utilizam, o que resulta em testes frágeis, lentos e difíceis de manter.

Testar hooks customizados de forma isolada oferece vários benefícios concretos: você consegue validar a lógica do hook independentemente de qualquer interface visual, os testes executam mais rápido, e você obtém feedback mais claro quando algo quebra. A biblioteca @testing-library/react fornece duas ferramentas específicas para isso: renderHook e act. Essas ferramentas não apenas facilitam o teste, como também o tornam mais intuitivo e alinhado com como os hooks realmente funcionam.

Entendendo renderHook e act

O que é renderHook?

renderHook é uma função utilitária que permite você renderizar um hook customizado em um ambiente de teste isolado. Diferente de renderizar um componente inteiro, renderHook cria um componente wrapper invisível especificamente para executar seu hook. O retorno de renderHook é um objeto que contém a propriedade result, onde você acessa o valor retornado pelo hook, além de outras utilidades como rerender e unmount.

Sem renderHook, você seria forçado a criar um componente de teste apenas para chamar seu hook, o que complicaria significativamente seus testes e misturaria responsabilidades. Com renderHook, você testa o hook de forma limpa e direta, focando apenas na lógica que importa.

O que é act?

act é uma função que envolve qualquer ação que cause mudança de estado no seu hook. Quando você chama uma função de estado, simula um evento do usuário, ou aguarda uma promise, essas ações devem estar dentro de um act. Isso garante que o React aplique todas as atualizações de estado e execute todos os efeitos antes de você fazer suas asserções no teste.

Sem act, você pode ter comportamentos impredizíveis onde o estado não foi atualizado no tempo esperado, resultando em testes que passam ou falham aleatoriamente. O act sincroniza seu teste com o ciclo de vida real do React.

Estrutura Básica e Primeiro Teste

Instalação e Importações

Para começar, você precisa das dependências corretas instaladas:

npm install --save-dev @testing-library/react @testing-library/jest-dom

Agora vamos criar nosso primeiro hook customizado simples. Imagine um hook que gerencia um contador:

// useCounter.js
import { useState } from 'react';

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);
  const reset = () => setCount(initialValue);

  return { count, increment, decrement, reset };
}

Primeiro Teste Funcional

Agora vamos testar este hook usando renderHook e act:

// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('deve inicializar com o valor padrão', () => {
    const { result } = renderHook(() => useCounter());

    expect(result.current.count).toBe(0);
  });

  it('deve inicializar com um valor customizado', () => {
    const { result } = renderHook(() => useCounter(10));

    expect(result.current.count).toBe(10);
  });

  it('deve incrementar o contador', () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it('deve decrementar o contador', () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.decrement();
    });

    expect(result.current.count).toBe(-1);
  });

  it('deve resetar o contador', () => {
    const { result } = renderHook(() => useCounter(5));

    act(() => {
      result.current.increment();
      result.current.increment();
    });

    expect(result.current.count).toBe(7);

    act(() => {
      result.current.reset();
    });

    expect(result.current.count).toBe(5);
  });
});

Observe que toda mudança de estado está envolvida em act(). Mesmo que seja apenas uma chamada a increment(), essa ação modifica o estado interno do hook, portanto deve estar dentro de act. O result.current sempre reflete o estado mais recente do hook após cada act.

Testando Hooks com Efeitos Colaterais

Hooks que Dependem de useEffect

Muitos hooks precisam lidar com efeitos colaterais como requisições HTTP, inscrições em eventos ou manipulação de temporizadores. Testar esses comportamentos requer cuidado especial. Vamos criar um hook que busca dados de uma API:

// useFetchUser.js
import { useState, useEffect } from 'react';

export function useFetchUser(userId) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let mounted = true;

    const fetchUser = async () => {
      try {
        setLoading(true);
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) throw new Error('Erro ao buscar usuário');
        const data = await response.json();

        if (mounted) {
          setUser(data);
          setError(null);
        }
      } catch (err) {
        if (mounted) {
          setError(err.message);
          setUser(null);
        }
      } finally {
        if (mounted) {
          setLoading(false);
        }
      }
    };

    fetchUser();

    return () => {
      mounted = false;
    };
  }, [userId]);

  return { user, loading, error };
}

Testando com Mock de API

Para testar este hook, precisamos mockar a função fetch e gerenciar as promises corretamente usando act e waitFor:

// useFetchUser.test.js
import { renderHook, act, waitFor } from '@testing-library/react';
import { useFetchUser } from './useFetchUser';

global.fetch = jest.fn();

describe('useFetchUser', () => {
  beforeEach(() => {
    fetch.mockClear();
  });

  it('deve estar em estado de carregamento inicialmente', () => {
    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => ({ id: 1, name: 'João' })
    });

    const { result } = renderHook(() => useFetchUser(1));

    expect(result.current.loading).toBe(true);
    expect(result.current.user).toBeNull();
  });

  it('deve buscar e exibir os dados do usuário', async () => {
    const mockUser = { id: 1, name: 'João Silva' };

    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => mockUser
    });

    const { result } = renderHook(() => useFetchUser(1));

    // Aguarde até que o loading seja false
    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.user).toEqual(mockUser);
    expect(result.current.error).toBeNull();
  });

  it('deve lidar com erros de API', async () => {
    fetch.mockResolvedValueOnce({
      ok: false,
      json: async () => ({})
    });

    const { result } = renderHook(() => useFetchUser(1));

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.error).toBe('Erro ao buscar usuário');
    expect(result.current.user).toBeNull();
  });

  it('deve fazer refetch quando userId muda', async () => {
    const mockUser1 = { id: 1, name: 'João' };
    const mockUser2 = { id: 2, name: 'Maria' };

    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => mockUser1
    });

    const { result, rerender } = renderHook(
      ({ userId }) => useFetchUser(userId),
      { initialProps: { userId: 1 } }
    );

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.user).toEqual(mockUser1);

    // Agora mude o userId
    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => mockUser2
    });

    act(() => {
      rerender({ userId: 2 });
    });

    await waitFor(() => {
      expect(result.current.user).toEqual(mockUser2);
    });
  });
});

Note a diferença crucial aqui: quando você envolve uma operação assíncrona, não coloca a promise dentro de act diretamente. Em vez disso, use waitFor para aguardar uma condição. O act é apenas para ações síncronas ou promises que você controla completamente. O waitFor cuida de chamar act internamente enquanto aguarda.

Cenários Avançados e Boas Práticas

Testing Library e Renderização com Providers

Às vezes, seus hooks dependem de contexto ou providers (como Redux, tema, etc.). Para isso, renderHook aceita uma opção wrapper:

// useAuthHook.js
import { useContext } from 'react';
import { AuthContext } from './AuthContext';

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth deve ser usado dentro de AuthProvider');
  }
  return context;
}
// useAuth.test.js
import { renderHook, act } from '@testing-library/react';
import { useAuth } from './useAuth';
import { AuthProvider, AuthContext } from './AuthContext';

describe('useAuth', () => {
  it('deve retornar o contexto de autenticação', () => {
    const wrapper = ({ children }) => (
      <AuthProvider>{children}</AuthProvider>
    );

    const { result } = renderHook(() => useAuth(), { wrapper });

    expect(result.current.user).toBeDefined();
    expect(result.current.login).toBeDefined();
    expect(result.current.logout).toBeDefined();
  });

  it('deve fazer login e atualizar o estado', async () => {
    const wrapper = ({ children }) => (
      <AuthProvider>{children}</AuthProvider>
    );

    const { result } = renderHook(() => useAuth(), { wrapper });

    act(() => {
      result.current.login('user@example.com', 'senha123');
    });

    await waitFor(() => {
      expect(result.current.user).toBe('user@example.com');
      expect(result.current.isAuthenticated).toBe(true);
    });
  });

  it('deve lançar erro se usado fora do provider', () => {
    expect(() => {
      renderHook(() => useAuth());
    }).toThrow('useAuth deve ser usado dentro de AuthProvider');
  });
});

Testando Hooks com Temporizadores

Hooks que usam setTimeout ou setInterval exigem gerenciamento especial de tempo nos testes:

// useDebounce.js
import { useState, useEffect } from 'react';

export function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(handler);
  }, [value, delay]);

  return debouncedValue;
}
// useDebounce.test.js
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './useDebounce';

jest.useFakeTimers();

describe('useDebounce', () => {
  it('deve debounce o valor após o delay', () => {
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: 'inicial', delay: 500 } }
    );

    expect(result.current).toBe('inicial');

    act(() => {
      rerender({ value: 'novo', delay: 500 });
    });

    // Ainda não passou o delay
    expect(result.current).toBe('inicial');

    act(() => {
      jest.advanceTimersByTime(500);
    });

    // Agora passou
    expect(result.current).toBe('novo');
  });

  it('deve cancelar o debounce se o valor mudar antes do delay', () => {
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: 'primeiro', delay: 500 } }
    );

    act(() => {
      rerender({ value: 'segundo', delay: 500 });
    });

    act(() => {
      jest.advanceTimersByTime(250);
    });

    act(() => {
      rerender({ value: 'terceiro', delay: 500 });
    });

    act(() => {
      jest.advanceTimersByTime(500);
    });

    expect(result.current).toBe('terceiro');
  });
});

afterAll(() => {
  jest.useRealTimers();
});

Quando usamos jest.useFakeTimers(), os temporizadores não executam realmente. Você controla o tempo com jest.advanceTimersByTime(), sempre dentro de act. Isso torna os testes de timing determinísticos e rápidos.

Validação de Dependências

É uma boa prática validar que seu hook responde corretamente a mudanças nas dependências:

// useCustomHook.test.js - exemplo genérico
it('deve atualizar quando a dependência muda', () => {
  const { result, rerender } = renderHook(
    ({ dep }) => useMyHook(dep),
    { initialProps: { dep: 'valor1' } }
  );

  const firstResult = result.current.data;

  act(() => {
    rerender({ dep: 'valor2' });
  });

  const secondResult = result.current.data;

  expect(firstResult).not.toEqual(secondResult);
});

Conclusão

Dominar testes de hooks customizados com renderHook e act transforma completamente sua capacidade de criar código confiável em React. Primeiro ponto importante: renderHook elimina a necessidade de componentes wrapper de teste, permitindo que você teste lógica de forma isolada e clara. Segundo ponto: act não é apenas uma ferramenta, é seu meio de comunicação com o React — ele sincroniza suas ações com o ciclo de vida real do framework, garantindo que você teste comportamentos reais e não fluk aleatórios. Terceiro ponto: combinar renderHook com rerender, waitFor e jest.useFakeTimers cobre praticamente todos os cenários que você encontrará na prática, desde hooks simples até aqueles com efeitos colaterais complexos e temporizadores.

Referências


Artigos relacionados