Saturday, October 18, 2008

Modelando código testável

No mundo do desenvolvimento de software, testes unitários são aceitos por quase 100% dos profissionais, empresas e clientes como uma excelente ferramenta para o incremento da qualidade, manutenibilidade, confiabilidade e mais vários *dade que a literatura tanto gosta de enumerar. Paradoxalmente, quase ninguém aceita arcar com os custos associados à utilização deste tipo de teste quando percebe que eles não são tão triviais como são pintados pela maioria dos TDD Evangelists de plantão.
Em muitos casos, o código de um determinado teste unitário é mais extenso e/ou complexo que o código que está sendo testado. Infelizmente só nos damos conta disso quando começamos a bater cabeça. Como os projetos do mundo real nunca são tão simplórios quanto os exemplos Hello World like dos livros, e os prazos sempre são apertados, a boa intensão de criar aquela suite de testes mágica que vai verificar o código automaticamente de hora em hora, vai por água abaixo.
Outra importante causa de fracasso neste campo é que muita gente não modela o código de forma que o mesmo seja testável. Não é óbvio à primeira vista, mas nem todo código é testável. Usualmente, código bem modelado é testável, mas o desenvolvedor tem que pensar, o tempo todo, que aquela classe tem que ser testada isoladamente, livre de todas as dependências. Assim, o requisito mais importante para utilizar testes unitários como uma ferramenta efetiva, é a modelagem do código de forma que ele seja facilmente testável via frameworks como JUnit ou TestNG. Se o código não atende a este requisito, refatore-o ou esqueça os testes unitários.
Antes de dizer como modelar código testável, prefiro dizer o que torna uma classe difícil de testar:
  • Classes faz-tudo com milhares de linhas contendo métodos faz-tudo com centenas de linhas são muito difíceis de testar. Quando o código a ser testado possui uma grande quantidade de fluxos possíveis, é muito complicado configurar um código de teste que exercite todos estes fluxos. É importante que as classes tenham responsabilidades bem definidas e que elas deleguem outras responsabilidades para outras classes. Isso é modelagem OO tradicional mesmo, nada de novo.

  • Classes que referenciam outras classes (colaborators) diretamente, usando o operador new. É praticamente impossível substituir os colaborators por mocks.

  • Classes que dependem diretamente de recursos externos, tais como: conexões de rede, sistemas de arquivos, datasources, etc são uma pedra no sapato do autor de um teste unitário.

O exemplo a seguir mostra uma classe que depende que um arquivo com um certo nome exista no sistema de arquivos, o que força o pobre desenvolvedor do teste unitário a garantir que o arquivo em questão exista no mesmo diretório, exatamente com o mesmo nome usado pelo código alvo do teste:
public class ImportadorDadosVendas{
  public void importar(){
    File arquivoVendas = new File(obterNomeArquivoVendas());
    //--- Processar arquivo linha a linha, bla, bla
  }
  private String obterNomeArquivoVendas(){
    //
  }  
}
É muito simples refatorar o código acima de modo a torná-lo mais amigável a um teste unitário. Como o sistema de arquivos é um recurso externo, temos que garantir que qualquer acesso a ele seja feito indiretamente, através de uma classe auxiliar. Neste exemplo vou usar o Spring para ilustrar a aplicação da inversão de controle (IoC) neste tipo de situação. O código a seguir ilustra esta refatoração:
/**
 * Interface que define um servico de acesso indireto ao filesysten
 **/
public interface ProvedorArquivos{
  File getReferenciaArquivo(String nomeArquivo);
}

/**
 * Implementação padrão da interface ProvedorArquivos
 **/
public class ProvedorArquivosImpl implements ProvedorArquivos{
  public File getReferenciaArquivo(String nomeArquivo){
    return new File(nomeArquivo);
  }  
}

/**
 * Serviço original refatorado
 **/
public class ImportadorDadosVendas{
  private ProvedorArquivos provedorArquivos;
  public void importar(){
    //--- Referência ao arquivo passa a ser fornecida pelo objeto provedorArquivos
    File arquivoVendas = provedorArquivos.getReferenciaArquivo(obterNomeArquivoVendas());

    //--- Processar arquivo linha a linha, bla, bla
  }
  private String obterNomeArquivoVendas(){
    //
  }

  /**
   * IMPORTANTE: Este método é chamado pelo Spring, que injeta o colaborator
   * O teste unitário pode chamar este método para fornecer um mock
   **/
  public setProvedorArquivos(ProvedorArquivos provedorArquivos){
    this.provedorArquivos = provedorArquivos;
  }
}
Para o Spring, temos a seguinte configuração:

  
  
  

  
  
    
    
    
  

A classe ImportadorDadosVendas agora usa uma implementação da interface ProvedorArquivos para recuperar uma referência ao arquivo que será importado. Note também que a instância do provedor de arquivos não é criada diretamente a partir do código da classe ImportadorDadosVendas, mas é fornecida externamente pelo Spring via um setter. Esta é uma mudança crucial: as dependências não são criadas localmente, mas fornecidas externamente.
Quando a classe ImportadorDadosVendas for executada normalmente, será fornecida uma instância de ProvedorArquivosImpl via setter. Porém, quando for executada por um teste unitário, será fornecida uma implementação alternativa da interface ProvedorArquivos, com comportamento previsível dentro do contexto do teste:
public class ImportadorDadosVendas extends Testcase{
  public void testImportar(){
    ImportadorDadosVendas importador = new ImportadorDadosVenda();
    ProvedorArquivos provedorArquivos = new ProvedorArquivosMock();
    importador.setProvedorArquivos(provedorArquivos);
    importador.importar();
    //--- Asserts a partir daqui
  }

  public static class ProvedorArquivosMock implements ProvedorArquivos{
    public File getReferenciaArquivo(String novoArquivo){
      File arquivoParaTeste = File.createTempFile("testeImportacao.dat");
      //--- Popular arquivo temporario com dados para o teste
    }
  }
}
Esta abordagem pode ser aplicada a qualquer dependência da classe a ser testada. O uso de objetos “falsos”, conhecidos como mocks e stubs é uma das técnicas mais importantes para a implementação de testes unitários. Note que a referência à instância do provedor de arquivos é feita através de uma interface. Isso esconde a verdadeira implementação e facilita a substituição da classe real por um mock. Existem diversas libs de suporte à criação de mocks e stubs. Entre elas, as mais usadas são EasyMock e JMock.
O uso de frameworks de inversão de controle (AKA IoC) como Spring ou Google Guice traz uma grande contribuição para a testabilidade do código. Estes frameworks funcionam como super factories de objetos, sendo responsáveis tanto pela criação dos objetos, quanto dos relacionamentos entre eles. A chave está justamente no gerenciamento das dependências. Uma vez que as dependências são injetadas de forma transparente, via setters, o código do teste unitário pode substituir estas dependências por mocks usando os mesmos setters que normalmente seriam invocados pelo container de IoC.
Está fora do escopo deste post abordar o funcionamento dos containers IoC, mas quero enfatizar que eles são a principal ferramenta no caminho para a criação de código bem estruturado, pouco acoplado e portanto testável.
Criar suites de testes efetivas não é uma tarefa simples, mas pode ser facilitada:
  • Procure dividir a complexidade em classes menores com responsabilidade bem definida.

  • Sempre acesse colaborators através de interfaces.

  • Use frameworks de IoC como o Spring para tornar o mecanismo de injeção de dependências mais fácil de ser substituído pelo setup do código de teste.

  • Isole o acesso a recursos externos como bancos de dados e sistemas de arquivos. Referências a estes recursos devem ser fornecidas por interfaces injetadas via IoC.

5 comments:

Unknown said...

Gostei muito do exemplo sobre acesso a recursos externos e a dificuldade de criação de testes em classes escritas de modo "tradicional", sem testes unitários em mente.

Importante ressaltar que existe uma sinergia entre testes unitários e refactoring. Refactorings podem ser usados para transformar um código de forma a deixá-lo mais testável. Por outro lado, testes unitários tornam refactorings mais confiáveis na medida em que verificam se as alterações realizadas introduziram algum efeito colateral indesejável.

Projetos "do mundo real" normalmente contam com mais de um desenvolvedor. É importante que todos os desenvolvedores tenha a mesma mentalidade de escrever código testável. Isso não é atividade só do arquiteto, só do líder técnico ou só do desenvolvedor mais experiente. Testes unitários só são efetivos se todos testarem.

No mais, parabéns pela escolha do tema e por seu desenvolvimento, Wilson.

Paulo Ferreira de Moura Jr. said...

Mas não se esqueça que em projetos "do mundo real" há prazos que muitas vezes inviabilizam o uso de boas práticas. :-) Infelizmente em vários casos os prazos são exíguos, a equipe trabalha sobre pressão e não há tempo hábil para adotá-las. Enquanto os clientes não estiverem dispostos a pagar pelo custo de desenvolvimento de testes unitários, o que pode levar até a um custo total menor do que os que são desenvolvidos ad-hoc, infelizmente não veremos tais práticas sendo adotadas na maioria das empresas de desenvolvimento.

Anonymous said...

Wilson,

É bom ler relatos da vida real no mundo do desenvolvimento de software. O assunto é bem interessante e foi abordado com propriedade. Parabéns pelo post!

Wilson Freitas said...

Fabrício,

Obrigado por ler o post. Fico feliz por ter sido útil :)

Dei uma olhada rápida no seu blog e achei bem interessante. Mais tarde vou ler seus posts com calma.

[]s,

Wilson

Unknown said...

"Classes que dependem diretamente de recursos externos, tais como: conexões de rede, sistemas de arquivos, datasources, etc são uma pedra no sapato do autor de um teste unitário."

Esse é o meu caso. Entrei em um projeto que não foi modelado para testes e minhas tentativas de aplicar TDD, ou melhor, de implementar testes unitários tem sido um fracasso.

Abraços e parabéns pelo blog.