Testing en Java en 2026: JUnit 5, Mockito y la cultura de tests que funciona

La mayoría de equipos que dicen "tenemos tests" tienen en realidad una colección de pruebas que no detectan nada importante, duplican la lógica del código en lugar de verificarla, o tardan tanto en ejecutarse que nadie los lanza antes de hacer un push. El problema rara vez es la herramienta: es no saber qué probar ni cómo estructurarlo.

JUnit 5 y Mockito, bien usados, son suficientes para montar una suite de tests sólida en cualquier proyecto Java. Esta guía va al grano: qué hace cada pieza, cómo se conecta con las demás y qué errores conviene evitar desde el principio.

JUnit 5: la estructura de tres módulos

JUnit 5 no es un único JAR. Se divide en tres módulos con responsabilidades distintas, y entenderlos ayuda a saber qué dependencias añadir y por qué.

  • JUnit Platform: la capa de ejecución. Los IDEs (IntelliJ, Eclipse) y las herramientas de build (Maven Surefire, Gradle Test Task) hablan con esta API para lanzar los tests y recoger los resultados. Tú no interactúas con ella directamente.
  • JUnit Jupiter: la API que usas al escribir tests. Aquí viven @Test, @ParameterizedTest, @BeforeEach, @AfterAll y el resto de anotaciones.
  • JUnit Vintage: permite ejecutar tests escritos con JUnit 4 o JUnit 3 sin tocarlos. Útil en proyectos que migran de forma progresiva.

Para proyectos nuevos, solo necesitas junit-jupiter en el classpath. Si usas el BOM de Spring Boot, ya lo incluye con la versión correcta y no tienes que gestionar versiones a mano:

<!-- Maven -->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

Con Gradle añades testImplementation 'org.junit.jupiter:junit-jupiter' y activas useJUnitPlatform() en el bloque test.

La anatomía de un test en JUnit 5

El patrón Given-When-Then (también llamado Arrange-Act-Assert) es la estructura más legible para tests unitarios. Separa claramente qué preparas, qué ejecutas y qué verificas:

@Test
@DisplayName("Debería crear usuario con nombre y email válidos")
void deberiaCrearUsuarioCorrecto() {
    // Given
    var req = new CreateUserRequest("Ana", "[email protected]");

    // When
    var usuario = servicio.crear(req);

    // Then
    assertThat(usuario.getNombre()).isEqualTo("Ana");
    assertThat(usuario.getEmail()).isEqualTo("[email protected]");
}

@DisplayName permite poner un nombre descriptivo en español o en el idioma que uses para documentar. Ese texto aparece en el informe de tests del IDE y en los logs de CI, lo que facilita mucho identificar qué ha fallado sin leer el código.

Para verificar que se lanza una excepción, assertThrows envuelve el código que debe fallar:

@Test
void deberiaLanzarExcepcionSiNoExisteElUsuario() {
    assertThrows(UsuarioNoEncontradoException.class,
        () -> servicio.buscar(999L));
}

AssertJ: aserciones que se leen de un vistazo

JUnit tiene sus propios asserts (assertEquals, assertTrue, assertNotNull), pero AssertJ es considerablemente más expresivo y los mensajes de error cuando falla un test son mucho más informativos. La dependencia es assertj-core y Spring Boot la incluye con spring-boot-starter-test.

Encadenar aserciones sobre una colección:

assertThat(lista)
    .hasSize(3)
    .contains("elemento")
    .doesNotContain("otro")
    .isSortedAccordingTo(Comparator.naturalOrder());

Verificar propiedades de un objeto:

assertThat(usuario)
    .isNotNull()
    .extracting(Usuario::getNombre)
    .isEqualTo("Ana");

Verificar que una excepción tiene el mensaje correcto:

assertThatThrownBy(() -> servicio.eliminar(-1L))
    .isInstanceOf(IllegalArgumentException.class)
    .hasMessage("ID inválido");

Este último es especialmente útil cuando importa tanto el tipo de excepción como el mensaje, que es lo que le llega al usuario o al consumidor de la API.

Tests parametrizados con @ParameterizedTest

Repetir el mismo test con distintos valores de entrada es uno de los casos más comunes. En lugar de copiar y pegar el mismo método cambiando un dato, @ParameterizedTest lo hace con una sola definición:

@ParameterizedTest
@ValueSource(strings = {"", " ", "abc", "nombre_sin_arroba"})
void deberiaRechazarEmailInvalido(String email) {
    assertThrows(Exception.class, () -> servicio.validarEmail(email));
}

Cuando necesitas combinar varios parámetros por caso, @CsvSource es la opción más directa:

@ParameterizedTest
@CsvSource({
    "'Ana García', true",
    "'', false",
    "'x', false",
    "'[email protected]', true"
})
void validaEmail(String email, boolean esperadoValido) {
    assertThat(servicio.esEmailValido(email)).isEqualTo(esperadoValido);
}

Para casos de prueba más complejos donde los objetos no caben en una cadena CSV, @MethodSource acepta un método que devuelve Stream<Arguments>. Ese método puede construir objetos, leer fixtures desde un fichero JSON o lo que necesites.

Mockito: mocks sin boilerplate

Un mock es un objeto falso que sustituye a una dependencia real. Su utilidad principal: aislar la clase que estás probando de sus colaboradores (repositorios, servicios externos, colas de mensajes). Mockito se encarga de crear esos objetos falsos y de que puedas definir qué devuelven y verificar cómo se han llamado.

Con la extensión de JUnit 5, la configuración es mínima:

@ExtendWith(MockitoExtension.class)
class UsuarioServiceTest {

    @Mock
    UsuarioRepository repo;

    @InjectMocks
    UsuarioService servicio;

    @Test
    void deberiaEncontrarUsuarioPorId() {
        var usuario = new Usuario(1L, "Ana", "[email protected]");
        when(repo.findById(1L)).thenReturn(Optional.of(usuario));

        var resultado = servicio.buscar(1L);

        assertThat(resultado.getNombre()).isEqualTo("Ana");
    }
}

@Mock crea el mock, @InjectMocks instancia el servicio inyectando los mocks disponibles. when(...).thenReturn(...) define qué devuelve el mock cuando se llama con un argumento concreto.

Para verificar que el servicio ha llamado al repositorio con los argumentos correctos:

verify(repo, times(1)).save(any(Usuario.class));
verify(repo, never()).delete(any());

Y para que el mock lance una excepción:

doThrow(new RuntimeException("fallo de base de datos"))
    .when(repo).delete(any());

ArgumentCaptor: verificar qué se pasó exactamente

any(Usuario.class) en un verify confirma que se llamó al método con algún usuario, pero no dice nada sobre qué usuario. Si el servicio construye el objeto antes de persistirlo, puede que le ponga campos incorrectos sin que el test lo detecte. ArgumentCaptor resuelve esto:

@Test
void deberiaGuardarElUsuarioConFechaDeCreacion() {
    var req = new CreateUserRequest("Ana", "[email protected]");

    servicio.crear(req);

    ArgumentCaptor<Usuario> captor = ArgumentCaptor.forClass(Usuario.class);
    verify(repo).save(captor.capture());

    var guardado = captor.getValue();
    assertThat(guardado.getNombre()).isEqualTo("Ana");
    assertThat(guardado.getFechaCreacion()).isNotNull();
}

Con @Captor como anotación de campo evitas crear el captor a mano dentro de cada test.

Tests de integración con Spring Boot

Los tests unitarios prueban la lógica de negocio aislada. Los de integración comprueban que las capas funcionan juntas: el controller recibe la petición, la pasa al servicio, el servicio usa el repositorio y la respuesta vuelve correctamente formateada.

@SpringBootTest y MockMvc

@SpringBootTest
@AutoConfigureMockMvc
class UserControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Test
    void deberiaDevolver200YElNombreDelUsuario() throws Exception {
        mockMvc.perform(get("/api/users/1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.nombre").value("Ana"));
    }
}

@SpringBootTest levanta el contexto completo de Spring. MockMvc simula peticiones HTTP sin levantar un servidor real, lo que hace los tests más rápidos que lanzar un servidor embebido.

Slices: probar solo una capa

Para tests más rápidos y enfocados, Spring Boot ofrece anotaciones que levantan solo una parte del contexto:

  • @DataJpaTest: levanta solo la capa JPA con una base de datos H2 en memoria. Útil para probar queries personalizadas en los repositorios.
  • @WebMvcTest(UserController.class): solo el controller, con mocks del servicio. No hace falta la base de datos ni el servicio real.
  • @RestClientTest: para probar clientes HTTP que consumen APIs externas.

Testcontainers: la base de datos real en tests

H2 en memoria es práctico, pero a veces las queries que funcionan en H2 fallan en PostgreSQL por diferencias de dialecto SQL. Testcontainers levanta un contenedor Docker real durante los tests:

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UsuarioRepositoryTest {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16");

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Test
    void deberiaEncontrarPorEmail() {
        // test contra Postgres real
    }
}

El contenedor arranca una vez para toda la clase de tests y se destruye al terminar. En CI funciona sin configuración adicional si el runner tiene Docker disponible.

Cobertura con JaCoCo

JaCoCo genera informes de cobertura que muestran qué líneas y ramas del código ejecutan los tests. Con Maven:

mvn test jacoco:report

El informe HTML queda en target/site/jacoco/index.html. La métrica más relevante no es la cobertura de líneas sino la de branches: cada if, switch o operador ternario tiene dos caminos posibles, y lo interesante es que los tests cubran ambos.

Un paso más allá es el mutation testing con PIT (pitest). PIT modifica el código fuente de forma automática (cambia > por >=, invierte condiciones booleanas, elimina llamadas a métodos) y luego comprueba si los tests detectan esos cambios. Si un test sigue pasando con el código mutado, es que no verifica realmente la lógica que dice verificar:

mvn test-compile pitest:mutationCoverage

El informe de PIT muestra qué mutaciones sobrevivieron, que es exactamente donde tienes tests que no detectan nada. No hace falta perseguir el 100% de mutantes eliminados, pero los que sobreviven en lógica de negocio crítica merecen una revisión.

En CI puedes configurar JaCoCo para bloquear el merge si la cobertura de branches baja del umbral que definas. El plugin de Maven acepta reglas de mínimo en el fichero pom.xml dentro de <configuration><rules>.

Cómo estructurar la suite para que sea mantenible

Un par de decisiones de organización que marcan la diferencia a largo plazo:

  • Separa tests unitarios de tests de integración en directorios o perfiles de Maven distintos. Los unitarios se ejecutan en cada commit; los de integración, en el pipeline de CI antes del merge.
  • Nombra los tests con lo que verifican, no con el método que llaman. deberiaRechazarEmailVacio es útil; testCrearUsuario no dice nada cuando falla.
  • Un test que requiere mucho setup para casos simples suele ser señal de que la clase tiene demasiadas responsabilidades. Los tests actúan como detector temprano de diseño mejorable.

Si te interesa profundizar en escenarios más complejos, puedes ver cómo enfocar el testing de concurrencia en Java: cómo verificar que no hay race conditions, donde los mocks no bastan y hacen falta otras estrategias. Y para la capa de seguridad, el artículo sobre testing de seguridad en Spring Boot: JUnit 5 y MockMvc cubre cómo probar endpoints protegidos con JWT y OAuth2 sin levantar el servidor completo.

Imagen: Pexels / RealToughCandy.com

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP