Orientações de Trabalho: Manutenção de Conjuntos de Testes Automatizados
Tópicos
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 é até mais provável que o código ao qual os testes se apliquem tenha sido alterado de modo a gerar corretamente falhas neles. 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);
Ocorrerá falha em qualquer teste que utilize login. Ele não será nem compilado. Como não se pode fazer quase nada de útil sem efetuar o login, não poderão ser elaborados muitos testes úteis que não utilizem login. Poderão surgir centenas ou milhares de testes em que ocorram falhas.
É possível corrigi-los utilizando a ferramenta global de busca e substituição que encontrará cada instância de login(something)e a substituirá por login(something, "dummy password"). 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 freqüentemente é o caso as mudanças nos testes não forem feitas com tanta facilidade. 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, "dummy password");
}
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 poderão ser todos antecipados talvez você só perceba que necessita de um método de utilitário testLogin depois que o login do produto for alterado pela primeira vez. Sendo assim, os métodos de utilitário serão freqüentemente "fatorados" dos testes existentes conforme o necessário. É muito importante que você efetue essas correções constantes nos testes, mesmo sob pressão de tempo. 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.
Observação: Os testes do método de login do produto ainda o chamarão diretamente. Se o comportamento desse método de login for alterado, alguns ou todos esses testes terão que ser atualizados. (Se não ocorrerem falhas em nenhum teste de login quando o comportamento for alterado, é sinal de que os testes provavelmente não estão detectando defeitos de maneira eficiente.)
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 poderá e deverá ser abstraída em um único método com um nome sugestivo como, por exemplo, 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 sintática interna do compilador e as transformações feitas nela. Há uma série de testes que constroem árvores sintáticas e testam as transformações. Um teste desse tipo poderá ser expresso da seguinte maneira:
/*
* Given
* while (i<0) { f(a+i); i++;}
* "a+i" cannot be hoisted from the loop because
* it contains a variable changed in the 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 sintáticas. Será melhor, nesse momento, reescrever completamente os testes a fim de usá-los:
loop=Parser.parse("while (i<0) { f(a+i); i++; }");
// Get a pointer to the "a+i" part of the 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.
Há uma ligeira desvantagem nesse método: 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 estará, agora, imune aos detalhes de como as árvores sintáticas são construídas. Ele ainda estará sensível à estrutura da árvore sintática de um loop while (devido às seqüências de acessos necessárias para procurar a+i na subárvore). Se essa estrutura se mostrar sujeita a mudanças, 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));
Agora, o teste estará sensível ao seguinte: à definição da linguagem (por exemplo, que os números inteiros poderão ser aumentados com ++) e às regras que regem 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 maior parte das pessoas resiste à idéia de descartar testes pelo menos, até o momento em que a manutenção deles as deixem tão sobrecarregadas a ponto de decidirem realmente descartar 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 sintáticas (o construtor LessOp e recursos do mesmo tipo). 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).
Copyright
(c) 1987 - 2001 Rational Software Corporation
|