A continuación se muestra un ejemplo del código defectuoso:
File file = new File(stringName);
file.delete();
El defecto es que File.delete puede fallar, pero el código no lo comprueba. Solucionarlo
requiere la adición del código resaltado que se muestra a continuación:
File file = new File(stringName);
if (file.delete()
== false) {...}
En esta directriz se describe un método para detectar casos en que el código no gestiona el resultado de la llamada a
método. (Tenga en cuenta que presupone que el método que se llama produce el resultado correcto para cualquier entrada
que proporcione. Es algo que se debe probar, pero crear ideas de prueba para el método llamado es una tarea aparte. Es
decir, no es su responsabilidad probar File.delete.)
La noción clave es que debe crear una idea de prueba para cada resultado relevante no gestionado distinto de una
llamada a método. Para definir este término, miremos primero el resultado. Cuando se ejecuta un método, cambia
el estado del mundo. Estos son algunos ejemplos:
-
Puede forzar la devolución de valores en la pila de tiempo de ejecución.
-
Puede lanzar una excepción.
-
Puede cambiar una variable global.
-
Puede actualizar un registro en una base de datos.
-
Puede enviar datos por la red.
-
Puede imprimir un mensaje en la salida estándar.
Miremos ahora lo relevante, utilizando de nuevo algunos ejemplos.
-
Imaginemos que el método que se llama imprime un mensaje en la salida estándar. Esto "cambia el estado del mundo",
pero no puede afectar al proceso posterior del programa. No importa lo que se imprima, aunque no se imprima nada,
no puede afectar a la ejecución del código.
-
Si el método devuelve true para correcto y false para anomalía, el programa probablemente debería ramificar según
el resultado. Así que el valor de retorno es relevante.
-
Si el método que se llama actualiza un registro de base de datos que el código lee y utiliza posteriormente, el
resultado (actualizar el registro) es relevante.
(No hay ninguna línea absoluta entre relevante e irrelevante. Si llama imprimir, el método puede
provocar la asignación de los almacenamientos intermedios, y esta asignación puede ser relevante. Es concebible que un
defecto pueda depender de si se asignan, y qué almacenamientos intermedios se asignaron. Es concebible, ¿pero es
plausible?)
Un método puede tener un gran número de resultados, pero sólo algunos de ellos serán distintos. Por ejemplo,
imagine un método que escriba bytes en un disco. Puede devolver un número inferior a cero para indicar la anomalía; si
no, devuelve el número de bytes escritos (pueden ser menos que el número solicitado). El gran número de posibilidades
se puede agrupar en tres resultados distintos:
-
un número inferior a cero.
-
el número escrito equivale al número solicitado
-
algunos bytes se han escrito, pero menos que el número solicitado.
Todos los valores inferiores a cero se agrupan en un resultado porque ningún programa razonable hará distinciones entre
ellos. Todos ellos (si, en realidad, es posible más de uno) debe tratarse como un error. Del mismo modo, si el código
solicitó que se escribieran 500 bytes, no importa si en realidad se escribieron 34, o 340: probablemente se hará lo
mismo con los bytes que no se han escrito. (Si se debe hacer algo diferente para algún valor, como 0, esto formará un
resultado distinto nuevo).
Hay una última palabras en el término definitorio que se debe explicar. Esta técnica de prueba concreta no se preocupa
de los diferentes resultados que ya se han entregado. Imagine, de nuevo, este código:
File file = new File(stringName);
if (file.delete() == false) {...}
Hay dos resultados distintos (true y false). El código los gestiona. Puede gestionarlos incorrectamente, pero las ideas
de prueba de la Directriz de producto de trabajo: Ideas de prueba para booleanos y límites lo
comprobará. Esta técnica de prueba se preocupa de los diferentes resultados que no se manejan específicamente con
códigos distintos. Esto puede suceder por dos motivos: pensó que la distinción era irrelevante, o simplemente la ha
pasado por alto. A continuación se muestra un ejemplo del primer caso:
result = m.method();
switch (result) {
case FAIL:
case CRASH:
...
break;
case DEFER:
...
break;
default:
...
break;
}
FAIL CRASH se gestionan con el mismo código. Sería inteligente comprobar
que realmente es adecuado. A continuación se muestra un ejemplo de una distinción que se ha pasado por alto:
result = s.shutdown();
if (result == PANIC) {
...
} else {
// success! Shut down the reactor.
...
}
Resulta que la conclusión puede devolver un resultado distinto adicional: RETRY. El código
escrito trata el caso igual que el caso correcto, que es incorrecto casi siempre.
Su objetivo es pensar en los resultados relevantes distintos que antes había pasado por alto. Parece imposible: ¿Por
qué debería darse cuenta de que son relevantes ahora si no lo vio antes?
La respuesta es que un repaso sistemático del código, en una trama de prueba y no de programación, puede producir
nuevas impresiones. Puede cuestionar sus propias suposiciones revisando metódicamente el código, mirando los
métodos que llama, volviendo a comprobar la documentación, y pensando. A continuación se indican algunos casos que hay
que revisar.
Casos "imposibles"
A menudo, parecerá que la devolución de errores es imposible. Vuelva a comprobar sus suposiciones.
El ejemplo siguiente muestra una implementación de Java de un giro común de Unix para manejar archivos temporales.
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();
El objetivo es garantizar que el archivo temporal siempre se elimina, independientemente de cómo salga el programa.
Esto se lleva a cabo creando el archivo temporal, y suprimiéndolo inmediatamente. En Unix, puede seguir trabajando con
el archivo suprimido, y el sistema operativo se encarga de la limpieza cuando el proceso sale. Un programador de Unix
poco esmerado puede no escribir el código para comprobar una eliminación fallida. Como acaba de crear
satisfactoriamente el archivo, debe poder suprimirlo.
Este truco no funciona en Windows. La eliminación fallará porque el archivo está abierto. Descubrir esto es complicado:
en agosto del 2000, la documentación de Java no enumeraba las situaciones en que delete podía
fallar; sólo indicaba que podía suceder. Pero -quizás- en "modalidad de prueba", el programador podía cuestionar las
suposiciones. Como el código se suponía que se "escribía una vez y se ejecutaba en todas partes", podía solicitar a un
programador de Windows cuando File.delete falla en Windows para descubrir la terrible verdad.
Casos "irrelevantes"
Otra fuerza contraria a la identificación de un valor relevante distinto es estar convencido de que no importa. Un
método Comparator de compare de Java devuelve un número <0, 0, o un
número >0. Estos son tres casos distintos que se pueden probar. Este código une dos de ellos:
void allCheck(Comparator c) {
...
if (c.compare(o1, o2) <= 0) {
...
} else {
...
}
Pero esto puede ser incorrecto. El modo de descubrir si lo es o no es intentar los dos casos por separado, incluso si
cree realmente que no habrá diferencias. (Lo que está probando realmente son sus opiniones). Tenga en cuenta que puede
estar ejecutando el caso then de la sentencia if más de una vez por otros
motivos. ¿Por qué no intentar uno con el resultado inferior a 0 y uno con el resultado exactamente igual a cero?
Excepciones no detectadas
Los excepciones son un tipo de resultado diferente. Por ejemplo, imagine este código:
void process(Reader r) {
...
try {
...
int c = r.read();
...
} catch (IOException e) {
...
}
}
Esperaría comprobar si el código manejador realmente hace lo correcto con una anomalía leída. Pero imagine que una
excepción no se maneja explícitamente. En cambio, se permite que se propague hacia arriba a través del código que se
está probando. En Java, sería similar a:
void process(Reader r)
throws IOException {
...
int c = r.read();
...
}
Esta técnica le pide que pruebe este caso aunque el código explícitamente no lo maneja. ¿Por qué? Por este tipo
de error:
void process(Reader r) throws IOException {
...
Tracker.hold(this);
...
int c = r.read();
...
Tracker.release(this);
...
}
Aquí, el código afecta al estado global (a través de Tracker.hold). Si la excepción se lanza,
Tracker.release nunca se llamará.
(Tenga en cuenta que si no se puede lanzar, probablemente no tendrá consecuencias obvias inmediatas. El problema
probablemente no será visible hasta que el proceso se vuelva a llamar, donde el intento de mantener el objeto por segunda vez fallará. Un buen artículo sobre estos defectos es "Testing for Exceptions" de Keith Stobi.
(Obtener Adobe Reader))
Esta técnica concreta no se dirige a todos los defectos asociados con las llamadas a método. Mostramos dos tipos que es
poco probable que se detecten.
Argumentos incorrectos
Imagine estas dos líneas de código C, donde la primera línea es incorrecta y la segunda línea es correcta.
... strncmp(s1, s2, strlen(s1)) ...
... strncmp(s1, s2, strlen(
s2)) ...
strncmp compara dos cadenas de caracteres y devuelve un número inferior a 0 si la primera es
lexicográficamente menos que la segunda (aparecería antes en un diccionario). Devuelve un "0" si son iguales. Devuelve
un número superior a 0 si la primera es lexicográficamente superior. Sin embargo, sólo compara el número de caracteres
determinados por el tercer argumento. El problema es que la longitud de la primera cadena de caracteres se utiliza para
limitar la comparación, donde debería ser la longitud de la segunda.
Esta técnica requeriría tres pruebas, una para cada valor devuelto distinto. Estas son las tres que se podrían
utilizar:
s1
|
s2
|
resultado esperado
|
resultado real
|
"a"
|
"bbb"
|
<0
|
<0
|
"bbb"
|
"a"
|
>0
|
>0
|
"foo"
|
"foo"
|
=0
|
=0
|
El defecto no se descubre porque nada en esta técnica fuerza que el tercer argumento tenga un valor concreto. Lo
que necesita es un guión de prueba como el siguiente:
s1
|
s2
|
resultado esperado
|
resultado real
|
"foo"
|
"food"
|
<0
|
=0
|
Mientras que existen técnicas adecuadas para capturar tales defectos, casi nunca se utilizan a la práctica. El esfuerzo
de prueba se gasta mejor, probablemente, en un conjunto completo de pruebas que destina muchos tipos de defectos (y que
usted espera que detecte este tipo como efecto secundario).
Resultado indistinto
Existe un peligro que surge cuando está codificando - y probando - método a método. A continuación se muestra un
ejemplo. Hay dos métodos. El primero, connect, quiere establecer una conexión de red:
void connect() {
...
Integer portNumber = serverPortFromUser();
if (portNumber == null) {
// pop up message about invalid port number
return;
}
Cuando necesita un número de puerto, llama serverPortFromUser . Este método devuelve dos valores distintos.
Devuelve un número de puerto escogido por el usuario si el número escogido es válido (1000 o superior). Si no, se
devuelve nulo. Si se devuelve nulo, el código que se está probando produce un mensaje de error y abandona.
Cuando connect se probó, funcionó como estaba previsto: un número de puerto válido produjo el
establecimiento de una conexión, y uno no válido produjo un mensaje emergente.
El código para serverPortFromUser es un poco más complicado. Primero abre una ventana que pide
una cadena de caracteres que tiene los botones Aceptar y Cancelar estándares. Basándose en lo que hace el usuario,
existen cuatro casos:
-
Si el usuario escribe un número válido, ese número se devuelve.
-
Si el número es demasiado pequeño (inferior a 1000), se devuelve nulo (se mostrará el mensaje sobre el número de
puerto no válido).
-
Si el número no tiene el formato correcto, se devuelve de nuevo nulo (y el mismo mensaje es apropiado).
-
Si el usuario pulsa CANCELAR, se devuelve nulo.
Este código también funciona como estaba previsto.
La combinación de las dos partes de código, sin embargo, tiene una consecuencia negativa: el usuario pulsa CANCELAR y
obtiene un mensaje sobre un número de puerto no válido. Todo el código funciona según lo previsto, pero el efecto
global sigue siendo incorrecto. Se probó de forma razonable, pero faltaba un defecto.
El problema aquí es que nulo es un resultado que representa dos significados distintos
("valor erróneo" y "cancelado por el usuario"). Nada en esta técnica le obliga a darse cuenta del problema con el
diseño de serverPortFromUser.
No obstante, las pruebas pueden ayudar. Cuando serverPortFromUser se prueba aisladamente - sólo
para ver si devuelve el valor previsto en cada uno de los cuatro casos - el contexto de uso se pierde. En su lugar,
imagine que se probara mediante connect. Existirían cuatro pruebas que ejercerían ambos métodos
simultáneamente:
entrada
|
resultado esperado
|
proceso pensado
|
tipos de usuario "1000"
|
la conexión del puerto 1000 está abierta
|
serverPortFromUser devuelve un número, que se utiliza.
|
tipos de usuario "999"
|
aviso emergente sobre número de puerto no válido
|
serverPortFromUser devuelve un valor nulo, que provoca el aviso emergente
|
tipos de usuario "i99"
|
aviso emergente sobre número de puerto no válido
|
serverPortFromUser devuelve un valor nulo, que provoca el aviso emergente
|
usuarios pulsan CANCELAR
|
todo el proceso de conexión debe cancelarse
|
serverPortFromUser devuelve un valor nulo, espera un momento que esto
no tiene sentido...
|
Como suele ocurrir, las pruebas en grandes contextos revelan problemas de integración que escapan a las pruebas de
pequeña escala. Y, como también suele ocurrir, la precisión durante el diseño de la prueba revela el problema antes de
ejecutar la prueba. (Pero si el defecto no se captura entonces, se capturará cuando se ejecute la prueba).
|