Testing Library: testes de React que refletem o uso real

A ilusão do controle em testes de interface

A maioria dos testes de React falha não por erro técnico, mas por má interpretação do que está sendo testado. O problema raiz é a tentativa de simular a implementação em vez de validar o comportamento percebido pelo usuário. Quando um teste verifica se um componente chamou setState ou atualizou um estado interno, ele está acoplado à estrutura do código, não à funcionalidade entregue. Isso gera fragilidade: qualquer refatoração mínima que altere o nome de uma classe CSS ou a lógica interna de um hook quebra a suite de testes, mesmo que o produto final continue funcionando exatamente como esperado.

Ismael Douglas, desenvolvedor full stack e contribuidor open source, construiu sua carreira técnica observando essa dinâmica de perto. Ao trabalhar com integrações de e-commerce e PDV, onde a precisão dos dados é crítica, percebeu que testar a "caixa preta" — o que entra e o que sai — é a única forma de garantir estabilidade em sistemas complexos. No ecossistema React, essa abordagem é institucionalizada pela Testing Library. A filosofia não é apenas uma convenção de código; é uma mudança de mentalidade que força o desenvolvedor a pensar como um usuário real, e não como um engenheiro de software manipulando variáveis de memória.

Este artigo explora como aplicar a Testing Library de forma rigorosa, evitando as armadilhas comuns e criando suítes de testes que realmente protegem sua aplicação contra regressões.

O princípio: teste o que o usuário vê

A premissa central da Testing Library é que o código do seu componente é uma implementação detalhada que o usuário final nunca vê. O usuário não sabe que existe um div com a classe btn-primary. Ele sabe que existe um botão escrito "Salvar". O usuário não sabe que o componente recebe um prop chamado isLoading. Ele sabe que o botão está desabilitado e mostrando um spinner de carregamento.

Testar a implementação é um erro de estratégia. Se você escreve um teste que verifica container.querySelector('.btn-primary'), você está assumindo que a classe CSS nunca mudará. Se amanhã você decide usar Tailwind ou CSS Modules, seu teste quebra. Se você escreve container.getByRole('button', { name: 'Salvar' }), você está testando a acessibilidade e a funcionalidade. A única maneira de um usuário interagir com uma interface web é através de roles, labels, textos e atributos HTML padrão.

Essa abordagem elimina o acoplamento frágil. Ela obriga o desenvolvedor a garantir que os elementos tenham semântica HTML correta e acessível. Se você não pode encontrar um botão pelo seu nome visível, talvez ele não esteja bem estruturado para acessibilidade, e isso é um problema maior do que o teste em si.

Queries: a linguagem da biblioteca

A Testing Library oferece um conjunto de queries para buscar elementos no DOM. A escolha da query certa define a robustez do seu teste. A hierarquia de preferência é clara: priorize sempre as queries baseadas em acessibilidade.

GetByRole: A query mais fundamental. Ela busca elementos pelo seu papel ARIA (role). Botões, links, cabeçalhos, campos de entrada. É a query que mais se aproxima da experiência do usuário. GetByText: Útil para textos que não são necessariamente roles, como parágrafos ou títulos de seção. GetByLabelText: Essencial para formulários. Busca o input associado ao label. É a forma mais segura de testar campos de formulário, pois ignora IDs e classes. GetByDisplayValue: Busca inputs pelo valor atual. Muito útil para verificar se um campo foi preenchido automaticamente ou pelo usuário. GetByTestId: A query mais perigosa. Ela requer um atributo data-testid no HTML. Isso reintroduz o acoplamento à implementação. O atributo data-testid não faz parte do padrão HTML e não é visível para usuários de tecnologia assistiva. Use-a apenas como último recurso, quando não há outra maneira de identificar o elemento de forma única e acessível.

A diferença entre getBy, findBy e queryBy é temporal. getBy espera que o elemento esteja presente imediatamente. Se não encontrar, lança erro. queryBy retorna null se não encontrar, não lança erro. findBy retorna uma Promise que aguarda até o elemento aparecer ou o tempo de espera expirar.

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

test('renderiza o botão de salvar', () => {
  render(<MyComponent />);
  
  // Busca pelo papel e nome visível
  const button = screen.getByRole('button', { name: /salvar/i });
  expect(button).toBeInTheDocument();
});

Este exemplo é simples, mas ilustra o ponto. Não importa como MyComponent é estruturado internamente. Se ele renderiza um botão com o texto "Salvar" e o papel button, o teste passa. Se você refatorar o componente para usar um <button> em vez de um <div> com onClick, o teste ainda passa. Se você mudar o texto para "Confirmar", o teste falha, porque o comportamento visível mudou.

User events: simulando a interação real

Testar componentes estáticos é fácil. Testar interatividade é onde a Testing Library brilha. A biblioteca @testing-library/user-event (ou as funções integradas em versões mais recentes) permite simular ações do usuário de forma realista. O segredo não é clicar no elemento DOM bruto, mas sim usar a API de eventos de alta fidelidade.

UserEvent.click(): Simula um clique completo, incluindo focos, mouse down, mouse up e click. UserEvent.type(): Simula digitação caractere por caractere, disparando eventos de input, change e keypress. UserEvent.selectOptions(): Para selects nativos. UserEvent.hover(): Para tooltips e menus suspensos.

A razão para usar userEvent em vez de fireEvent é a realismo. fireEvent dispara eventos no elemento, mas não simula o comportamento do mouse ou teclado do navegador. userEvent gera uma sequência de eventos que o navegador processaria naturalmente. Isso é crucial para testar validações de formulário, que dependem da ordem dos eventos e do estado do DOM durante a digitação.

import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

test('valida email ao digitar', async () => {
  render(<LoginForm />);
  const user = userEvent.setup();
  const emailInput = screen.getByLabelText(/email/i);
  const submitButton = screen.getByRole('button', { name: /entrar/i });

  // Simula digitação lenta e realista
  await user.type(emailInput, 'invalido');
  
  // O componente deve mostrar erro de validação
  expect(screen.getByText(/email invalido/i)).toBeInTheDocument();
});

Ao usar user.type, garantimos que os handlers de onChange e onInput sejam disparados na ordem correta. Se usássemos fireEvent.change, poderíamos pular etapas de validação em tempo real que dependem do fluxo de eventos do navegador.

Async testing: lidando com o tempo

Aplicações modernas dependem de requisições assíncronas. Dados são buscados de APIs, processos são executados em segundo plano. Testar código assíncrono exige paciência e o uso correto das queries findBy.

A armadilha comum é tentar esperar por um tempo fixo com await new Promise(resolve => setTimeout(resolve, 1000)). Isso cria testes lentos e não determinísticos. Se a API responder em 50ms, o teste espera 1000ms desnecessariamente. Se levar 1500ms, o teste falha. A Testing Library resolve isso com polling inteligente.

FindByRole, findByText, etc., retornam Promises que ficam em loop até o elemento aparecer ou o timeout expirar (geralmente 1000ms por padrão). Isso se alinha perfeitamente com o ciclo de vida do React e das APIs assíncronas.

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

test('carrega lista de usuarios', async () => {
  render(<UserList />);
  
  // Aguarda ate que o elemento esteja visivel
  const userItem = await screen.findByText(/usuario 1/i);
  expect(userItem).toBeInTheDocument();
  
  // Ou verifica que o loading desapareceu
  const loadingSpinner = screen.queryByRole('status');
  expect(loadingSpinner).toBeNull();
});

É importante notar que findBy requer que a função de teste seja async e use await. Isso garante que o teste só continue quando o estado da interface estiver estável e atualizado. Sem isso, você estaria verificando o estado da tela antes da requisição terminar, gerando falsos positivos ou negativos.

Mocking API: isolando a camada de dados

Testar integração real com APIs de produção é uma péssima ideia. É lento, depende da estabilidade do serviço externo e introduz variáveis não controláveis. O padrão é mockar as respostas da API.

Há duas abordagens principais. A primeira é mockar o fetch global ou bibliotecas como axios. A segunda é mockar os hooks de dados, como React Query ou SWR. A escolha depende da arquitetura do projeto. Para a maioria dos casos, mockar a camada de comunicação de rede é suficiente.

Usar jest.fn() ou vi.fn() (no Vitest) permite controlar o comportamento da função de busca. Você pode definir que uma chamada específica retorne um JSON predeterminado. Isso isola o teste do componente da lógica de negócio da API, focando apenas em como o componente reage aos dados recebidos.

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';

// Mock do fetch global
global.fetch = vi.fn();

test('exibe erro ao falhar na busca', async () => {
  // Define que a API falhará
  global.fetch.mockRejectedValueOnce(new Error('Falha na rede'));

  render(<UserSearch />);
  
  const user = userEvent.setup();
  const input = screen.getByLabelText(/pesquisar/i);
  await user.type(input, 'usuario');
  
  // Aguarda a mensagem de erro aparecer
  await waitFor(() => {
    expect(screen.getByText(/erro na busca/i)).toBeInTheDocument();
  });
});

Essa técnica permite testar cenários de erro, limites de paginação e estados de carregamento sem depender de um backend real. É rápido, confiável e reproduzível.

Coverage: métrica, não objetivo

A cobertura de testes (code coverage) é uma métrica útil para identificar blocos de código não executados pelos testes, mas é perigosa quando usada como objetivo. Ter 100% de cobertura não significa que seu software está livre de bugs. Significa apenas que você tocou em todas as linhas de código.

A Testing Library incentiva testes baseados em comportamento, não em execução de linhas. Um teste pode cobrir 50 linhas de lógica interna de um componente, mas se não verificar o output visível, ele não garante que o componente funciona. Inversamente, um teste bem escrito pode cobrir toda a lógica de negócio complexa através de uma única interação na interface.

Foque na cobertura de cenários de uso. Quais são os fluxos críticos do usuário? Quais são os estados de erro mais comuns? Teste esses fluxos. Não teste getters e setters triviais. A cobertura deve ser um subproduto da qualidade dos testes, não a motivação para escrevê-los.

Conclusão

A Testing Library não é apenas uma ferramenta; é uma filosofia de desenvolvimento. Ela força o desenvolvedor a pensar na interface como um contrato com o usuário, não como um detalhe de implementação. Ao priorizar acessibilidade, simular interações reais e lidar com assincronicidade de forma nativa, ela produz testes que são mais fáceis de manter e mais confiáveis.

A transição de testes baseados em implementação para testes baseados em comportamento exige disciplina. Há uma tentação constante de usar getByTestId para facilitar a vida no curto prazo. Resistir a essa tentação paga dividendos enormes no médio e longo prazo. Quando o componente muda, os testes baseados em acessibilidade se adaptam. Os testes baseados em IDs quebram.

Para quem constrói sistemas reais, como integrações de varejo ou dashboards complexos, essa robustez é essencial. A velocidade de desenvolvimento pode parecer menor no início, mas o tempo gasto corrigindo testes quebrados por refatorações desnecessárias consome muito mais recursos.

Takeaways práticos:

  • Priorize sempre queries baseadas em acessibilidade (getByRole, getByLabelText). Use getByTestId apenas como último recurso.
  • Simule interações de usuário reais com userEvent. Evite disparar eventos manualmente com fireEvent, a menos que tenha uma razão específica e documentada.
  • Use findBy para esperar por elementos assíncronos. Nunca use setTimeout para sincronização de testes.
  • Mocke camadas de dados (APIs, hooks de estado remoto) para isolar testes de componentes.
  • View a code coverage como uma métrica de diagnóstico, não como uma meta a ser atingida a qualquer custo. Testes comportamentais valem mais que linhas executadas.
  • Escreva testes que verifiquem o que o usuário vê e faz, não o que o componente faz internamente.

Glossário do post

react
Biblioteca JavaScript para construir interfaces de usuário a partir de componentes reutilizáveis, mantida pela Meta.
css
Cascading Style Sheets: a linguagem usada para estilizar páginas web, controlando cores, fontes, layout e responsividade.
pdv
Ponto de Venda: sistema utilizado para registrar e processar vendas diretamente no balcão de um comércio.
html
HyperText Markup Language: a linguagem de marcação que define a estrutura e o conteúdo de uma página web.
dom
Document Object Model: representação em árvore da página que o navegador expõe, permitindo que JavaScript leia e modifique o conteúdo.
event
Ocorrência relevante no sistema (clique, pedido, mensagem) que dispara ações assíncronas, base de arquiteturas event-driven.
api
Application Programming Interface: conjunto de regras que permite a um programa conversar com outro, expondo dados e funcionalidades de forma padronizada.
json
JavaScript Object Notation: formato leve de troca de dados baseado em pares chave/valor, legível para humanos e máquinas.
Ismael Douglas

Sobre o autor

Ismael Douglas

Desenvolvedor Full Stack apaixonado por construir produtos web rápidos e bem projetados. Escrevo sobre o que aprendo no caminho — código, infra e boas práticas.

Veja meus projetos no portfólio →