Veja um exemplo de código defeituoso:
File file = new File(stringName);
file.delete();
O defeito é que File.delete pode falhar, mas o código não verifica isso. Para consertar isso é necessária a adição do código em itálico exibido abaixo:
File file = new File(stringName);
if (file.delete()
== false) {...}
Esta diretriz descreve um método para detectar os casos em que seu código não trate o resultado da chamada de um método. (Note que ela assume que o método chamado produz o resultado correto, qualquer que seja a entrada fornecida. Isso é algo que deve ser testado, mas a criação de ideias de teste para o método chamado é uma atividade separada. Ou seja, não é seu trabalho testar File.delete.)
O principal conceito é que você deve criar uma ideia de teste para cada resultado relevante não tratado de uma chamada de método. Para definir esse termo, vamos primeiro olhar para o resultado. Quando um método é executado, ela muda o estado do mundo. Aqui estão alguns exemplos:
- Ele poderia colocar valores de retorno na pilha do interpretador.
- Ele poderia gerar uma exceção.
- Ele poderia alterar uma variável global.
- Ele poderia atualizar um registro em um banco de dados.
- Ele poderia enviar dados através da rede.
- Ele poderia imprimir uma mensagem na saída padrão.
Agora vamos analisar relevantes novamente, usando alguns exemplos.
- Suponha que o método chamado imprima uma mensagem na saída padrão. Isso "muda o estado do mundo", mas não pode afetar o processamento posterior deste programa. Independente do que foi impresso, mesmo não sendo nada, isso não pode afetar a execução do seu código.
- Se o método retorna verdadeiro para o sucesso e falso para o insucesso, é muito provável que o seu programa desvie com base no resultado. Então esse valor de retorno é relevante.
- Se o método chamado atualizar um registro em uma base de dados que o seu código irá ler e usar mais tarde, o resultado (atualizando o registro) é relevante.
(Não existe uma diferença clara entre relevante e irrelevante. Ao chamar imprimir, o seu método poderia fazer com que buffers fossem alocados, e que essa alocação poderia ser relevante após o retorno de imprimir. É possível que um defeito possa depender de se e quais buffers foram alocados. É possível, mas é sempre plausível?)
Um método poderá muitas vezes ter uma grande quantidade de resultados, mas apenas alguns deles serão distintos. Por exemplo, considere um método que escreva bytes em disco. Ele pode retornar um número menor que zero para indicar falha; caso contrário, retorna a quantidade de bytes escritos (que poderá ser inferior a quantidade solicitada). A maioria das possibilidades pode ser agrupada em três resultados distintos:
- um número menor que zero.
- a quantidade escrita igual a quantidade solicitada
- alguns bytes foram escritos, mas menos do que a quantidade solicitada.
Todos os valores menores que zero são agrupados em um único resultado porque nenhum programa razoável irá fazer distinção entre eles. Todos eles (se for possível ter mais de um) devem ser tratados como erro. De modo semelhante, se o código solicitou que 500 bytes fossem escritos, não importa se 34 ou 340 foram efetivamente escritos: a mesma coisa provavelmente será feita com os bytes não escritos. (se algo diferente tiver que ser feito para algum valor, tal como 0, isso formará um novo resultado distinto.)
Existe uma última palavra no termo da definição para explicar. Esta técnica especial de testes não se preocupa com os resultados diferentes que já tenham sido tratados. Considere, novamente, esse código:
File file = new File(stringName);
if (file.delete() == false) {...}
Existem dois resultados distintos (verdadeiro e falso). O código trata ambos. Ele poderia tratá-los incorretamente, mas as ideias de teste em Guideline: Ideias de Teste Para Valores Limítrofes e Booleanos irão verificá-los. Esta técnica de teste se preocupa com resultados distintos que não são tratados especificamente por código distinto. Isso pode acontecer por dois motivos: você pensou que a distinção era irrelevante, ou você simplesmente a ignorou. Aqui está um exemplo do primeiro caso:
result = m.method();
switch (result) {
case FAIL:
case CRASH:
...
break;
case DEFER:
...
break;
default:
...
break;
}
FAIL e CRASH são tratados pelo mesmo código. É aconselhável verificar se isso é realmente adequado. Aqui está um exemplo de uma distinção ignorada:
result = s.shutdown(); if (result == PANIC) { ... } else { // success! Desligue o reator. ... }
Verifica-se que o método pode retornar um resultado distinto adicional: RETRY. O código escrito trata o caso da mesma forma que no caso de sucesso, o que está quase certamente errado.
Então a sua meta é pensar nesses resultados relevantes distintos que você previamente ignorou. Isso parece impossível: porque você acha que eles são relevantes agora se você não achava antes?
A resposta é que um re-exame sistemático do seu código, com um espírito de teste e não de programação, pode às vezes fazer-lhe ter novos pensamentos. Você pode questionar os seus próprios pressupostos caminhando metodicamente através do seu código, olhando os métodos chamados, verificando novamente a documentação, e pensando. Aqui estão alguns casos, para verificar.
Casos "impossíveis"
Muitas vezes, parecerá que alguns retornos de erro são impossíveis. Reverifique seus pressupostos.
Este exemplo mostra uma implementação Java de uma expressão comum do Unix para tratamento de arquivos temporários.
File file = new File("tempfile");
FileOutputStream s;
try {
// open the temp file.
s = new FileOutputStream(file);
} catch (IOException e) {...}
// Make sure temp file will be deleted
file.delete();
A meta é ter certeza de que um arquivo temporário é sempre excluído, independentemente da forma como o programa termina. Você faz isso criando o arquivo temporário e excluindo-o imediatamente. No Unix, você pode continuar trabalhando com o arquivo excluído, e o sistema operacional cuida da limpeza quando o processo acabar. Um programador Unix pouco meticuloso poderá deixar de escrever o código para verificar se há uma falha de exclusão. Uma vez que tenha criado o arquivo com sucesso, deverá ser capaz de apagá-lo.
Esse truque não funciona no Windows. A exclusão irá falhar porque o arquivo está aberto. Descobrir esse fato é difícil: até Agosto de 2000, a documentação Java não enumerava as situações onde excluir poderia falhar, mas apenas dizia que poderia. Porém-Entretanto-Todavia no "Modo de teste", o programador pode questionar seus pressupostos. Uma vez que se supõe que seu código será "escrito uma vez, e executado em qualquer lugar", ele poderia perguntar a um programador, quando File.delete falharia no Windows e assim descobrir a terrível verdade.
Casos "irrelevantes"
Outra força contra a percepção de um valor relevante distinto, é já estar convencido que isso não importa. Um método comparar de um Comparador Java retorna um número menor que 0, 0 ou maior que 0. Esses são os três casos distintos que podem ser tentados. Este código coloca dois deles juntos:
void allCheck(Comparator c) {
...
if (c.compare(o1, o2) <= 0) {
...
} else {
...
}
Mas pode estar errado. A forma de descobrir se é ou não é tentar os dois casos em separado, mesmo se você realmente acreditar que não irão fazer nenhuma diferença. (O que você está realmente testando são as suas crenças). Note que você pode estar executando o ramo then da declaração if mais de uma vez por outras razões. Porque não experimentar um deles com o resultado inferior a 0 e outro com o resultado exatamente igual a zero?
Exceções não capturadas
As exceções são um tipo de resultado distinto. A título de exemplo, considere este código:
void process(Reader r) {
...
try {
...
int c = r.read();
...
} catch (IOException e) {
...
}
}
Você poderia verificar se o código de tratamento realmente faz a coisa certa com uma falha de leitura. Mas suponha que uma exceção seja explicitamente não tratada. Ao invés disso, é permitida a propagação, para cima, através do código sob teste. Em Java, poderia ser semelhante a este:
void process(Reader r)
throws IOException {
...
int c = r.read();
...
}
Esta técnica lhe pede para testar esse caso mesmo que o código não trate ele explicitamente. Por quê? Devido a este tipo de falha:
void process(Reader r) throws IOException {
...
Tracker.hold(this);
...
int c = r.read();
...
Tracker.release(this);
...
}
Aqui, o código afeta o estado global (through tracker.hold). Se a exceção acontecer, tracker.release nunca será chamado.
(Note que a falha para liberar provavelmente não terá consequências óbvias imediatas. Provavelmente o problema não será visível até que process seja chamado novamente, sendo que a tentativa de manter o objeto uma segunda vez irá falhar. Um bom artigo sobre esses defeitos é "Testando Exceções" de Keith Stobie. (Get Adobe Reader))
Esta técnica em particular não trata todos os defeitos associados com chamadas de métodos. Aqui estão dois tipos pouco prováveis de captura.
Argumentos incorretos
Considere estas duas linhas de código C, onde a primeira está errada e a segunda correta.
... strncmp(s1, s2, strlen(s1)) ...
... strncmp(s1, s2, strlen(
s2)) ...
strncmp compara as duas strings e retorna um número inferior a 0 se a primeira, de forma léxica, for menor do que a segunda (viria na frente em um dicionário), 0 se elas fossem iguais e um número superior a 0 se a primeira fosse maior. Entretanto, ela apenas compara a quantidade de caracteres fornecida pelo terceiro argumento. O problema é que o comprimento da primeira string é usado para limitar a comparação, quando deveria ser o comprimento da segunda.
Esta técnica exigiria três testes, um para cada valor de retorno distinto. Aqui estão os três que você poderia usar:
s1
|
s2
|
resultado esperado |
resultado real |
"a"
|
"bbb"
|
<0
|
<0
|
"bbb"
|
"a"
|
>0
|
>0
|
"foo"
|
"foo"
|
=0
|
=0
|
O defeito não foi descoberto porque nada nesta técnica força, o terceiro argumento a ter qualquer valor. O que é necessário é um caso de teste como este:
s1
|
s2
|
resultado esperado
|
resultado real
|
"foo"
|
"food"
|
<0
|
=0
|
Mesmo existindo técnicas adequadas para a captura de tais defeitos, elas raramente são usadas na prática. O seu esforço de teste será provavelmente mais bem empregado em um rico conjunto de testes que visem muitos tipos de defeitos (e que você espere capturar este tipo, como um efeito colateral).
Resultados indistintos
Existe um perigo quando você está codificando - e testando – método por método. Aqui está um exemplo. Existem dois métodos. O primeiro, connect, pretende estabelecer uma conexão de rede:
void connect() {
...
Integer portNumber = serverPortFromUser();
if (portNumber == null) {
// pop up message about invalid port number
return;
}
Ele chama serverPortFromUser para obter um número de porta. Este método retorna dois valores distintos. Ele retorna um número de porta escolhido pelo usuário, se o número escolhido for válido (1000 ou superior). Caso contrário, retorna nulo. Se nulo for retornado, o código sob teste mostra uma mensagem de erro e termina.
Quando connect foi testado, funcionou como o esperado: um número de porta válido fez com que uma conexão fosse estabelecida, e um inválido direcionou para uma mensagem.
O código para serverPortFromUser é um pouco mais complicado. Primeiro ele mostra uma janela que pede por uma string e tem os botões padrão OK e CANCEL. Com base no que o usuário fizer, existirão quatro casos:
- Se o usuário digitar um número válido, esse número é retornado.
- Se o número for muito pequeno (menos de 1000), nulo é retornado (de modo que a mensagem sobre número de porta inválido seja exibida).
- Se o número for mal formado, nulo é novamente retornado (e a mesma mensagem é apropriada).
- Se o usuário clicar em CANCEL, nulo é retornado.
Este código também funciona como previsto.
A combinação dos dois pedaços de código, porém, tem uma consequência ruim: o usuário pressiona CANCEL e recebe uma mensagem sobre um número de porta inválido. Todo o código funciona como o esperado, mas o efeito geral ainda está errado. Ele foi testado de forma razoável, mas um defeito foi esquecido.
O problema aqui é que nulo é um resultado que representa dois significados distintos ("valor ruim" e "usuário cancelou"). Nada nessa técnica força você a perceber o problema com o design de serverPortFromUser.
Entretanto, o teste pode ajudar. Quando serverPortFromUser é testado em condições de isolamento - só para ver se ele retorna o valor esperado em cada um desses quatro casos - o contexto de uso é perdido. Ao invés, suponha que ele tenha sido testado através de connect. Existiriam quatro testes que exercitariam ambos os métodos simultaneamente:
entrada |
resultado esperado |
processo pensado |
usuário digita "1000" |
a conexão para a porta 1000 é aberta |
serverPortFromUser retorna um número, que é usado. |
o usuário digita "999"
|
mensagem sobre número de porta inválido
|
serverPortFromUser retorna nulo, o que leva a uma mensagem
|
usuário digita "i99"
|
mensagem sobre número de porta inválido |
serverPortFromUser retorna nulo, o que leva a uma mensagem |
o usuário clica em CANCEL |
todo processo de conexão deve ser cancelado |
serverPortFromUser retorna nulo. Ei espere um minuto, isso não faz sentido...
|
Como normalmente é o caso, a execução de testes em um contexto maior revela problemas de integração que escapam dos testes em pequena escala. E, como também é muitas vezes o caso, uma cuidadosa reflexão durante o design de testes revela o problema antes do teste ser executado. (Mas se o defeito não for previsto, então ele será capturado quando o teste for executado.)
|