Generación dinámica de tests con JUnit 5

En esta entrada vamos a conocer una de las funcionalidades que incluye JUnit 5: los tests dinámicos. Contaremos qué son, cómo se utilizan y los compararemos con las opciones que teníamos antes de su aparición para resolver el mismo problema.

Índice de contenidos

1. Introducción

¿Cuántas veces nos ha pasado que tenemos una pieza de código que debería funcionar igual para un conjunto de entradas distintas?, ¿y cuántas veces hemos testeado que el comportamiento es igual para todas ellas? Es cierto que la mayoría de las ocasiones es imposible probar todos los casos porque hay infinitos valores de entrada (por ejemplo si trabajamos con un String), pero siempre podemos identificar los más problemáticos. Sin embargo, es muy pesado crearnos todos los tests y pocas veces lo hacemos como deberíamos.

Por suerte hay diversas técnicas para ayudarnos, si no a testear absolutamente todos los casos, al menos a aumentar el número de pruebas con el menor esfuerzo posible. Yo me centraré en la generación de tests dinámicos que ofrece JUnit 5, pero también veremos algunas alternativas usando JUnit 4.

2. Entorno

He escrito y desarrollado el tutorial usando este entorno:

  • Hardware: Portátil Mac Book Pro 15″ (2,4 Ghz Intel Core i5, 8 GB DDR3)
  • Sistema Operativo: Mac OS Sierra
  • Entorno de desarrollo: Eclipse Java EE IDE, Mars Release (4.5.2)
  • Java 8
  • Librerías: JUnit 5 y Hamcrest 1.3

3. ¿Qué son los tests dinámicos y qué ventajas tienen?

La generación dinámica de tests nos permite crear, en tiempo de ejecución, un número variable de tests. Cada uno de ellos se lanza independientemente, por lo que ni su ejecución ni el resultado dependen del resto. El uso más habitual es tener un número variable de repeticiones de un mismo test para que se ejecuten de forma individual sobre cada uno de los estados de entrada de un conjunto (por estado de entrada nos referimos al dato o conjunto de datos que definen la entrada de la funcionalidad que vamos a probar).

La ventaja más clara con los test tradicionales es que podemos probar nuestro código frente a una cantidad mayor de casos de entrada sin tener que escribir cada caso explícitamente. Esto es especialmente útil para probar los extremos de un conjunto de valores, listas vacías, strings con caracteres extraños, nulos, etc; que no requieren un tratamiento especial dentro de nuestro código (entonces habría que ir pensando en tener un test propio para ese caso) pero aún así queremos asegurarnos que no provocan un comportamiento inesperado.

Además, aunque de primeras no llame tanto la atención, la independencia entre los tests es una característica igual de interesante. Gracias a ella podemos crear todos los tests que necesitemos sin temor de que al fallar uno se aborte la ejecución de los que faltan.

4. Usando @TestFactory con Junit 5

Para implementar los tests dinámicos, JUnit 5 nos ofrece nuevos elementos. Por un lado tenemos la clase DynamicTest, que no es otra cosa que el objeto que define el test que vamos a ejecutar. Se crea a partir de dos componentes: el nombre que se mostrará en el árbol al ejecutarlo y una instancia de la interfaz Executable con el propio código que se ejecutará.

Executable es, básicamente, una interfaz funcional igual que Runnable pero que puede llegar a lanzar una instancia de Throwable (todas las excepciones de Java, así como los errores y los fallos en las aserciones de JUnit extienden de esta clase). Y precisamente por ser interfaz funcional podemos expresarla también en forma de lambda. Además de para la creación de tests dinámicos también sirve como parámetro de algunas de las nuevas aserciones, pero eso no es objeto de esta entrada.

El último de los nuevos elementos que vamos a ver por ahora es la anotación @TestFactory, que utilizamos con métodos que devuelven un conjunto iterable de DynamicTest (este conjunto es cualquier instancia de Iterator, Iterable o Stream). Al ejecutar la clase, JUnit encontrará la nueva etiqueta, creará todos los tests dinámicos que devuelve y los tratará como si se tratasen de métodos anotados con @Test (aunque con un ciclo de vida ligeramente distinto).

Como vemos a continuación, es muy sencillo crear una lista de tests dinámicos que aparecerán por separado en los resultados:

Ejecución de tests dinámicos con fallos

Este código solo muestra cómo se crean los tests y deja ver que el fallo de uno no afecta al otro. Ayudándonos de imports estáticos, lambdas y refactorizando un poco podemos llegar a esto:

Sin embargo, la verdad es que así no tiene demasiados casos de uso. Es al trabajar con un conjunto de valores cuando podemos empezar a ver su potencial. Pongámonos en el caso de que estamos intentando probar la función contains que trae la clase String (ya sabemos que debería funcionar, es solo para tener un ejemplo). Uno de los tests que haríamos es comprobar que devuelve true cuando nuestra cadena contiene la cadena objetivo, da igual su posición. En caso de usar tests dinámicos quedaría algo similar a esto:

Hemos creado un stream a partir de la lista y mapeado cada elemento a un test dinámico que comprueba que contiene la cadena “Alex”. Obviamente, no es la única manera de lograrlo. Recordad que podemos devolver colecciones o iteradores además de un stream.

Existe una segunda manera de implementar tests dinámicos. La clase DynamicTest ofrece un método estático que nos crea un stream de tests dinámicos a partir de tres elementos: un generador de datos de entrada, un generador de nombres y el código de los tests que vamos a crear. Los datos de entrada se definen con una instancia de la interfaz Iterator, que podemos definir nosotros mismos u obtener a partir de una colección. Los nombres se generan usando una función que recibe un elemento del tipo generado (o que extienda de él) y devuelve un String. Por último, el código del test se especifica instanciando la interfaz ThrowingConsumer, otro de los elementos que nos ofrece JUnit 5. Se trata de una interfaz funcional con un método que consume un dato y no devuelve nada, pero que es susceptible de lanzar una instancia de Throwable. Es decir, ThrowingConsumer es a la interfaz Consumer lo que Executable es a Runnable.

Para demostrar cómo creamos nuestro propio Iterator, vamos a comprobar que la suma de la clase BigInteger funciona igual que el operador de suma de los enteros. Generamos 1000 tests dinámicos que reciben dos enteros aleatorios y los suman usando las dos formas que vamos a comparar. (Disclaimer: usar valores aleatorios para testear no es buena idea porque los tests no serán repetibles a no ser que usemos la misma semilla. En estos ejemplos solo lo estamos usando para poner de manifiesto que los datos también pueden ser dinámicos).

De esta forma también podemos representar cualquiera de los tests que hubiésemos creado con el método que vimos antes. Como prueba, nuestro ejemplo del contains quedaría así (tras dividirlo en generadores, aplicar lambdas y ponerlos directamente como parámetros):

4.1. Tengamos en cuenta antes utilizarlos…

Antes de que creáis que los tests dinámicos son lo mejor del mundo y que debemos usarlos siempre a partir de ahora (que nunca deberíamos pensar así de ninguna herramienta) hay unas limitaciones que quiero comentar.

  1. Debuguear se vuelve un poco complicado porque tanto al implementar clases anónimas como lambdas se pìerde el contexto de algunas variables y es más difícil saber qué tiene cada campo.
  2. Cuando falla un assert, el stacktrace que se muestra es un poco menos descriptivo de lo habitual. El error aparecerá justo después del fallo, pero la línea no tendrá el nombre de nuestro método sino accept o execute (esto depende de cómo los estemos creando). No es un problema demasiado grave, pero hay que saberlo para no volverse loco.
  3. Las anotaciones @BeforeEach y @AfterEach no funcionan correctamente. Si los utilizamos veremos cómo se ejecutan antes y después del método anotado con @TestFactory pero no de los tests que creamos dentro. Solo nos sirven para hacer inicializaciones sobre valores que no tengan que resetearse entre ellos.
  4. Por último, las interfaces que implementamos están limitadas en el número de parámetros (Executable no admite ninguno y ThrowingConsumer solo uno). Se puede solucionar pasando clases complejas, conjuntos de datos, teniendo colecciones externas del mismo tamaño o algún truco similar. Pero aun así supone una desventaja con las alternativas que veremos a continuación.

4.2. Buenas prácticas

Aunque cada uno es libre de utilizar como quiera las herramientas a su disposición, voy a daros un par de consejos para que no se nos vaya de las manos.

  1. Como regla general, solo utilizaremos las factorías para crear varias instancias del mismo test. Ni siquiera para pasar un conjunto completo de valores de entrada si nuestro SUT (System Under Test) no responde de la misma forma ante ellos. No deberíamos usarlos para agrupar tests que tienen las mismas preparaciones. Si lo hacemos perderemos legibilidad y mantenibilidad y a la larga nos acabará pasando factura. Refactorizando siempre podemos sacar el código común fuera y no tener que recurrir a esto. Además JUnit 5 también pone a nuestra disposición las Nested Classes precisamente para los agrupamientos.
  2. Con tantas interfaces funcionales susceptibles de instanciar con lambdas es fácil crear un código que con el tiempo no entendamos ni nosotros. Si es corto y simple no pasa nada, pero a medida que se complica nos pasará factura. En estos casos es mejor utilizar clases anónimas que, aunque más largas, se pueden entender mejor.
  3. A partir de una cierta complejidad es conveniente utilizar la generación con tres elementos porque nos permite modularizar más el código, entenderlo mejor e incluso reutilizar los generadores de valores de entrada.

5. Alternativas a la generación dinámica sin JUnit 5

Lo que hemos visto parece curioso cuanto menos, pero tiene la pega de que solo se puede usar con JUnit 5 y es posible que por restricciones del proyecto no tengamos acceso a él. Sin embargo, antes hemos dicho que hay más técnicas para lograrlo. Ahora vamos a ver cuáles.

5.1. Usando un bucle for

Si queremos hacer las mismas aserciones sobre una cantidad significativa de datos de entrada, lo primero que se nos viene a la mente es usar un bucle for de la siguiente manera:

De esta forma ejecutamos el mismo test sobre varios datos de prueba, pero no logramos el mismo resultado. Si el assert que ejecutamos dentro falla en alguna iteración (el código de arriba falla en la tercera y quinta) nos daría algo como esto:

Ejecución de tests en un for con fallos

En el detalle del error podemos ver qué ha fallado y, dependiendo del caso concreto, en qué iteración. Pero con el primer error saldremos del bucle y no ejecutaremos el resto. Es decir, si el test pasa es que todos los casos contemplados funcionan pero si falla para uno de ellos no tendremos información de qué hubiera pasado en los que no se han lanzado. Esta es la principal desventaja frente a los tests dinámicos, que si nos dan información de todos los casos de prueba que hemos definido porque se ejecutan de forma independiente.

Podemos arreglar esto si metemos el bloque de código del test dentro de un try/catch que capture un AssertionError. De esta forma, aunque alguno de los tests falle el resto se seguirá ejecutando. Pero como solo tenemos una salida para los tests, habrá que sacar los errores por la consola. Además luego no debemos olvidar lanzar la excepción para que nuestro test salga en rojo:

Salida de resultados con try en un for

Salida por consola con try en un for

Así sí que podemos lograr un efecto parecido a lo que buscábamos, aunque deja un poco que desear tanto el código como el resultado. Vamos a ver dos alternativas más usando JUnit.

5.2. Parametrización de tests

Los tests parametrizados permiten, al igual que los dinámicos, añadir tests en tiempo de ejecución. Se definen los atributos de la clase en los que se aparecerán los valores de los distintos casos de entrada. Posteriormente se creará una ejecución del test con cada uno de estos estados.

Para utilizarlos disponemos de la anotación @Parameters para definir el método generador de los estados de entrada y @Parameter para marcar las variables donde se guardarán. En el repositorio que indico al final de la entrada también podéis ver cómo inyectar los valores mediante constructor en lugar de con anotaciones.

Volvamos al ejemplo de comprobar que las operaciones en BigInteger son iguales que en los Integer. Pero esta vez, además de la suma, también usaremos la resta para ilustrar la principal diferencia de la parametrización con respecto al resto de opciones.

Ejecución de tests parametrizados

Como vemos, en este caso también poblamos nuestro árbol de tests con cada uno de los casos particulares en tiempo de ejecución. La diferencia fundamental en cómo ocurre. Antes se miraba para cada test qué estados de entrada íbamos a tener. Ahora miraremos cada estado de entrada y lanzaremos todos los tests de la clase con él, por lo que se agrupan bajo casos de entrada. Esto implica que todas las pruebas que definimos en la clase tendrán el mismo número de ejecuciones y con los mismos datos. En ocasiones como la que acabamos de ver puede ser útil, pero normalmente nos limita bastante y nos obliga a cambiar la mentalidad a la hora de elegir nuestro SUT.

5.3. Testing con teorías

Las teorías son otra utilidad de JUnit que permiten definir un número variable de valores para cada tipo de dato. Esto se puede hacer definiendo los DataPoints, que pueden ser valores constantes, colecciones o métodos generadores. Posteriormente en cada test se define qué parámetros va a recibir y se ocupará de probar todas las posibles combinaciones con los valores correspondientes a su tipo. Esta es la principal diferencia con las opciones que hemos visto hasta ahora y que había que hacer explícitamente. Además se pueden filtrar los valores que queremos usar eligiendo el DataPoint concreto si tenemos varios para un mismo tipo.

Su uso es tan sencillo como definir las entradas con @DataPoints si anotamos un método generador o @DataPoint si se trata de una variable y luego hacer que el test reciba los parámetros que necesitemos.

Para poner de manifiesto la selección de DataPoints vamos a cambiar de ejemplo y probar que la multiplicación de un número positivo y uno negativo da como resultado uno negativo y que elevar al cuadrado da siempre un resultado positivo.

Gracias a lo que hemos sacado por pantalla podemos ver que efectivamente para el primer test solo se han cogido los valores que queríamos para cada parámetro mientras que para el segundo se han usado todos los DataPoints disponibles.

Salida por consola de las combinaciones de valores con teorías

Resultados de los tests con teorías

Sin embargo, esta vez no se están creando varios tests en el árbol sino uno solo. En realidad está funcionando de forma muy similar a nuestra primera aproximación con un for. Esto implica que tenemos las mismas dificultades a la hora de trabajar con las aserciones fallidas, aunque la solución no es tan sencilla. Aparte de esto, la principal diferencia es que nos abstrae de la generación y combinación de valores de entrada, pero no es nada que no pudiésemos simular nosotros sin demasiadas dificultades.

5.4. Comparativa de todas las alternativas

Aunque solo hemos dado una visión general de cómo funcionan los tests parametrizados y las teorías, es importante hacer una comparación. A priori la generación dinámica de tests es la herramienta más potente porque permite, con mayor o menor esfuerzo, simular el comportamiento de cualquiera de las demás. Pero en general todas las opciones son muy similares y, si se utilizan algunos trucos o se combinan con otras técnicas, pueden dar los mismos resultados. Vamos a compararlas en base a tres puntos: la visualización de los resultados, la sintaxis y la dificultad de adaptarse a algunos casos concretos.

  1. Con respecto a la visualización, a mi parecer los tests dinámicos son los que organizan mejor la información para casos normales porque puedes ver fácilmente todas las instancias de un test concreto. Además ofrecen una armonía entre centrarnos en el comportamiento (teorías y bucles for) y en que todo un conjunto de datos valide contra las operaciones sean cuales sean (parametrización).
  2. A la hora de escribir y leer creo que las teorías son las más intuitivas, mientras que la parametrización es algo más pesada y tienes que involucrar a la clase entera para utilizarla. Por su parte, los tests dinámicos son un poco complejos al principio pero según vas haciéndote a ellos se vuelven muy sencillos y se puede reutilizar bastante código.
  3. Por último, en flexibilidad la generación dinámica tiene las de ganar porque tiene una dificultad similar para implementar casi cualquier caso. En cambio el resto de soluciones son mucho más sencillas bajo las condiciones correctas pero también más complejas en situaciones adversas.

Para concluir, si tuviese que quedarme con una opción para usar a diario en cualquier situación me quedaría con la protagonista de esta entrada. Pero aún así, cada una tiene sus puntos fuertes y convendría analizar primero a qué nos vamos a enfrentar para poder elegir con fundamento.

5.5. Property-based testing

Antes de terminar quería hacer un último apunte sobre la similitud de los tests dinámicos con el property-based testing. Si bien es cierto que en ambos casos tenemos como entrada un conjunto de estados y probamos cada uno de ellos por separado, no hay que confundirlos porque están en dos niveles claramente diferenciables. La creación de tests dinámicos de la que hemos hablado es simplemente una herramienta que nos ofrece la posibilidad de lanzar un test todas las veces que queramos. Por su parte, property-based testing es una técnica que parte de la filosofía de que cada test comprueba que una propiedad concreta de nuestro código (ya sea un método, una secuencia de ellos o una funcionalidad aún mayor) se cumple siempre para todas las entradas del dominio válido. Quizá las teorías, por tener un enfoque original más matemático, son las que más se asemejan a esto.

No obstante, sí que es cierto que los tests dinámicos y el resto de alternativas que hemos comentado son un buen punto de partida para comenzar a usarlo y crear frameworks específicos.

5.6. Otras herramientas

En esta entrada me he limitado a ver la alternativas con las que contamos si utilizamos únicamente JUnit, pero por supuesto hay más opciones si nos vamos a herramientas externas.

En lo que a tests parametrizados se refiere hay algunas muy interesantes, pero quizá la más usada sea JUnitParams. Permite personalizar la obtención de parámetros y evitar la restricción de que todos los tests tienen que ejecutarse con todos los valores. Ahora podremos especificar para cada uno si los queremos de uno o varios métodos concretos, una lista, un fichero…

Además también tenemos a nuestra disposición herramientas para property-based testing, que nos servirían para lo que estamos buscando. Las hay para casi todos los lenguajes, pero para Java la más conocida es junit-quickcheck. Estos frameworks tienen más utilidades que simplemente dar valores de entrada a los tests, pero puede servirnos perfectamente solo para esto.

6. Conclusiones

En esta entrada hemos visto qué es la generación dinámica de tests, cuándo nos puede venir bien y sus limitaciones. Pero aunque pueda llamar la atención, no ha venido ni mucho menos para destronar a los tests tradicionales. Simplemente son una herramienta más para facilitarnos el trabajo en casos concretos. Es importante ver primero si nuestra situación es adecuada y, en ese caso, cuál de las alternativas que hemos comentado se adapta mejor a las circunstancias.

No obstante, os animo a experimentar un poco por vuestra cuenta fuera de proyectos reales. Si es para divertirse un rato no es tan importante guardar las formas y cualquiera de las opciones que hemos tratado se pueden potenciar y combinar para dar resultados muy interesantes.

Por último, aquí tenéis el enlace al repositorio de GitHub donde está subido el código del tutorial y algunas pruebas más, listo para importar y ejecutar.

7. Referencias