TDD e sua influência no acoplamento e coesão
Escrever testes de unidade é uma prática cada vez mais adotada. Ela ajuda a verificar se tudo funciona como o esperado mesmo após mudanças, trazendo mais segurança para a equipe ao alterar o código. Mas os testes de unidade vão além, possibilitando a validação de um design.
Um código fácil de testar tende a apresentar um bom design. _Existe uma grande sinergia entre testabilidade e um bom design_. Isso acontece pois, para que o programador consiga testar uma classe de maneira isolada e facilmente, essa classe deve lidar muito bem com suas dependências (buscando sempre um baixo acoplamento) e possuir pouca responsabilidade (ou seja, ser altamente coesa). Caso contrário, o programador gastará muito tempo tentando testá-la, um possível indicador de que a classe apresenta um design pobre.
Um design que apresenta classes com alto acoplamento são difíceis de testar, surgindo a necessidade de passar as dependências para nossos objetos e só então testá-los.
O primeiro resultado ao adicionar testes de unidade a um sistema é a mudança de padrões imperativos de execução onde instanciamos objetos e invocamos métodos para a injeção de dependências.
O exemplo a seguir mostra um processador sendo instanciado, o que torna difícil verificar se o comportamento do ExecutorDeBatch
foi correto sem executar o código da classe Processador
:
public class ExecutorDeBatch { public void le(ListaDeItens itens) { Processador processador = new Processador(); while(itens.temProximo()) { String item = sc.proximo(); processador.interpreta(item); } } }
O teste acima verifica o comportamento do ExecutorDeBatch
junto com o Processador
. Esse tipo de teste é considerado de integração, não passando muita informação sobre o acoplamento entre as classes, mas somente garantindo o comportamento em conjunto.
Para isolar a classe acima seria necessário a utilização de um mock no lugar do Processador
. Da maneira que o código está, seria bastante trabalhoso. No Java exigiria até manipulação de bytecode durante o classload para que fosse possível alterar a classe instanciada no momento do new Processador()
por exemplo. Para isso, o programador deve deixar essas dependências bem explícitas, recebendo-as através do construtor, por exemplo. Teríamos então o seguinte código:
public class ExecutorDeBatch { private Processador processador; public ExecutorDeBatch(Processador processador) { this.processador = processador; }
public void le(ListaDeItens itens) { while(itens.temProximo()) { String item = sc.proximo(); processador.interpreta(item); } } }
Passar um mock para a classe acima e consequentemente testá-la de maneira isolada ficou é fácil. A adoção dos testes de unidade deixa explícito o excesso de dependências (ou dependências implícitas escondidas).
Por fim, os testes precisam ser simples e curtos, limitando seu escopo; um teste muito grande ou complicado pode indicar que o método em questão possui muita responsabilidade.
Considere uma classe que é responsável por gerar uma nota fiscal a partir de uma fatura e disparar a mesma para o e-mail do cliente, assim como para um sistema SAP qualquer. Essa classe já possui as dependências bem explícitas:
public class GeradorDeNotaFiscal { private final Sap sap; private final EnviadorDeEmails enviador;
public GeradorDeNotaFiscal(Sap sap, EnviadorDeEmails enviador) { this.sap = sap; this.enviador = enviador; }
private NotaFiscal geraNotaFiscalAPartirDa(Fatura fatura) { // codigo de geracao da nota fiscal }
public void gera(Fatura fatura) { NotaFiscal nf = geraNotaFiscalAPartirDa(fatura);
sap.armazena(nf); enviador.envia(nf); } }
Passar mocks para essa classe é fácil, já que é possível injetar todas as dependências nela. O problema é que, para criar testes para ela, o programador se preocuparia em testar se a nota fiscal foi gerada, se ela é enviada ao SAP e se ela é enviada para o cliente:
@Test public void deveGerarANf() { ... } @Test public void deveArmazenarNfNoSap() { ... } @Test public void deveEnviarNfParaOEmailDoCliente() { ... }
Perceba que essa classe tem responsabilidades demais: ela gera NF, envia para o SAP, envia para o cliente. Ao escrever esses testes, o programador perceberia que, em cada teste, ele estaria preocupado em testar uma responsabilidade da classe, sem se importar com a outra. No teste deveArmazenarNfNoSap
, por exemplo, o programador seria obrigado a passar um stub da classe Email
, mesmo não sendo o foco daquele teste. Isso torna a escrita do teste mais massante é um sinal de que a classe tem baixa coesão.
Portanto, se o seu design possue um alto acoplamento ou uma baixa coesão, será praticamente impossível escrever testes de unidade. Por esse mesmo motivo métodos estáticos, singletons, factories e estado global dificultam os testes de unidade.
Pensar e criar os testes antes do código também auxiliam no design. Usar Test-Driven Development força que a concentração do pensamento lógico foque nas responsabilidades de cada método, ao invés da exata implementação do mesmo.
Esse impacto no design é tão grande que muitos vão utilizar a sigla TDD para Test-Driven Design. Com TDD o programador é forçado a pensar em decisões de design antes do código de implementação, criando um maior desacoplamento, deixando mais claro suas necessidades.
O TDD, através dos testes de unidade e refatoração, possibilitam que o design apareça de uma maneira mais evolutiva, mas sem mágicas, sempre dependendo de conhecimento do desenvolvedor em relação a design e orientação a objetos.