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.

21 Comentários

  1. Gustavo Oliva 17/02/2011 at 13:36 #

    Excelente post, Aniche. De fato, sempre acreditei na testabilidade como forte indicador de qualidade estrutural do design. Contudo, acho que há um pequeno typo “no momento do new EnviadorDeEmails() por exemplo”. Não serie new Processador()? Abraços

  2. Gustavo Ansaldi Oliva 17/02/2011 at 13:56 #

    Excelente post, Aniche. Sempre acreditei na testabilidade como forte indicador da qualidade estrutural do design. Também concordo que não há silver bullet: se o programador não souber design/orientação a objetos, ele não irá longe. Aliás, para ser absolutamente sincero, ao longo da minha carreira eu vi pouquíssimos bons designs OO. Por fim, acho só que há um typo aqui “alterar a classe instanciada no momento do new EnviadorDeEmails() por exemplo”. Não seria “new Processador()”? Abraços!

  3. André Silva 17/02/2011 at 14:04 #

    Excelente post Guilherme e Mauricio.

    Os ganhos que o TDD trás no desenvolvimento de um projeto são evidentes.

    O maior resultado é visto na prática do mesmo.

  4. Vinícius Godoy 17/02/2011 at 14:31 #

    Só tem que cuidar para não recair nisso aqui:
    http://chaosinmotion.com/blog/?p=622

    Eu trabalhei por muitos anos com sistemas de tempo real, testando sistemas complexos. Ainda é realmente difícil testar requisitos de tempo real em sistemas com muita concorrência. Há frameworks inteiros para isso, mas nunca achei um que realmente funcione.

    Alguns mocks também estão longe de serem fáceis de implementar. Embora existam muitas frameworks que auxiliam no trabalho, já tive casos onde criar o Mock seria quase tão trabalhoso que mereceria um projeto próprio (de fato, até iniciei um).

    Isso não invalida os testes unitários. Só gosto de ressaltar que, dependendo do nicho que você atue, não são também um mar de rosas. De qualquer forma, jamais abra mão deles, a menos que isso seja o seu último recurso. E, mesmo quando for, sobrecarregue o trecho não testado com mais testes indiretos. Testar sempre.

  5. Guilherme Silveira 17/02/2011 at 14:38 #

    Oi Gustavo! Ótimo, já corrigi o typo, obrigado!

    Obrigado André realmente é na prática e com o bom conhecimento de design que o desenvolvedor percebe esses benefícios…

    Abraço!

  6. Guilherme Silveira 17/02/2011 at 14:53 #

    Oi Vinicius,

    Tenho a sensação que no post do william ele usa péssimas abordagens de design para mostrar o ponto do exagero, e é por isso que não adianta só fazer testes… tem que saber o que é um bom design e um péssimo design para o seu caso. Se o desenvolvedor achar que o bom é um design ruim, não serão os testes que corrigirão isso, mas eles entregarão algumas dicas.

    Você tem um exemplo desses casos do mock para tentarmos trabalhar em cima? Conheço algums situações tristes de mock mas, essas que conheço, em geral envolvem um design que podia ser melhorado (novamente sendo um smell que o teste mostrou)

    Abraço

  7. Mauricio Aniche 17/02/2011 at 16:25 #

    Oi Vinicius,

    A experiência do desenvolvedor é parte fundamental de qualquer prática de engenharia de software. Nenhuma delas resolve o problema, apenas tentam mostrar o problema o quanto antes; quem resolve o problema no fim, é o programador!

    Concordo com você: testar sistemas complexos é difícil, sim. Mas veja que TDD não é uma prática de testes, e sim uma prática de design. Aposto que mesmo os sistemas com os requisitos não-funcionais mais complicados, no fundo possuem classes e os mesmos problemas de acoplamento e coesão que outros sistemas. Acredito que você possa usar TDD para resolver esse problema, e também usar práticas mais avançadas de teste para garantir requisitos como performance, escalabilidade, etc.

    Abraços!

  8. João Bosco 17/02/2011 at 19:05 #

    Oi,

    Não entendi muito bem esta parte: “Para fazer isso, ele seria obrigado a setar expectativas em mocks, que não seriam utilizadas, tornando mais difícil a escrita desse teste. Isso é um sinal de que a classe tem baixa coesão.”

    Pq as expectativas não seriam utilizadas?

    Que eu saiba, sempre que se usa mocks, é necessário setar expectativas e elas são sempre usadas, não? A não ser que você esteja se referindo a “stubs” anteriormente no artigo e não mocks. “Mocks aren’t stubs”: http://martinfowler.com/articles/mocksArentStubs.html

    “Stubs” não tem expectativas pois servem para testar estado e não comportamento.

    [ ]s

    Bosco

  9. Mauricio Aniche 17/02/2011 at 20:16 #

    Oi João Bosco,

    Realmente a frase está confusa, e eu não fiz bom uso dos termos mocks e stubs! Sua definição está correta!

    O que quis dizer é o seguinte: Imagine a implementação do teste deveArmazenarNfNoSap(). Esse teste muito provavelmente criará um stub da classe Email, correto?

    Mas repare que esse não é o foco desse teste! Nele eu não estou nem um pouco preocupado se o e-mail será disparado. Isso pra mim é um alerta de que essa classe tem mais de uma responsabilidade!

    Faz sentido?

    Abraços!

  10. João Bosco 17/02/2011 at 23:10 #

    Aniche,

    Faz total sentido neste caso. Mas você acha que todo uso de dublês (mocks ou stubs) é um sinal de design ruim? Ou somente às vezes?

    Imaginando uma arquitetura web clássica em 3 camadas, por exemplo. Se você quiser testar somente a camada do meio (business), provavelmente terá que usar um dublê pra tua camada de acesso a dados (dao), não?

    [ ]s e parabéns pelo artigo.

    Bosco

  11. Rafael 18/02/2011 at 10:10 #

    Eu não entendi muito bem a parte do “No teste deveArmazenarNfNoSap, por exemplo, o programador seria obrigado a passar um stub da classe Email, mesmo não sendo o foco daquele teste. ”

    O cara precisa mandar um email, então qual seria a alternativa?

  12. Bruno 18/02/2011 at 12:46 #

    Olá, sempre tive essa dúvida em relação a colocar dependências no construtor, vejam o exemplo:

    Supondo que são todas classes de negócio

    Classe A utiliza as classes B e C. C utiliza a classe D. Todas recebem suas dependências via construtor.
    Para instanciar A eu devo passar B C e D, criando um acoplamento com D.
    Se eu tiver um AController eu teria que instanciar A, B, C e D.

    Eu poderia levar essa dependências até o main(). Então a dúvida seria: Até onde eu paro colocando as dependências via construtor?

  13. Mauricio Aniche 20/02/2011 at 10:26 #

    Oi João,

    Não, não acho que o uso de mocks indica design com problemas. Pelo contrário, objetos devem trocar mensagens entre si!

    O problema que me refiro no texto é em relação ao acoplamento e coesão. E os testes nos dão indicação de possíveis problemas, como discuti no texto.

    No seu exemplo, o uso de mocks é correto. Você tem uma classe responsável por acesso a dados e uma outra responsável por alguma regra de negócios, que depende de alguma coisa que faça a acesso a dados. Para testá-la, você precisa “mocká-la” sim!

  14. Mauricio Aniche 20/02/2011 at 10:28 #

    Oi Rafael,

    A alternativa seria diminuir as responsabilidades dessa classe. Ela já é responsável por gerar uma nota fiscal (que não é um processo simples). Os outros comportamentos, como enviar um e-mail e a nota para o SAP, poderiam ser feitos de outras formas.

    Você pode compor esses comportamentos, por exemplo. Usando um Observer [GoF], você faria com que a classe GeradorDeNotaFiscal apenas gerasse a NF, e depois notificaria todos os observadores; no caso, seriam uma classe que enviaria e-mail e outra que enviaria para o SAP.

    Repare que, dessa forma diminuimos a responsabilidade de todas as classes, e ainda tornamos a GeradorDeNotaFiscal extensível.

    Conseguiu entender o exemplo?

    Abraços,
    Mauricio

  15. Mauricio Aniche 20/02/2011 at 10:36 #

    Oi Bruno,

    Gosto de pensar que as dependências devem ser sempre passadas pelo construtor. Dessa maneira, evitamos ter objetos em “estados inconsistentes” em algum momento da nossa aplicação. Um objeto deve estar pronto para responder à qualquer comportamento, não importa o momento.

    No seu exemplo, sim, existe um acoplamento de A em relação a D, mas ele é indireto. Repare que A depende na verdade somente do contrato que C assina.

    Na sua implementação, C depende de D para fazer o que deve ser feito. Talvez algum dia apareçam outras possíveis implementações de C. Repare que se você implementar um C2, que assine o mesmo contrato, mas não use D para fazer o trabalho, a classe A não se importará com isso!

    E isso vai acontecer, Bruno. A ideia é que você tenha sempre pequenas classes, que juntas fazem algo grande. E para isso você vai ter que compor esses comportamentos de alguma forma. Uma das formas, mas não a única, é recebendo dependências no construtor. Por isso, é importante depender sempre de interfaces e não de implementações concretas.

    O problema é obviamente criar esse grafo de objetos e para isso algum framework de injeção de dependências como Spring, Guice, PicoContainer pode ajudá-lo.

    Abraços,
    Mauricio

  16. Ricardo 21/02/2011 at 14:35 #

    Eu só concordo com uso de mock em ÚLTIMOS casos, para mim ele quebra encapsulamento, eu to replicando o código da minha aplicação para o teste (com as expectativas). Em teoria pode ser legal, na pratica seus testes unitários não vão servir para nada dependendo do sistema. Se seu sistema for um CRUD simples e vc esta mockando o banco, de que adianta? a parte crucial do seu sistema está mockado, usa spring, faz testes de integração com rollback no final e pronto, muito mais útil.

    Eu acho que tem que cuidar muito com essa teoria de testes unitários, vc acaba criando um mostrinho para ficar conceitualmente legal. Se o gerador de nota fiscal não envia email, algum serviço acima dele vai precisar enviar (que usa o gerador e envia email), e essa classe precisa ser testada, logo, você tem o mesmo problema. Usar Observer? eu gostei em principio, mas seria aquilo, complexibilidade adicional ao sistema, ao invés de simplesmente ter uma classe que faz isso, você criou todo um esquema de notificação, ao invés de simplesmente se injetar as dependências você precisa criar suas classes, registra listeners, etc enfim, depois para dar manutenção, olhar a classe e saber quais são os listeners dela, o que esta executando, enfim, na pratica eu acho ruim, conceitualmente bonito, porém difícil depois de manter

  17. Mauricio Aniche 21/02/2011 at 14:52 #

    Oi Ricardo,

    O uso de qualquer prática é totalmente contextual. No seu primeiro exemplo, um CRUD, talvez não faça sentido mesmo mockar o DAO. Um teste de integração como você mesmo disse é mais útil e dá mais feedback. Você deve usar o teste que mais fizer sentido para aquele determinado trecho de código.

    No seu segundo exemplo, acho que depende do ponto de vista. No código do post, repare que a classe GeradorDeNotaFiscal faz uma série de atividades depois de gerar a nota, como mandar e-mail, mandar pro Sap. São apenas duas nesse momento, mas isso pode crescer (e cresceu, já que o exemplo acima foi retirado de um código real). O que hoje são “apenas 2 dependências”, amanhã pode crescer e aí sim virar um monstrinho, como você mesmo disse.

    É um trade-off. Deixar o código mais extensível e, por consequência, menos explícito (como você mesmo levantou, você precisa descobrir quais são os listeners dela), ou deixar o código menos extensível e mais explícito.
    O programador deve pesar isso na hora de desenhar as classes.

    No código acima, refatoramos para um observer, e tínhamos uma classe responsável por construir esse GeradorDeNotaFiscal com os listeners básicos. Ou seja, se o programador quisesse ver quais os listeners executados, poderia simplesmente olhar essa classe.

    Abraços,
    Mauricio

  18. camilo lopes 25/02/2011 at 00:01 #

    excelente post! Guilherme, estou vivendo isso no meu projeto atual e vejo que ainda há muitos programadores que acreditam que TDD nao funciona que é só conversinha ou que TDD se limita a ter os tests cases. Tenho tentado provar o quanto isso a cada feature que vou entregando e mostrando que o que entrego é coeso e seguro, pq TDD motiva eu ser assim.
    Parabens! pelo post. Não sei se tem um post aqui, mas seria bao TDD nao é “TDT – Test Driven Test” ou seja, criar um tests case nao é TDD, ainda confudem isso.
    abracos

Deixe uma resposta