Boas práticas para escrever testes unitários
Testes unitários deveriam funcionar como uma rede de segurança, mas muitas vezes acabam virando um problema. Testes frágeis, presos a detalhes de implementação, quebram com refatorações simples e geram mais ruído do que ajudam. Escrever esses testes deixa de parecer trabalho de engenharia e vira tarefa chata. Com o tempo, a suíte para de evitar bugs e vira só um custo a mais em cada mudança, atrasando todo mundo.
O problema geralmente vem de entender errado o que um teste unitário deveria verificar. A gente testa como o componente funciona por dentro, e não o que ele entrega por fora. Isso cria uma versão duplicada da lógica dentro dos testes, que também precisa ser mantida. Quando você refatora, muda o código de produção e depois precisa ajustar a mesma coisa nos testes. Aqui vão algumas práticas para manter os testes úteis e fáceis de manter.
Pare de testar detalhes de implementação
Testes acoplados à implementação são um dos principais motivos de a suíte ficar difícil de manter. Eles quebram quando você renomeia uma função privada, muda uma estrutura interna ou troca uma dependência, mesmo que o comportamento do componente continue igual. Esses falsos negativos no CI acabam fazendo o time parar de confiar nos alertas ou simplesmente remover testes que quebraram.
A gente chega nisso porque é o caminho mais fácil. Você escreve a função e depois um teste que chama essa função e faz mock das dependências. No fim, o teste só replica a estrutura do código. Se a função processOrder chama um validateCart interno e um helper calculateTaxes, um teste desse tipo vai acabar verificando se esses dois helpers foram chamados.
A solução é tratar o que você está testando como algo sem depender dos detalhes internos. Os testes devem usar só a API pública e validar o resultado. Um teste não precisa conhecer métodos privados nem o estado interno. Em teste unitário, a unidade é o comportamento, não uma função ou classe isolada.
Considere um serviço simples de processamento de pedidos. Um teste acoplado à implementação faria mock e verificaria chamadas a colaboradores internos.
// Ruim: o teste conhece o validador e o calculador internos
test('processOrder chama o validador e o calculador', () => {
const mockValidator = jest.fn().mockReturnValue(true);
const mockCalculator = jest.fn().mockReturnValue({ total: 110 });
const orderService = new OrderService(mockValidator, mockCalculator);
orderService.processOrder({ items: [{ price: 100 }] });
expect(mockValidator).toHaveBeenCalled();
expect(mockCalculator).toHaveBeenCalled();
});
Esse teste não te diz nada sobre o resultado real. Se você refatorar o OrderService para usar um único passo de validação e cálculo, o teste quebra mesmo que o pedido final seja processado corretamente. Um teste focado em comportamento seria diferente.
// Bom: o teste só se importa com o estado final
test('processOrder retorna um pedido processado com total correto', () => {
// Use colaboradores reais e simples ou fakes, se necessário
const validator = new RealValidator();
const calculator = new RealTaxCalculator();
const orderService = new OrderService(validator, calculator);
const result = orderService.processOrder({ items: [{ price: 100 }] });
expect(result.status).toBe('PROCESSED');
expect(result.total).toBe(110); // Assumindo 10% de imposto
});
test('processOrder lança erro para um pedido inválido', () => {
const orderService = new OrderService(new RealValidator(), new RealTaxCalculator());
expect(() => {
orderService.processOrder({ items: [] }); // Pedido vazio inválido
}).toThrow('Invalid order');
});
A segunda versão verifica o contrato: dê um pedido válido, receba um resultado processado. Dê um inválido, receba um erro. Ela não se importa com como o OrderService faz o trabalho. Você pode reescrever completamente o interno do OrderService, e desde que o comportamento seja preservado, os testes continuam passando.
Boas práticas para escrever testes unitários
Pensar em termos de comportamento leva a alguns bons hábitos no dia a dia.
Dê nomes aos testes descrevendo cenário e resultado
Quando um teste falha, o nome dele é a primeira coisa que um desenvolvedor lê. Um nome como testProcessOrder é inútil porque não explica nada. Você precisa ler o teste inteiro para entender o que deu errado.
O nome deve descrever o estado, a ação e o resultado esperado. Você pode usar convenções como Given[Context]_When[Action]_Then[ExpectedResult] ou simplesmente escrever uma frase clara.
- Ruim:
test_user_update - Bom:
updateUser_with_invalid_email_throws_validation_error - Bom:
returns_error_when_attempting_to_deactivate_last_admin_account
Um bom nome torna o log de falha no CI compreensível por si só. Você sabe exatamente qual regra de negócio quebrou sem precisar abrir um arquivo.
Mantenha asserções focadas em um único resultado lógico
Um teste deve ter apenas um motivo para falhar. Se você coloca várias asserções não relacionadas em um único teste, vira um pesadelo para depurar. Você não vai saber se a segunda asserção falhou sozinha ou por causa da primeira.
Isso não é uma regra rígida de “uma asserção por teste”. Tudo bem verificar múltiplas propriedades em um único objeto de resultado. O ponto é validar um conceito lógico por teste.
// Ruim: misturando dois comportamentos em um teste
test('fluxo de criação e atualização de usuário', () => {
const user = userService.create({ name: 'test', email: 'test@test.com' });
expect(user.id).toBeDefined();
expect(user.createdAt).toBeInstanceOf(Date);
const updatedUser = userService.update(user.id, { name: 'new name' });
expect(updatedUser.name).toBe('new name');
expect(updatedUser.updatedAt).toBeInstanceOf(Date);
});
// Bom: dois testes separados para dois comportamentos diferentes
test('cria um usuário com id e timestamp de criação', () => {
const user = userService.create({ name: 'test', email: '[test@test.com](mailto:test@test.com)' });
expect(user.id).toBeDefined();
expect(user.createdAt).toBeInstanceOf(Date);
});
test('atualiza o nome do usuário e define o timestamp de atualização', () => {
const user = createTestUser(); // Usando um helper de teste
const updatedUser = userService.update(user.id, { name: 'new name' });
expect(updatedUser.name).toBe('new name');
expect(updatedUser.updatedAt).toBeInstanceOf(Date);
});
Evite valores fixos sem contexto
Se você vê expect(user.balance).toBe(115.75), de onde veio esse número? É 100 + 15% de imposto + 0.75 de frete? Um teste cheio de valores sem explicação é impossível de manter. Quando a lógica muda, ninguém lembra como aquele número foi calculado.
Use constantes ou variáveis bem nomeadas no setup do teste para deixar clara a relação entre entrada e saída.
// Ruim: números mágicos sem explicação
test('calcula o preço final com imposto e frete', () => {
const result = calculator.calculateFinalPrice(100, 'US');
expect(result).toBe(120);
});
// Bom: variáveis explicam o cálculo
test('calcula o preço final com imposto e frete', () => {
const initialPrice = 100;
const taxRate = 0.15;
const shippingCost = 5;
const expectedFinalPrice = initialPrice * (1 + taxRate) + shippingCost; // 120
const result = calculator.calculateFinalPrice(initialPrice, 'US');
expect(result).toBe(expectedFinalPrice);
});
O segundo exemplo documenta a lógica de negócio dentro do próprio teste. Se o cálculo de imposto mudar, a correção fica óbvia.
Cuidado com mocks
Mocks são uma causa comum de testes difíceis de manter. Quanto mais você usa mocks, mais o teste fica preso à forma como o código está organizado por dentro. Um teste que faz mock de cinco dependências diferentes, no fim, só verifica a sequência de chamadas, não o resultado que o código entrega.
O ideal é usar mocks apenas nas bordas do sistema. Faça mock de coisas lentas, não determinísticas ou com efeitos colaterais, como banco de dados, APIs de terceiros, sistema de arquivos ou o relógio. Para colaboradores internos, como um formatador simples ou uma classe de cálculo, tente usar os objetos reais. Isso transforma o teste em uma pequena integração, que verifica se as classes funcionam juntas e geralmente dá mais confiança. Também vale preferir fakes ou stubs em vez de mocks. Um mock normalmente verifica comportamento (essa função foi chamada?), enquanto um fake é uma implementação leve do componente real, como um banco em memória. Fakes permitem que seus testes foquem em mudanças de estado em vez de detalhes de interação.
Testes também são código: manutenção e trade-offs
Escrever bons testes envolve fazer trade-offs. Não existe uma regra única que funcione em todos os casos.
Equilibrando isolamento e realismo
Um teste unitário puro que faz mock de todas as dependências é rápido e isolado, o que ajuda a identificar falhas. O problema é que ele não dá muita confiança de que o sistema funciona como um todo. Um teste de integração com banco real é lento e pode ser instável, mas prova que as peças se encaixam.
O ponto ideal geralmente está no meio. Um “teste unitário” para um serviço pode usar objetos de valor e helpers reais, mas um repositório fake que guarda dados em memória. Assim, você consegue validar como um pequeno conjunto de objetos funciona junto sem o custo de dependências externas.
Cobertura é uma ferramenta, não um objetivo
Perseguir 100% de cobertura de linhas é uma armadilha. Isso incentiva desenvolvedores a escrever testes inúteis para getters e setters simples ou a cobrir condições de erro obscuras só para fazer um número subir. No fim, você tem custos de manutenção maiores com quase nenhum benefício.
Use relatórios de cobertura para encontrar o que você deixou passar. Se uma parte central da lógica de negócio não tem cobertura, isso é um problema. Se uma estrutura de dados interna sem lógica não está coberta, tanto faz. Foque em testar pontos de decisão e lógica complexa, não cada linha de código.
O papel dos assistentes de IA para escrever testes
Assistentes de IA podem ajudar a escrever testes, principalmente para gerar boilerplate ou cobrir funções puras. Você cola uma função e recebe alguns testes cobrindo o happy path e alguns edge cases comuns.
O problema é que a IA não entende intenção. Ela tende a testar a implementação. Vai fazer mock de todas as dependências e escrever asserções que espelham a estrutura do código, criando exatamente o tipo de teste frágil que você quer evitar. O código gerado sempre precisa de uma revisão cuidadosa. Pense na IA como um programador júnior. Ela pode te dar um primeiro rascunho, mas é seu papel moldar isso em um teste que valide o contrato público e remover qualquer asserção ligada a detalhes internos.