Clase 11. Análisis dinámico, 2ª parte. Continuamos con el mismo tema de la clase anterior, pero esta vez nos ocuparemos principalmente de la fase de prueba. Nos detendremos brevemente en algunas de las nociones básicas que subyacen en las actividades de prueba y analizaremos las técnicas más extendidas. Por último, recopilaremos algunas directrices prácticas para ayudarle en sus propias tareas de prueba. 11.1 Fase de prueba Si adopta un enfoque sistemático, la fase de prueba resultará mucho más efectiva y mucho menos complicada. Antes de empezar, considere los siguientes puntos: • qué propiedades desea probar; • qué módulos desea probar y en qué orden; • cómo va a generar los casos de prueba; • cómo va a comprobar los resultados; • cómo sabrá si ha terminado. Para decidir qué propiedades desea probar es necesario conocer el dominio del problema, con el fin de saber qué clase de fallos son más importantes, así como el programa, para saber lo difícil que va a resultar descubrir los tipos de errores. La elección de módulos es más sencilla. Pruebe sobre todo los módulos que sean críticos, complejos o que estén escritos por el peor de sus programadores (o por el más aficionado a utilizar trucos brillantes dentro del código). O tal vez el módulo que se escribió a altas horas de la noche, o justo antes del lanzamiento… El diagrama de dependencia modular sirve para determinar el orden. Si el módulo depende de otro que aún no está implementado, tendrá que escribir un stub (o esqueleto de un módulo) que hará el papel del módulo que está fallando durante la fase de prueba. El stub proporciona el rendimiento necesario para la prueba. Es posible, por ejemplo, buscar respuestas en una tabla en lugar de realizar la computación verdadera. La comprobación de los resultados puede resultar complicada. Algunos programas –como el Foliotracker que va a construir en los ejercicios 5 y 6– ni siquiera tienen comportamiento repetitivo. En otros, los resultados son sólo la punta del iceberg y para comprobar que las cosas marchan bien, será necesario verificar las estructuras internas. Más adelante hablaremos de cómo generar casos de prueba y cómo saber cuando el trabajo está completo. 11.2 Pruebas de regresión Es muy importante ser capaz de volver a ejecutar las pruebas cuando se modifica el código. Por esta razón, no es buena idea realizar pruebas específicas que no pueden ser repetidas. Puede parecer un trabajo arduo, pero a largo plazo, resulta menos laborioso construir un conjunto práctico de pruebas que pueden ser reejecutadas a partir de un archivo. Es lo que se denomina pruebas de regresión. Un enfoque de la fase de prueba que recibe el nombre de test first programming, y que es parte de la nueva doctrina de desarrollo denominada extreme programming, apuesta por la construcción de pruebas de regresión antes incluso de que se haya escrito el código de aplicación. JUnit, el marco de pruebas que ha utilizado, fue concebido para esto. La construcción de pruebas de regresión para un sistema grande es una empresa importante. Es posible que sólo la ejecución de los scripts dure una semana. Por lo tanto un área de investigación que es muy interesante actualmente es intentar determinar qué pruebas de regresión pueden omitirse. Si sabe qué casos de prueba aplicar a las partes del código, podrá determinar que un cambio local en una parte del código no exige que todos los casos sean reejecutados. 11.3 Criterios Para entender cómo se generan y evalúan las pruebas, podemos pensar de manera abstracta sobre la finalidad y la naturaleza de la fase de prueba. Suponga que tenemos un programa P que debe cumplir una especificación S. Asumiremos, para que sea más sencillo, que P es una función que transforma las entradas de datos en salida de datos, y S es una función que recibe una entrada de datos y una salida de datos y devuelve un tipo booleano. Nuestro objetivo al realizar las pruebas es encontrar un caso de prueba t tal que: S (t, P(t)) sea falso: esto es, P produce un resultado para la entrada t que no es permitido por S. Llamaremos a t un caso de prueba fallido, aunque en realidad es un caso de prueba con éxito, ya que nuestra finalidad es encontrar errores. Una suite de pruebas T es un conjunto de casos de prueba. Ahora nos hacemos la siguiente pregunta: ¿cuándo una suite puede considerarse suficientemente buena? En lugar de intentar evaluar cada suite de forma que dependa de la situación, podemos aplicar criterios generales. Puede pensar en un criterio como una función: C: Suite, Program, Spec ~ Boolean que recibe una suite de pruebas, un programa y una especificación, y devuelve verdadero o falso de acuerdo con el hecho de que la suite sea suficientemente buena para el programa y la especificación dados, todo ello de forma sistemática. La mayoría de los criterios no incluyen ambos, el programa y la especificación. Si sólo se incluye el programa se denomina criterio basado en el programa. También se utilizan términos como ‘whitebox’, ‘clearbox’, ‘glassbox’, o pruebas estructurales para describir fases de prueba que utilizan criterios basados en programas. Un criterio que sólo incluye la especificación se denomina criterio basado en la especificación. El término ‘blackbox’ se utiliza en asociación con este criterio, para dar a entender que las pruebas se juzgan sin que se pueda analizar la parte interna del programa. También se utiliza el término pruebas funcionales. 11.4 Subdominios Los criterios prácticos se inclinan por una estructura y propiedades singulares. Por ejemplo, pueden aceptar una suite de casos T y sin embargo rechazar una suite T’ que es igual que T pero con algunos casos adicionales. También tienden a no ser sensibles en lo que se refiere a las combinaciones de los casos de prueba escogidas. Estas características no son, necesariamente, buenas propiedades; simplemente surgen del modo sencillo en que la mayoría de los criterios se definen. El dominio de los datos de entrada se divide en subregiones, algunas de las cuales se denominan subdominios, y cada una contiene un conjunto de datos de entrada. Los subdominios juntos engloban todos los dominios de los datos de entrada: esto es, toda entrada está en por lo menos un subdominio. Una división del dominio de datos de entrada en subdominios define un criterio implícito: que define que deba existir al menos un caso de prueba para cada subdominio. Por lo general los subdominios no son inconexos, por lo tanto, un único caso de prueba puede estar en todos los subdominios. La idea que subyace tras el subdominio tiene dos aspectos. En primer lugar, es fácil (al menos conceptualmente) determinar si una suite de pruebas es suficientemente buena. En segundo lugar, esperamos que al exigir un caso de prueba de cada subdominio haremos que las pruebas se orienten a regiones de datos más propensas a revelar fallos. De forma intuitiva, cada subdominio representa un conjunto de casos de prueba similares; deseamos maximizar el beneficio de la actividad de prueba escogiendo casos de prueba que no sean similares; es decir, casos de prueba que provengan de subdominios diferentes. En el mejor de los casos, un subdominio es revelador, lo que significa que cada caso de prueba que contiene hace que el programa falle o tenga éxito. Así, el subdominio agrupa casos verdaderamente equivalentes. Si todos los dominios son reveladores, una suite de pruebas que satisfaga el criterio será completa, ya que tendremos la garantía de que encontrará cualquier fallo. En la práctica, sin embargo, resulta bastante difícil obtener subdominios reveladores, pero escogiendo con cuidado los subdominios es posible tener al menos algún subdominio cuya tasa de error –la proporción de entradas que conducen a salidas de datos erróneas– sea mucho mayor que la tasa de error media del dominio de datos de entrada como un todo. 11.5 Criterios de subdominio El criterio ordinario y más ampliamente utilizado en las pruebas basadas en programa es la cobertura de sentencias: esto es, que cada sentencia o segmento de un programa deba ejecutarse al menos una vez. Por la definición, se entiende por qué se trata de un criterio de subdominio: defina para cada sentencia del programa el conjunto de entregas que hacen que se ejecute y escoja al menos un caso de prueba para cada subdominio. Desde luego, el subdominio nunca se construye explícitamente; es una noción conceptual. En vez de eso, lo que ocurre es que se ejecuta una versión instrumental del programa que registra cada sentencia ejecutada. Debe continuar añadiendo casos de prueba hasta que todas las sentencias sean ejecutadas. Existen más criterios aparte de la cobertura de sentencias. El denominado cobertura de decisión o de condición requiere que se ejecuten todas las aristas del gráfico de flujo de control del programa: es como exigir que todas las ramas de un programa sean ejecutadas. No está tan clara la razón por la que este enfoque está considerado más riguroso que la cobertura de sentencias. Piense en la posibilidad de aplicar este criterio a un procedimiento que devuelva el menor de dos valores: static int minimum (int a, int b) { if (a ≤ b) return a; else return b; Para este código, la cobertura de sentencias requerirá entradas con a menor que b y viceversa. Sin embargo, para el código: static int minimum (int a, int b) { int result = b; if (b ≤ a) result = b; return result; un único caso de prueba con b menor que a satisfará el criterio de la cobertura de sentencias, y el fallo se pasará por alto. La cobertura de decisión requeriría un caso en el que el comando if no sea ejecutado, exponiendo de esta forma el fallo. Hay muchas formas de cobertura de condición que exigen, de diversas formas, que las expresiones booleanas probadas como condición sean evaluadas tanto para verdadero (true) como para falso (false). Una forma específica de cobertura de condición, conocida como MCDC, es exigida por una norma denominada DoD específica para software de seguridad crítica, como los de aviación. Esta norma, DO-178B, clasifica los fallos en tres niveles y exige un diferente nivel de cobertura para cada uno: Nivel C: el fallo reduce el margen de seguridad Ejemplo: link de datos vía radio Requiere: cobertura de sentencia Nivel B: el fallo reduce la capacidad de la nave o de la tripulación Ejemplo: GPS Requiere: cobertura de decisión Nivel A: el fallo provoca la pérdida de la nave Ejemplo: sistema de gestión de vuelo Requiere: cobertura MCDC Otra forma común de criterio de subdominio de tipo basada en programa es la que se utiliza en las pruebas de casos límite. Esto requiere la evaluación de los casos límite de cada condición. Por ejemplo, si su programa prueba x < n, serían necesarios casos de prueba que produjeran x = n, x = n-1, y x=n+1. Los criterios basados en especificación también se presentan en términos de subdominios. Como las especificaciones son por lo general informales –esto es, no están escritas en ninguna notación precisa– los criterios tienden a ser más vagos. El planteamiento más común es definir los subdominios de acuerdo con la estructura de la especificación y los valores de los tipos de datos subyacentes. Por ejemplo, los subdominios para un método que inserte un elemento en un conjunto pueden ser: • • • el conjunto está vacío el conjunto no está vacío y el elemento no está en el conjunto el conjunto no está vacío y el elemento está en el conjunto También puede, en la especificación, utilizar cualquier estructura condicional para guiar la división en subdominios. Es más, en la práctica, los encargados de realizar las pruebas utilizan sus conocimientos al respecto de los tipos de error que muchas veces surgen en los códigos. Por ejemplo, si está probando un procedimiento que encuentra un elemento en un array, probablemente colocará el elemento al principio, en el medio y al final, simplemente porque estos casos son propensos a ser manipulados de forma diferente en el código. 11.6 Viabilidad La cobertura total es raramente posible. De hecho, incluso logrando una cobertura de sentencias del 100% es imposible alcanzar la cobertura total. Esta imposibilidad ocurre en razón del código de programación defensiva, código que, en gran parte, nunca se debería ejecutar. Las operaciones de un tipo abstracto de datos, que no tienen ningún cliente, no se ejecutarán mediante casos de prueba independientemente del rigor aplicado, aunque se pueden ejecutar por pruebas de unidad. Se dice que un criterio es factible si es posible satisfacerlo. En la práctica, los criterios no suelen ser factibles. En términos de subdominio, contienen subdominios vacíos. La cuestión práctica es determinar si un subdominio esta vacío o no; si está vacío, no hay razón para tratar de encontrar un caso de prueba que lo satisfaga. Por regla general, cuanto más elaborado sea el criterio, más difícil llega a ser su determinación. Por ejemplo, la cobertura de caminos requiere que todos los caminos del programa sean ejecutados. Suponga que tengamos el siguiente programa: if C1 then S1; if C2 then S2; Entonces, para determinar si el camino S1;S2 es factible, necesitamos determinar si las condiciones C1 y C2 pueden ambas ser verdaderas. Para un programa complejo, no se trata de una tarea trivial y, en el peor de los casos, no es más fácil que determinar la corrección del programa mediante razonamiento. A pesar de estos problemas, la idea de cobertura es muy importante en la práctica. Si existen partes importantes del programa que nunca han sido ejecutadas, ¡más vale que no confíe demasiado en su exactitud! 11.7 Directrices prácticas Ha de quedar claro por qué ni los criterios basados en programas, ni los basados en especificaciones son, por sí solos, suficientes . Si sólo se fija en el programa, pasará por alto errores de omisión. Si sólo observa la especificación, no detectará errores que surgen de problemas de implementación, como, por ejemplo, cuando se alcanzan los límites de un recurso computacional, caso en que se necesita un procedimiento de compensación. En la implementación de la clase ArrayList de Java, por ejemplo, el array de la representación se sustituye cuando está lleno. Para probar este comportamiento, será necesario insertar elementos suficientes en la ArrayList para que el array quede lleno. La experiencia sugiere que el mejor modo de realizar una suite de pruebas es utilizar el criterio basado en la especificación para guiar el desarrollo de la suite y, para evaluar la suite, es mejor que se utilicen los criterios basados en el programa. De este modo, podrá examinar la especificación y definir subdominios de entrada. Basándose en estas premisas, usted puede escribir los casos de prueba. A continuación, se ejecutan los casos y se mide la cobertura de las pruebas en relación con el código. Si la cobertura es inadecuada, bastaría con añadir nuevos casos de prueba. En un entorno profesional, se utilizaría una herramienta especial para medir la cobertura. En este curso, no exigiremos que aprenda a utilizar otra herramienta. En vez de eso, deberá escoger casos de prueba suficientemente elaborados para que pueda argumentar que ha alcanzado una cobertura considerable del código. Las certificaciones en tiempo de ejecución, sobre todo las que representan comprobaciones de invariante, aumentarán de manera espectacular la fuerza de sus pruebas, esto es, podrá hallar más fallos con menos casos y será capaz de solucionarlos más fácilmente.