Directriz: Mantenimiento de conjuntos de aplicaciones de prueba automatizados
Esta directriz presenta principios de diseño y gestión que facilitan el mantenimiento de los conjuntos de aplicaciones de prueba.
Relaciones
Descripción principal

Introducción

Al igual que los objetos físicos, las pruebas se pueden estropear. No se estropean, sino que algo ha cambiado en su entorno. Quizás se han trasladado a un sistema operativo nuevo. O, más probablemente, el código que ejercen ha cambiado de modo que provoca correctamente que la prueba falle. Imagine que está trabajando en la versión 2.0 de una aplicación de banca electrónica. En la versión 1.0, este método se utilizaba para iniciar la sesión:

registro booleano público (cadena de caracteres nombre de usuario);

En la versión 2.0, el departamento de marketing ha considerado que la protección por contraseña puede ser una buena idea. El método ha pasado a ser este:

registro booleano público (Cadena de caracteres nombre de usuario, Cadena de caracteres contraseña);

Todas las pruebas que utilicen el Inicio de sesión fallarán Ni siquiera compilará. Como no se puede hacer demasiado trabajo útil en este punto, no se pueden escribir demasiadas pruebas útiles sin iniciar la sesión. Puede encontrar cientos o miles de pruebas con fallos.

Estas pruebas se pueden solucionar utilizando una herramienta de buscar y reemplazar global que encuentre todas las instancias de registro (algo) y lo reemplace con registro (algo, "contraseña ficticia"). A continuación, establezca que todas las cuentas de prueba utilicen esta contraseña, y estará solucionando el problema.

Más adelante, cuando marketing decida que las contraseñas no pueden contener espacios, deberá repetirlo todo de nuevo.

Esto es una carga inútil, especialmente cuando los cambios en las pruebas no son tan sencillos -lo que ocurre a menudo. Existe un modo mejor.

Imagine que las pruebas originalmente no llamaron el registro del método del producto. En su lugar, llamaron un método de biblioteca que hace lo que puede para registrar la prueba y está listo para continuar. Inicialmente, este método podría ser parecida al siguiente:

public boolean testLogin (String username) {
  return product.login(username);
}

Cuando se produce el cambio a la versión 2.0, la biblioteca del programa de utilidad se cambia para que coincida:

public Boolean testLogin (String username) {
  return  product.login(username

, "dummy password");
}

En lugar de cambiar mil pruebas, cambien un método.

Idealmente, todos los métodos de biblioteca necesario estarán disponibles al principio del esfuerzo de prueba. A la práctica, no se pueden anticipar todos. Es posible que no se de cuenta de que necesita un método de programa de utilidad testLogin hasta la primera vez que cambie el registro del producto. Por eso los métodos de programa de utilidad de prueba suelen extraerse de pruebas existentes cuando sean necesarias. Es muy importante que efectúe esta reparación de prueba constante, incluso bajo presión de la planificación. Si no lo hace, gastará mucho tiempo tratando un conjunto de aplicaciones de prueba feo e insostenible. Es posible que esté desaprovechando, o que no pueda escribir los números necesarios para las nuevas pruebas porque todo el tiempo de prueba disponible se dedica a mantener los antiguos.

Nota: las pruebas del método de registro del producto todavía lo llamarán directamente. Si cambia este comportamiento, una parte o todas las pruebas deberán actualizarse. (Si ninguna prueba de registro falla cuando cambie el comportamiento, probablemente no son demasiado buenos detectando defectos).

La abstracción ayuda a gestionar la complejidad

El ejemplo anterior mostraba cómo las pruebas pueden abstraerse de la aplicación concreta. Muy probablemente puede realizar más abstracción. Encontrará que una serie de pruebas empiezan con una secuencia común de llamadas a método: se registran, establecen algún estado y navegan hasta la parte de la aplicación que está probando. Sólo entonces cada prueba hace algo diferente. Toda esta configuración podría -y debería- contenerse en un único método con un nombre evocador como readyAccountForWireTransfer. De este modo, ahorrará un tiempo considerable cuando se escriban nuevas pruebas de un tipo concreto y también realiza la intención de que cada prueba sea mucho más comprensible.

Es importante tener unas pruebas comprensibles. Un problema común con los conjuntos de aplicaciones de prueba antiguos es que nadie sabe qué hacen las pruebas o por qué. Cuando se estropean, la tendencia es arreglarlos del modo más sencillo posible. Esto suele resultar en pruebas más débiles a la hora de encontrar defectos. Ya no prueban lo que pretendían probar originalmente.

Otro ejemplo

Imagine que está probando un compilador. Algunas de las primeras clases escritas definen el árbol de análisis interno del compilador y las transformaciones que se han realizado sobre este. Tiene una serie de pruebas que construyen árboles de análisis de construcción y prueban las transformaciones. Una prueba podría ser parecida a la siguiente:

/* 
 * 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))

Esta prueba es difícil de leer. Imagine que pasa el tiempo. Algo cambia que requiere que se actualicen las pruebas. En este punto, tiene más infraestructura de producto en la que dibujar. Concretamente, puede tener una rutina de análisis que convierte las cadenas de caracteres en árboles de análisis. Sería mejor reescribir completamente las pruebas para utilizarlas en este punto:

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));

Estas pruebas serán mucho más fáciles de comprender, y ahorrarán tiempo inmediatamente y en el futuro. En realidad, su coste de mantenimiento es muy inferior y sería lógico convertir la mayoría de ellas hasta que el analizador esté disponible.

Existe un pequeño inconveniente en este enfoque: estas pruebas pueden descubrir un defecto en el código de transformación (tal como estaba previsto) o en el analizador (por accidente). El problema del aislamiento y la depuración puede resultar algo más difícil. Por otro lado, encontrar un problema que las pruebas de analizador no detectan no es tan negativo.

También existe la posibilidad de que un defecto del analizador pueda enmascarar un defecto en el código de transformación. La posibilidad de que suceda es más bien pequeña, y su coste es ciertamente inferior al coste de mantener las pruebas más complicadas.

Centrarse en la mejora de la prueba

Un conjunto de aplicaciones de prueba grande contendrá algunos bloques de prueba que no cambian. Corresponden a áreas estables de la aplicación. Otros bloques de prueba cambiarán a menudo. Corresponden a áreas de la aplicación en que el comportamiento cambia a menudo. Estos últimos bloques de prueba tendirán a utilizar más las bibliotecas de programas de utilidad. Cada prueba probará comportamientos específicos en el área cambiable. Las bibliotecas de programa de utilidad están diseñadas para permitir que esta prueba compruebe los comportamientos de destino manteniéndose relativamente inmune a los cambios de los comportamientos no probados.

Por ejemplo, la prueba "loop hoisting" que se ha mostrado anteriormente ahora es inmune a los detalles de cómo se construyen los árboles de análisis. Sigue siendo sensible a la estructura de un árbol de análisis del bucle mientras (debido a la secuencia de accesos necesarios para buscar el subárbol para a+i). Si esta estructura se puede cambiar, la prueba puede ser más abstracta creando un método de programa de utilidad fetchSubtree :

loop=Parser.parse("while (i<0) { f(a+i); i++; }");


aPlusI = fetchSubtree(loop, "a+i");

expect(false, loop.canHoist(aPlusI));

La prueba ahora es sensible sólo a dos cosas: la definición del lenguaje (por ejemplo, que los enteros se puedan incrementar con ++), y las reglas que gobiernan el loop hoisting (el comportamiento cuya corrección se está comprobando).

Desechar pruebas

Incluso con las bibliotecas, una prueba se puede romper periódicamente con los cambios de comportamiento que no están relacionados con lo que comprueba. Al arreglar la prueba es poco probable que se encuentre un defecto debido al cambio; es algo que se hace para mantener las posibilidades de que la prueba encuentre otro defecto en algún momento. Pero el coste de esta serie de correcciones puede superar el valor de que la prueba encuentre, hipotéticamente, un defecto. Puede ser mejor desechar, simplemente, la prueba y dedicar el esfuerzo a crear nuevas pruebas con un mayor valor.

La mayoría de personas se resisten a la noción de deshacerse de una prueba - al menos hasta que están tan cargados con el peso del mantenimiento que desechan todas las prueba. Es mejor tomar la decisión con cuidado y con continuidad, prueba a prueba, preguntando:

  1. ¿Cuánto trabajo será necesario para arreglar esta prueba, quizás añadiendo a la biblioteca del programa de utilidad?
  2. ¿De qué otro modo se podría utilizar el tiempo?
  3. ¿Qué probabilidad existe de que la prueba encuentra defectos graves en el futuro? ¿Cuál ha sido el registro de seguimiento de ésta y de las pruebas relacionadas?
  4. ¿Cuánto tardará la prueba en volverse a romper?

Las respuestas a estas preguntas serán estimaciones imprecisas o suposiciones. Pero preguntarlas producirá unos mejores resultados que tener simplemente una política de corrección de todas las pruebas.

Otro motivo para desechar las pruebas es que ahora sean redundantes. Por ejemplo, en un estadio temprano del desarrollo, puede haber una multitud de pruebas simples de métodos de construcción de árboles de análisis (el constructor LessOp y similares). Posteriormente, durante la escritura del analizador existirán una serie de pruebas de analizador. Como el analizador utiliza métodos de construcción, las pruebas de analizador también las probarán indirectamente. Cuando los cambios del código rompan las pruebas de construcción es razonable descartar algunas de las que son redundantes. Por supuesto, cualquier comportamiento de construcción nuevo o cambiado necesitará nuevas pruebas. Se pueden implementar directamente (si es difícil probarlas exhaustivamente con el analizador) o indirectamente (si las pruebas con el analizador son adecuadas y más sostenibles).