Escrevendo código testável

Publicados: 2022-11-03

O teste de unidade é um instrumento essencial na caixa de ferramentas de qualquer desenvolvedor de software. Escrever testes de unidade é relativamente fácil ao lidar com uma base de código que segue as melhores práticas, padrões e princípios de design de software. O problema real surge ao tentar testar a unidade de código mal projetado e não testável.

Este blog discutirá como escrever mais código testável por unidade e quais padrões e práticas ruins devem ser evitados para melhorar a testabilidade.

Código testável e não testável

Ao trabalhar em aplicativos de grande escala que precisam ser mantidos a longo prazo, devemos confiar em testes automatizados para manter a qualidade geral do sistema alta. Em comparação com os testes de integração, nos quais você testa várias unidades como um todo, os testes de unidade têm a vantagem de serem rápidos e estáveis. Rápido porque estamos instanciando, idealmente, apenas a classe em teste e estável porque geralmente simulamos as dependências externas, por exemplo, banco de dados ou conexão de rede.

Se você não estiver familiarizado com a diferença exata entre testes de unidade e de integração, leia mais sobre esse tópico em nosso blog Introdução ao teste.

O código testável pode ser isolado do restante de nossa base de código. Em outras palavras, as menores unidades podem ser testadas independentemente. O código não testável é escrito de tal forma que é difícil, ou mesmo impossível, escrever um bom teste de unidade para ele.

Vamos revisar alguns antipadrões e práticas ruins que devemos evitar ao escrever código testável.

Os exemplos são escritos em Java, mas as convenções de codificação mencionadas aqui se aplicam a qualquer linguagem de programação orientada a objetos e estrutura de teste. Usaremos assertj e JUnit5 para exemplos nesta postagem do blog.

Injeção de dependência

A injeção de dependência é um dos padrões de projeto mais importantes para obter o isolamento de teste. A injeção de dependência é um padrão de design no qual um objeto recebe outros objetos (dependências) por meio de parâmetros ou setters do construtor, em vez de ter que construí-los sozinho.

Com a injeção de dependência, podemos isolar facilmente a classe em teste simulando as dependências de um objeto.

Vejamos um exemplo sem injeção de dependência:

Como a dependência do mecanismo está sendo construída no construtor da classe Car, você pode dizer que as classes Car e Engine são fortemente acopladas. Eles são altamente dependentes um do outro – mudar um exigiria uma mudança no outro.

De uma perspectiva de teste, você não pode testar a classe Car isoladamente porque o exemplo acima não pode substituir a implementação concreta do Engine por um teste duplo.

No entanto, podemos obter o isolamento com o uso de injeção de dependência e polimorfismo:

Agora podemos construir várias implementações de motores e, portanto, carros com motores diferentes:

Testar isoladamente agora é possível porque podemos criar uma implementação simulada da abstração Engine e passá-la para nossa classe Car:

Ao lidar com objetos que requerem outros objetos (dependências), você deve fornecê-los por meio de parâmetros do construtor (injeção de dependência), idealmente escondidos atrás de alguma abstração.

Seguindo esse padrão, seu código se torna mais legível e adaptável a mudanças ao longo do tempo. Além disso, você deve evitar fazer trabalho real em construtores – qualquer coisa além de atribuições de campo é trabalho real. A palavra-chave `new` em construtores é sempre um sinal de alerta de código não testável.

Uma coisa a notar aqui é que, em algumas situações, o acoplamento rígido (por exemplo, novas palavras-chave em construtores, classes internas para isolamento lógico, mapeadores de objetos) não é uma prática ruim – classes que não fariam sentido como uma classe “independente”.

Estado global

O compartilhamento de um estado global geralmente pode produzir testes irregulares (às vezes passam, às vezes falham), especialmente em ambientes multithread.

Imagine um cenário em que vários objetos em teste compartilham o mesmo estado global – se um método em um dos objetos acionar um efeito colateral que altera o valor do estado global compartilhado, a saída de um método no outro objeto se torna imprevisível. Evite usar métodos estáticos impuros porque eles alteram o estado global de alguma forma ou são proxy para algum estado global.

Vejamos este método estático impuro:

Essencialmente, esse método lê a data e hora atuais do sistema e retorna um resultado com base nesse valor. Seria muito difícil escrever um teste de unidade baseado em estado adequado para este método porque a chamada estática LocalDateTime.now() produzirá resultados diferentes durante a execução de nossos testes. Escrever testes para este método é impossível sem alterar a data e hora do sistema.

Para corrigir isso, passaremos a data e hora para o método timeOfDay como um argumento:

O método estático timeOfDay agora é puro – as mesmas entradas sempre produzem os mesmos resultados. Agora podemos passar facilmente objetos dateTime isolados como argumentos em nossos testes:

Lei de Deméter

A Lei de Deméter, ou o princípio do mínimo conhecimento, afirma que os objetos devem saber apenas sobre objetos intimamente relacionados ao primeiro objeto. Em outras palavras, um objeto deve ter acesso apenas aos objetos de que precisa. Por exemplo, temos um método que aceita um objeto de contexto como argumento:

Este método viola a Lei de Deméter porque precisa percorrer um grafo de objetos para obter as informações necessárias para realizar seu trabalho. Passar informações desnecessárias para classes e métodos prejudica a testabilidade.

Imagine um enorme objeto BillingContext contendo referências a outros objetos:

Como podemos ver, nosso teste está cheio de informações não essenciais. Testes que criam gráficos de objetos complexos são difíceis de ler e apresentam complexidade desnecessária.

Vamos corrigir nosso exemplo anterior:

Você deve sempre passar dependências diretas em suas classes e métodos. No entanto, passar muitos argumentos em métodos também não é uma boa prática – idealmente, você deve passar dois argumentos no máximo ou envolver argumentos próximos em objetos de dados.

Deus objeta

O objeto Deus é um objeto que faz referência a muitos outros objetos distintos, tem mais de uma responsabilidade e tem vários motivos para mudar. Se for um desafio resumir o que a classe faz, ou se o resumir incluir a palavra “e”, a classe provavelmente tem mais de uma responsabilidade.

Objetos de Deus são difíceis de testar, pois estamos lidando com múltiplas dependências não relacionadas, misturando vários níveis de abstrações e preocupações, e eles produzem muitos efeitos colaterais. Consequentemente, é difícil atingir o estado desejado para nossos casos de teste.

Por exemplo:

UserService tem mais de uma responsabilidade – registrar novos usuários e enviar e-mails. Ao testar o registro do usuário, precisamos lidar com o serviço de e-mail e vice-versa:

Imagine um UserService com mais de duas dependências não relacionadas. Essas dependências têm suas próprias dependências e assim por diante. Acabaríamos com um teste ilegível, cheio de informações não relacionadas e muito difícil de entender. Portanto, cada classe deve ter apenas uma responsabilidade e motivo para mudar. Uma classe que tem apenas um motivo para mudar é um dos cinco princípios de design de software chamados de princípio de responsabilidade única.

Você pode ler mais sobre os princípios SOLID aqui.

Conclusão

Uma base de código que segue as melhores práticas de design de software torna a escrita de testes de unidade muito mais gerenciável.

Por outro lado, pode ser muito desafiador, ou às vezes até impossível, escrever um teste de unidade funcional para uma base de código usando os antipadrões mencionados. Escrever um bom código testável requer muita prática, disciplina e esforço extra. A vantagem mais significativa do código testável é a facilidade de testabilidade e a capacidade de entender, manter e estender esse código.

Esperamos que este blog ajude você a escrever código testável.