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.)
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.
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.
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).
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:
-
Quanto trabalho será necessário para corrigir este teste adequadamente, talvez recorrendo à biblioteca de
utilitários?
-
De que outra maneira o tempo poderá ser utilizado?
-
Qual a probabilidade de o teste detectar defeitos sérios no futuro? Como tem sido o registro de controle dele e dos
testes relacionados?
-
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).
|