Testing con JUnit 4

Última actualización: 17/04/2021

junit

Las pruebas automáticas suelen ser unas grandes olvidadas en el proceso de desarrollo de software y la primera “víctima” de las desviaciones de presupuestos y plazos de entrega. Sin embargo, deben ser parte fundamental del proceso de creación de software. Incluso existe una metodología denominada “Desarrollo dirigido por pruebas” (TDD) en la que, a grandes rasgos, el desarrollo de la aplicación se basa en las pruebas automáticas y no al revés, esto es, primero se diseñan los tests y luego se escribe el código que los verifica.


Veamos brevemente algunas ventajas del testing automatizado.

  1. Lo más evidente es que facilita la detección de errores lo que aumenta la fiabilidad del software.
  2. Permite avanzar en la implementación de un proyecto o en la inclusión de nuevas funcionalidades asegurando, con un nivel de certeza no absoluto pero sí razonable, que los cambios y añadidos no “rompen” funcionalidades ya existentes y operativas. Utilizando un símil, supone programar con una red de seguridad.
  3. Una vez implementadas, las pruebas automáticas se ejecutan todas las veces que sean necesarias sin ningún coste adicional en comparación con las realizadas de forma manual. Esto no implica que se descarten las pruebas manuales, sino que estas se inviertan donde aporten verdadero valor, y se evite perder el tiempo -y el dinero- en repetir una y otra vez las mismas tareas de fácil automatización.
  4. Exige que el código de la aplicación sea fácil de testear, lo que redunda en un mejor diseño. Este “efecto colateral” ayuda a aumentar la calidad del código, facilitando su mantenimiento, y es la principal justificación del enfoque que propone TDD. De hecho, un código difícil de probar es un buen indicador de un probable diseño deficiente.

La clasificación de los tipos de test o pruebas que pueden realizarse es extensa. Como desarrolladores, generalmente vamos a centrarnos en la implementación de pruebas unitarias que suelen -y deberían- ser las más numerosas. Aquí lo que probamos son unidades mínimas de código (métodos muy concretos) de forma exhaustiva y totalmente independiente del resto de la aplicación, hasta tal punto que en muchos casos simularemos la interacción del código que probamos con otros componentes mediante mocks, stubs, dummys, etc. Para esto último podemos utilizar Mockito.

Si el lector está interesado en una pequeña introducción teórica al testing automatizado, he preparado este artículo. Aunque forma parte de mi curso de Jakarta EE, su contenido es genérico.

JUnit 4

JUnit es el marco de trabajo o “framework” de testing para Java más popular. Si bien la versión 5, reescrita desde cero y concebida como una plataforma de testing, presenta importantes cambios y novedades, JUnit 4 se sigue utilizando ampliamente, sobre todo en desarrollos un poco antiguos y en el mundo Android. No obstante, en JUnit 5 es posible ejecutar pruebas implementadas con las versiones 3 y 4, así que recomiendo dar el salto, ya que no es obligatorio migrar las pruebas que ya tengamos funcionando.

Ver introducción a JUnit 5

A pesar de lo que su nombre sugiere, JUnit se puede utilizar para implementar cualquier tipo de testing automatizado y no solo pruebas unitarias. Provee una infraestructura básica sobre la que escribir nuestras pruebas, en las que muchas veces nos apoyaremos en otras herramientas como Mockito o Selenium, o bien en mecanismos de integración con los marcos de trabajo que utilicemos, por ejemplo Spring y Spring Boot.

Los tests de JUnit 4 se implementan en clases POJO sin ninguna particularidad: no es necesario heredar de clase alguna porque JUnit “conoce” los test a ejecutar en función de las anotaciones que encuentre (@Test). Estas clases pueden tener cualquier nombre, aunque se suele seguir la convención de utilizar el sufijo “Test”.

En el caso de Maven, las clases con las pruebas se ubican en la carpeta /src/test/java y los recursos que sean específicos de los test estarán en el directorio /src/test/resources.

En el pom solo tenemos que añadir una dependencia. Utilizaremos el ámbito test para que JUnit, o cualquier otra dependencia con este scope, no se incluya en los artefactos finales del proyecto (jar, .war, .ear).

<dependency>
  <groupId>junit</groupId>
  <artifactId>junit</artifactId>
  <version>4.13.2</version>
  <scope>test</scope>
</dependency>

Al “ejecutarse” una clase con JUnit se invocarán los métodos que contengan las siguientes anotaciones. Deben ser públicos, sin parámetros y no devolver nada (void).

  • @Test – cada método que implemente un test. Lo veremos más adelante. Nota: los test (métodos y clases) marcados con @Ignore no se ejecutarán.
  • @Before – se invoca siempre antes de la ejecución de cada @Test de la clase.
  • @After – se invoca siempre después de la ejecución de cada @Test.
  • @BeforeClass – método estático. Se invoca una única vez al ejecutarse la clase antes de cualquier método @Test o @Before. Por ejemplo, podríamos utilizar este método para arrancar una única vez una base de datos embebida como HSQLDB que usarán todas las pruebas.
  • @AfterClass – método estático. Equivalente al anterior, pero ahora el método es el último que se invoca al ejecutarse la clase.

Para los métodos anotados con @Before y @BeforeClass, la convención habitual consiste en nombrarlos con el prefijo setUp, mientras que para los anotados con @After y @AfterClass se recurre al prefijo tearDown. En los métodos @Test a veces se utiliza test como prefijo o sufijo aunque lo recomendable es usar nombres los más descriptivos posibles para aumentar la legibilidad del código y, sobre todo, de los resultados. Algunos programadores prefieren nombrar los métodos con notación Pascal-case, esto es, utilizando minúsculas y el carácter _ para separar las palabras. Naturalmente, el lector deberá elegir la convención que más le guste (suponiendo, claro está, que le dejen decidirlo).

Con la siguiente clase, que no prueba nada, veremos en detalle el flujo de ejecución.

package com.danielme.blog.testing;

import org.apache.log4j.Logger;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;

public class LifecycleTest {

    private static final Logger logger = Logger.getLogger(LifecycleTest.class);

    @BeforeClass
    public static void setUpClass() {
        logger.info("");
    }

    @AfterClass
    public static void tearDownClass() {
        logger.info("");
    }

    @Before
    public void setUpTest() {
        logger.info("");
    }

    @After
    public void tearDownTest() {
        logger.info("");
    }

    @Test
    public void testNothing1() {
        logger.info("");
    }

    @Test
    public void testNothing2() {
        logger.info("");
    }

}

Podemos ejecutar todas las pruebas del proyecto con mvn test (o una clase en concreto con mvn test -Dtest=clase). Asimismo, al crearse el artefacto (el .jar. el .war…) del proyecto con la orden package de Maven, se ejecutarán todas las pruebas de tal modo que si hay algún fallo o error el artefacto no se generará. En este último escenario, podemos desactivar la ejecución de las pruebas con la opción -DskipTests.

Esta es la salida de Maven.

Running com.danielme.blog.testing.LifecycleTest
2021-04-16 17:20:32 INFO  LifecycleTest:16.setUpClass - 
2021-04-16 17:20:32 INFO  LifecycleTest:26.setUpTest - 
2021-04-16 17:20:32 INFO  LifecycleTest:36.testNothing1 - 
2021-04-16 17:20:32 INFO  LifecycleTest:31.tearDownTest - 
2021-04-16 17:20:32 INFO  LifecycleTest:26.setUpTest - 
2021-04-16 17:20:32 INFO  LifecycleTest:41.testNothing2 - 
2021-04-16 17:20:32 INFO  LifecycleTest:31.tearDownTest - 
2021-04-16 17:20:32 INFO  LifecycleTest:21.tearDownClass - 
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0 sec

En la carpeta /target/surefire-reports/ encontraremos los informes, en formato txt y xml, con los resultados de la ejecución (Surefire es el plugin de Maven que ejecuta los tests). La siguiente captura se corresponde con el proyecto final de todo el tutorial.

Otra posibilidad es recurrir al soporte proporcionado por el IDE que utilicemos. Eclipse e IntelliJ, tanto la versión Ultimate (de pago) como la Community (gratis), ofrecen de serie integración con JUnit. Podemos lanzar los tests pulsando sobre una clase con pruebas, o un paquete con clases de este tipo, con el botón derecho y seleccionando Run en el menú contextual que se despliega. Es lo que muestra la siguiente imagen (IntelliJ a la izquierda y Eclipse a la derecha).

Los resultados se ofrecen de forma visual y clara.

Comprobación de resultados (aserciones)

La ejecución de un test (a partir de ahora me referiré con test a todo método anotado con @Test) se considera exitosa si no hemos llamado a fail para informar que la ejecución ha sido fallida porque los resultados obtenidos al invocarse el código sujeto de las pruebas no son los esperados. Y si la ejecución no se puede completar (por ejemplo, se produjo una excepción inesperada), el test se considera erróneo.

Nota. Los métodos de la clase Assert se suelen utilizar con importaciones estáticas.

import static org.junit.Assert.fail;

En el ejemplo de este tutorial probaremos la siguiente clase.

package com.danielme.blog.testing;

import java.util.ArrayList;
import java.util.List;

public enum UserService {

    INSTANCE;

    private final String[] users = { "Bombur", "Bofur", "Bifur", "Nori", "Ori", "Dori", "Thorin",
            "Balin", "Dwalin", "Oin", "Gloin", "Fili", "Kili" };

    public List<String> getUsersByName(String name) {
        List<String> res = new ArrayList<>();
        if (name != null) {
            for (String user : users) {
                if (user.toUpperCase().contains(name.toUpperCase())) {
                    res.add(user);
                }
            }
        }
        return res;
    }

    public String getUserByPosition(int pos) {
        return users[pos];
    }

}

Vamos a crear un test para probar el método getUsersByName. Haremos una búsqueda y comprobaremos que se devuelven exactamente los dos resultados esperados. No es una prueba muy buena que digamos, pero nos permitirá ver cómo verificar el funcionamiento de getUsersByName.

    @Test
    public void testGetUsersByName() {
        List<String> usersByName = UserService.INSTANCE.getUsersByName("oin");
        if (usersByName.size() != 2) {
            fail("size must be 2");
        } else if (!usersByName.contains("Gloin")) {
            fail("Gloin not found!!");
        }  else if (!usersByName.contains("Oin")) {
            fail("Oin not found!!");
        }
    }

En caso de error, por ejemplo la lista obtenida no tiene dos elementos, este será el resultado.

testGetUsersByName(com.danielme.blog.testing.UserServiceTest)  Time elapsed: 0.005 sec  <<< FAILURE!
java.lang.AssertionError: size must be 2
	at org.junit.Assert.fail(Assert.java:88)
	at com.danielme.blog.testing.UserServiceTest.testGetUsersByName(UserServiceTest.java:53)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:57)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:606)
	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
	at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
	at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26)
	at org.junit.internal.runners.statements.RunAfters.evaluate(RunAfters.java:27)
	at org.junit.rules.ExpectedException$ExpectedExceptionStatement.evaluate(ExpectedException.java:239)
	at org.junit.internal.runners.statements.FailOnTimeout$CallableStatement.call(FailOnTimeout.java:298)
	at org.junit.internal.runners.statements.FailOnTimeout$CallableStatement.call(FailOnTimeout.java:292)
	at java.util.concurrent.FutureTask.run(FutureTask.java:262)
	at java.lang.Thread.run(Thread.java:745)


Results :

Failed tests:   testGetUsersByName(com.danielme.blog.testing.UserServiceTest): size must be 2

Con la finalidad de facilitar las comprobaciones (aserciones) de los resultados, JUnit pone a nuestra disposición una amplia colección de métodos estáticos en la clase Assert que realizan validaciones sobre sus parámetros de entrada e invocarán a fail cuando no verifiquen la condición esperada.

Podemos reescribir nuestro test tal que así para que sea más elegante y legible.

    @Test
    public void testGetUsersByName() {
        List<String> usersByName = UserService.INSTANCE.getUsersByName("oin");
        assertEquals(2, usersByName.size());
        assertTrue("Gloin not found!!", usersByName.contains("Gloin"));
        assertTrue("Oin not found!!", usersByName.contains("Oin"));        
    }

Es una buena práctica utilizar las versiones de los métodos assert que admiten un texto de mensaje, aunque en el caso concreto de assertEquals no lo he hecho porque la descripción generada de forma automática es bastante descriptiva.

Failed tests:   testGetUsersByName(com.danielme.blog.testing.UserServiceTest): expected: 2 but was: 3

Asimismo, obsérvese que en los assert que reciben dos parámetros, por ejemplo assertEquals, el primero representa el valor esperado y el segundo es la respuesta que estamos verificando. Este criterio, al menos a mí, me resulta confuso.

Estas aserciones son una ayuda, pero pequeña, pues resultan muy rudimentarias. Por ello, recomiendo sin dudar AssertJ, 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 JUnit.

Vamos a incluir AssertJ en el pom.xml.

        <dependency>
			<groupId>org.assertj</groupId>
			<artifactId>assertj-core</artifactId>
			<version>3.19.0</version>
			<scope>test</scope>
		</dependency>

Por ejemplo, se puede reescribir testGetUsersByName del siguiente modo.

   @Test
    public void getUsersByNameAssertJ() {
        List<String> usersByName = UserService.INSTANCE.getUsersByName("oin");
        assertThat(usersByName)
        .hasSize(2)
        .containsExactlyInAnyOrder("Gloin", "Oin");;
    }    

El anterior fragmento de código demuestra el uso típico de la API de AssertJ: la estructura que indiqué líneas atrás se construye aplicando validaciones de forma sucesiva al objeto proporcionado a una versión del método assertThat. Vemos lo fácil e intuitivo que resulta aplicar varias aserciones sobre una lista, casuística con la que me encuentro a menudo. Eso sí, la llamada a hasSize es redundante y la existencia de dos elementos ya se comprueba en containsExactlyInAnyOrder.

AssertJ es poderoso y la documentación está llena de ejemplos prácticos, así que remito al lector a la misma para que descubra y saque partido a sus funcionalidades.

El patrón Arrange-Act-Assert

Demos una vuelta de tuerca a la escritura de getUsersByNameAssertJ. Así es como aparece en la versión final disponible en GitHub.

    @Test
    public void testGetUsersByNameAssertJ() {
        UserService userService = UserService.INSTANCE;

        List<String> usersByName = userService.getUsersByName("oin");
        
        assertThat(usersByName)
                .containsExactlyInAnyOrder("Gloin", "Oin");
    }

La división en tres bloques es intencionada y responde a la estructura Arrange-Act-Assert (AAA) promovida por la metodología BDD.

  • Arrange (Organizar). Se configuran los elementos necesarios para realizar la prueba. Esta tarea puede incluir instanciar las clases a probar y preparar los objetos dobles de tests cuando sea necesario. A veces, esta fase no hace falta porque su lógica ya se realiza en un método de tipo @Before o @BeforeAll.
  • Act (Actuar). Se ejecuta la operación a validar.
  • Assert (Afirmar). Comprobamos que el resultado de Act es el esperado.

Esta organización en fases o bloques es simple pero efectiva y en la actualidad es prácticamente un estándar de facto. 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.

Parametrización

Para hacer pruebas exhaustivas necesitaremos con frecuencia ejecutar varias veces el mismo test con distintos valores de entrada en el método que estamos probando. JUnit admite la creación de tests parametrizados, pero el sistema que proporciona es algo engorroso y poco flexible. En su lugar prefiero utilizar la librería JUnitParams.

La incluimos en nuestro pom.xml.

<dependency>
  <groupId>pl.pragmatists</groupId>
  <artifactId>JUnitParams</artifactId>
  <version>1.1.1</version>
  <scope>test</scope>
</dependency>

Ahora, a modo de ejemplo vamos a implementar un test sobre el método getUsersByName que se ejecutará tres veces, cambiando cada vez la cadena de búsqueda y el resultado esperado.

@RunWith(JUnitParamsRunner.class)
public class UserServiceTest {

    @Test
    @Parameters({"oin, 2", "balin, 1", "gimli, 0" })
    public void testGetUsersByNameParameterised(String name, int result) {
        List<String> usersByName = UserService.INSTANCE.getUsersByName(name);
        assertThat(usersByName)
                .hasSize(result);        
    }

En el resultado de las pruebas podemos ver con claridad cómo se va ejecutando el test parametrizado, y comprobar que en realidad son tres distintos e independientes; aunque falle alguno de ellos, siempre se ejecutan todos. Por tanto, es más interesante crear el test parametrizado que escribir un test “normal” con un bucle.

JUnitParams eclipse

Excepciones

En ocasiones el resultado que queremos validar en un test es el lanzamiento de una excepción. Hay cuatro maneras distintas de hacerlo.

  • A mano, con try-catch y fail.
        @Test
        public void testGetUserByPositionExceptionFail() {
            try {
                UserService.INSTANCE.getUserByPosition(-1);
                fail("Exception not thown");
            } catch (IndexOutOfBoundsException ex) {
                assertThat(ex.getMessage())
                        .contains("-1");
            }
        }
    
  • Indicando la excepción que se debe lanzar en la anotación @Test.
        @Test(expected = IndexOutOfBoundsException.class)
        public void testGetUserByPositionExceptionAnnotation() {
            UserService.INSTANCE.getUserByPosition(-1);
        }
    
  • Con las reglas de JUnit que posibilitan modificar el comportamiento de los tests. En concreto, usaremos la regla ExpectedException, incluida en JUnit, del siguiente modo:
    1. Definir la regla:
          @Rule
          public ExpectedException exception = ExpectedException.none(); 
      
    2. Indicar al inicio del test la excepción esperada. También es posible validar el mensaje de la excepción (hay varios métodos para ello).
          @Test
          public void testGetUserByPositionExceptionRule() {
              exception.expect(IndexOutOfBoundsException.class);
              exception.expectMessage("-1");
           
              UserService.INSTANCE.getUserByPosition(-1);
          }
      
  • Desde JUnit 4.13 (enero de 2020), contamos con los métodos de tipo assertThrows.
    @Test
    public void testExceptionAssertJUnit413() {
        assertThrows(IndexOutOfBoundsException.class, () -> UserService.INSTANCE.getUserByPosition(-1));        
    }
    

Ninguna de las tres primeras opciones es ideal. La primera tiene una legibilidad pobre, y en las otras dos no se puede precisar la invocación que debe lanzar la excepción (¡y además los test no tienen ningún assert!). La cuarta es, por tanto, la alternativa que recomiendo.

Con todo, la solución que más me gusta consiste en emplear AssertJ porque permite validar a fondo el lanzamiento de excepciones. Un ejemplo típico es el siguiente.

  @Test
    public void testExceptionAssertJ() {
        assertThatThrownBy(() -> UserService.INSTANCE.getUserByPosition(-1))
        .isInstanceOf(IndexOutOfBoundsException.class)
        .hasMessageContaining("-1");
    }

Timeout

Es posible establecer en cada prueba un “timeout” o tiempo máximo de ejecución en milisegundos. Si pasado ese tiempo el test no ha finalizado, su resultado será fallido.

El timeout se configura de forma similar a la validación de excepciones:

  • Con la anotación @Test : @Test(timeout=5000)
  • Con una regla que se aplicará a cada uno de los test de la clase.
  •     
    @Rule
    public Timeout timeout = Timeout.millis(5000);
    

    Esta regla tiene en consideración el tiempo de ejecución de los métodos @Before y @After que acompañan a cada test.

Orden de ejecución

Una de las preguntas más frecuentes que suelen realizar los neófitos es cómo se puede establecer el orden de ejecución de los tests. El planteamiento es erróneo: un test ha de ser autónomo y nunca depender de otro. Por tanto, no debería ser necesario ejecutarlos siempre en el mismo orden.

A pesar de esto, con la anotación @FixMethodOrder se asegura la ejecución de las pruebas en orden alfabético.

Suite de tests

Una Suite consiste en una agrupación de varias clases con tests para que se ejecuten de forma conjunta. Por ejemplo, podemos crear una suite con los tests que prueban la capa de negocio, otra con las pruebas de integración, etc. Lanzando su ejecución, se ejecutarán todos los tests que la componen.

Definir una suite es muy sencillo, pues basta con crear una clase y usar un par de anotaciones. @RunWith debe solicitar la ejecución de la clase con el runner (*) para suites, y con @Suite.SuiteClasses especificamos las clases con pruebas que componen la suite.

package com.danielme.blog.testing;

import org.junit.runner.RunWith;
import org.junit.runners.Suite;

@RunWith(Suite.class)
@Suite.SuiteClasses({
        UserServiceTest.class,
        LifecycleTest.class
})
public class TestingSuite {
//clase vacia

}

TestingSuite se ejecuta como cualquier clase con pruebas, y a su vez, ejecuta las clases UserServiceTest y LifecycleTest siguiendo el orden indicado en la anotación.

(*) Un runner es una especialización, directa o indirecta, de Runner y es responsable de ejecutar las pruebas. Este sistema se usa para integrar JUnit 4 con librerías y marcos de trabajo (Spring, Arquillian…), pero tiene una limitación importante: solo se puede usar un runner al mismo tiempo. El mecanismo equivalente en JUnit 5 (extensiones) no tiene este problema y es más potente y flexible.

Cobertura

La cobertura es una métrica que informa del código que es ejecutado durante la realización de las pruebas. Es muy útil y popular, pero no debemos considerarla como una métrica absoluta para evaluar la calidad de los tests, ya que aquí intervienen otros factores tales como la rigurosidad de las aserciones o la similitud entre las configuraciones de test y de producción en el caso de pruebas de integración. Tenemos que poner el foco en la utilidad de las pruebas, y no escribirlas con el único propósito de conseguir altas coberturas.

Eclipse incluye una herramienta para medir la cobertura de los tests ejecutados desde el IDE utilizando la opción Run -> Coverage.

Se indica el código ejecutado (verde) y el que no (rojo), así como las bifurcaciones o ramas en las que no se ha entrado por todas las condiciones (amarillo).

En la vista de cobertura se muestran las estadísticas.

Por supuesto, IntelliJ no es menos y cuenta con la misma funcionalidad.


Código de ejemplo

El proyecto completo se encuentra disponible en GitHub. Para más información sobre cómo utilizar GitHub, consultar este artículo.

Otros tutoriales sobre testing

Introducción al testing automático

Introducción a JUnit 5

Test doubles con Mockito

Testing Spring con JUnit 4

Testing en Spring Boot con JUnit 4\5. Mockito, MockMvc, REST Assured, bases de datos embebidas.

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. Salir /  Cambiar )

Google photo

Estás comentando usando tu cuenta de Google. Salir /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Salir /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Salir /  Cambiar )

Conectando a %s

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