Puede parecer un poco prematuro introducir apenas recién comenzado el curso las pruebas (testing) automáticas. A fin de cuentas, parece razonable pensar que el único objetivo es escribir el código de la aplicación para implementar todas sus funcionalidades lo antes posible…
Nota. Este capítulo es una breve introducción teórica al mundo del testing. Si bien no tiene relación directa con la temática principal del curso, he considerado oportuna su inclusión para presentar conceptos que cualquier programador debería conocer antes de empezar a implementar y diseñar sus primeras pruebas. Su lectura, por tanto, es opcional, pero recomendable.
La importancia de las pruebas automáticas
La realidad, a veces tristemente ignorada, es que las pruebas no son un complemento accesorio. Y, sin embargo, son las grandes víctimas de la presunta falta de tiempo y presupuesto. Forman parte intrínseca de cualquier programa que aspire a cumplir con unos requisitos mínimos de calidad, entendiendo la misma desde una perspectiva doble: la del usuario que quiere recibir un producto fiable que cumpla con las funcionalidades prometidas, pero también la de los implicados en su desarrollo -en especial los programadores- que ansían avanzar con paso firme.
Ambas visiones deben ser una. Es complicado que usuarios y clientes queden satisfechos si el proceso de desarrollo y el código son un desastre y se producen innumerables errores. Esta afirmación es igualmente válida para los que estamos «al otro lado» diseñando e implementando las aplicaciones y sistemas.
Más allá de las verificaciones manuales y el «testing exploratorio» que siempre van a existir, aunque sean realizadas sobre la marcha por el propio equipo de desarrollo y no personal especializado, como técnicos vamos a centrarnos en las pruebas automáticas realizadas vía software. No estaremos solo en esta tarea y, tal y como iremos viendo en capítulos posteriores, contamos con numerosas herramientas y librerías que nos prestarán su inestimable ayuda.
Estas pruebas automáticas son un elemento fundamental en la consecución de un proyecto exitoso que alcance unas cotas de calidad aceptables. ¿En qué me baso para realizar semejante afirmación? Voy a desglosar algunos de sus beneficios.
- Lo más evidente es que ayudan a detectar errores, aunque debe quedar claro que no siempre evitan su aparición ni garantizan su ausencia.
- Permite avanzar en el desarrollo del proyecto asegurando, con un nivel de fiabilidad no absoluto pero sí razonable, que los cambios no afectan al correcto comportamiento de las funcionalidades ya existentes. Utilizando un símil, supone programar con una red de seguridad que proporciona una tranquilidad tremenda. Cada vez que te estrelles contra el piso, perderás bastante tiempo en volver a ponerte de pie.
- Ligado a lo anterior, es la única manera de afrontar con seguridad tareas de refactorización (cambiar la estructura del código, sin modificar su comportamiento, para mejorar su diseño),
- Una vez implementadas, se pueden ejecutar todas las veces que sean necesarias sin ningún coste adicional frente a las realizadas de forma manual. Estas prueba manuales deben seguir practicándose, pero solo en aquellas comprobaciones en las que aporte verdadero valor.
- Como efecto colateral, requiere que el código sea fácil de testar, acentuando la necesidad de que esté bien estructurado. Si es difícil probarlo, estamos ante un «code smell» (indicativo de que algo podría estar mal) que apunta a un posible mal diseño. Esta exigencia es uno de los fundamentos de la metodología TDD (Test-driven development, desarrollo guiado por pruebas) consistente, a grandes rasgos, en crear primero las pruebas y a partir de ellas ir escribiendo el código que las verifica.
- Hay pruebas que son difíciles o imposibles de realizar de forma manual. ¿Cómo se comporta el sistema si lo sometemos a una carga de trabajo elevada con cientos de usuarios simultáneos? ¿Qué sucede cuando se produce un error interno desconocido? Tenemos que escribir pruebas que simulen estas situaciones porque es inviable reunir a cientos de personas para que usen la aplicación al mismo tiempo o que un usuario pueda provocar un error interno de forma intencionada.
- Las pruebas sirven de ejemplo y documentación del uso de las APIs y casos de uso del sistema.Y lo mejor de todo es que se trata de una documentación siempre actualizada y válida. Si no fuera así, las pruebas fallarían o no compilarían.
En apariencia, las pruebas automáticas presentan un inconveniente: es código que hay que escribir y mantener actualizado. Es fácil que este trabajo se perciba como un coste prescindible, pues su valor parece escaso en comparación con el que proporcionan las funcionalidades de la aplicación que son de implementación ineludible y justifican la existencia del software.
El razonamiento anterior es una pobre excusa para argumentar la ausencia de pruebas automáticas en aras de una mayor rentabilidad. El supuesto beneficio económico se produce a costa de la calidad del producto y la satisfacción de los usuarios. Asimismo, se reduce a medida que surgen errores que van retrasando el avance del proyecto y obligan a revisar y rehacer tareas ya finalizadas.
Estrategias
Repasemos tres técnicas o estrategias básicas con las que afrontar el diseño de las pruebas.
Caja negra
Las pruebas de caja negra (black-box) se diseñan desconociendo por completo la estructura interna de la aplicación. Se basan exclusivamente en las especificaciones de las interfaces que el sistema objeto de las pruebas ofrece al exterior. Por ejemplo, la documentación de una API. Desconocemos cómo está implementada, ni siquiera el diseño técnico a alto nivel o incluso el lenguaje de programación, pero podemos evaluar si se comporta como cabe esperar según la información que nos han proporcionado. Nos interesa comprobar qué hace la aplicación, y no cómo lo hace.
Este enfoque es valioso porque sitúa al probador, que puede (y debe) ser alguien ajeno al equipo de desarrollo e incluso sin conocimientos técnicos, en la posición del usuario de la caja. Los errores indican casos en los que el sistema incumple las especificaciones anunciadas, y los programadores tendrán que abrir y examinar la caja para encontrar la causa.
Caja blanca
El enfoque contrario es el de caja blanca (white-box) o caja transparente (glass-box). Ahora vemos el código y podemos interactuar con él. Son pruebas que aportan un gran valor a los programadores porque les permiten probar su propio código con la profundidad que sea necesaria, y localizar y detectar los errores enseguida. Asimismo, facilitan alcanzar una amplia cobertura (*).
(*) Métrica que indica el porcentaje de código de la aplicación ejecutado («cubierto») por las pruebas. Suele desglosarse en clases y paquetes. Es muy útil para encontrar «zonas oscuras» que los tests no comprueban con el suficiente detalle, pero no debemos obsesionarnos con estos números: una cobertura alta no garantiza la calidad de las pruebas.
Con esta estrategia, a diferencia de lo que sucede con la perspectiva de caja negra, las pruebas tienen que ser automáticas e implementadas, idealmente, por los programadores del código que se está probando. Asimismo, son sensibles ante las refactorizaciones, mientras que las pruebas de caja negra son más estables a lo largo todo el ciclo de vida del desarrollo, o al menos eso cabría esperar.
Caja gris
Por último, existe una estrategia mixta llamada caja gris (grey-box) o caja translúcida. La arquitectura del sistema es conocida a nivel general pero no se tiene acceso al código. Esto permite la realización de pruebas black-box que contemplen escenarios de especial interés que solo pueden deducirse teniendo cierto conocimiento del contenido de la caja.
Tipología
La clasificación de las pruebas en tipos es extensa y la Wikipedia puede dar fe de ello. En esta sección veremos los tres grupos más aceptados, aunque en función del autor pueda haber matices importantes en la definición exacta. En general, en el mundo del testing muchos conceptos son opinables.
Unitarias
A veces, cuando simplemente se habla de pruebas automáticas, se tiende a suponer que son de este tipo. Las pruebas unitarias verifican «unidades» muy pequeñas, concretas y aisladas del resto de la aplicación. Esto implica que deberían ser sencillas y rápidas de escribir y ejecutar. Son las de más «bajo nivel», esto es, las más cercanas al código, y el ejemplo típico del enfoque de caja blanca. Por lo común, se considera que una unidad se refiere a un método o una clase. En este último caso, la prueba unitaria es en realidad una colección de, valga la redundancia, pruebas unitarias sobre sus métodos.
Una buena manera de aproximarse a la creación de este tipo de pruebas consiste en aplicar los principios F.I.R.S.T, aunque la mayoría de ellos pueden trasladarse a otros tipos. El acrónimo es muy laxo, pues según el autor algunas iniciales significan una cosa u otra. En la siguiente lista he recopilado las interpretaciones más extendidas.
- Fast-rapidez. Al probarse unidades pequeñas de forma aislada, las pruebas deberían ser tan rápidas que nos movemos en el orden de milisegundos. Esta propiedad otorga una gran practicidad a los tests unitarios porque permite ejecutar cientos de ellos en muy pocos segundos y obtener una respuesta inmediata que valide los cambios que estamos realizando.
- Independent-independientes. El éxito en la ejecución de una prueba unitaria no puede depender del resultado de otra. Todas deben poder ser ejecutadas en cualquier orden o de forma individual. Las librerías de testing suelen incluir la posibilidad de establecer un orden preciso; si usamos esta funcionalidad, pensémoslo dos veces.
- Isolated\Asilamiento. Si nuestra unidad depende de componentes externos (aquí me refiero a otras clases), para probarla de forma aislada la técnica a seguir es el reemplazo de esos objetos por unos «dobles de test» (test doubles) con el comportamiento que nos convenga. Para la creación de estos dobles, en Java contamos con Mockito, e invito al lector a consultar el tutorial correspondiente. No obstante, si en una prueba unitaria tenemos que crear numerosos y complejos dobles de tests, quizás debamos replantearnos su diseño o incluso el del propio código que estamos probando.
- Repeatable-repetible. Las pruebas son deterministas: siempre ofrecen el mismo resultado para unos valores de entrada predefinidos. Nada de generar parámetros aleatorios. Por muchas veces que las ejecutemos, siempre harán lo mismo y de igual manera.
- Self-validating\autovalidadas. La propia prueba debe indicar si su ejecución ha sido correcta o no. Esto evita tener que perder tiempo revisando logs para averiguar qué ha pasado en caso de fallo.
- Thorough\Exhaustivas. Suele ser inviable probar todas las combinaciones de parámetros de entradas de un método, o todos los caminos que pueden recorren los datos, pero las pruebas han de ser lo más completas posibles. Debemos verificar que el código hace lo que tiene que hacer, y que no hace lo que no debe. Por ejemplo, las pruebas deben contemplar el tratamiento de errores.
- Timely\Momento oportuno. Las pruebas deben implementarse en el momento oportuno: justo antes de escribir el código que se va a probar. Este es un principio básico de TDD y ayuda a diseñar mejor la aplicación, pero es una técnica difícil de adoptar.
Integración
Aun cuando nuestras pequeñas unidades funcionen de forma adecuada por sí mismas, una aplicación consiste en la colaboración de todas ellas. Con las pruebas de integración subimos el nivel de abstracción porque queremos demostrar la correcta interacción entre varios componentes y las funcionalidades fruto de esa colaboración. El enfoque a seguir puede ser tanto de caja blanca como de caja negra, en función de la naturaleza de los componentes involucrados (métodos, clases, servicios web…). Recomiendo poner el foco en aquellas integraciones que puedan ser problemáticas o críticas.
La integración añade una dificultad adicional debido a que suele ser necesaria una infraestructura que incluya, en nuestro caso, el servidor de aplicaciones porque ofrece servicios imprescindibles requeridos por esa integración. Este problema puede hacer más complicada la implementación de las pruebas, aunque lo peor es que supone una importante penalización en su velocidad de ejecución. Ahora pasamos de milisegundos a segundos, magnitud que, siendo pequeña, se antoja demasiado cuando hay muchas pruebas.
Los dobles de test ya no parecen tener sentido porque son las relaciones entre componentes lo que queremos probar. Pueden seguir siendo necesarios en algunos casos, por ejemplo para implementar los límites de nuestro sistema, como una API REST externa que no queremos -o podemos- simular. También serán útiles si los distintos componentes se van desarrollando siguiendo un orden en el que la integración entre todos ellos es imposible en el momento actual. Los dobles serán reemplazados por las implementaciones reales cuando estén disponibles.
De extremos a extremo \ End-to-end \ e-2-e
Estas pruebas pueden verse como los tests de integración «definitivos». Ahora se va a probar el sistema completo simulando la interacción del usuario final. Este puede ser, por ejemplo, una persona que usa la aplicación a través de su interfaz gráfica o bien un programa que consume una API REST. Son el caso más claro de pruebas de caja negra.
Parecen «ideales» por su realismo, aunque tienen mala reputación debido a sus grandes desventajas. El principal problema es la gran cantidad de tiempo que consumen. Ya comenté este inconveniente unos párrafos atrás cuando hablé de las pruebas de integración, pero ahora es aún peor porque interviene todo el sistema al completo (o casi). Supongamos que de media una prueba e-2-e tarda en ejecutarse cinco segundos. Si bien es poco, en ese tiempo deberían ejecutarse cientos de pruebas unitarias, mientras que cien de tipo e-2-e suponen ocho minutos a los que puede que haya que sumar el arranque y parada del entorno necesario (servidor, base de datos, etc.).
Esta lentitud hace inviable la ejecución reiterada de la totalidad de las pruebas a medida que vamos desarrollando. Nos conformaremos con ejecutar un subconjunto relacionado con la parte de la aplicación con la que estemos trabajando en cada momento. La ejecución de todo el lote quedará relegada a los entornos de integración continua (*) y cuando recibamos el informe con los errores detectados tendremos que revisar un código que dimos por bueno.
(*) Aquí me estoy refiriendo a los servidores que, utilizando productos como Jenkins o Bamboo, periódicamente construyen el proyecto y ejecutan todas las pruebas, notificando cualquier problema a los posibles interesados.
Además de la tardanza, en las pruebas e-2-e también vamos a encontrar las siguientes dificultades, algunas de ellas inherentes al concepto de caja negra.
- En este nivel la complejidad puede ser alta porque necesitamos configurar y disponer del sistema completo. En ocasiones esto resultará muy complicado y puede requerir de ciertos compromisos, además de mayor tiempo de desarrollo.
- Fragilidad. Son muy sensibles a problemas de red, condiciones de carrera si involucran procesos asíncronos, lentitud en el refresco de interfaces gráficas (esto es muy habitual y problemático), etc. En definitiva, problemas que no se deben a errores o defectos en la propia aplicación y que hacen a las pruebas poco fiables al ofrecer falsos negativos (fallos aleatorios que no son tales).
- Los errores son difíciles de encontrar. La causa del fallo en una prueba unitaria debería ser evidente, pero en una e-2-e puede estar en cualquier parte de la aplicación y hay que investigarlo.
- Están limitadas a los parámetros de entrada que se pueden proporcionar desde el exterior. Esto impide el buen testeo de muchas partes del código y la cobertura será baja.
Quizás dé la impresión de que tengo grandes reparos a este tipo de pruebas a la vista de los inconvenientes que he citado. Sin embargo, soy un firme defensor de ellas. Son muy importantes porque verifican que el sistema ofrece a nuestros usuarios las funcionalidades esperadas. Podemos refactorizar el código e ir rompiendo y adaptando las pruebas unitarias, pero en última instancia las interfaces externas deberán seguir cumpliendo con los casos de uso y las especificaciones pactadas salvo que cambien deliberadamente.
La pirámide de pruebas
La siguiente imagen muestra la célebre «pirámide de pruebas» propuesta por Martin Fowler. Cada escalafón representa la cantidad de pruebas de cierto tipo que tiene una aplicación.

La idea es sencilla y se basa en las caracterizaciones de los tipos de pruebas que hemos visto. Cada vez que subimos un nivel, las pruebas son más complejas y lentas con respecto al nivel inferior. Por tanto, se puede asumir que su número debe ser decreciente.
Nos encontramos ante una recomendación práctica y sensata. Si el reparto de nuestras pruebas según su tipo no adopta una estructura piramidal, es una señal de alerta. La pirámide nos recuerda que debemos implementar las pruebas en el nivel más bajo posible por su menor coste, rapidez de ejecución y la facilidad para simular escenarios.
Ahora bien, tampoco podemos considerar la pirámide como una ley absoluta. Debemos ser flexibles y adaptarnos a las características de cada proyecto. Digan lo que digan, y esta es una opinión muy personal, no estamos actuando de forma «incorrecta» si el grueso de nuestras pruebas no son de tipo unitario, siempre y cuando sea una decisión consciente avalada por las circunstancias.
Es absurdo perder el tiempo programando pruebas que pueden llegar a resultar ridículas con el único fin de cumplir una proporción prefijada. Con esto me refiero a reglas del estilo «los tests unitarios deben suponer el 70 % del total». A estas pruebas sin sentido las llamo de «cartón piedra» porque son un decorado que simula que tenemos una buena suite de tests, cuando lo cierto es que muchas de ellos en realidad no verifican nada. Y, lo peor de todo, dan una falsa sensación de seguridad. Pero consiguen que los informes de cobertura y similares luzcan bien cuando se muestran a los managers y clientes. Siempre debemos evaluar la relación coste\beneficio de cada prueba para decidir si su implementación merece la pena.