Laboratorio de Programación  >  Material de estudio  >  Apuntes  >  Prueba de programas

Prueba de Programas

José A. Mañas
16 de marzo, 1994
1. Introducción
1.1. ¿Qué es probar?
1.2. La Prueba Exhaustiva es Imposible
1.3. Organización
2. Prueba de Unidades
2.1. Caja blanca
2.2. Caja negra
3. Pruebas de Integración
4. Pruebas de Aceptación
5. Otros tipos de pruebas
6. Depuración (debugging)
7. Plan de Pruebas
8. Aspectos Sicológicos y Organización del Trabajo
9. Conclusiones
A. Bibliografia
B. Dictionary
C. Caso Práctico

1. Introducción

Una de las últimas fases del ciclo de vida antes de entregar un programa para su explotación, es la fase de pruebas.

Una de las sorpresas con las que suelen encontrar los nuevos programadores es la enorme cantidad de tiempo y esfuerzo que requiere esta fase. Se estima que la mitad del esfuerzo de desarrollo de un programa (tanto en tiempo como en gastos) se va en esta fase. Si hablamos de programas que involucran vidas humanas (medicina, equipos nucleares, etc) el costo de la fase de pruebas puede fácilmente superar el 80%.

Pese a su enorme impacto en el coste de desarrollo, es una fase que muchos programadores aún consideran clasificable como un arte y, por tanto, como difícilmente conceptualizable. Es muy difícil entrenar a los nuevos programadores, que aprenderán mucho más de su experiencia que de lo que les cuenten en los cursos de programación.

Aún siendo una tarea abocada al fracaso, voy a intentarlo.

1.1. ¿Qué es probar?

Como parte que es de un proceso industrial, la fase de pruebas añade valor al producto que se maneja: todos los programas tienen errores y la fase de pruebas los descubre; ese es el valor que añade. El objetivo específico de la fase de pruebas es encontrar cuantos más errores, mejor.

Es frecuente encontrarse con el error de afirmar que el objetivo de esta fase es convencerse de que el programa funciona bien. En realidad ese es el objetivo propio de las fases anteriores (¿quién va a pasar a la sección de pruebas un producto que sospecha que está mal?). Cumplido ese objetivo, lo mejor posible, se pasa a pruebas. Esto no obsta para reconocer que el objetivo último de todo el proceso de fabricación de programas sea hacer programas que funcionen bien; pero cada fase tiene su objetivo específico, y el de las pruebas es destapar errores.

Probar un programa es ejercitarlo con la peor intención a fin de encontrarle fallos.

Por poner un ejemplo duro, probar un programa es equivalente a la actividad de ciertos profesores para los que examinar a un alumno consiste en poner en evidencia todo lo que no sabe. Esto es penoso cuando se aplica a personas; pero es exactamente lo que hay que hacerle a los programas.

1.2. La Prueba Exhaustiva es Imposible

La prueba ideal de un sistema sería exponerlo en todas las situaciones posibles, así encontraríamos hasta el último fallo. Indirectamente, garantizamos su respuesta ante cualquier caso que se le presente en la ejecución real.

Esto es imposible desde todos los puntos de vista: humano, económico e incluso matemático.

Dado que todo es finito en programación (el número de líneas de código, el número de variables, el número de valores en un tipo, etc etc) cabe pensar que el número de pruebas posibles es finito. Esto deja de ser cierto en cuanto entran en juego bucles, en los que es fácil introducir condiciones para un funcionamiento sin fin. Aún en el irrealista caso de que el número de posibilidades fuera finito, el número de combinaciones posibles es tan enorme que se hace imposible su identificación y ejecución a todos los efectos prácticos.

Probar un programa es someterle a todas las posible variaciones de los datos de entrada, tanto si son válidos como si no lo son. Imagínese hacer esto con un compilador de cualquier lenguaje: ¡habría que escribir, compilar y ejecutar todos y cada uno de los programas que se pudieran escribir con dicho lenguaje!

Sobre esta premisa de imposibilidad de alcanzar la perfección, hay que buscar formas humanamente abordables y ecónomicamente aceptables de encontrar errores. Nótese que todo es muy relativo y resbaladizo en este área.

1.3. Organización

Hay multitud de conceptos (y palabras clave) asociadas a las tareas de prueba. Clasificarlas es difícil, pues no son mutuamente disjuntas, sino muy entrelazadas. En lo que sigue intentaremos la siguiente estructura para la presentación:

Fases de prueba:

La prueba de unidades se plantea a pequeña escala, y consiste en ir probando uno a uno los diferentes módulos que constituyen una aplicación.

Las pruebas de integración y de aceptación son pruebas a mayor escala, que puede llegar a dimensiones industriales cuando el número de módulos es muy elevado, o la funcionalidad que se espera del programa es muy compleja.

Las pruebas de integración se centran en probar la coherencia semántica entre los diferentes módulos, tanto de semántica estática (se importan los módulos adecuados; se llama correctamente a los procedimientos proporcionados por cada módulo), como de semántica dinámica (un módulo recibe de otro lo que esperaba). Normalmente estas pruebas se van realizando por etapas, englobando progresivamente más y más módulos en cada prueba.

Las pruebas de integración se pueden empezar en cuanto tenemos unos pocos módulos, aunque no terminarán hasta disponer de la totalidad. En un diseño descendente (top-down) se empieza a probar por los módulos más generales; mientras que en un diseño ascendente se empieza a probar por los módulos de base.

El planteamiento descendente tiene la ventaja de estar siempre pensando en términos de la funcionalidad global; pero también tiene el inconveniente de que para cada prueba hay que "inventarse" algo sencillito (pero fiable) que simule el papel de los módulos inferiores, que aún no están disponibles.

El planteamiento ascendente evita tener que escribirse módulos ficticios, pues vamos construyendo pirámides más y más altas con lo que vamos teniendo. Su desventaja es que se centra más en el desarrollo que en las espectativas finales del cliente.

Estas clasificaciones no son las únicas posibles. Por ejemplo, en sistemas con mucha interacción con el usuario es frecuente codificar sólo las partes de cada módulo que hacen falta para una cierta funcionalidad. Una vez probada, se añade otra funcionalidad y así hasta el final. Esto da lugar a un planteamiento más "vertical" de las pruebas. A veces se conoce como "codificación incremental".

Por último, las pruebas de aceptación son las que se plantea el cliente final, que decide qué pruebas va a aplicarle al producto antes de darlo por bueno y pagarlo. De nuevo, el objetivo del que prueba es encontrar los fallos lo antes posible, en todo caso antes de pagarlo y antes de poner el programa en producción.

2. Prueba de Unidades

¿Cómo se prueban módulos sueltos?

Normalmente cabe distinguir una fase informal antes de entrar en la fase de pruebas propiamente dicha. La fase informal la lleva a cabo el propio codificador en su despacho, y consiste en ir ejecutando el código para convencerse de que "básicamente, funciona". Esta fase suele consistir en pequeños ejemplos que se intentan ejecutar. Si el módulo falla, se suele utilizar un depurador para observar la evolución dinámica del sistema, localizar el fallo, y repararlo.

En lenguajes antiguos, poco rigurosos en la sintaxis y/o en la semantica de los programas, esta fase informal llega a ser muy dura, laboriosa, y susceptible de dejar pasar grandes errores sin que se note. En lenguajes modernos, con reglas estrictas, hay herramientas que permiten análisis exhaustivos de los aspectos estáticos de la semántica de los programas: tipado de las variables, ámbitos de visibilidad, parámetros de llamada a procedimientos, etc etc

Hay asimismo herramientas más sofisticadas capaces de emitir "opiniones" sobre un programa y alertar de construcciones arriesgadas, de expresiones muy complicadas (que se prestan a equivocaciones), etc. etc. A veces pueden prevenir sobre variables que pueden usarse antes de tomar algún valor (no inicializadas), variables que se cargan pero luego no se usan, y otras posibilidades que, sin ser necesariamente errores en sí mismas, sí suelen apuntar a errores de verdad.

Más adelante, cuando el módulo parece presentable, se entra en una fase de prueba sistemática. En esta etapa se empieza a buscar fallos siguiendo algún criterio para que "no se escape nada". Los criterios más habituales son los denominados de caja negra y de caja blanca.

Se dice que una prueba es de caja negra cuando prescinde de los detalles del código y se limita a lo que se ve desde el exterior. Intenta descubrir casos y circunstancias en los que el módulo no hace lo que se espera de él.

Por oposición al término "caja negra" se suele denominar "caja blanca" al caso contrario, es decir, cuando lo que se mira con lupa es el código que está ahí escrito y se intenta que falle. Quizás sea más propio la denominación de "pruebas de caja transparente".

2.1. Caja blanca

Sinónimos:

En estas pruebas estamos siempre observando el código, que las pruebas se dedican a ejecutar con ánimo de "probarlo todo". Esta noción de prueba total se formaliza en lo que se llama "cobertura" y no es sino una medida porcentual de ¿cuánto código hemos cubierto?

Hay diferentes posibilidades de definir la cobertura. Todas ellas intentan sobrevivir al hecho de que el número posible de ejecuciones de cualquier programa no trivial es (a todos los efectos prácticos) infinito. Pero si el 100% de cobertura es infinito, ningún conjunto real de pruebas pasaría de un infinitésimo de cobertura. Esto puede ser muy interesante para los matemáticos; pero no sirve para nada.

Cobertura de segmentos
A veces también denominada "cobertura de sentencias". Por segmento se entiende una secuencia de sentencias sin puntos de decisión. Como el ordenador está obligado a ejecutarlas una tras otra, es lo mismo decir que se han ejecutado todas las sentencias o todos los segmentos.

El número de sentencias de un programa es finito. Basta coger el código fuente e ir contando. Se puede diseñar un plan de pruebas que vaya ejercitando más y más sentencias, hasta que hayamos pasado por todas, o por una inmensa mayoría.

En la práctica, el proceso de pruebas termina antes de llegar al 100%, pues puede ser excesivamente laborioso y costoso provocar el paso por todas y cada una de las sentencias.

A la hora de decidir el punto de corte antes de llegar al 100% de cobertura hay que ser precavido y tomar en consideración algo más que el índice conseguido. En efecto, ocurre con harta frecuencia que los programas contienen código muerto o inalcanzable. Puede ser que este trozo del programa, simplemente "sobre" y se pueda prescindir de él; pero a veces significa que una cierta funcionalidad, necesaria, es inalcanzable: esto es un error y hay que corregirlo.

Cobertura de ramas
La cobertura de segmentos es engañosa en presencia de segmentos opcionales. Por ejemplo:
IF Condicion THEN EjecutaEsto; END;

Desde el punto de vista de cobertura de segmentos, basta ejecutar una vez, con éxito en la condición, para cubrir todas las sentencias posibles. Sin embargo, desde el punto de vista de la lógica del programa, también debe ser importante el caso de que la condición falle (si no lo fuera, sobra el IF). Sin embargo, como en la rama ELSE no hay sentencias, con 0 ejecuciones tenemos el 100%.

Para afrontar estos casos, se plantea un refinamiento de la cobertura de segmentos consistente en recorrer todas las posibles salidas de los puntos de decisión. Para el ejemplo de arriba, para conseguir una cobertura de ramas del 100% hay que ejecutar (al menos) 2 veces, una satisfaciendo la condición, y otra no.

Estos criterios se extienden a las construcciones que suponen elegir 1 de entre varias ramas. Por ejemplo, el CASE.

Nótese que si lograramos una cobertura de ramas del 100%, esto llevaría implícita una cobertura del 100% de los segmentos, pues todo segmento está en alguna rama. Esto es cierto salvo en programas triviales que carecen de condiciones (a cambio, basta 1 sóla prueba para cubrirlo desde todos los puntos de vista). El criterio también debe refinarse en lenguajes que admiten excepciones (por ejemplo, Ada). En estos casos, hay que añadir pruebas para provocar la ejecución de todas y cada una de las excepciones que pueden dispararse.

Cobertura de condición/decisión
La cobertura de ramas resulta a su vez engañosa cuando las expresiones booleanas que usamos para decidir por qué rama tirar son complejas. Por ejemplo:
IF Condicion1 OR Condicion2 THEN HazEsto; END;

Las condiciones 1 y 2 pueden tomar 2 valores cada una, dando lugar a 4 posibles combinaciones. No obstante sólo hay dos posibles ramas y bastan 2 pruebas para cubrirlas. Pero con este criterio podemos estar cerrando los ojos a otras combinaciones de las condiciones.

Consideremos sobre el caso anterior las siguientes pruebas:

  Prueba 1: Condicion1 = TRUE   y  Condicion2 = FALSE
  Prueba 2: Condicion1 = FALSE  y  Condicion2 = TRUE
  Prueba 3: Condicion1 = FALSE  y  Condicion2 = FALSE
  Prueba 4: Condicion1 = TRUE   y  Condicion2 = TRUE

Bastan las pruebas 2 y 3 para tener cubiertas todas las ramas. Pero con ellos sólo hemos probado una posibilidad para la Condición1.

Para afrontar esta problemática se define un criterio de cobertura de condición/decisión que trocea las expresiones booleanas complejas en sus componentes e intenta cubrir todos los posibles valores de cada uno de ellos.

Nótese que no basta con cubrir cada una de las condciones componentes, si no que además hay que cuidar de sus posibles combinaciones de forma que se logre siempre probar todas y cada una de las ramas. Así, en el ejemplo anterior no basta con ejecutar las pruebas 1 y 2, pues aun cuando cubrimos perfectamente cada posibilidad de cada condición por separado, lo que no hemos logrado es recorrer las dos posibles ramas de la decisión combinada. Para ello es necesario añadir la prueba 3.

El conjunto mínimo de pruebas para cubrir todos los aspectos es el formado por las pruebas 3 y 4. Aún así, nótese que no hemos probado todo lo posible. Por ejemplo, si en el programa nos colamos y ponemos AND donde queríamos poner OR (o viceversa), este conjunto de pruebas no lo detecta. Sólo queremos decir que la cobertura es un criterio útil y práctico; pero no es prueba exhaustiva.

Cobertura de bucles
Los bucles no son más que segmentos controlados por decisiones. Así, la cobertura de ramas cubre plenamente la esencia de los bucles. Pero eso es simplemente la teoría, pues la práctica descubre que los bucles son una fuente inagotable de errores, todos triviales, algunos mortales. Un bucle se ejecuta un cierto número de veces; pero ese número de veces debe ser muy preciso, y lo más normal es que ejecutarlo una vez de menos o una vez de más tenga consecuencias indeseables. Y, sin embargo, es extremadamente fácil equivocarse y redactar un bucle que se ejecuta 1 vez de más o de menos.

Para un bucle de tipo WHILE hay que pasar 3 pruebas

  1. 0 ejecuciones
  2. 1 ejecución
  3. más de 1 ejecución

Para un bucle de tipo REPEAT hay que pasar 2 pruebas

  1. 1 ejecución
  2. más de 1 ejecución

Los bucles FOR, en cambio, son muy seguros, pues en su cabecera está definido el número de veces que se va a ejecutar. Ni una más, ni una menos, y el compilador se encarga de garantizarlo. Basta pues con ejecutarlos 1 vez.

No obstante, conviene no engañarse con los bucles FOR y examinar su contenido. Si dentro del bucle se altera la variable de control, o el valor de alguna variable que se utilice en el cálculo del incremento o del límite de iteración, entonces eso es un bucle FOR con trampa.

También tiene "trampa" si contiene sentencias del tipo EXIT (que algunos lenguajes denominan BREAK) o del tipo RETURN. Todas ellas provocan terminaciones anticipadas del bucle.

Estos últimos párrafos hay que precisarlos para cada lenguaje de programación. Lo peor son aquellos lenguajes que permiten el uso de sentencias GOTO. Tampoco conviene confiarse de lo que prometen lenguajes como MODULA-2, que se supone que prohiben ciertas construcciones arriesgadas. Los compiladores reales suelen ser más tolerantes que lo que anuncian los libros.

Si el programa contiene bucles LOOP, o simplemente bucles con trampa, la única cobertura aplicable es la de ramas. El riesgo de error es muy alto; pero no se conocen técnicas sistemáticas de abordarlo, salvo reescribir el código.

Y en la práctica ¿qué hago?
Tanta definición acaba resultando un tanto académica e inútil.

En la práctica de cada día, se suele procura alcanzar una cobertura cercana al 100% de segmentos. Es muy recomendable (aunque cuesta más) conseguir una buena cobertura de ramas. En cambio, no suele hacer falta ir a por una cobertura de decisiones atomizadas.

¿Qué es una buena cobertura?
Pues depende de lo crítico que sea el programa. Hay que valorar el riesgo (o coste) que implica un fallo si éste se descubre durante la aplicación del programa. Para la mayor parte del software que se produce en Occidente, el riesgo es simplemente de imagen (si un juego fallece a mitad, queda muy feo; pero no se muere nadie). En estas circunstancias, coberturas del 60-80% son admisibles.

La cobertura requerida suele ir creciendo con el ámbito previsto de distribución. Si un programa se distribuye y falla en algo grave puede ser necesario redistribuirlo de nuevo y urgentemente. Si hay millones de clientes dispersos por varios paises, el coste puede ser brutal. En estos casos hay que exprimir la fase de pruebas para que encuentre prácticamente todos los errores sin pasar nada por alto. Esto se traduce al final en buscar coberturas más altas.

Es aún más delicado cuando entramos en aplicaciones que involucran vidas humanas (aplicaciones sanitarias, centrales nucleares, etc) Cuando un fallo se traduce en una muerte, la cobertura que se busca se acerca al 99% y además se presta atención a las decisiones atómicas.

También se suele perseguir coberturas muy elevadas (por encima del 90%) en las aplicaciones militares. Esto se debe a que normalmente van a ser utilizadas en condiciones muy adversas donde el tiempo es inestimable. Si un programa fallece, puede no haber una segunda oportunidad de arrancarlo de nuevo.

La ejecución de pruebas de caja blanca puede llevarse a cabo con un depurador (que permite le ejecución paso a paso), un listado del módulo y un rotulador para ir marcando por dónde vamos pasando. Esta tarea es muy tediosa, pero puede ser automatizada. Hay compiladores que a la hora de generar código máquina dejan incrustado en el código suficiente código como para poder dejar un fichero (tras la ejecución) con el número de veces que se ha ejecutado cada sentencia, rama, bucle, etc.

Limitaciones
Lograr una buena cobertura con pruebas de caja blanca es un objetivo deseable; pero no suficiente a todos los efectos. Un programa puede estar perfecto en todos sus términos, y sin embargo no servir a la función que se pretende.

Por ejemplo, un Rolls-Royce es un coche que sin duda pasaría las pruebas más exigentes sobre los últimos detalles de su mecánica o su carrocería. Sin embargo, si el cliente desea un todo-terreno, difícilmente va a comprárselo.

Por ejemplo, si escribimos una rutina para ordenar datos por orden ascendente, pero el cliente los necesita en orden decreciente; no hay prueba de caja blanca capaz de detectar la desviación.

Las pruebas de caja blanca nos convencen de que un programa hace bien lo que hace; pero no de que haga lo que necesitamos.

2.2. Caja negra

Sinónimos:

Las pruebas de caja negra se centran en lo que se espera de un módulo, es decir, intentan encontrar casos en que el módulo no se atiene a su especificación. Por ello se denominan pruebas funcionales, y el probador se limita a suministrarle datos como entrada y estudiar la salida, sin preocuparse de lo que pueda estar haciendo el módulo por dentro.

Las pruebas de caja negra están especialmente indicadas en aquellos módulos que van a ser interfaz con el usuario (en sentido general: teclado, pantalla, ficheros, canales de comunicaciones, etc etc) Este comentario no obsta para que sean útiles en cualquier módulo del sistema.

Las pruebas de caja negra se apoyan en la especificación de requisitos del módulo. De hecho, se habla de "cobertura de especificación" para dar una medida del número de requisitos que se han probado. Es fácil obtener coberturas del 100% en módulos internos, aunque puede ser más laborioso en módulos con interfaz al exterior. En cualquier caso, es muy recomendable conseguir una alta cobertura en esta línea.

El problema con las pruebas de caja negra no suele estar en el número de funciones proporcionadas por el módulo (que siempre es un número muy limitado en diseños razonables); sino en los datos que se le pasan a estas funciones. El conjunto de datos posibles suele ser muy amplio (por ejemplo, un entero).

A la vista de los requisitos de un módulo, se sigue una técnica algebráica conocida como "clases de equivalencia". Esta técnica trata cada parámetro como un modelo algebráico donde unos datos son equivalentes a otros. Si logramos partir un rango excesivamente amplio de posibles valores reales a un conjunto reducido de clases de equivalencia, entonces es suficiente probar un caso de cada clase, pues los demás datos de la misma clase son equivalentes.

El problema está pues en identificar clases de equivalencia, tarea para la que no existe una regla de aplicación universal; pero hay recetas para la mayor parte de los casos prácticos:

Ejemplo: utilizamos un entero para identificar el día del mes. Los valores posibles están en el rango [1..31]. Así, hay 3 clases:

  1. números menores que 1
  2. números entre 1 y 31
  3. números mayores que 31

Durante la lectura de los requisitos del sistema, nos encontraremos con una serie de valores singulares, que marcan diferencias de comportamiento. Estos valores son claros candidatos a marcar clases de equivalencia: por abajo y por arriba.

Una vez identificadas las clases de equivalencia significativas en nuestro módulo, se procede a coger un valor de cada clase, que no esté justamente al límite de la clase. Este valor aleatorio, hará las veces de cualquier valor normal que se le pueda pasar en la ejecución real.

La experiencia muestra que un buen número de errores aparecen en torno a los puntos de cambio de clase de equivalencia. Hay una serie de valores denominados "frontera" (o valores límite) que conviene probar, además de los elegidos en el párrafo anterior. Usualmente se necesitan 2 valores por frontera, uno justo abajo y otro justo encima.

Limitaciones
Lograr una buena cobertura con pruebas de caja negra es un objetivo deseable; pero no suficiente a todos los efectos. Un programa puede pasar con holgura millones de pruebas y sin embargo tener defectos internos que surgen en el momento más inoportuno (Murphy no olvida).

Por ejemplo, un PC que contenga el virus Viernes-13 puede estar pasando pruebas de caja negra durante años y años. Sólo falla si es viernes y es día 13; pero ¿a quién se le iba a ocurrir hacer esa prueba?

Las pruebas de caja negra nos convencen de que un programa hace lo que queremos; pero no de que haga (además) otras cosas menos aceptables.

3. Pruebas de Integración

Las pruebas de integración se llevan a cabo durante la construcción del sistema, involucran a un número creciente de módulos y terminan probando el sistema como conjunto.

Estas pruebas se pueden plantear desde un punto de vista estructural o funcional.

Las pruebas estructurales de integración son similares a las pruebas de caja blanca; pero trabajan a un nivel conceptual superior. En lugar de referirnos a sentencias del lenguaje, nos referiremos a llamadas entre módulos. Se trata pues de identificar todos los posibles esquemas de llamadas y ejercitarlos para lograr una buena cobertura de segmentos o de ramas.

Las pruebas funcionales de integración son similares a las pruebas de caja negra. Aquí trataremos de encontrar fallos en la respuesta de un módulo cuando su operación depende de los servicios prestados por otro(s) módulo(s). Según nos vamos acercando al sistema total, estas pruebas se van basando más y más en la especificación de requisitos del usuario.

Las pruebas finales de integración cubren todo el sistema y pretenden cubrir plenamente la especificación de requisitos del usuario. Además, a estas alturas ya suele estar disponible el manual de usuario, que también se utiliza para realizar pruebas hasta lograr una cobertura aceptable.

En todas estas pruebas funcionales se siguen utilizando las técnicas de partición en clases de equivalencia y análisis de casos límite (fronteras).

4. Pruebas de Aceptación

Estas pruebas las realiza el cliente. Son básicamente pruebas funcionales, sobre el sistema completo, y buscan una cobertura de la especificación de requisitos y del manual del usuario. Estas pruebas no se realizan durante el desarrollo, pues sería impresentable de cara al cliente; sino una vez pasadas todas las pruebas de integración por parte del desarrollador.

La experiencia muestra que aún despues del más cuidadoso proceso de pruebas por parte del desarrollador, quedan una serie de errores que sólo aparecen cuando el cliente se pone a usarlo. Los desarrolladores se suelen llevar las manos a la cabeza:

"Pero, ¿a quién se le ocurre usar así mi programa?"
Sea como sea, el cliente siempre tiene razón. Decir que los requisitos no estaban claros, o que el manual es ambiguo puede salvar la cara; pero ciertamente no deja satisfecho al cliente. Alegar que el cliente es un inútil es otra tentación muy fuerte, que conviene reprimir.

Por estas razones, muchos desarrolladores ejercitan unas técnicas denominadas "pruebas alfa" y "pruebas beta". Las pruebas alfa consisten en invitar al cliente a que venga al entorno de desarrollo a probar el sistema. Se trabaja en un entorno controlado y el cliente siempre tiene un experto a mano para ayudarle a usar el sistema y para analizar los resultados.

Las pruebas beta vienen despues de las pruebas alfa, y se desarrollan en el entorno del cliente, un entorno que está fuera de control. Aquí el cliente se queda a solas con el producto y trata de encontrarle fallos (reales o imaginarios) de los que informa al desarrollador.

Las pruebas alfa y beta son habituales en productos que se van a vender a muchos clientes. Algunos de los potenciales compradores se prestan a estas pruebas bien por ir entrenando a su personal con tiempo, bien a cambio de alguna ventaja económica (mejor precio sobre el producto final, derecho a mantenimiento gratuito, a nuevas versiones, etc etc). La experiencia muestra que estas prácticas son muy eficaces.

5. Otros tipos de pruebas

Recorridos (walkthroughs)
Quizás es una técnica más aplicada en control de calidad que en pruebas. Consiste en sentar alrededor de una mesa a los desarrolladores y a una serie de críticos, bajo las órdenes de un moderador que impida un recalentamiento de los ánimos. El método consiste en que los revisores se leen el programa línea a línea y piden explicaciones de todo lo que no está meridianamente claro. Puede que simplemente falte un comentario explicativo, o que detecten un error auténtico o que simplemente el código sea tan complejo de entender/explicar que más vale que se rehaga de forma más simple. Para un sistema complejo pueden hacer falta muchas sesiones.

Esta técnica es muy eficaz localizando errores de naturaleza local; pero falla estrepitosamente cuando el error deriva de la interacción entre dos partes alejadas del programa. Nótese que no se está ejecutando el programa, sólo mirándolo con lupa, y de esta forma sólo se ve en cada instante un trocito del listado.

Aleatorias (random testing)
Ciertos autores consideran injustificada una aproximación sistemática a las pruebas. Alegan que la probabilidad de descubrir un error es prácticamente la misma si se hacen una serie de pruebas aleatoriamente elegidas, que si se hacen siguiendo las instrucciones dictadas por criterios de cobertura (caja negra o blanca).

Como esto es muy cierto, probablemente sea muy razonable comenzar la fase de pruebas con una serie de casos elegidos al azar. Esto pondrá de manifiesto los errores más patentes. No obstante, pueden permanecer ocultos errores más sibilinos que sólo se muestran ante entradas muy precisas.

Si el programa es poco crítico (una aplicación personal, un juego, ...) puede que esto sea suficiente. Pero si se trata de una aplicación militar o con riesgo para vidas humanas, es de todo punto insuficiente.

Solidez (robustness testing)
Se prueba la capacidad del sistema para salir de situaciones embarazosas provocadas por errores en el suministro de datos. Estas pruebas son importantes en sistemas con una interfaz al exterior, en particular cuando la interfaz es humana.

Por ejemplo, en un sistema que admite una serie de órdenes (commands) se deben probar los siguientes extremos:

Aguante (stress testing)
En ciertos sistemas es conveniente saber hasta dónde aguantan, bien por razones internas (¿hasta cuantos datos podrá procesar?), bien externas (¿es capaz de trabajar con un disco al 90%?, ¿aguanta una carga de la CPU del 90?, etc etc)

Prestaciones (performance testing)
A veces es importante el tiempo de respuesta, u otros parámetros de gasto. Típicamente nos puede preocupar cuánto tiempo le lleva al sistema procesar tantos datos, o cuánta memoria consume, o cuánto espacio en disco utiliza, o cuántos datos transfiere por un canal de comunicaciones, o ... Para todos estos parámetros suele ser importante conocer cómo evolucionan al variar la dimensión del problema (por ejemplo, al duplicarse el volumen de datos de entrada).

Conformidad u Homologación (conformance testing)
En programas de comunicaciones es muy frecuente que, además de los requisitos específicos del programa que estamos construyendo, aparezca alguna norma más amplia a la que el programa deba atenerse. Es frecuente que organismos internacionales como ISO y el CCITT elaboren especificaciones de referencia a las que los diversos fabricantes deben atenerse para que sus ordenadores sean capaces de entenderse entre sí.

Las pruebas, de caja negra, que se le pasan a un producto para detectar discrepancias respecto a una norma de las descritas en el párrafo anterior se denominan de conformidad u homologación. Suelen realizarse en un centro especialmente acreditado al efecto y, si se pasan satisfactoriamente, el producto recibe un sello oficial que dice: "homologado".

Interoperabilidad (interoperability tesing)
En el mismo escenario del punto anterior, programas de comunicaciones que deden permitir que dos ordenadores se entiendan, aparte de las pruebas de conformidad se suelen correr una serie de pruebas, también de caja negra, que involucran 2 o más productos, y buscan problemas de comunicación entre ellos.

Regresión (regression testing)
Todos los sistemas sufren una evolución a lo largo de su vida activa. En cada nueva versión se supone que o bien se corrigen defectos, o se añaden nuevas funciones, o ambas cosas. En cualquier caso, una nueva versión exige una nueva pasada por las pruebas. Si éstas se han sistematizado en una fase anterior, ahora pueden volver a pasarse automáticamente, simplemente para comprobar que las modificaciones no provocan errores donde antes no los había.

El mínimo necesario para usar unas pruebas en una futura revisión del programa es una documentación muy muy clara.

Las pruebas de regresión son particularmente espectaculares cuando se trata de probar la interacción con un agente externo. Existen empresas que viven de comercializar productos que "graban" la ejecución de una prueba con operadores humanos para luego repetirla cuantas veces haga falta "reproduciendo la grabación". Y, obviamente, deben monitorizar la respuesta del sistema en ambos casos, compararla, y avisar de cualquier discrepancia significativa.

Mutación (mutation testing)
Es una técnica curiosa consistente en alterar ligeramente el sistema bajo pruebas (introduciendo errores) para averiguar si nuestra batería de pruebas es capaz de detectarlo. Si no, más vale introducir nuevas pruebas. Todo esto es muy laborioso y francamente artesano.

6. Depuración (debugging)

Casi todos los compiladores suelen llevar asociada la posibilidad de ejecutar un programa paso a paso, permitiéndole al operador conocer dónde está en cada momento, y cuánto valen las variables.

Los depuradores pueden usarse para realizar inspecciones rigurosas sobre el comportamiento dinámico de los programas. La práctica demuestra, no obstante, que su uso es tedioso y que sólo son eficaces si se persigue un objetivo muy claro. El objetivo habitual es utilizarlo como consecuencia de la detección de un error. Si el programa se comporta mal en un cierto punto, hay que averiguar la causa precisa para poder repararlo. La causa a veces es inmediata (por ejemplo, un operador booleano equivocado); pero a veces depende del valor concreto de los datos en un cierto punto y hay que buscar la causa en otra zona del programa.

En general es mala idea "correr al depurador", tanto por el tiempo que se pierde buceando sin una meta clara, como por el riesgo de corregir defectos intermedios sin llegar a la raiz del problema. Antes de entrar en el depurador hay que delimitar el error y sus posibles causas. Ante una prueba que falla, hay que identificar el dominio del fallo, averiguar las características de los datos que provoca el fallo (y comprobar experimentalmente que todos los datos con esas características provocan ese fallo, y los que no las tienen no lo provocan).

El depurador es el último paso para convencernos de nuestro análisis y afrontar la reparación con conocimiento de causa.

7. Plan de Pruebas

Un plan de pruebas está constituido por un conjunto de pruebas. Cada prueba debe

Las pruebas angelicales carecen de utilidad, tanto si no se sabe exactamente lo que se quiere probar, o si no está claro cómo se prueba, o si el análisis del resultado se hace "a ojo".

Estas mismas ideas se suelen agrupar diciendo que un caso de prueba consta de 3 bloques de información:

  1. El propósito de la prueba
  2. Los pasos de ejecución de la prueba
  3. El resultado que se espera

Y todos y cada uno de esos puntos debe quedar perfectamente documentado. Las pruebas de usar y tirar más vale que se tiren directamente, aún antes de usarlas.

Cubrir estos puntos es muy laborioso y, con frecuencia, tedioso, lo que hace desagradable (o al menos muy aburrida) la fase de pruebas. Es mucho mas divertido codificar que probar. Tremendo error en el que, no obstante, es fácil incurrir.

Respecto al orden de pruebas, una práctica frecuente es la siguiente:

  1. Pasar pruebas de caja negra analizando valores límite. Recuerde que hay que analizar condiciones límite de entrada y de salida.
  2. Identificar clases de equivalencia de datos (entrada y salida) y añadir más pruebas de caja negra para contemplar valores normales (en las clases de equivalencia en que estos sean diferentes de los valores límite; es decir, en rangos amplios de valores).
  3. Añadir pruebas basadas en "presunción de error". A partir de la experiencia y el sentido común, se aventuran situaciones que parecen proclives a padecer defectos, y se buscan errores en esos puntos. Son pruebas del tipo "¡Me lo temía!"
  4. Medir la cobertura de caja blanca que se ha logrado con las fases previas y añadir más pruebas de caja blanca hasta lograr la cobertura deseada. Normalmente se busca una buena cobertura de ramas (revise los comentarios expuestos al hablar de caja blanca).

8. Aspectos Sicológicos y Organización del Trabajo

Parecen tonterías; pero pueden cambiar radicalmente el éxito de una fase de pruebas:
  1. Probar es ejercitar un programa para encontrarle fallos.
    Jamás se debería probar un programa con el ánimo de mostrar que funciona; ese no es el objetivo.

  2. Un caso de prueba tiene éxito cuando encuentra un fallo.
    Lo gracioso no es encontrar un caso en el que el programa funciona perfectamente. Eso es, simplemente, lo normal. Lo guai es encontrar el caso en el que falla.

  3. Las pruebas debe diseñarlas y pasarlas una persona distinta de la que ha escrito el código; es la única forma de no ser "comprensivo con los fallos".

    Hacer una "obra maestra" cuesta mucho esfuerzo y requiere gran habilidad. Encontrarle fallos a una "obra maestra" cuesta aún más esfuerzo y exige otro tipo de habilidad.

  4. Las pruebas no pueden esperar a que esté todo el código escrito para empezar a pasarlas. Deben irse pasando pruebas según se va generando el código para descubrir los errores lo antes posible y evitar que se propaguen a otros módulos. En realidad el nombre "fase de pruebas" es engañoso, pues hay muchas actividades que se desarrollan concurrentemente o, al menos, no se necesita cerrar una fase antes de pasar a la siguiente. Algunos autores llegan al extremo de afirmar que "primero hay que probar y luego codificar". Frase graciosa que se plasma en aspectos mas concretos como que el programa se escriba pensando en que hay que probarlo.

  5. Si en un módulo (o sección de un programa, en general) se encuentran muchos fallos, hay que insistir sobre él. Es muy habitual que los fallos se concentren en pequeñas zonas. Hay mil causas para que ocurra este efecto: Además, cuanto más se parchea un trozo de código, tanto más ruinoso queda y susceptible a derrumbamientos. A la larga hay que acabar tirándolo y empezando de nuevo.

  6. Si se detecta un fallo aislado, puede bastar una corrección aislada. Pero si se detectan muchos fallos en un módulo, lo único práctico es desecharlo, diseñarlo de nuevo, y recodificarlo. La técnica de ir parcheando hasta que se pasan una serie de pruebas es absolutamente suicida y sólo digna del avestruz.

  7. Las pruebas pueden encontrar fallos; pero jamás demostrar que no los hay.

    Es como las bruxas: nadie las ha visto; pero haberlas, haylas.

    Ningún programa (no trivial) se ha probado jamás al 100%.

  8. Las pruebas también tienen fallos. Los errores son propios de los humanos: todo el mundo se equivoca. Si una prueba falla, hay que revisar tanto lo que se prueba como lo que lo prueba. No obstante, la experiencia muestra que (casi siempre) hay más fallos el probado que en el probador.

9. Conclusiones

Probar es buscarle los fallos a un programa.

La fase de pruebas absorbe una buena porción de los costes de desarrollo de software. Además, se muestra renuente a un tratamiento matemático o, simplemente, automatizado. Su ejecución se basa en metodología (reglas que se les dan a los encargados de probar) que se va desarrollando con la experiencia. Es tediosa, es un arte, es un trabajo que requiere una buena dosis de mala intención, y provoca difíciles reacciones humanas.

Aunque se han desarrollado miles de herramientas de soporte de esta fase, todas han limitado su éxito a entornos muy concretos, frecuentemente sólo sirviendo para el producto para el que se desarrollaron. Sólo herramientas muy generales como analizadores de complejidad, sistemas de ejecución simbólica y medidores de cobertura han mostrado su utilidad en un marco más amplio. Pero al final sigue siendo imprescindible un artista humano que sepa manejarlas.

A. Bibliografia

  1. Glenford J. Myers
    El Arte de Probar el Software (The Art of Software Testing)
    El Ateneo, 1983 (John Wiley & Sons, Inc. 1979)

    Es "el clásico" por antonomasia. Está muy bien escrito, claro y conciso. Sólo adolece de cierta vejez en cuanto los ejemplos se refieren a PL/I, y otras anticuallas.

  2. Barbee Teasley Mynatt
    Software Engineering with Student Project Guidance
    Prentice-Hall International Editions, 1990

    Es un libro muy pragmático, escrito por una sicóloga metida a ingeniera software. No se anda por las ramas.

  3. Boris Beizer
    Software Testing Techniques
    Van Nostrand Reinhold (N.Y.) 2a ed. 1990

    Es como la biblia de las pruebas. Un libro quizás algo excesivo y sin duda exhaustivo sobre el tema.

  4. Roger S. Pressman
    Software Engineering: A Practitioner's Approach
    McGraw-Hill Intl. Eds. 1987

    No está mal, aunque quizás se enrolla un poco y no concreta.

La mayor parte de los libros tratan esta fase del desarrollo de programas de formas muy peculiares, con más rollo que ciencia y sin dejar claro lo que hay que hacer en un caso práctico. Es muy raro que los libros que se dedican a enseñar un lenguaje o a enseñar a programar traten seriamente este tema. Hay que ir necesariamente a libros de ingeniería software.

B. Dictionary

Aunque he intentado utilizar traducciones razonables e intuitivas de los términos mas habitulamente utilizados, es bien cierto que lo más frecuente es que en la práctica nos encontremos la literatura en inglés. Esta mini-diccionario intenta cubrir la terminología anglosajona.
acceptance testing pruebas de aceptación
alpha testing pruebas a nivel alfa
back-box testing pruebas de caja negra
beta testing pruebas a nivel beta
boundary testing pruebas de casos límite
branch coverage cobertura de ramas
conformance testing pruebas de homologación
coverage cobertura
debugging depuración
decision coverage cobertura de decisiones
desk checking pruebas de despacho
dynamic testing pruebas dinámicas
equivalence partitioning particiones de equivalencia
error-prone modules módulos sospechosos
functional tests pruebas funcionales
hand execution ejecución manual
incremental coding codificación incremental
integration testing pruebas de integración
interoperability testing pruebas de interoperabilidad
loop coverage cobertura de bucles
performance tests pruebas de prestaciones
quality calidad
regression testing pruebas de regresión
robustness tests pruebas de robustez
segment coverage cobertura de segmentos
statement coverage cobertura de sentencias
static testing pruebas estáticas
stress tests pruebas de robustez
structural tests pruebas estructurales
test harness banco de pruebas
testing pruebas
testing in the large pruebas a escala industrial
testing in the small pruebas a pequeña escala
unit testing pruebas de unidades
validation validación
verification verificación
white-box testing pruebas de caja blanca

C. Caso Práctico

Los ejemplos de pruebas de programas suelen irse a uno de dos extremos: o son triviales y no se aprende nada, o son tan enormes que resultan tediosos. El ejemplo elegido para esta sección pretende ser comedido, a costa de no contemplar mas que un reducido espectro de casos.

Nos dan para probar un procedimiento

PROCEDURE Busca (C: CHAR; V: ARRAY OF CHAR): BOOLEAN;

A este procedimiento se le proporciona un caracter C y un array V de caracteres. El ARRAY debe estar ordenado alfabéticamente, en orden ascendente. El procedimiento devuelve TRUE si C está en V, y FALSE si no. Trabajamos en Modula-2.

Lo primero que hay que hacer es identificar clases de equivalencia sobre su interfaz:

Para probar algo necesitamos saber más. La única forma es tener una charla con el que especificó la función y aclarar estos extremos. Todas estas aclaraciones deben quedar recogidas por escrito en una nueva versión de la especificación:

A este procedimiento se le proporciona un caracter C y un array V de caracteres. Se admitirá cualquier caracter de 8 bits de los representables en un PC con Modula-2. El ARRAY podrá tener entre 0 y 10.000 caracteres y estar ordenado alfabéticamente, en orden ascendente. El orden de los caracteres es el proporcionado por el Modula-2 sobre el tipo CHAR. Es admisible cualquier cadena de caracteres construida según el convenio de Modula-2 para este tipo de datos. El procedimiento devuelve TRUE si C está en V, y FALSE si no. Trabajamos en Modula-2.

Con estas explicaciones identificamos las siguientes clases de equivalencia

Por último, cabe considerar combinaciones significativas de datos de entrada: que C sea el primero o el último del ARRAY.

  1. Pruebas de caja negra: valores límite
    1. Buscar el caracter 'k' en el ARRAY "" Debe devolver FALSE.
    2. Buscar el caracter 'k' en el ARRAY "k" Debe devolver TRUE.
    3. Buscar el caracter 'k' en el ARRAY "j" Debe devolver FALSE.
    4. Buscar el caracter 'k' en el ARRAY "kl" Debe devolver TRUE.
    5. Buscar el caracter 'k' en el ARRAY "jk" Debe devolver TRUE.
    6. Buscar el caracter 'k' en el ARRAY de 10.000 "a" Debe devolver FALSE.

    Vamos a olvidar de momento las posibles pruebas referentes a la ordenación del ARRAY.

  2. Pruebas de caja negra: valores normales
    1. Buscar el caracter 'k' en el ARRAY "abc" Debe devolver FALSE.
    2. Buscar el caracter 'k' en el ARRAY "jkl" Debe devolver TRUE.

    Para pasar a caja blanca necesitamos conocer el código interno:

         1    PROCEDURE Busca (C: CHAR; V: ARRAY OF CHAR): BOOLEAN;
         2      VAR a, z, m: INTEGER;
         3      BEGIN
         4        a:= 0;
         5        z:= Str.Length (V) -1;
         6        WHILE (a <= z) DO
         7          m:= (a+z) DIV 2;
         8          IF V[m] = C THEN RETURN TRUE;
         9          ELSIF V[m] < C THEN a:= m+1;
        10          ELSE z:= m-1;
        11          END;
        12        END;
        13        RETURN FALSE;
        14      END Busca;
    

    Es laborioso; pero si nos molestamos en ejecutar todas las pruebas anteriores marcando por dónde vamos pasando sobre el código, nos encontraremos con que hemos ejecutado todas las sentencias con excepción de la rama de la línea 10. Para atacar este caso necesitamos un caso de prueba adicional de caja blanca

  3. Pruebas de caja blanca:
    1. Buscar el caracter 'k' en el ARRAY "l" Debe devolver FALSE.

    Con el conjunto de pruebas que llevamos hemos logrado una cobertura al 100% de segmentos y de condiciones. Respecto del bucle, la prueba 1.1 lo ejecuta 0 veces, y las demás pruebas 1 o más veces.

El conjunto de pruebas identificado se puede traducir en un banco de pruebas con el siguiente aspecto:

  IF     Busca ('k', "")    THEN IO.WrStr ("falla 1.1"); END;
  IF NOT Busca ('k', "k")   THEN IO.WrStr ("falla 1.2"); END;
  IF     Busca ('k', "j")   THEN IO.WrStr ("falla 1.3"); END;
  IF NOT Busca ('k', "kl")  THEN IO.WrStr ("falla 1.4"); END;
  IF NOT Busca ('k', "jk")  THEN IO.WrStr ("falla 1.5"); END;
  IF     Busca ('k', aaaa)  THEN IO.WrStr ("falla 1.6"); END;
  IF     Busca ('k', "abc") THEN IO.WrStr ("falla 2.1"); END;
  IF NOT Busca ('k', "jkl") THEN IO.WrStr ("falla 2.2"); END;
  IF     Busca ('k', "l")   THEN IO.WrStr ("falla 3.1"); END;

Aún podríamos pasar algunas pruebas más para comprobar la solidez del programa. Concretamente, sería bueno considerar qué ocurre si sobrepasamos el tamaño máximo de 10.000 caracteres o si el ARRAY estuviera desordenado. La especificación del módulo no dice nada de esto, por lo que el análisis del resultado es vidrioso. Sobre el código concreto podemos apreciar que el tamaño del ARRAY puede llegar hasta el máximo entero soportable por la implementación de Modula-2 que estemos usando. Sobrepasado este límite se puede producir un error de asignación fuera de rango en la línea 5. Por otra parte, si el ARRAY está desordenado, el resultado es arbitrario, aunque la función siempre termina devolviendo TRUE o FALSE.