Saltar para o conteúdo
Testes em React com Testing Library: Do Básico ao Avançado
React

Testes em React com Testing Library: Do Básico ao Avançado

5 de junho de 2024·Paulo de Paula

Testing Library parte de uma premissa simples: teste o que o usuário vê e faz, não como o componente é implementado. Isso significa buscar elementos pelo texto visível ou papel semântico, não por classes CSS ou nomes de função.

Queries: encontrando elementos

import { render, screen } from '@testing-library/react';

render(<button disabled>Salvar</button>);

// getBy*  — falha se não encontrar (ou encontrar mais de um)
screen.getByRole('button', { name: 'Salvar' });
screen.getByText('Salvar');
screen.getByLabelText('E-mail');    // para inputs com <label>
screen.getByPlaceholderText('Nome');
screen.getByTestId('submit-btn');   // último recurso

// queryBy* — retorna null se não encontrar (não lança erro)
// Útil para assertions de ausência:
expect(screen.queryByText('Erro')).not.toBeInTheDocument();

// findBy* — assíncrono, retorna Promise (aguarda o elemento aparecer)
const modal = await screen.findByRole('dialog');

Prioridade de queries (mais semântico → menos semântico):

  1. getByRole — mais próximo de como leitores de tela enxergam
  2. getByLabelText — para campos de formulário
  3. getByText — para conteúdo visível
  4. getByTestId — só quando não há alternativa semântica

userEvent vs fireEvent

import userEvent from '@testing-library/user-event';

// ❌ fireEvent: simula o evento mas não o comportamento real
fireEvent.click(button);
fireEvent.change(input, { target: { value: 'texto' } });

// ✅ userEvent: simula o usuário real (foco, teclas, ponteiro)
const user = userEvent.setup();
await user.click(button);
await user.type(input, 'Paulo');        // dispara keydown, keypress, keyup, input
await user.clear(input);
await user.selectOptions(select, 'SP');
await user.upload(fileInput, file);

userEvent é mais lento mas mais fiel ao comportamento real. Use-o sempre que possível.

Testando componentes simples

// components/Badge.tsx
function Badge({ count }: { count: number }) {
  if (count === 0) return null;
  return <span aria-label={`${count} notificações`}>{count}</span>;
}

// Badge.test.tsx
describe('Badge', () => {
  it('renderiza a contagem quando maior que zero', () => {
    render(<Badge count={5} />);
    expect(screen.getByText('5')).toBeInTheDocument();
  });

  it('não renderiza quando count é zero', () => {
    render(<Badge count={0} />);
    expect(screen.queryByRole('status')).not.toBeInTheDocument();
  });
});

Testando formulários

describe('LoginForm', () => {
  it('exibe erro quando senha está vazia', async () => {
    const user = userEvent.setup();
    render(<LoginForm onLogin={jest.fn()} />);

    await user.type(screen.getByLabelText('E-mail'), 'paulo@example.com');
    await user.click(screen.getByRole('button', { name: 'Entrar' }));

    expect(await screen.findByText('Senha obrigatória')).toBeInTheDocument();
  });

  it('chama onLogin com as credenciais corretas', async () => {
    const onLogin = jest.fn().mockResolvedValue(undefined);
    const user = userEvent.setup();
    render(<LoginForm onLogin={onLogin} />);

    await user.type(screen.getByLabelText('E-mail'), 'paulo@example.com');
    await user.type(screen.getByLabelText('Senha'), 'senha123');
    await user.click(screen.getByRole('button', { name: 'Entrar' }));

    await waitFor(() => {
      expect(onLogin).toHaveBeenCalledWith({
        email: 'paulo@example.com',
        password: 'senha123',
      });
    });
  });
});

Testando comportamento assíncrono

// Componente que busca dados
function UserCard({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(setUser);
  }, [userId]);

  if (!user) return <p>Carregando...</p>;
  return <h2>{user.name}</h2>;
}

// Teste com mock de fetch
global.fetch = jest.fn();

it('exibe nome do usuário após carregar', async () => {
  (fetch as jest.Mock).mockResolvedValueOnce({
    ok: true,
    json: async () => ({ id: '1', name: 'Paulo de Paula' }),
  });

  render(<UserCard userId="1" />);

  // Estado de loading
  expect(screen.getByText('Carregando...')).toBeInTheDocument();

  // Aguarda o conteúdo assíncrono
  expect(await screen.findByText('Paulo de Paula')).toBeInTheDocument();
  expect(screen.queryByText('Carregando...')).not.toBeInTheDocument();
});

Mockando React Query

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

function renderWithQuery(ui: React.ReactElement) {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } }, // sem retry em testes
  });
  return render(
    <QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
  );
}

// Mock do módulo de API
jest.mock('../api/users', () => ({
  fetchUser: jest.fn(),
}));

import { fetchUser } from '../api/users';

it('exibe dados do usuário via React Query', async () => {
  (fetchUser as jest.Mock).mockResolvedValue({ name: 'Paulo' });

  renderWithQuery(<UserProfile userId="1" />);

  expect(await screen.findByText('Paulo')).toBeInTheDocument();
});

Mockando React Router

import { MemoryRouter, Route, Routes } from 'react-router-dom';

function renderWithRouter(
  ui: React.ReactElement,
  { route = '/' }: { route?: string } = {},
) {
  return render(
    <MemoryRouter initialEntries={[route]}>
      <Routes>
        <Route path="*" element={ui} />
      </Routes>
    </MemoryRouter>
  );
}

it('redireciona para login quando não autenticado', async () => {
  renderWithRouter(<ProtectedPage />, { route: '/dashboard' });
  expect(await screen.findByText('Faça login para continuar')).toBeInTheDocument();
});

Antipadrões comuns

// ❌ Testando implementação: se renomear a função, o teste quebra
expect(component.instance().handleClick).toBeDefined();

// ❌ Snapshot gigante: qualquer mudança de layout quebra o teste
expect(container).toMatchSnapshot(); // evite em componentes grandes

// ❌ Seletor por classe CSS: frágil, não reflete comportamento
document.querySelector('.btn-primary').click();

// ❌ act() manual desnecessário: findBy* já lida com async
await act(async () => {
  await screen.findByText('Resultado'); // findBy já faz isso
});

// ✅ Prefira sempre
screen.getByRole('button', { name: /salvar/i }); // case-insensitive regex
await screen.findByRole('alert');                 // aguarda automaticamente

O sinal de que seu teste está bem escrito: você pode refatorar a implementação do componente completamente (mudar estado para context, hooks para Zustand, etc.) e o teste continua passando sem mudanças.