Diretrizes: Idéias de Teste para Chamadas de Método
Tópicos
Um exemplo de código com defeito é:
File file = new File(stringName);
file.delete();
O defeito é que File.delete pode falhar, mas o código não verifica isso. A correção requer a adição do código em itálico mostrado aqui:
File file = new File(stringName);
if (file.delete() == false) {...}
Esta diretriz descreve um método para detectar casos em que o código não manipula o resultado da chamada de um método. (Observe que pressupõe-se que o método chamado produza o resultado correto para quaisquer que sejam as entradas fornecidas. Isso é algo que deve ser testado, mas a criação de idéias de teste para o método chamado é uma atividade distinta. Ou seja, o seu trabalho não é testar File.delete.)
A noção básica é que você deve criar uma idéia de teste para cada resultado relevante distinto não manipulado de uma chamada de método. Para definir esse termo, primeiro vamos observar o resultado. Quando um método é executado, ele altera o estado de tudo. A seguir são apresentados alguns exemplos:
- Ele pode alocar os valores de retorno para a pilha em tempo de execução.
- Ele pode retornar a execução de uma exceção.
- Ele pode alterar uma variável global.
- Ele pode atualizar um registro de um banco de dados.
- Ele pode enviar dados pela rede.
- Ele pode imprimir uma mensagem para a saída padrão.
Agora, vamos observar o adjetivo relevante, uma vez mais usando alguns exemplos.
- Suponha que o método chamado imprima uma mensagem para a saída padrão. Isto muda todo o estado, mas não pode afetar o processamento seguinte deste programa. Não importa o que seja impresso, não pode afetar a execução do programa. Nada do que for impresso, mesmo que nada seja impresso, afetará a execução do código.
- Se o método retornar verdadeiro para êxito e falso para falha, é muito provável que o programa se ramifique com base no resultado. Assim, esse valor de retorno é relevante.
- Se o método chamado atualizar um registro do banco de dados que o código for ler e usar posteriormente, o resultado (atualização do registro) será relevante.
(Não há uma linha absoluta entre relevante e irrelevante. Quando print for chamado, o método poderá gerar a alocação de buffers e essa alocação poderá ser relevante após o retorno de print. É concebível que um defeito dependa se um buffer foi alocado e do que foi alocado em um buffer. É concebível, mas é plausível?)
Geralmente, um método pode ter um grande número de resultados, mas apenas alguns deles serão distintos. Por exemplo, considere um método que grava bytes em disco. Ele pode retornar um número menor que zero para indicar uma falha; caso contrário, retorna o número de bytes gravados (que pode ser um número menor do que o solicitado). As inúmeras possibilidades podem ser agrupadas em três resultados distintos:
- um número menor que zero.
- o número gravado é igual ao número solicitado.
- alguns bytes foram gravados, mas menos do que o número solicitado.
Todos os valores menores que zero são agrupados em um resultado porque nenhum programa razoável fará distinção entre eles. Todos eles (se, de fato, for possível haver mais de um) devem ser tratados como erro. Da mesma forma, se o código solicitar a gravação de 500 bytes, não importará se forem gravados efetivamente 34 ou 340: o mesmo provavelmente ocorrerá com os bytes não gravados. (Se for necessário fazer algo diferente para algum valor, como 0, isso formará um novo resultado distinto.)
Resta explicar uma última palavra do termo de definição. Essa técnica de teste específica não está interessada nos resultados distintos que já foram manipulados. Considere, uma vez mais, este código:
File file = new File(stringName);
if (file.delete() == false) {...}
Há dois resultados distintos (verdadeiro e falso). O código os manipula. Ele poderá manipulá-los de maneira incorreta, mas as idéias de teste provenientes de Diretriz: Idéias de Teste para Booleans e Fronteiras verificarão isso. Essa técnica de teste está interessada nos resultados distintos que não são manipulados especificamente por código distinto. Isso pode ocorrer por dois motivos: você achou que a distinção era irrelevante ou simplesmente a negligenciou. 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 manipulados pelo mesmo código. Convém verificar se isso é realmente apropriado. Um exemplo de uma distinção negligenciada é:
result = s.shutdown();
if (result == PANIC) {
...
} else {
// success! Shut down the reactor.
...
}
Deduz-se que shutdown pode retornar um resultado distinto adicional: RETRY. O código escrito trata esse caso da mesma maneira que o caso de êxito, o que provavelmente está errado.
Assim, a sua meta é considerar os resultados relevantes distintos que negligenciou anteriormente. Isso parece impossível: por que você chegaria à conclusão de que são relevantes agora se não achou que fossem anteriormente?
A resposta é que um novo exame sistemático do código, do ponto de vista de teste e não de programação, às vezes pode gerar uma nova forma de raciocínio. Você pode questionar as próprias suposições através de uma análise metódica das etapas do código, observando os métodos chamados, verificando novamente a respectiva documentação e pensando. A seguir estão alguns casos a serem considerados.
Casos "impossíveis" 
Geralmente, retornos de erro parecem impossíveis. Reavalie suas suposições.
Este exemplo mostra uma implementação em Java de um sabor Unix comum para a manipulação 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 é certificar-se de que os arquivos temporários sejam sempre excluídos, independentemente da forma de saída do programa. Para fazer isso, crie o arquivo temporário e, depois, exclua-o imediatamente. No Unix, você pode continuar a trabalhar com o arquivo excluído e o sistema operacional se encarregará da limpeza ao término do processo. Uma programadora de Unix pode não gravar o código para verificar uma exclusão que falhou. Como acaba de criar o arquivo com êxito, ela deverá conseguir exclui-lo.
Esse truque não funciona no Windows. A exclusão falhará porque o arquivo está aberto. É difícil descobrir esse fato: até agosto de 2000, a documentação da linguagem Java não enumerava as situações em que a delete poderia falhar; apenas informava que ela podia falhar. Mas, talvez, quando estiver no "modo de teste", a programadora possa questionar sua suposição. Como supõe-se que o código seja " escreva uma vez e execute em qualquer lugar", ela poderá perguntar a um programador para ambientes Windows quando File.delete pode falhar no Windows e, assim, descobrir a terrível verdade.
Casos "irrelevantes" 
Outro ponto que impede a detecção de um valor relevante distinto é o fato de já estar convencido de que ele não é importante. Um método Java Comparator's compare retorna um número <0, 0 ou um número >0. Esses são três casos distintos que poderiam ser testados. Este código agrupa dois deles:
void allCheck(Comparator c) {
...
if (c.compare(o1, o2) <= 0) {
...
} else {
...
}
Talvez isso esteja incorreto. A maneira de descobrir se é <0 ou igual a 0 é testar os dois casos separadamente, mesmo que você acredite realmente que não faz diferença. Suas convições são realmente o que você está testando. Observe que você poderia estar executando o caso then da instrução if mais de uma vez por outros motivos. Por que não tentar uma delas com o resultado menor que 0 e uma com o resultado igual a zero?
Exceções não tratadas 
As exceções são um tipo de resultado distinto. Como referência, considere este código:
void process(Reader r) {
...
try {
...
int c = r.read();
...
} catch (IOException e) {
...
}
}
Você esperaria verificar se o código adota realmente o procedimento correto em caso de falha de leitura. Mas suponha que uma exceção não seja manipulada explicitamente. Em vez disso, é possível que ela se propague para cima no código em teste. Em Java, isso poderia ter esta aparência:
void process(Reader r) throws IOException {
...
int c = r.read();
...
}
Essa técnica solicita o teste desse caso ainda que o código não o manipule explicitamente. Por quê? Devido a este tipo de erro:
void process(Reader r) throws IOException {
...
Tracker.hold(this);
...
int c = r.read();
...
Tracker.release(this);
...
}
Aqui, o código afeta o estado global (através de Tracker.hold). Se a exceção for apresentada, Tracker.release nunca será chamado.
(Observe que a falha na liberação do método release provavelmente não terá conseqüências imediatas óbvias. O mais provável é que o problema só se evidencie quando process for chamado novamente, após a tentativa de executar uma segunda vez um hold no objeto falhará. Um bom artigo sobre esses defeitos é "Testing for Exceptions" de Keith Stobie.)
Esta técnica específica não considera todos os defeitos associados a chamadas de método. A seguir estão dois tipos que provavelmente não serão detectados.
Argumentos incorretos 
Considere estas duas linhas de código C, onde a primeira está correta e a segunda está incorreta.
... strncmp(s1, s2, strlen(s1)) ...
... strncmp(s1, s2, strlen(s2)) ...
strncmp compara duas seqüências de caracteres e retorna um número menor que 0 se a primeira for lexicograficamente menor que a segunda (seria apresentada primeiro em um dicionário), 0 se forem iguais e um número maior que 0 se a primeira for lexicograficamente maior. Entretanto, ele apenas compara o número de caracteres fornecidos pelo terceiro argumento. O problema é que o comprimento da primeira seqüência de caracteres é usado para limitar a comparação, enquanto o comprimento da segunda é que deveria impor essa limitação.
Essa técnica exigiria três testes, um para cada valor de retorno distinto. A seguir estão três que podem ser usados:
| s1 |
s2 |
resultado esperado |
resultado real |
| "a" |
"bbb" |
<0 |
<0 |
| "bbb" |
"a" |
>0 |
>0 |
| "foo" |
"foo" |
=0 |
=0 |
O defeito não é descoberto porque nada nessa técnica força o terceiro argumento a ter qualquer valor específico. É necessário um caso de teste como este:
| s1 |
s2 |
resultado esperado |
resultado real |
| "foo" |
"food" |
<0 |
=0 |
Ainda que existam técnicas adequadas para detectar esses defeitos, elas raramente são usadas na prática. Provavelmente, será melhor empregar o esforço de teste em um conjunto abrangente de testes para detectar vários tipos de defeitos (e que detecte esse tipo como um efeito colateral).
Resultados vagos
Os processos de codificação e de teste, método por método, apresenta um perigo. Eis um exemplo. Há dois métodos. O primeiro, connect, deseja estabelecer uma conexão de rede:
void connect() {
...
Integer portNumber = serverPortFromUser();
if (portNumber == null) {
// envia uma mensagem informando número da porta inválido
return;
}
Ele chama serverPortFromUser para obter um número de porta. Esse método retorna dois valores distintos. Ele retornará um número de porta escolhido pelo usuário se o número escolhido for válido (1000 ou mais). Caso contrário, retornará null (nulo). Se nulo for retornado, o código em teste exibirá uma mensagem de erro e terminará.
Quando connect foi testado, funcionou conforme o esperado: um número de porta válido estabeleceu a conexão e um número inválido executou uma janela de erro.
O código para serverPortFromUser é um pouco mais complexo. Primeiro ele exibe uma janela que solicita uma seqüência de caracteres e possui os botões OK e CANCEL padrão. Há quatro casos baseados na ação do usuário:
- Se o usuário digitar um número válido, esse número será retornado.
- Se o número for muito pequeno (menor que 1000), será retornado nulo (assim, será exibida a mensagem sobre o número de porta inválido).
- Se o número estiver formatado de maneira incorreta, também será retornado nulo (e a mesma mensagem será exibida).
- Se o usuário clicar em CANCEL, será retornado nulo.
Esse código também funciona conforme o esperado.
No entanto, a combinação dos dois blocos de código tem uma conseqüência incorreta: o usuário pressiona CANCEL e recebe uma mensagem sobre um número de porta inválido. Todo o código funciona conforme o esperado, mas o efeito geral ainda está incorreto. Ele foi testado de maneira razoável, mas um defeito não foi detectado.
O problema aqui é que null é um resultado que representa dois significados distintos ("valor inválido" e "cancelado pelo usuário"). Nada nessa técnica força você a observar esse problema no design de serverPortFromUser.
Contudo, os testes podem ajudar. Quando serverPortFromUser é testado isoladamente - apenas para verificar se retorna o valor desejado em cada um dos quatro casos acima - o contexto de uso se perde. Em vez disso, suponha que fosse testado via connect. Há quatro testes que experimentariam ambos os métodos simultaneamente:
| entrada |
resultado esperado |
processo de raciocínio |
| o usuário digita "1000" |
é aberta a conexão com a porta 1000 |
serverPortFromUser retorna um número, que é usado. |
o usuário digita "999"
|
mensagem sobre número de porta inválido
|
serverPortFromUser retorna nulo, que exibe a mensagem
|
o usuário digita "i99"
|
mensagem sobre número de porta inválido |
serverPortFromUser retorna nulo, que exibe a mensagem |
| o usuário clica em CANCEL |
todo o processo de conexão deve ser cancelado |
serverPortFromUser retorna nulo; isso não faz sentido... |
Como ocorre com freqüência, a realização de testes em um contexto mais amplo revela problemas de integração que não ocorrem em testes de pequena escala. E, como também ocorre freqüentemente, um raciocínio criterioso durante o design do teste revelará o problema antes da sua execução. (Mas se o defeito não for detectado nesse momento, ele será durante a execução do teste.)
Copyright
(c) 1987 - 2001 Rational Software Corporation
|