Cobertura de Testes x Qualidade
Gostaria de compartilhar uma reflexão pessoal e uma dificuldade constante que enfrento diariamente: como garantir a qualidade dos testes? Este é um tema que me motiva a explorar a relação entre cobertura e qualidade. Diariamente, percebo como essa questão influencia diretamente na entrega dos produtos.
Na minha percepção, os líderes de alta hierarquia acreditam que uma cobertura de testes de 100% garante a qualidade e o funcionamento adequado dos produtos. A cobertura de testes unitários é amplamente considerada um indicador crucial de qualidade de software, pois sugere que toda a lógica do código foi testada antes da implantação. No entanto, mesmo assim, os produtos ainda podem falhar. Por quê?
Eu mesmo já investi horas criando testes unitários para alcançar uma cobertura acima de 90%, apenas para descobrir, na fase de produção, que o produto ainda continha bugs. Muitos desenvolvedores experientes já passaram por isso: cumprir as métricas do Sonar ou de algum painel de controle, mas sem alcançar o resultado esperado. Por quê?
A cobertura de testes basicamente indica que todas as unidades de código têm um teste correspondente. Mas isso é apenas o começo. Usar a cobertura de testes unitários como único indicador de confiabilidade do produto apresenta riscos significativos. Primeiro, ela não valida o produto como um todo. Segundo, os próprios testes unitários podem ser mal concebidos ou de baixa qualidade. A maioria das ferramentas automatizadas, como o Sonar, não consegue discernir isso. Elas simplesmente afirmam que todas as unidades são testadas, sem avaliar a qualidade desses testes.
Por exemplo, se um desenvolvedor cria um teste unitário que verifica algo tão trivial como “1 é igual a 1”, sem realizar asserções válidas, ferramentas de análise estática como o Sonar interpretam isso como uma unidade de código coberta por um teste, embora isso não prove efetivamente nada.
Eu já cansei de ver testes que não têm utilidade prática, como por exemplo:
Teste de componente que verifica título passado como prop
import React from 'react';
import { render } from '@testing-library/react';
import Layout from './Layout';
test('renders the title passed as prop', () => { // (cobertura de 100%)
const { getByText } = render(<Layout titulo="Test Title">Content</Layout>);
const titleElement = getByText(/Test Title/i);
expect(titleElement).toBeInTheDocument();
});
Por que isso não serve para nada?
- Este teste apenas verifica se o componente renderiza o título passado como prop, algo que é muito simples e não cobre lógica ou comportamento do componente.
- Testes que não têm lógica de negócio complexa tornam-se redundantes.
Testes de cálculo simples
function add(a, b) {
return a + b;
}
test('adds 1 + 2 to equal 3', () => { // (cobertura de 100%)
expect(add(1, 2)).toBe(3);
});
Por que isso não serve para nada?
- É obvio que 1 + 2 é 3.
- Testar uma função simples de adição não agrega valor, pois a operação de adição é garantida pelo próprio JavaScript.
- Testes de lógica trivial geralmente não refletem a complexidade e os casos de uso reais do sistema.
O importante não é obter 100% de cobertura do código fonte. O importante deve ser cobrir todos os cenários que possuam uma lógica que, se alterada, vai afetar o core do produto. Um teste deve ser criado para proteger uma lógica de negócios e minimizar o risco.
Para ilustrar um cenário, vou dar um exemplo, que reflete bem os problemas dos testes apenas para bater cobertura.
Código
class BankAccount {
constructor(initialBalance) {
this.balance = initialBalance;
}
deposit(amount) {
if (amount > 0) {
this.balance += amount;
}
}
getBalance() {
return this.balance;
}
}
module.exports = BankAccount;
Teste Unitário Ruim
const BankAccount = require('./BankAccount');
test('testDepositPositiveAmount', () => {
const account = new BankAccount(100);
account.deposit(50);
expect(account.getBalance()).toBe(150);
});
test('testGetBalance', () => {
const account = new BankAccount(100);
expect(account.getBalance()).toBe(100);
});
Por que este é um teste unitário ruim?
- Cobertura Superficial: Apenas verifica se a função deposit adiciona valores positivos e se getBalance retorna o saldo inicial. Não cobre outros cenários.
- Falta de Testes Negativos: Não há verificação de comportamento para valores inválidos ou negativos.
- Sem Validação de Tipos: Não testa comportamentos para tipos de dados incorretos, como strings ou valores null.
- Baixa Robustez: Não garante que a lógica de negócio está correta para todos os cenários possíveis.
Teste Unitário Bom
describe('BankAccount', () => {
test('should initialize with the correct balance', () => {
const account = new BankAccount(100);
expect(account.getBalance()).toBe(100);
});
test('should deposit positive amount correctly', () => {
const account = new BankAccount(100);
account.deposit(50);
expect(account.getBalance()).toBe(150);
});
test('should not deposit negative amount', () => {
const account = new BankAccount(100);
account.deposit(-50);
expect(account.getBalance()).toBe(100);
});
test('should not deposit zero amount', () => {
const account = new BankAccount(100);
account.deposit(0);
expect(account.getBalance()).toBe(100);
});
test('should handle string deposit gracefully', () => {
const account = new BankAccount(100);
account.deposit("50");
expect(account.getBalance()).toBe(100);
});
test('should handle null deposit gracefully', () => {
const account = new BankAccount(100);
account.deposit(null);
expect(account.getBalance()).toBe(100);
});
test('should handle undefined deposit gracefully', () => {
const account = new BankAccount(100);
account.deposit(undefined);
expect(account.getBalance()).toBe(100);
});
});
Por que este é um teste unitário bom?
- Cobertura Completa: Cobre todos os caminhos possíveis de execução da função deposit.
- Testes Negativos: Verifica comportamentos para valores inválidos, como negativos e zero.
- Validação de Tipos: Garante que a função deposit lida corretamente com tipos de dados incorretos, como strings, null e undefined.
- Robustez e Confiabilidade: Garante que a lógica de negócio está correta e robusta contra entradas inválidas e casos de borda.
- Segregação de Cenários: Cada cenário de teste é separado, facilitando a manutenção e entendimento do código de teste.
AMBOS OS TESTES APRESENTAM 100% DE COBERTURA. O ponto que quero destacar aqui é que, às vezes, tratar a cobertura de testes com tanta importância cria um ambiente de trabalho maçante, onde as pessoas precisam testar até cenários inúteis para atender à meta de cobertura. Isso leva a um comportamento em que as pessoas param de raciocinar e pensar de fato em quais testes são importantes, criando testes apenas para garantir essa meta.
Muitas vezes, a meta de cobertura de 100% é vista como um tabu, fazendo com que se testem até métodos sem importância, apenas para que as ferramentas indiquem 100% de cobertura. Esse tipo de pensamento pode causar sérios problemas, pois as pessoas deixam de considerar os casos de teste realmente importantes e acabam negligenciando cenários críticos.
Para concluir, é importante refletir sobre como podemos melhorar a qualidade dos nossos testes, em vez de focar na cobertura, uma métrica que não indica nada de concreto.
Acredito que testes estão intrinsecamente ligados à cultura, responsabilidade e ao dia a dia do desenvolvedor. Eles devem ser encarados como uma prática contínua e aprimorada constantemente, e não apenas uma meta numérica a ser atingida.
Espero que este artigo tenha sido útil para uma reflexão. Agradeço por dedicar seu tempo para ler e espero ter contribuído de alguma forma para o seu conhecimento. Fique à vontade para compartilhar suas opiniões nos comentários e continuar essa conversa. Até a próxima!