# Día 8: Testing Profesional en Java Puro: JUnit 5 y AssertJ

> Pruebas Unitarias Rápidas, Ciclo de Vida de Maven y TDD en Java 17 y 21

- Plan: [JavaMastery](https://aprende-java.webs-economicas.es/), día 8 de 30
- Fase 2: De Vanilla a la Necesidad de Herramientas
- Dedicación: 3.5h
- Profundidad: para dominar
- Revisado: 2026-10-29
- Versión interactiva: https://aprende-java.webs-economicas.es/dias/junit-5-assertj/

## Al terminar este día sabrás

- Estructurar cualquier test en preparar, actuar y comprobar, con un solo comportamiento por test.
- Escribir aserciones legibles con AssertJ sobre objetos, colecciones y excepciones.
- Separar tests unitarios (Surefire) de tests de integración (Failsafe) en Maven.
- Eliminar la fragilidad de los tests que dependen del orden, del estado compartido o del reloj.


## Conceptos

### 1. El Patrón Canónico: Arrange - Act - Assert

Estructura clara de un test: Preparar los datos de entrada (Arrange), ejecutar la acción bajo prueba (Act) y comprobar el resultado esperado (Assert).

**Cómo funciona por dentro.**

Arrange-Act-Assert (o Given-When-Then) divide cada test en tres bloques separados por una línea en blanco. En el primero preparas los datos y colaboradores, en el segundo ejecutas exactamente una acción del código bajo prueba y en el tercero compruebas el resultado. Si necesitas varias acciones, probablemente estás probando más de un comportamiento y conviene partir el test.

El nombre del test es documentación: debe decir qué se espera y en qué situación (`deberiaRechazarTituloVacio`, `devuelve404CuandoLaTareaNoExiste`). Con `@DisplayName` puedes escribir frases completas que aparecen en el informe. Cuando un test falla, el nombre y la aserción deberían bastar para saber qué se rompió sin abrir el código.

Un buen test unitario es rápido (milisegundos), determinista (mismo resultado siempre), independiente (no depende de otros tests) y prueba comportamiento observable, no detalles de implementación. Si refactorizas sin cambiar el comportamiento y el test se rompe, el test estaba acoplado a la implementación.

*Un test con estructura AAA*

```java
class TareaTest {

    private final Clock reloj = Clock.fixed(Instant.parse("2026-03-01T10:00:00Z"), ZoneOffset.UTC);

    @Test
    @DisplayName("completar una tarea pendiente guarda la fecha de cierre")
    void completarTareaPendiente() {
        // Arrange
        var tarea = new Tarea(1L, "Escribir tests");

        // Act
        tarea.completar(reloj);

        // Assert
        assertThat(tarea.estaCompletada()).isTrue();
        assertThat(tarea.completadaEn()).isEqualTo(Instant.parse("2026-03-01T10:00:00Z"));
    }

    @Test
    void completarDosVecesEsUnError() {
        var tarea = new Tarea(1L, "Escribir tests");
        tarea.completar(reloj);

        assertThatThrownBy(() -> tarea.completar(reloj))
            .isInstanceOf(IllegalStateException.class)
            .hasMessageContaining("ya estaba completada");
    }
}
```

- **En una línea:** `var calc = new Calculadora(); var res = calc.sumar(2, 3); assertThat(res).isEqualTo(5);`
- **Por qué importa:** Hace que las pruebas sean legibles, mantenibles y fáciles de diagnosticar cuando fallan.
- **Error típico:** Mezclar preparación y aserciones en desorden haciendo que el test sea incomprensible.
- **Java 17 frente a 21:** Idéntico en 17 y 21.
- **Se conecta con:** JUnit 5, AssertJ, Clean Testing

### 2. Aserciones Fluidas con AssertJ

Librería estándar que reemplaza los primitivos assertEquals por aserciones legibles en lenguaje natural.

**Cómo funciona por dentro.**

AssertJ sustituye las aserciones de JUnit por una API fluida que empieza siempre con `assertThat(valorReal)`. El autocompletado del IDE te ofrece solo las comprobaciones que tienen sentido para ese tipo: para una lista verás `hasSize`, `contains`, `containsExactly`; para un `Optional`, `isPresent` o `contains`; para un `String`, `startsWith` o `matches`.

La ventaja real aparece cuando el test falla. En lugar de «expected true but was false», AssertJ muestra qué contenía la colección, qué elemento faltaba o qué campo era distinto. Con `extracting` comparas solo los campos que te interesan de una lista de objetos, y con `usingRecursiveComparison` comparas objetos campo a campo sin necesidad de `equals`.

Con `SoftAssertions` puedes acumular varios fallos y verlos todos de una vez, útil cuando compruebas varios campos de un mismo resultado.

*Aserciones que explican el fallo*

```java
List<TareaDto> tareas = servicio.listarPendientes();

assertThat(tareas)
    .hasSize(3)
    .extracting(TareaDto::titulo)
    .containsExactly("Leer JEP 444", "Probar Testcontainers", "Revisar PR");

assertThat(tareas)
    .filteredOn(t -> t.prioridad() > 5)
    .extracting("id", "titulo")                 // tuplas de varios campos
    .containsOnly(tuple(2L, "Probar Testcontainers"));

// Comparar un objeto entero ignorando campos generados
assertThat(resultado)
    .usingRecursiveComparison()
    .ignoringFields("id", "creadaEn")
    .isEqualTo(esperado);

// Varios fallos en un solo informe
SoftAssertions.assertSoftly(soft -> {
    soft.assertThat(dto.titulo()).isEqualTo("Leer");
    soft.assertThat(dto.etiquetas()).contains("java");
    soft.assertThat(dto.completada()).isFalse();
});
```

- **En una línea:** `assertThat(lista).isNotNull().hasSize(3).contains("Java");`
- **Por qué importa:** Permite encadenar verificaciones y produce mensajes de error sumamente descriptivos.
- **Error típico:** Usar aserciones básicas de JUnit que solo dicen "expected 2 but was 3" sin contexto.
- **Java 17 frente a 21:** Idéntico en 17 y 21.
- **Se conecta con:** JUnit 5, Legibilidad, Calidad de Software

### 3. Tests Parametrizados con @ParameterizedTest

Un test parametrizado ejecuta el mismo método con varios conjuntos de datos. Los datos llegan con `@ValueSource`, `@CsvSource`, `@EnumSource` o `@MethodSource`, y cada combinación aparece como un caso independiente en el informe.

**Cómo funciona por dentro.**

`@ParameterizedTest` sustituye a `@Test` y recibe los datos de una o varias fuentes. `@ValueSource` da una lista de valores simples; `@CsvSource` da filas con varias columnas, convertidas automáticamente al tipo de cada parámetro (números, enums, fechas); `@EnumSource` recorre las constantes de un enum; y `@MethodSource` llama a un método estático que devuelve un `Stream` de `Arguments` cuando los datos son objetos complejos. `@NullAndEmptySource` añade los casos `null` y vacío.

Cada combinación se ejecuta como un test independiente, con su propio resultado en el informe. Con el atributo `name` controlas cómo aparece (`name = "{0} es válido"`), y así un fallo dice exactamente qué entrada lo provocó.

Son ideales para tablas de decisión y casos límite: valores frontera, formatos válidos e inválidos, combinaciones de estado. Lo que no conviene es meter lógica condicional en el cuerpo para comportarse distinto según el caso: si los casos válidos y los inválidos se comprueban de forma diferente, son dos tests.

*Validador y recargos con varias fuentes de datos*

```java
class ValidadorEmailTest {

    private final ValidadorEmail validador = new ValidadorEmail();

    @ParameterizedTest(name = "\"{0}\" es válido")
    @ValueSource(strings = {"ana@acme.com", "luis.perez@empresa.es", "x+etiqueta@dominio.io"})
    void acepta_emails_validos(String email) {
        assertThat(validador.esValido(email)).isTrue();
    }

    @ParameterizedTest(name = "\"{0}\" se rechaza")
    @NullAndEmptySource
    @ValueSource(strings = {"sin-arroba", "@sin-usuario.com", "ana@", "  "})
    void rechaza_emails_invalidos(String email) {
        assertThat(validador.esValido(email)).isFalse();
    }

    @ParameterizedTest(name = "{0} días de retraso -> recargo del {1} %")
    @CsvSource({
        "0,  0",
        "1,  5",
        "30, 5",
        "31, 10"
    })
    void recargo_por_retraso(int dias, int recargoEsperado) {
        assertThat(Recargos.porcentaje(dias)).isEqualTo(recargoEsperado);
    }
}
```

- **En una línea:** `@ParameterizedTest @CsvSource({"'', false", "ana@acme.com, true"}) void valida(String email, boolean esperado) { ... }`
- **Por qué importa:** Prueba muchos casos límite (vacío, negativo, máximo) sin copiar y pegar tests, y deja claro qué entradas producen qué salidas.
- **Error típico:** Meter ifs dentro del test parametrizado para decidir qué comprobar según la entrada: separa los casos válidos e inválidos en dos tests.
- **Java 17 frente a 21:** Igual en Java 17 y 21. Necesita `junit-jupiter-params`, que ya incluye `spring-boot-starter-test`.
- **Se conecta con:** Casos límite, JUnit 5, Tablas de decisión

### 4. Independencia de Tests y @BeforeEach

Cada test debe ejecutarse en un entorno limpio sin compartir objetos mutables con otros tests.

**Cómo funciona por dentro.**

JUnit 5 crea una instancia nueva de la clase de test para cada método (`Lifecycle.PER_METHOD`). Por eso los campos de instancia empiezan limpios en cada test y no hace falta resetearlos. `@BeforeEach` y `@AfterEach` se ejecutan alrededor de cada método; `@BeforeAll` y `@AfterAll` una vez por clase y, por defecto, deben ser `static`.

Los problemas aparecen con el estado que vive fuera de la instancia: campos `static`, singletons, ficheros temporales compartidos, una base de datos que no se limpia o el reloj del sistema. Un test que pasa solo cuando se ejecuta después de otro es una bomba de relojería: el orden de ejecución no está garantizado y cambiará al añadir tests o paralelizar.

Para detectarlo, puedes forzar un orden aleatorio con `junit.jupiter.testmethod.order.default=org.junit.jupiter.api.MethodOrderer$Random` en `junit-platform.properties`. Para ficheros, `@TempDir` te da un directorio limpio por test.

*Estado limpio por test*

```java
class ExportadorCsvTest {

    private ExportadorCsv exportador;          // nuevo en cada test
    private List<TareaDto> tareas;

    @TempDir
    Path directorio;                           // carpeta temporal distinta por test

    @BeforeEach
    void preparar() {
        exportador = new ExportadorCsv();
        tareas = new ArrayList<>(List.of(new TareaDto(1L, "A", List.of())));
    }

    @Test
    void exportaUnaLineaPorTarea() throws IOException {
        Path destino = directorio.resolve("tareas.csv");

        exportador.exportar(tareas, destino);

        assertThat(Files.readAllLines(destino)).containsExactly("1;A");
    }

    @Test
    void listaVaciaGeneraFicheroVacio() throws IOException {
        tareas.clear();                        // no afecta al otro test
        Path destino = directorio.resolve("vacio.csv");

        exportador.exportar(tareas, destino);

        assertThat(destino).isEmptyFile();
    }
}
```

- **En una línea:** Inicializar nuevas instancias dentro de `@BeforeEach void setUp() { ... }`
- **Por qué importa:** Previene falsos positivos y errores intermitentes causados por el orden de ejecución.
- **Error típico:** Usar variables estáticas mutables compartidas entre tests que provocan fallos según el orden.
- **Java 17 frente a 21:** Idéntico en 17 y 21.
- **Se conecta con:** JUnit Lifecycle, Aislamiento, Concurrencia

### 5. Verificación de Excepciones con assertThatThrownBy

Mecanismo de AssertJ para probar que un método lanza la excepción correcta bajo condiciones inválidas.

**Cómo funciona por dentro.**

Hay tres formas habituales de comprobar que un código lanza una excepción. `assertThrows` de JUnit devuelve la excepción para que la inspecciones. `assertThatThrownBy` de AssertJ encadena comprobaciones sobre tipo, mensaje y causa en una sola expresión. `assertThatExceptionOfType(X.class).isThrownBy(...)` pone el tipo esperado delante, lo que se lee mejor cuando el tipo es lo importante.

Comprueba algo más que el tipo: el mensaje o un campo de la excepción te protegen de falsos positivos, por ejemplo un `IllegalArgumentException` lanzado por otra validación distinta de la que querías probar. Y verifica también que el estado no ha cambiado tras el error: si una operación falla a medias, el objeto debería seguir siendo válido.

Para el caso contrario, `assertThatNoException().isThrownBy(...)` deja explícito que el código debe terminar sin error.

*Tres estilos para el mismo caso*

```java
var cuenta = new CuentaBancaria("ES00", new BigDecimal("100"));

// JUnit
var ex = assertThrows(SaldoInsuficienteException.class,
        () -> cuenta.retirar(new BigDecimal("150")));
assertEquals(new BigDecimal("50"), ex.faltante());

// AssertJ: tipo, mensaje y campos encadenados
assertThatThrownBy(() -> cuenta.retirar(new BigDecimal("150")))
    .isInstanceOf(SaldoInsuficienteException.class)
    .hasMessageContaining("faltan 50")
    .extracting("faltante").isEqualTo(new BigDecimal("50"));

// AssertJ con el tipo delante
assertThatExceptionOfType(IllegalArgumentException.class)
    .isThrownBy(() -> cuenta.retirar(BigDecimal.ZERO))
    .withMessage("El importe debe ser positivo");

// El fallo no debe dejar el objeto a medias
assertThat(cuenta.saldo()).isEqualByComparingTo("100");
```

- **En una línea:** `assertThatThrownBy(() -> servicio.buscar(null)).isInstanceOf(IllegalArgumentException.class);`
- **Por qué importa:** Asegura que los casos de error y validaciones de seguridad funcionan como se espera.
- **Error típico:** Poner un try-catch manual en el test olvidando hacer fail() si la excepción no se lanza.
- **Java 17 frente a 21:** Idéntico en 17 y 21.
- **Se conecta con:** Excepciones, Robustez, AssertJ


## Sesión de 90 minutos

### Bloque 1: Anatomía de un Test JUnit 5 (0 - 25 min)

- **Qué estudiar:** Anotaciones `@Test`, `@BeforeEach`, `@DisplayName` y estructura AAA.
- **Pregunta a responder:** ¿Cómo nombrar tests con intención clara de negocio?
- **Entregable:** Suite de pruebas para una calculadora financiera con nombres legibles.

### Bloque 2: Dominio de AssertJ (25 - 50 min)

- **Qué estudiar:** Aserciones sobre colecciones, nulos, strings y excepciones.
- **Pregunta a responder:** ¿Cómo comprobar en una sola línea que una lista tiene 3 elementos y no tiene nulos?
- **Entregable:** Pruebas con `assertThat()` y `assertThatThrownBy()`.

### Bloque 3: Compilación y Maven Wrapper (50 - 80 min)

- **Qué estudiar:** Ciclo de Maven, ejecución con `./mvnw test` y dependencias de test en pom.xml.
- **Pregunta a responder:** ¿Por qué el scope `<scope>test</scope>` es vital para no inflar el JAR final?
- **Entregable:** Ejecución limpia de la suite por terminal con Maven Wrapper.

### Cierre y Rúbrica (80 - 90 min)

- **Qué estudiar:** Repaso activo de los 5 conceptos clave de testing.
- **Pregunta a responder:** Responder mentalmente la pregunta del tutor sobre Clock y fechas fijas.
- **Entregable:** Progreso y kata de testing marcada en la base de datos de estudio.


## Explícalo desde cero

### Por qué un test unitario puro debe tardar milisegundos

- **Qué entender:** Un test unitario prueba solo lógica de Java en memoria. Si un test tarda 2 segundos, no es unitario; está abriendo red, archivos o levantando un framework pesado.
- **Qué suele explicarse mal:** Llamar "test unitario" a pruebas que levantan bases de datos completas o servidores web.
- **Ejemplo sencillo:** Comprobar el cálculo de una nómina en una hoja de papel (test unitario en memoria) frente a tener que enviar la carta al banco y esperar el extracto mensual (test de integración).
- **Señal de alerta:** Si necesitas poner `@SpringBootTest` para probar una clase de cálculo matemático.


## Preguntas

### Nivel 1 (Básico): ¿Por qué los métodos de test en JUnit 5 no necesitan ser `public` como en JUnit 4?

Porque JUnit 5 utiliza reflexión moderna de Java que puede invocar métodos con visibilidad de paquete (package-private).

**Respuesta razonada:** JUnit 4 necesitaba métodos `public` porque su runner los descubría y ejecutaba con reflexión sin cambiar la accesibilidad. JUnit 5 (Jupiter) descubre los métodos de test y los invoca habilitando el acceso por reflexión, así que le basta con que no sean `private`.

La recomendación oficial es usar visibilidad de paquete para clases y métodos de test: menos ruido y deja claro que no forman parte de ninguna API. Los métodos `private` sí se ignoran, porque Jupiter considera que no deben ejecutarse como tests.

### Nivel 2 (Intermedio): ¿Cuál es la diferencia entre `assertAll()` en JUnit 5 y múltiples aserciones consecutivas?

Las aserciones consecutivas se detienen en el primer fallo. `assertAll()` ejecuta todas las aserciones y reporta todos los fallos juntos.

**Respuesta razonada:** Con aserciones consecutivas, la primera que falla lanza un `AssertionError` y el método termina: no sabes si las siguientes también habrían fallado. Con `assertAll(...)` JUnit ejecuta todas las aserciones del grupo, recoge los fallos y lanza un único `MultipleFailuresError` que los lista todos.

Es útil cuando verificas varias propiedades independientes de un mismo resultado, por ejemplo los campos de un DTO. AssertJ ofrece lo mismo con `SoftAssertions`. No conviene abusar: si la segunda aserción no tiene sentido cuando falla la primera (por ejemplo, comprobar un campo de un objeto que es `null`), las aserciones consecutivas son lo correcto.

### Nivel 3 (Entrevista Senior): ¿Cómo pruebas con JUnit 5 un método que depende de la fecha actual (`Instant.now()`) sin que el test sea inestable en diferentes días o zonas horarias?

Inyectando un `java.time.Clock` en el constructor de la clase, permitiendo que el test le pase un `Clock.fixed(instante, zona)` inmutable.

**Respuesta razonada:** No llamando a `Instant.now()` o `LocalDate.now()` directamente en el código de negocio. El reloj es una dependencia más: se inyecta un `java.time.Clock` en el constructor y se usa `Instant.now(clock)`. En producción pasas `Clock.systemUTC()` (en Spring, un `@Bean Clock`); en los tests, `Clock.fixed(Instant.parse("2026-03-01T10:00:00Z"), ZoneOffset.UTC)`.

Así el test es determinista en cualquier día y zona horaria, y además puedes probar casos difíciles como un cambio de día, el 29 de febrero o el cambio de horario, simplemente fijando el reloj en ese instante.


## Reto del tutor

**Reto:** Un programador escribe un test sin ninguna aserción, diciendo: "Si el método corre y no lanza excepción, el test pasa a verde y significa que funciona". Evalúa esta práctica.

- **Lo que está bien:** Nota que efectivamente el test pasará a verde si no hay excepción.
- **Respuesta superficial:** Decir que "hace falta un assert porque es la regla de JUnit".
- **Lo que falta de fondo:** No entender que el test no está validando que el resultado sea correcto ni que el estado del sistema haya cambiado adecuadamente.
- **Respuesta senior:** Es un falso test ("smoke test ciego") que genera una falsa sensación de cobertura. El método podría devolver `null`, una lista vacía o valores corruptos y el test seguiría en verde. Un test unitario riguroso debe validar el valor de retorno exacto o los efectos colaterales mediante aserciones explícitas de AssertJ.


## Katas

### Kata 8.1: TDD para un analizador de textos

Escribe primero los tests unitarios con AssertJ para una clase `ContadorPalabras` (contar palabras, ignorar puntuación, detectar palabra más frecuente) y luego implementa la clase mínima para pasarlos a verde.


## Reglas de oro

- Estructura siempre tus tests en Arrange - Act - Assert.
- Cada test debe ser 100% independiente del orden de ejecución.
- Inyecta `java.time.Clock` para probar lógica temporal sin sorpresas.


## Proyecto guiado «Pedidos»: Tests del dominio

Proteger las reglas de negocio con tests rápidos que se lean como una especificación.

**Tareas:**

- Escribe tests de todas las transiciones de estado del pedido, las permitidas y las prohibidas.
- Prueba el cálculo de totales y comisiones con `@ParameterizedTest` y `@CsvSource`.
- Comprueba las excepciones con `assertThatThrownBy`, incluido su mensaje.

**Está listo cuando:**

- Los tests del dominio tardan menos de un segundo en total.
- Si cambias una regla de transición, al menos un test falla y dice cuál.


## Fuentes oficiales

- [Guía de usuario de JUnit](https://junit.org/junit5/docs/current/user-guide/)
- [Documentación de AssertJ](https://assertj.github.io/doc/)

