Curso Jakarta EE 9 (6). Pruebas automáticas (2): JUnit 5.

logo Jakarta EE

Espero que el lector tenga asumida la importancia de las pruebas automáticas. Para llevar la teoría a la práctica, necesitamos una librería o marco de trabajo que nos permita escribirlas y ejecutarlas, ofreciendo de forma clara el resultado. En este capítulo veremos las principales características de JUnit 5 que usaremos a lo largo del curso.

>>>> ÍNDICE <<<<

Presentando JUnit

JUnit es el marco de trabajo de código abierto para la realización de pruebas con Java más popular. Según este análisis, en 2016 era la librería más exitosa, y aunque en 2019 ha caído al puesto treinta y tres, sigue siendo la primera utilidad relacionada con el testing.  Entre las alternativas, es imprescindible mencionar TestNG y Spock.

A pesar de lo que sugiere su nombre, con JUnit podemos escribir pruebas de cualquier tipo y no solo unitarias. Nos proporciona la infraestructura necesaria, lo que hagan ya es cosa nuestra.

Pese a su enorme éxito y ser un estándar de facto en toda regla, mucho ha llovido desde que JUnit 4 se publicara en 2006. Si vamos más allá de las simples pruebas unitarias, nos hace falta integración, por ejemplo, con Spring o servidores como WildFly, y esa es una de las debilidades de JUnit 4: estamos ante una aplicación monolítica -un único .jar- con unas posibilidades de extensión limitadas. La integración con otros productos consiste en crear una versión específica del runner o “ejecutor de test” especializando la clase Runner o alguna de sus subclases. El problema es que una prueba solo puede ser ejecutada por un Runner.

Durante años ha sido necesario lidiar con este problema. En Android, por ejemplo, se usa el mecanismo de reglas. Esta y otras limitaciones provocaron el advenimiento de JUnit 5 cuyo desarrollo fue inicialmente financiado con una campaña de crowdfunding. El resultado es un moderno marco de trabajo modular y extensible, escrito desde cero con Java 8 y compatible con versiones previas. Como usuarios finales, lo que más nos interesa es la inclusión de funcionalidades históricamente muy demandadas y que ahora son posibles.

En esta figura vemos de forma simplificada la arquitectura de JUnit 5 y sus tres componentes principales.

junit5 arquitectura
  • Platform. El corazón de todo el marco de trabajo, una plataforma de alto nivel para ejecutar pruebas en la máquina virtual de Java a través de librerías que implementen su “engine”. También provee una API que permite la ejecución de las pruebas con herramientas externas tales como Maven, Gradle, IntelliJ, Eclipse… (JUnit 5 incluye la aplicación Console Launcher para lanzar los test).
  • Jupiter. Es una librería de testing que forma parte JUnit 5. Cuando hablamos de pruebas implementadas con JUnit 5, en realidad nos referimos al uso de Jupiter. Viene cargada de útiles características y no deja de evolucionar.
  • Vintage. Otra librería de testing. Sirve para ejecutar pruebas escritas con JUnit 3 y 4, así que no hay que migrarlas cuando empecemos a utilizar Jupiter en un proyecto en curso. De este modo, se facilita la adopción de la nueva plataforma.

Si bien Jupiter y Vintage forman parte del proyecto JUnit 5, de ahí que en la figura aparezcan en la misma “caja” que platform, son módulos opcionales, pues no dejan de ser dos librerías de testing desarrolladas sobre la plataforma del mismo modo que puede haber muchas otras. Las librerías de terceros y las extensiones -las veremos al final de este capítulo- se recopilan en este documento.

El primer test

Ha llegado el momento de ponerse manos a la obra con un proyecto de ejemplo. Necesitamos la dependencia de Jupiter en su pom.

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
    <version>${junit.version}</version>
    <scope>test</scope>
</dependency>

Es importante asegurar que se utiliza la última versión del plugin Surefire para que los tests puedan ejecutarse desde la línea de comandos con Maven (con Eclipse o IntelliJ no suele hacer falta).

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>${maven.surefire.version}</version>
</plugin>

El ámbito o scope de tipo test indica que la librería solo se usará para la realización de pruebas por lo que no debe formar parte del artefacto final que empaquete la aplicación.

Cada prueba se escribe en un método. Según el estándar de Maven, las clases con estos métodos estarán en la carpeta /src/test/java y sus recursos en /src/test/resources. Al igual que la dependencia, estas clases y ficheros tampoco se incluyen en el artefacto final. Una práctica habitual consiste en organizar las pruebas en paquetes según su tipología (unitario, integración, e-2-e) porque vimos en el capítulo previo que la naturaleza y, sobre todo, los tiempos de ejecución de cada categoría son muy dispares.

He aquí el primer ejemplo.

package com.danielme.jakartaee;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.Comparator;

import static org.junit.jupiter.api.Assertions.assertTrue;

class ComparatorsTest {

    @Test
    @DisplayName("probando naturalOrder con enteros en JUnit 5")
    void testComparatorLessThan() {
        Comparator integerComparator = Comparator.naturalOrder();

        int result = integerComparator.compare(1, 2);

        if (result < -1) {
            fail("el resultado no es un número negativo. resultado: " + result);
        }
    }

}

La clase ComparatorsTest cumple dos requisitos.

  • No es privada. Para decidir su nombre, he seguido la convención de añadir el sufijo Test al nombre de la clase sujeto de las pruebas.
  • La prueba en sí misma es un método anotado con @Test. No devuelve nada y no puede ser ni privado ni estático. Si también tenemos la librería Vintage en el proyecto, no confundamos la anotación @org.junit.jupiter.api.Test con @org.junit.Test. Vintage y Jupiter comparten el nombre de algunas anotaciones, y es el típico error “tonto” que puede hacernos perder bastante tiempo.

Prestemos especial atención al nombre del método: ha de ser corto pero descriptivo. Suele empezar por test o should (“debería”). Algunos programadores prefieren usar el estilo Snake Case en lugar de Camel case: las palabras se separan con un guion bajo “_” en vez de comenzar la siguiente palabra con mayúsculas. No es mi costumbre; el lector debe elegir el criterio que considere más cómodo.

Se puede usar la anotación @DisplayName en el método y/o la clase para indicar con una descripción corta (¡admite emojis!) qué hace el test. Esta información aparece en los resultados y aumenta su legibilidad.

Veamos cómo se ha escrito el código de testComparatorLessThan. La división en tres bloques es intencionada y responde a la estructura Arrange-Act-Assert (AAA) promovida por la metodología BDD.

  1. Arrange (Organizar). Se configuran los elementos necesarios para realizar la prueba. Esta tarea incluye instanciar las clases a probar y preparar los objetos dobles de tests cuando sea necesario.
  2. Act (Actuar). Se realiza la operación a validar.
  3. Assert (Afirmar). Comprobamos que el resultado de la operación es el esperado.

Esta organización en fases o bloques es simple pero efectiva. Nos ayuda a diseñar mejor los tests, sistematizando su creación y haciéndolos más claros. Si la prueba que estamos escribiendo realiza más de una instrucción en la fase Act, quizás esté mal enfocada, sobre todo si es de tipo unitario. Un test solo debería probar una cosa y solo puede fallar por una causa conocida de antemano.

¿Cuál es el resultado de una prueba? Es fallida si invocó un método fail porque la aserción no se cumple. Será errónea si no se ha podido completar su ejecución por un error inesperado, por ejemplo una excepción producida durante la fase Act. En el resto de casos, la prueba es exitosa.

Ejecución

Vamos a lanzar la prueba con las herramientas de desarrollo que estamos usando en el curso.

Maven

Por omisión, todos los tests del proyecto se ejecutarán cuando se solicite un empaquetado de la aplicación. Si algún test es fallido o erróneo, el artefacto no se generará. La ejecución de las pruebas se puede obviar con el parámetro -DskipTests.

De forma explícita, las pruebas se ejecutan así (el proyecto de ejemplo incluye el wrapper de Maven como es habitual)

$ mvn test
Maven junit5 resultados

Además de la salida de Maven en la terminal que vemos en la imagen anterior, los resultados de la ejecución se guardan en la carpeta target/surefire-reports, un informe por cada clase con pruebas.

maven informes surefire
IntelliJ y Eclipse

Ambos IDE incluyen de serie de integración con Jupiter y Vintage. Las pruebas contenidas en una clase se lanzan la opción “Run” de su menú contextual. Se puede hacer lo mismo con un paquete para ejecutar todos los tests contenidos en sus clases.

run test IDES

Los resultados se muestran de forma bastante comprensible en la vista correspondiente (Run en IntelliJ, JUnit en Eclipse).

test resultados IDE

También se pueden ejecutar las pruebas en modo “Cobertura”.

En IntelliJ.

IntelliJ JUnit 5 code coverage

En Eclipse.

Eclipse JUnit 5 code coverage

En los dos entornos tenemos una vista especifica para los resultados. Las siguientes capturas, pertenecientes a mi tutorial sobre JUnit 4 porque en el proyecto de este capítulo no existe código sobre el que medir la cobertura, dan buena cuenta de ello.

Aserciones

Jupiter

Hay una manera más sencilla de hacer la aserción de los resultados que implementar la comprobación e invocar a fail. Jupiter incluye una clase (org.junit.jupiter.Assertions) con numerosos métodos estáticos para verificar condiciones básicas sobre objetos y tipos de datos primitivos. Destacan los siguientes.

  • assertTrue\assertFalse: verifica que una condición se evalúa como true\false.
  • assertNull\assertNotNulll: verifica que un objeto es nulo\no nulo.
  • assertEquals\assertNotEquals: verifica que dos objetos sean iguales\distintos utilizando equals.
  • assertSame\assertNotSame: verifica que dos objetos son el mismo\distintos con el operador “==”.
  • assertIterableEquals: verifica que dos objetos de tipo Iterable son iguales según el operador “==” y el equals de los objetos que contienen.
  • assertLinesMatch: verifica que dos listas de String contengan las mismas cadenas.

Estos métodos presentan numerosas sobrecargas y algunas admiten una cadena con un mensaje, así como el uso de lambdas. Si vemos su código, descubriremos que no hacen nada especial o sofisticado: llaman a fail cuando la condición no se verifica. Una convención curiosa (y práctica): su importación suele hacerse de manera estática.

Simplifiquemos nuestro ejemplo.

    void testComparatorLessThanAssertJupiter() {
        Comparator<Integer> integerComparator = Comparator.naturalOrder();

        int result = integerComparator.compare(1, 2);

        assertTrue(result < 0, "el resultado no es menor que cero");
    }

El método assertAll permite agrupar varias aserciones escritas con lambdas.

    @Test
    @DisplayName("ejemplo de lambdas en assertAll")
    void testOrderStringAssertJupiter() {
        Comparator<String> stringComparator = Comparator.naturalOrder();
        List<String> names = new ArrayList<>(3);
        names.add("Daniel");
        names.add("Juan");
        names.add("Antonio");

        names.sort(stringComparator);

        assertAll("el orden de los nombres es incorrecto",
                () -> assertEquals("Antonio", names.get(0)),
                () -> assertEquals("Daniel", names.get(1)),
                () -> assertEquals("Juan", names.get(2)));
    }
AssertJ

A pesar de que las aserciones de Jupiter nos facilitan el trabajo, les encuentro dos defectos.

  • En los assert que reciben dos parámetros, el primero es el esperado y el segundo el evaluado. Me resulta poco intuitivo y creo que sería más lógico que la signatura del método fuera la contraria.
  • No son de mucha ayuda a la hora de escribir aserciones complejas que impliquen, por ejemplo, comprobar los valores de atributos concretos de los objetos contenidos en una lista.

AssertJ es una librería de aserciones independiente de cualquier marco de trabajo para pruebas. Con su API “fluida” se escriben verificaciones fáciles de leer siguiendo este patrón

comprobar que (A) 
                 ->verifica (condición 1) 
                 ->verifica (condición 2) 

A esta estructura tan clara hay que añadir la disponibilidad de condiciones más numerosas y potentes que las proporcionadas por Jupiter.

Veamos un pequeño ejemplo. En primer lugar, añadimos la dependencia.

<dependency>
     <groupId>org.assertj</groupId>
     <artifactId>assertj-core</artifactId>
     <version>${assertj.version}</version>
     <scope>test</scope>
</dependency>

Reescribamos las aserciones de los tests.

    @Test
    @DisplayName("probando naturalOrder con enteros en JUnit 5 y AssertJ")
    void testComparatorLessThanAssertJ() {
        Comparator<Integer> integerComparator = Comparator.naturalOrder();

        int result = integerComparator.compare(1, 2);

        assertThat(result)
                .isLessThan(0);
    }
    @Test
    @DisplayName("ejemplo de colección con AssertJ")
    void testOrderStringAssertJ() {
        Comparator<String> stringComparator = Comparator.naturalOrder();
        List<String> names = new ArrayList<>(3);
        names.add("Daniel");
        names.add("Juan");
        names.add("Antonio");

        names.sort(stringComparator);

        assertThat(names)
                .containsExactly("Antonio", "Daniel", "Juan");
    }

Resulta más claro y conciso. Con withFailMessage se puede indicar el mensaje en caso de fallo, pero la descripción que AssertJ genera es bastante buena. En el siguiente ejemplo modifiqué testOrderStringAssertJ para provocar un incumplimiento en la aserción.

java.lang.AssertionError: 
Expecting:
  >["Antonio", "Daniel", "Juan"]<
to contain exactly (and in same order):
  >["Antoanio", "Daniel", "Juan"]
but some elements were not found:
  >["Antoanio"]<
and others were not expected:
  >["Antonio"]<

¡Aún hay más! Es posible componer verificaciones realmente complejas y la documentación es excelente y llena de ejemplos. Son muchos beneficios, así que vamos a aprovecharlos.

Excepciones

Echemos un vistazo rápido a unas aserciones peculiares: las que verifican excepciones. Quizás parezca extraño probar errores, pero dijimos en el capítulo anterior que hay que testear que la aplicación hace lo que tiene que hacer y que no hace lo que no debe.

El tratamiento de excepciones debería ser parte de un plan de pruebas que aspire a ser completo y relevante con el fin de asegurar el comportamiento adecuado del sistema ante cualquier eventualidad. En torno a este principio, se ha desarrollado una técnica de testing denominada Failure injection o inyección de errores considerada una buena práctica cuando se hacen pruebas de caja blanca.

Entre las aserciones de Jupiter encontramos varias con nombres tan descriptivos como assertThrows y assertDoesNotThrow. Veamos un ejemplo muy simple forzando una excepción.

@DisplayName("Ejemplos de aserciones para excepciones")
public class ExceptionAssertionTest {

    @Test
    @DisplayName("ejemplo de assert de excepción con Jupiter")
    void testExceptionAssertJupiter() {
        List strings = new ArrayList<>(0);

       IndexOutOfBoundsException exception =
           assertThrows(IndexOutOfBoundsException.class, () -> strings.get(0));
           assertEquals("Index 0 out of bounds for length 0", exception.getMessage());
    }

}

En esta prueba, la fase Act y la Assert son la misma porque el método a probar debe ser ejecutado por assertThrows dentro de una implementación de la interfaz funcional Executable. También se podría hacer esa implementación en la fase Arrange si consideramos que el código queda más claro. La excepción se ha recuperado en una variable para realizar comprobaciones adicionales y mejorar la rigurosidad del test.

Con AssertJ podemos hacer lo mismo pero de forma más elegante en un único bloque de código.

    @Test
    @DisplayName("ejemplo de assert de excepción con AssertJ")
    void testExceptionAssertJ() {
        List<String> strings = new ArrayList<>(0);

        assertThatExceptionOfType(IndexOutOfBoundsException.class)
                .isThrownBy(() -> strings.get(0))
                .withMessage("Index 0 out of bounds for length 0");

    }

Extensiones

Jupiter nos permite intervenir en el ciclo de ejecución de las pruebas y realizar las operaciones que queramos, tales como reutilizar código de configuración (Arrange) compartido por los tests o hacer un Arrange global de la clase. Estas acciones se implementan en métodos anotados del siguiente modo.

  • @BeforeEach. Se ejecuta antes de cada método @Test. Debe ser estático.
  • @AfterEach. Se ejecuta después de cada método @Test. Debe ser estático.
  • @BeforeAll. Se ejecuta antes de empezar la ejecución del primer método @Test.
  • @AfterAll. Se ejecuta después de que hayan finalizado todos los métodos @Test.
junit 5 ciclo de ejecución

Comprobemos la secuencia de la figura.

package com.danielme.jakartaee.test;

import org.junit.jupiter.api.*;

import java.util.logging.Logger;

public class JupiterLifeCycleAnnotationsTest {

    private final static Logger log = Logger.getLogger(JupiterLifeCycleAnnotationsTest.class.getName());

    @BeforeAll
    static void beforeAll() {
        log.info("se ejecuta una única vez y antes de ejecutarse el primer test de la clase");
    }

    @AfterAll
    static void afterAll() {
        log.info("se ejecuta después de haberse ejecutado todos los tests");
    }

    @BeforeEach
    void beforeEach() {
        log.info("    se ejecuta antes de ejecutarse cada test");
    }

    @AfterEach
    void afterEach() {
        log.info("    se ejecuta despues de cada test");
    }

    @Test
    void testFake1() {
        log.info("        test");
    }

    @Test
    void testFake2() {
        log.info("        test 2");
    }

}

La salida del log no deja lugar a dudas.

Feb 10, 2021 8:50:02 PM com.danielme.jakartaee.test.JupiterLifeCycleAnnotationsTest beforeEach
INFO:     se ejecuta antes de ejecutarse cada test
Feb 10, 2021 8:50:02 PM com.danielme.jakartaee.test.JupiterLifeCycleAnnotationsTest testFake1
INFO:         test
Feb 10, 2021 8:50:02 PM com.danielme.jakartaee.test.JupiterLifeCycleAnnotationsTest afterEach
INFO:     se ejecuta despues de cada test
Feb 10, 2021 8:50:02 PM com.danielme.jakartaee.test.JupiterLifeCycleAnnotationsTest beforeEach
INFO:     se ejecuta antes de ejecutarse cada test
Feb 10, 2021 8:50:02 PM com.danielme.jakartaee.test.JupiterLifeCycleAnnotationsTest testFake2
INFO:         test 2
Feb 10, 2021 8:50:02 PM com.danielme.jakartaee.test.JupiterLifeCycleAnnotationsTest afterEach
INFO:     se ejecuta despues de cada test
Feb 10, 2021 8:50:02 PM com.danielme.jakartaee.test.JupiterLifeCycleAnnotationsTest afterAll
INFO: se ejecuta después de haberse ejecutado todos los tests

Cuando empleamos las anotaciones anteriores estamos haciendo un uso indirecto y simplificado de Extension API. Una extensión consiste en un método que se ejecuta en cierto momento, llamado punto de extensión o extension point, del ciclo de ejecución del test. La aplicamos con la anotación @ExtendWith, válida tanto para un método como para una clase. Se pueden usar todas las extensiones que queramos en una prueba, superándose así la limitación de los runners de JUnit 4 que señalé al principio del capítulo.

Escribir una extensión es sencillo en lo que respecta a la creación de la estructura de su código, otro asunto bien diferente es su lógica. Basta con implementar las interfaces correspondientes a los puntos de extensión en los que queremos ejecutar nuestro código. Es lo que podemos hacer, por ejemplo, para replicar la funcionalidad de @BeforeAll: implementar la interfaz BeforeAllCallback.

No vamos a entrar en detalles porque sería raro que nos viésemos en la necesidad de escribir nuestras propias extensiones. Numerosas librerías y marcos de trabajo las utilizan para integrarse con Jupiter. Esto lo veremos en el próximo capítulo en el que volveremos el mundo de Jakarta EE, así que dejo los ejemplos para ese momento.

Código de ejemplo

El código de ejemplo del capítulo se encuentra en GitHub (todos los proyectos están en un único repositorio). Para más información sobre cómo utilizar GitHub, consultar este artículo.

>>>> ÍNDICE <<<<

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión /  Cambiar )

Google photo

Estás comentando usando tu cuenta de Google. Cerrar sesión /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión /  Cambiar )

Conectando a %s

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios .