Diretriz: Manutenção de Conjuntos de Testes Automatizados
Esta diretriz apresenta princípios de design e gerenciamento que facilitam a manutenção de conjuntos de teste.
Relacionamentos
Descrição Principal

Introdução

Assim como os objetos físicos, os testes também podem ser danificados. Não significa que eles se desgastem. O que ocorre é que algo muda em seu ambiente. Talvez eles tenham sido transportados para um novo sistema operacional. Ou é mais provável que o código que eles aplicam seja alterado de forma a fazer com que o teste falhe corretamente. Suponha que você esteja trabalhando na versão 2.0 de um aplicativo de banco eletrônico. Na versão 1.0, o método para efetuar login é o seguinte:

public boolean login (String username);

Na versão 2.0, o departamento de marketing percebe que a proteção por senha pode ser uma boa idéia. O método passa então a ser o seguinte:

public boolean login (String username, String password);

Qualquer teste que utilize login falhará. Não será nem compilado. Como não se pode fazer quase nada de útil nesse ponto, não poderão ser gravados muitos testes úteis sem efetuar login. Poderão surgir centenas ou milhares de testes em que ocorram falhas.

Esses testes podem ser corrigidos utilizando uma ferramenta global de procura e substituição que localizará cada instância de login(something) e a substituirá por login(something, "senha fictícia"). Em seguida, faça com que todas as contas de teste utilizem essa senha e você poderá prosseguir.

Quando o departamento de marketing decidir que não será permitido que as senhas contenham espaços, você terá que fazer tudo novamente.

Esse tipo de preocupação será um aborrecimento desnecessário, especialmente quando as mudanças de teste não forem feitas com tanta facilidade e geralmente é o caso. Há uma maneira melhor.

Suponha que os testes originalmente não chamem o método de login do produto. Em vez disso, os testes chamarão um método de biblioteca que fará tudo o que for necessário para que o login seja efetuado e eles possam prosseguir. Inicialmente, esse método poderá ser expresso da seguinte maneira:

public boolean testLogin (String username) {
  return product.login(username);
}

Quando a mudança para a versão 2.0 ocorrer, a biblioteca de utilitários será alterada para corresponder a:

public Boolean testLogin (String username) {
  return  product.login(username

, "senha fictícia");
}

Em vez de alterar milhares de testes, você alterará apenas um método.

O ideal seria que todos os métodos de biblioteca necessários estivessem disponíveis no início da execução dos testes. Na prática, eles não podem ser todos antecipados; você talvez não perceba que precisa de um método de utilitário testLogin até a primeira vez em que o login do produto for alterado. Portanto, os métodos de utilitário de teste são geralmente "fatorados" de testes existentes, conforme necessário. É muito importante desempenhar esse reparo de teste contínuo, mesmo sob pressão do planejamento. Caso contrário, você desperdiçará muito tempo tendo que lidar com um conjunto de testes repleto de erros e cuja manutenção é praticamente impossível. É provável que você tenha que descartar testes ou não consiga escrever a quantidade necessária de novos testes porque todo o seu tempo de teste disponível será gasto na manutenção dos testes antigos.

Nota: os testes do método de login do produto continuarão a chamá-lo diretamente. Se o comportamento desse método de login for alterado, alguns ou todos esses testes terão que ser atualizados. (Se nenhum dos testes de login falhar quando seu comportamento for alterado, provavelmente eles não são bons na detecção de defeitos.)

A Abstração Ajuda a Gerenciar a Complexidade

O exemplo anterior mostrou como os testes podem se abstrair do aplicativo concreto. É muito provável que você possa fazer um número consideravelmente maior de abstrações. Você poderá descobrir que há uma série de testes iniciados por uma seqüência comum de chamadas de método: essas chamadas permitem efetuar login, configurar algum estado e navegar para a parte do aplicativo que está sendo testada. É somente a partir desse momento que cada teste executa alguma ação diferente. Toda essa configuração poderia e deveria estar contida em um único método com um nome sugestivo, como readyAccountForWireTransfer. Ao fazer isso, você economizará muito tempo quando novos testes de um determinado tipo forem escritos e também conseguirá tornar o intuito de cada teste muito mais compreensível.

É importante fazer uso de testes compreensíveis. Um problema comum dos conjuntos de testes antigos é que ninguém sabe o que os testes estão fazendo nem por que estão fazendo. Quando eles são danificados, a tendência é corrigi-los da maneira mais simples possível. Isso freqüentemente resulta em testes que são menos eficientes na detecção de defeitos. Eles não testam mais o que foram programados para testar originalmente.

Um outro Exemplo

Suponha que você esteja testando um compilador. Algumas das primeiras classes escritas definem a árvore de análise interna do compilador e as transformações feitas nela. Há uma série de testes que constroem árvores de análise e testam as transformações. Um teste desse tipo poderá ser expresso da seguinte maneira:

/*
 * Em um dado
 *   while (i<0) { f(a+i); i++;}
 * "a+i" não pode ser suspenso do loop porque
 * contém uma variável alterada no loop.
 */
loopTest = new LessOp(new Token("i"), new Token("0"));
aPlusI = new PlusOp(new Token("a"), new Token("i"));
statement1 = new Statement(new Funcall(new Token("f"), aPlusI));
statement2 = new Statement(new PostIncr(new Token("i"));
loop = new While(loopTest, new Block(statement1, statement2));
expect(false, loop.canHoist(aPlusI))

Este é um teste de difícil leitura. Suponha que o tempo passe e que ocorra alguma mudança que o obrigue a atualizar os testes. Nesse momento, você poderá contar com uma infra-estrutura maior do produto. Especificamente, você poderá ter uma rotina de análise que transforme as seqüências de caracteres em árvores de análise. Será melhor, nesse momento, reescrever completamente os testes a fim de usá-los:

loop=Parser.parse("while (i<0) { f(a+i); i++; }");
// Obter um ponteiro para a parte "a+i" do loop.
aPlusI = loop.body.statements[0].args[0];
expect(false, loop.canHoist(aPlusI));

Esses testes serão compreendidos muito mais facilmente, o que economizará tempo imediatamente e no futuro. Na verdade, seus custos de manutenção serão tão mais baixos que talvez seja justificável adiar a maior parte deles até que o analisador esteja disponível.

Existe uma leve desvantagem para essa abordagem: esses testes poderão descobrir um defeito no código de transformação (conforme planejado) ou no analisador (por acaso). Sendo assim, o isolamento e a depuração do problema poderão ser um pouco mais difíceis. Por outro lado, descobrir um problema não detectado pelos testes do analisador não é tão ruim assim.

Há também uma possibilidade de que um defeito no analisador mascare um defeito no código de transformação. No entanto, é pouco provável que isso ocorra e o custo disso certamente é menor do que o custo de manter testes mais complicados.

Mantendo o Foco no Aprimoramento do Teste

Um conjunto de testes extenso contém alguns blocos de testes que não sofrem mudanças. Eles correspondem a áreas estáveis do aplicativo. Outros blocos de testes sofreram mudanças freqüentes. Eles correspondem às áreas do aplicativo cujo comportamento está freqüentemente mudando. Esses últimos blocos de teste tenderão a fazer um uso muito maior das bibliotecas de utilitários. Cada teste testará comportamentos específicos da área sujeita a mudanças. As bibliotecas de utilitários são projetadas para permitir que esse tipo de teste verifique seus comportamentos-alvo enquanto permanecem relativamente imunes às mudanças nos comportamentos não testados.

Por exemplo, o teste de "suspensão de loop" mostrado acima agora está imune aos detalhes de como as árvores de análise são construídas. Ele ainda é sensível à estrutura de uma árvore de análise do loop while (por causa das seqüências de acesso necessárias para buscar a subárvore para a+i). Se essa estrutura se mostrar sujeita a alterações, o teste poderá se tornar mais abstrato, criando-se um método de utilitário fetchSubtree :

loop=Parser.parse("while (i<0) { f(a+i); i++; }");


aPlusI = fetchSubtree(loop, "a+i");

expect(false, loop.canHoist(aPlusI));

O teste agora é sensível apenas aos seguintes itens: à definição da linguagem (por exemplo, que inteiros poderão ser incrementados com ++) e às regras que controlam a suspensão de loop (cujo comportamento será verificado pelo teste a fim de confirmar se está correto).

Descartando Testes

Até mesmo com as bibliotecas de utilitários, um teste poderá ser danificado periodicamente pelas mudanças de comportamento que não têm qualquer relação com o que ele verifica. Corrigir o teste não representará necessariamente uma possibilidade de detectar um defeito devido à mudança; é algo que é feito para preservar a possibilidade de o teste vir a detectar algum outro defeito algum dia. Mas o custo dessa série de correções poderá exceder os benefícios de o teste vir a detectar, hipoteticamente, outros defeitos. Talvez seja melhor simplesmente descartar o teste e dedicar-se à criação de novos testes mais vantajosos.

A maioria das pessoas resiste a noção de descartar um teste, pelos menos até o momento em que a manutenção as deixem tão sobrecarregadas que descartem todos os testes. É melhor tomar a decisão com cuidado e constantemente, teste por teste, fazendo as seguintes perguntas:

  1. Quanto trabalho será necessário para corrigir este teste adequadamente, talvez recorrendo à biblioteca de utilitários?
  2. De que outra maneira o tempo poderá ser utilizado?
  3. Qual a probabilidade de o teste detectar defeitos sérios no futuro? Como tem sido o registro de controle dele e dos testes relacionados?
  4. Quanto tempo passará até que o teste fique danificado novamente?

As respostas para essas perguntas serão estimativas grosseiras ou até suposições. Mas perguntá-las produzirá melhores resultados do que simplesmente adotar a política de corrigir todos os testes.

Outra razão para descartar os testes é o fato de se tornarem redundantes em um determinado momento. Por exemplo, no início do desenvolvimento, poderá haver uma série de testes simples de métodos básicos de construção de árvores de análise (o construtor LessOp e similares). Posteriormente, durante a criação do analisador, haverá uma série de testes do analisador. Como o analisador utiliza os métodos de construção, os testes do analisador também testarão indiretamente esses métodos. À medida que as mudanças no código danificarem os testes de construção, será adequado descartar alguns desses testes por serem redundantes. É claro que qualquer comportamento de construção novo ou alterado necessitará de novos testes. Eles poderão ser implementados diretamente (se forem difíceis de serem executados totalmente através do analisador) ou indiretamente (se os testes feitos através do analisador forem adequados e de manutenção mais fácil).