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:
-
UNIDADES
Planteamientos:
-
INTEGRACIÓN
-
ACEPTACIÓN
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
-
0 ejecuciones
-
1 ejecución
-
más de 1 ejecución
Para un bucle de tipo REPEAT hay que pasar 2 pruebas
-
1 ejecución
-
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:
-
si un parámetro de entrada debe estar comprendido en un cierto rango,
aparecen 3 clases de equivalencia: por debajo, en y por encima del rango.
-
si una entrada requiere un valor concreto, aparecen 3 clases de
equivalencia: por debajo, en y por encima del rango.
-
si una entrada requiere un valor de entre los de un conjunto, aparecen 2
clases de equivalencia: en el conjunto o fuera de él.
-
si una entrada es booleana, hay 2 clases: si o no.
-
los mismos criterios se aplican a las salidas esperadas: hay que intentar
generar resultados en todas y cada una de las clases.
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:
-
números menores que 1
-
números entre 1 y 31
-
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:
-
órdenes correctas, todas y cada una
-
órdenes con defectos de sintaxis, tanto pequeñas desviaciones como
errores de bulto
-
órdenes correctas, pero en orden incorrecto, o fuera de lugar
-
la orden nula (línea vacia, una o más)
-
órdenes correctas, pero con datos de más
-
provocar una interrupción (BREAK, ^C, o lo que corresponda al sistema
soporte) justo después de introducir una orden.
-
órdenes con delimitadores inapropiados (comas, puntos, ...)
-
órdenes con delimitadores incongruentes consigo mismos (por ejemplo,
esto]
- 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
-
dejar claro qué tipo de propiedades se quieren probar (corrección,
robustez, fiabilidad, amigabilidad, ...)
-
dejar claro cómo se mide el resultado
-
especificar en qué consiste la prueba (hasta el último detalle de cómo se
ejecuta)
-
definir cual es el resultado que se espera (identificación, tolerancia,
...) ¿Cómo se decide que el resultado es acorde con lo esperado?
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:
-
El propósito de la prueba
-
Los pasos de ejecución de la prueba
-
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:
-
Pasar pruebas de caja negra analizando valores límite. Recuerde que hay
que analizar condiciones límite de entrada y de salida.
-
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).
-
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!"
-
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:
-
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.
-
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.
-
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.
-
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.
-
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:
-
código escrito por un programador malo
-
código muy difícil
-
código mal o insuficientemente especificado
-
código escrito en un mal día, con prisas, ...
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.
-
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.
-
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%.
-
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
-
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.
-
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.
-
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.
-
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:
-
C: CHAR
El parámetro C está muy poco especificado. Sólo se dice que es un caracter, lo
que queda de lo más ambiguo pues esto significa conjuntos diferentes
dependiendo del ordenador.
-
V: ARRAY OF CHAR
No se dice nada del conjunto de caracteres posibles (como en C), ni de las
simensiones límite del ARRAY. Tampoco se dice nada del criterio de ordenación.
-
resultado: BOOLEAN
Éste si está perfectamente claro.
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
-
C: CHAR
-
Cualquier caracter
-
V: ARRAY OF CHAR
-
El ARRAY vacio.
-
Un ARRAY entre 1 y 10.000 elementos, ambos inclusive, ordenado.
-
Un ARRAY entre 1 y 10.000 elementos, ambos inclusive, desordenado.
-
resultado: BOOLEAN
-
TRUE
-
FALSE
Por último, cabe considerar combinaciones significativas de datos de entrada:
que C sea el primero o el último del ARRAY.
-
Pruebas de caja negra: valores límite
-
Buscar el caracter 'k' en el ARRAY "" Debe devolver FALSE.
-
Buscar el caracter 'k' en el ARRAY "k" Debe devolver TRUE.
-
Buscar el caracter 'k' en el ARRAY "j" Debe devolver FALSE.
-
Buscar el caracter 'k' en el ARRAY "kl" Debe devolver TRUE.
-
Buscar el caracter 'k' en el ARRAY "jk" Debe devolver TRUE.
-
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.
-
Pruebas de caja negra: valores normales
-
Buscar el caracter 'k' en el ARRAY "abc" Debe devolver FALSE.
-
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
-
Pruebas de caja blanca:
-
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.
Autor: José A.
Mañas |