Boas Práticas de Testing Library em Profundidade: Queries, Fire Events e Async para Times Ágeis Já leu

Testing Library em Profundidade: Queries, Fire Events e Async Testing Library é uma biblioteca de testes que oferece uma abordagem centrada no usuário. Em vez de testar implementações internas (como estado local ou props), testamos o comportamento real que o usuário experimenta. Essa filosofia mudou fundamentalmente como a comunidade React escreve testes, tornando-os mais robustos e menos frágeis. Durante minha carreira, vi inúmeros testes quebrarem porque um componente foi refatorado internamente, mesmo que o comportamento visual permanecesse igual. Testing Library resolve isso testando o que importa: o que o usuário vê e interage. Neste artigo, vou guiá-lo através dos três pilares essenciais: queries para encontrar elementos, fire events para simular interações e técnicas assíncronas para lidar com operações que não são instantâneas. Queries: A Arte de Encontrar Elementos As queries são o alicerce de qualquer teste com Testing Library. Elas definem como você localiza elementos no DOM de forma que se alinhe com a experiência do usuário. Existem diferentes tipos

Testing Library em Profundidade: Queries, Fire Events e Async

Testing Library é uma biblioteca de testes que oferece uma abordagem centrada no usuário. Em vez de testar implementações internas (como estado local ou props), testamos o comportamento real que o usuário experimenta. Essa filosofia mudou fundamentalmente como a comunidade React escreve testes, tornando-os mais robustos e menos frágeis.

Durante minha carreira, vi inúmeros testes quebrarem porque um componente foi refatorado internamente, mesmo que o comportamento visual permanecesse igual. Testing Library resolve isso testando o que importa: o que o usuário vê e interage. Neste artigo, vou guiá-lo através dos três pilares essenciais: queries para encontrar elementos, fire events para simular interações e técnicas assíncronas para lidar com operações que não são instantâneas.

Queries: A Arte de Encontrar Elementos

As queries são o alicerce de qualquer teste com Testing Library. Elas definem como você localiza elementos no DOM de forma que se alinhe com a experiência do usuário. Existem diferentes tipos de queries, cada uma com um propósito específico e uma preferência de uso.

Entendendo a Hierarquia de Queries

Testing Library organiza suas queries em três categorias: queries que retornam um elemento, queries que retornam múltiplos elementos e queries que lançam erro se nada for encontrado. A hierarquia de preferência é crítica: sempre prefira métodos que retornam resultados acessíveis ao usuário.

A ordem de preferência é: getByRole (mais semântico), getByLabelText (para formulários), getByPlaceholderText, getByText, getByTestId (menos preferido, apenas para casos extremos). Essa hierarquia existe porque métodos mais altos no topo refletem melhor como um usuário real interage com a interface.

// Exemplo prático de diferentes queries
import { render, screen } from '@testing-library/react';

function LoginForm() {
  return (
    <form>
      <label htmlFor="username">Usuário</label>
      <input id="username" type="text" placeholder="Digite seu usuário" />
      <button type="submit">Entrar</button>
    </form>
  );
}

// ❌ Evite: Testando implementação
test('input tem o id correto', () => {
  render(<LoginForm />);
  expect(document.getElementById('username')).toBeInTheDocument();
});

// ✅ Preferir: Testando como o usuário interage
test('usuário consegue preencher formulário de login', () => {
  render(<LoginForm />);

  // getByRole é o mais preferido
  const usernameInput = screen.getByRole('textbox', { name: /usuário/i });
  expect(usernameInput).toBeInTheDocument();

  // getByLabelText para inputs associados a labels
  const inputByLabel = screen.getByLabelText('Usuário');
  expect(inputByLabel).toBeInTheDocument();

  // getByPlaceholderText como fallback
  const inputByPlaceholder = screen.getByPlaceholderText('Digite seu usuário');
  expect(inputByPlaceholder).toBeInTheDocument();
});

QueryByX vs GetByX vs FindByX

Essas variações são fundamentais e confundem muitos iniciantes. GetByX lança erro se o elemento não existir (melhor para afirmações positivas). QueryByX retorna null se nada for encontrado (ideal para testar ausência). FindByX é assíncrono e aguarda até que o elemento apareça (essencial para dados dinâmicos).

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

function NotificationComponent() {
  const [visible, setVisible] = React.useState(false);

  return (
    <div>
      {visible && <div role="status">Mensagem de sucesso!</div>}
      <button onClick={() => setVisible(!visible)}>Toggle</button>
    </div>
  );
}

test('entendendo GetByX, QueryByX e FindByX', async () => {
  render(<NotificationComponent />);

  // ✅ QueryByX: elemento não existe ainda (retorna null)
  expect(screen.queryByRole('status')).not.toBeInTheDocument();

  // ✅ GetByX: vai lançar erro porque elemento não existe
  expect(() => {
    screen.getByRole('status');
  }).toThrow();

  // Simular interação que renderiza o elemento
  const button = screen.getByRole('button');
  button.click();

  // ✅ Agora o elemento existe, GetByX funciona
  expect(screen.getByRole('status')).toBeInTheDocument();
});

Using getByRole para Máxima Acessibilidade

getByRole é a query mais poderosa porque força você a pensar em acessibilidade. Todo elemento deve ter um role (papel) semântico. Inputs têm role textbox, botões têm role button, e assim por diante. Usar getByRole garante que seu componente é acessível.

function ProductCard({ name, onAddToCart }) {
  return (
    <article>
      <h2>{name}</h2>
      <button onClick={onAddToCart}>Adicionar ao carrinho</button>
    </article>
  );
}

test('card de produto está semanticamente correto', () => {
  const handleClick = jest.fn();
  render(<ProductCard name="Teclado" onAddToCart={handleClick} />);

  // getByRole encontra por semantic HTML
  const heading = screen.getByRole('heading', { level: 2, name: /teclado/i });
  expect(heading).toBeInTheDocument();

  const button = screen.getByRole('button', { name: /adicionar ao carrinho/i });
  expect(button).toBeInTheDocument();
});

Fire Events: Simulando Interações do Usuário

Fire events é como você simula cliques, digitação, submissão de formulários e outras interações. Porém, há uma nuance importante: fireEvent é o método mais baixo nível. Para a maioria dos casos, usar userEvent é mais apropriado porque simula eventos reais que um navegador geraria.

fireEvent vs userEvent

fireEvent dispara um evento diretamente no elemento. É rápido mas não simula o comportamento real do navegador. userEvent, por outro lado, simula como um usuário real interagiria, gerando múltiplos eventos na sequência correta. Por exemplo, digitar um caractere gera focus, keydown, keyup, input e change. FireEvent dispara apenas change.

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

function SearchBox({ onSearch }) {
  const [query, setQuery] = React.useState('');

  const handleChange = (e) => {
    setQuery(e.target.value);
    onSearch(e.target.value);
  };

  return (
    <input
      type="text"
      value={query}
      onChange={handleChange}
      placeholder="Buscar..."
    />
  );
}

test('diferença entre fireEvent e userEvent', async () => {
  const handleSearch = jest.fn();
  render(<SearchBox onSearch={handleSearch} />);

  const input = screen.getByPlaceholderText('Buscar...');

  // ❌ fireEvent: Muito direto, não simula behavior real
  fireEvent.change(input, { target: { value: 'teste' } });
  expect(handleSearch).toHaveBeenCalledWith('teste');

  handleSearch.mockClear();

  // ✅ userEvent: Simula comportamento real do usuário
  // Isso é a forma correta em testes modernos
  const user = userEvent.setup();
  await user.type(input, 'teste');
  expect(handleSearch).toHaveBeenCalledWith('teste');
});

Padrão userEvent.setup()

Com versões recentes de @testing-library/user-event, o padrão mudou. Você precisa chamar userEvent.setup() no início do teste (ou antes de cada teste). Isso cria uma instância que gerencia estado entre eventos.

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

function TodoForm({ onAdd }) {
  const [input, setInput] = React.useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (input.trim()) {
      onAdd(input);
      setInput('');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="Nova tarefa"
      />
      <button type="submit">Adicionar</button>
    </form>
  );
}

test('usuário pode adicionar tarefa', async () => {
  const handleAdd = jest.fn();
  render(<TodoForm onAdd={handleAdd} />);

  const user = userEvent.setup();
  const input = screen.getByPlaceholderText('Nova tarefa');
  const button = screen.getByRole('button', { name: /adicionar/i });

  // userEvent mantém estado entre eventos
  await user.type(input, 'Estudar Testing Library');
  await user.click(button);

  expect(handleAdd).toHaveBeenCalledWith('Estudar Testing Library');
  expect(input.value).toBe(''); // Input foi limpo
});

Testando Eventos Complexos

Alguns eventos são mais complexos: hover, focus, teclado especial. userEvent fornece métodos específicos para cada um. Conhecê-los evita tentativas fracassadas.

function DropdownMenu() {
  const [open, setOpen] = React.useState(false);

  return (
    <div>
      <button onClick={() => setOpen(!open)}>Menu</button>
      {open && (
        <menu>
          <li><a href="/profile">Perfil</a></li>
          <li><a href="/settings">Configurações</a></li>
          <li><a href="/logout">Sair</a></li>
        </menu>
      )}
    </div>
  );
}

test('interações complexas com dropdown', async () => {
  render(<DropdownMenu />);
  const user = userEvent.setup();

  const button = screen.getByRole('button', { name: /menu/i });

  // Menu não deve estar visível inicialmente
  expect(screen.queryByRole('menuitem')).not.toBeInTheDocument();

  // Click abre o menu
  await user.click(button);
  expect(screen.getAllByRole('link')).toHaveLength(3);

  // Click novamente fecha
  await user.click(button);
  expect(screen.queryByRole('menuitem')).not.toBeInTheDocument();

  // Tab e Enter também funcionam
  await user.click(button);
  const profileLink = screen.getByRole('link', { name: /perfil/i });
  await user.tab();
  expect(document.activeElement).toBe(profileLink);
  await user.keyboard('{Enter}');
});

Padrões Assíncronos: Testando Dados Dinâmicos

A maioria das aplicações reais busca dados de APIs, faz requisições HTTP ou aguarda estados que mudam com o tempo. Testing Library oferece ferramentas específicas para lidar com esses padrões assíncronos sem tornar seus testes frágeis e dependentes de timing.

waitFor: Aguardando Condições

waitFor é a ferramenta fundamental para testes assíncronos. Ela executa uma função repetidamente até que a condição seja verdadeira ou timeout expire. A diferença crítica: waitFor aguarda uma condição lógica, não apenas a presença de um elemento.

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

function UserProfile({ userId }) {
  const [user, setUser] = React.useState(null);
  const [loading, setLoading] = React.useState(true);

  React.useEffect(() => {
    const fetchUser = async () => {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      setUser(data);
      setLoading(false);
    };
    fetchUser();
  }, [userId]);

  if (loading) return <div>Carregando...</div>;
  return <div>{user?.name}</div>;
}

test('renderiza dados de usuário após buscar', async () => {
  global.fetch = jest.fn(() =>
    Promise.resolve({
      json: () => Promise.resolve({ name: 'João Silva' })
    })
  );

  render(<UserProfile userId="123" />);

  // ❌ Errado: Tentar acessar imediatamente lança erro
  // expect(screen.getByText('João Silva')).toBeInTheDocument();

  // ✅ Correto: Aguardar a condição ser verdadeira
  await waitFor(() => {
    expect(screen.getByText('João Silva')).toBeInTheDocument();
  });
});

findBy: O Atalho Assíncrono

findBy é basicamente waitFor + getBy. Ela retorna uma promise que resolve quando o elemento aparece. É mais concisa para casos simples onde você apenas quer encontrar um elemento que aparecerá depois.

test('findBy é mais conciso para buscar elementos assíncronos', async () => {
  render(<UserProfile userId="123" />);

  // ✅ findBy retorna uma promise
  const userName = await screen.findByText('João Silva');
  expect(userName).toBeInTheDocument();
});

Mockando APIs com MSW (Mock Service Worker)

Para testes realmente confiáveis, mocking da API é essencial. Mock Service Worker (MSW) intercepta requisições HTTP em nível de rede, permitindo testes realistas sem tocar em servidores reais.

import { render, screen, waitFor } from '@testing-library/react';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
  http.get('/api/users/:userId', () => {
    return HttpResponse.json({ name: 'Maria Santos', email: 'maria@example.com' });
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

function UserCard({ userId }) {
  const [user, setUser] = React.useState(null);

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

  return user ? (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  ) : null;
}

test('renderiza card de usuário com dados da API', async () => {
  render(<UserCard userId="123" />);

  await waitFor(() => {
    expect(screen.getByText('Maria Santos')).toBeInTheDocument();
  });

  expect(screen.getByText('maria@example.com')).toBeInTheDocument();
});

test('trata erros de API corretamente', async () => {
  server.use(
    http.get('/api/users/:userId', () => {
      return HttpResponse.json(
        { error: 'Not found' },
        { status: 404 }
      );
    })
  );

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

  // Component não renderiza nada em caso de erro
  expect(screen.queryByRole('heading')).not.toBeInTheDocument();
});

Testando Loading States e Transições

Um padrão comum é testar que o componente mostra loading antes de mostrar dados. Para isso, use screen.queryByText para verificar presença antes, e waitFor para verificar depois.

function DataTable({ endpoint }) {
  const [data, setData] = React.useState([]);
  const [loading, setLoading] = React.useState(true);

  React.useEffect(() => {
    fetch(endpoint)
      .then(r => r.json())
      .then(items => {
        setData(items);
        setLoading(false);
      });
  }, [endpoint]);

  return (
    <div>
      {loading && <p role="status">Carregando dados...</p>}
      {!loading && data.map(item => (
        <div key={item.id}>{item.name}</div>
      ))}
    </div>
  );
}

test('transição de loading para dados', async () => {
  render(<DataTable endpoint="/api/items" />);

  // Inicialmente mostra loading
  expect(screen.getByRole('status')).toHaveTextContent('Carregando dados...');

  // Aguarda loading desaparecer e dados aparecerem
  await waitFor(() => {
    expect(screen.queryByRole('status')).not.toBeInTheDocument();
  });

  // Agora devem aparecer os itens
  await screen.findByText('Item 1');
});

Boas Práticas e Padrões Avançados

Configurando um Setup Completo

Em projetos reais, você quer um setup reutilizável. Crie um arquivo de configuração que outras suites de testes herdam.

// test-utils.jsx
import React from 'react';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

const renderWithUser = (component) => {
  return {
    user: userEvent.setup(),
    ...render(component),
  };
};

export * from '@testing-library/react';
export { renderWithUser };

// Em seus testes
import { screen, renderWithUser } from './test-utils';

test('exemplo usando o wrapper customizado', async () => {
  const { user } = renderWithUser(<MyComponent />);

  await user.click(screen.getByRole('button'));
  // ...
});

Testando Formulários Complexos

Formulários com validação, campos dinâmicos e submissão assíncrona são comuns. O padrão é: renderizar, preencher, submeter, aguardar resultado.

function RegistrationForm({ onSubmit }) {
  const [errors, setErrors] = React.useState({});
  const [submitted, setSubmitted] = React.useState(false);

  const handleSubmit = async (e) => {
    e.preventDefault();
    const form = new FormData(e.target);

    // Validação simples
    const newErrors = {};
    if (!form.get('email').includes('@')) newErrors.email = 'Email inválido';

    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      return;
    }

    await onSubmit(Object.fromEntries(form));
    setSubmitted(true);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" />
      {errors.email && <span role="alert">{errors.email}</span>}

      <button type="submit">Registrar</button>

      {submitted && <p role="status">Registrado com sucesso!</p>}
    </form>
  );
}

test('validação de formulário de registro', async () => {
  const handleSubmit = jest.fn();
  const { user } = renderWithUser(
    <RegistrationForm onSubmit={handleSubmit} />
  );

  // Tentar submeter com email inválido
  await user.type(screen.getByLabelText('Email'), 'invalido');
  await user.click(screen.getByRole('button', { name: /registrar/i }));

  // Erro é exibido
  expect(screen.getByRole('alert')).toHaveTextContent('Email inválido');
  expect(handleSubmit).not.toHaveBeenCalled();

  // Limpar e tentar novamente com email válido
  const input = screen.getByLabelText('Email');
  await user.clear(input);
  await user.type(input, 'usuario@example.com');
  await user.click(screen.getByRole('button', { name: /registrar/i }));

  // Aguardar sucesso
  await screen.findByRole('status', { name: /sucesso/i });
  expect(handleSubmit).toHaveBeenCalledWith(
    expect.objectContaining({ email: 'usuario@example.com' })
  );
});

Evitando Armadilhas Comuns

A armadilha mais comum é testar implementação em vez de comportamento. Outra é não usar waitFor quando necessário, causando testes que falham intermitentemente (flaky tests). Uma terceira é usar setTimeout em testes, o que é sempre um sinal de design ruim.

// ❌ RUIM: Testa implementação interna
test('estado local é atualizado', () => {
  const component = render(<MyComponent />);
  // Não teste estado, teste o que o usuário vê
});

// ❌ RUIM: Usar setTimeout
test('elemento aparece após delay', () => {
  render(<Component />);
  setTimeout(() => {
    expect(screen.getByText('Apareceu')).toBeInTheDocument();
  }, 1000);
});

// ✅ BOM: Teste o comportamento visível
test('elemento aparece após ação', async () => {
  const { user } = renderWithUser(<Component />);
  await user.click(screen.getByRole('button'));
  await screen.findByText('Apareceu'); // Aguarda naturalmente
});

Conclusão

Três pilares transformam seus testes em confiáveis e resilientes:

  1. Queries semanticamente corretas (getByRole preferencialmente) garantem que você está testando a mesma forma como usuários reais interagem, e como consequência, força acessibilidade no código.

  2. userEvent em vez de fireEvent simula comportamento real do navegador, detectando bugs que fireEvent simples não revelaria, como eventos faltantes ou sequência incorreta.

  3. Padrões assíncronos apropriados (waitFor, findBy, MSW) permitem testar dados dinâmicos e requisições sem fragilidade, tornando seus testes verdes consistentemente.

Testing Library não é apenas uma biblioteca, é uma filosofia. Quando você escreve testes que focam no comportamento do usuário, seu código fica melhor, mais acessível e mais fácil de refatorar. Comece com essas práticas agora e evitará a maioria dos problemas que vejo em codebases reais.

Referências


Artigos relacionados