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.
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).
En una líneavar calc = new Calculadora(); var res = calc.sumar(2, 3); assertThat(res).isEqualTo(5);
Un test con estructura AAAjava
classTareaTest {
privatefinalClockreloj= Clock.fixed(Instant.parse("2026-03-01T10:00:00Z"), ZoneOffset.UTC);
@Test@DisplayName("completar una tarea pendiente guarda la fecha de cierre")voidcompletarTareaPendiente() {
// Arrangevartarea=newTarea(1L, "Escribir tests");
// Act
tarea.completar(reloj);
// Assert
assertThat(tarea.estaCompletada()).isTrue();
assertThat(tarea.completadaEn()).isEqualTo(Instant.parse("2026-03-01T10:00:00Z"));
}
@TestvoidcompletarDosVecesEsUnError() {
vartarea=newTarea(1L, "Escribir tests");
tarea.completar(reloj);
assertThatThrownBy(() -> tarea.completar(reloj))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("ya estaba completada");
}
}
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.
Hace que las pruebas sean legibles, mantenibles y fáciles de diagnosticar cuando fallan.
Mezclar preparación y aserciones en desorden haciendo que el test sea incomprensible.
Idéntico en 17 y 21.
Se conecta conJUnit 5AssertJClean Testing
2
Aserciones Fluidas con AssertJ
Librería estándar que reemplaza los primitivos assertEquals por aserciones legibles en lenguaje natural.
En una líneaassertThat(lista).isNotNull().hasSize(3).contains("Java");
Aserciones que explican el fallojava
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();
});
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.
Permite encadenar verificaciones y produce mensajes de error sumamente descriptivos.
Usar aserciones básicas de JUnit que solo dicen "expected 2 but was 3" sin contexto.
Idéntico en 17 y 21.
Se conecta conJUnit 5LegibilidadCalidad 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.
En una línea@ParameterizedTest @CsvSource({"'', false", "ana@acme.com, true"}) void valida(String email, boolean esperado) { ... }
Validador y recargos con varias fuentes de datosjava
classValidadorEmailTest {
privatefinalValidadorEmailvalidador=newValidadorEmail();
@ParameterizedTest(name = "\"{0}\" es válido")@ValueSource(strings = {"ana@acme.com", "luis.perez@empresa.es", "x+etiqueta@dominio.io"})voidacepta_emails_validos(String email) {
assertThat(validador.esValido(email)).isTrue();
}
@ParameterizedTest(name = "\"{0}\" se rechaza")@NullAndEmptySource@ValueSource(strings = {"sin-arroba", "@sin-usuario.com", "ana@", " "})voidrechaza_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"
})voidrecargo_por_retraso(int dias, int recargoEsperado) {
assertThat(Recargos.porcentaje(dias)).isEqualTo(recargoEsperado);
}
}
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.
Prueba muchos casos límite (vacío, negativo, máximo) sin copiar y pegar tests, y deja claro qué entradas producen qué salidas.
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.
Igual en Java 17 y 21. Necesita junit-jupiter-params, que ya incluye spring-boot-starter-test.
Se conecta conCasos límiteJUnit 5Tablas de decisión
4
Independencia de Tests y @BeforeEach
Cada test debe ejecutarse en un entorno limpio sin compartir objetos mutables con otros tests.
En una línea Inicializar nuevas instancias dentro de @BeforeEach void setUp() { ... }
Estado limpio por testjava
classExportadorCsvTest {
private ExportadorCsv exportador; // nuevo en cada testprivate List<TareaDto> tareas;
@TempDir
Path directorio; // carpeta temporal distinta por test@BeforeEachvoidpreparar() {
exportador = newExportadorCsv();
tareas = newArrayList<>(List.of(newTareaDto(1L, "A", List.of())));
}
@TestvoidexportaUnaLineaPorTarea()throws IOException {
Pathdestino= directorio.resolve("tareas.csv");
exportador.exportar(tareas, destino);
assertThat(Files.readAllLines(destino)).containsExactly("1;A");
}
@TestvoidlistaVaciaGeneraFicheroVacio()throws IOException {
tareas.clear(); // no afecta al otro testPathdestino= directorio.resolve("vacio.csv");
exportador.exportar(tareas, destino);
assertThat(destino).isEmptyFile();
}
}
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.
Previene falsos positivos y errores intermitentes causados por el orden de ejecución.
Usar variables estáticas mutables compartidas entre tests que provocan fallos según el orden.
Idéntico en 17 y 21.
Se conecta conJUnit LifecycleAislamientoConcurrencia
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.
En una líneaassertThatThrownBy(() -> servicio.buscar(null)).isInstanceOf(IllegalArgumentException.class);
Tres estilos para el mismo casojava
varcuenta=newCuentaBancaria("ES00", newBigDecimal("100"));
// JUnitvarex= assertThrows(SaldoInsuficienteException.class,
() -> cuenta.retirar(newBigDecimal("150")));
assertEquals(newBigDecimal("50"), ex.faltante());
// AssertJ: tipo, mensaje y campos encadenados
assertThatThrownBy(() -> cuenta.retirar(newBigDecimal("150")))
.isInstanceOf(SaldoInsuficienteException.class)
.hasMessageContaining("faltan 50")
.extracting("faltante").isEqualTo(newBigDecimal("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");
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.
Asegura que los casos de error y validaciones de seguridad funcionan como se espera.
Poner un try-catch manual en el test olvidando hacer fail() si la excepción no se lanza.
Idéntico en 17 y 21.
Se conecta conExcepcionesRobustezAssertJ
Cuatro bloques cronometrados. No pases al siguiente sin tener el resultado esperado.
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?
Resultado esperado. 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?
Resultado esperado. 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?
Resultado esperado. 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.
Resultado esperado. Progreso y kata de testing marcada en la base de datos de estudio.
Tres formas de comprobar si lo has entendido, de menos a más exigente: explicarlo con tus palabras, responder preguntas trampa y resolver el reto del tutor.
Explícalo desde cero
Lee cada ficha y explícala en voz alta antes de seguir: si puedes contárselo a alguien que empieza, lo has entendido.
Por qué un test unitario puro debe tardar milisegundos
Lo que debes 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.
Analogía para contarlo
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).
Lo que suele explicarse mal
Llamar "test unitario" a pruebas que levantan bases de datos completas o servidores web.
Señal de que aún no lo dominas
Si necesitas poner @SpringBootTest para probar una clase de cálculo matemático.
Preguntas trampa
Responde de memoria, en voz alta o por escrito, y después abre la respuesta razonada: lo importante es el porqué, no la palabra exacta.
Nivel 1 (Básico)¿Por qué los métodos de test en JUnit 5 no necesitan ser public como en JUnit 4?Ver respuesta
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.
Qué está evaluando la pregunta
Porque JUnit 5 utiliza reflexión moderna de Java que puede invocar métodos con visibilidad de paquete (package-private).
Nivel 2 (Intermedio)¿Cuál es la diferencia entre assertAll() en JUnit 5 y múltiples aserciones consecutivas?Ver respuesta
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.
Qué está evaluando la pregunta
Las aserciones consecutivas se detienen en el primer fallo. assertAll() ejecuta todas las aserciones y reporta todos los fallos juntos.
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?Ver respuesta
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.
Qué está evaluando la pregunta
Inyectando un java.time.Clock en el constructor de la clase, permitiendo que el test le pase un Clock.fixed(instante, zona) inmutable.
Reto del tutor
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 una respuesta suele tener bien
Nota que efectivamente el test pasará a verde si no hay excepción.
Lo que suele quedarse en la superficie
Decir que "hace falta un assert porque es la regla de JUnit".
El concepto de fondo que suele faltar
No entender que el test no está validando que el resultado sea correcto ni que el estado del sistema haya cambiado adecuadamente.
Cómo lo respondería alguien senior en una entrevista
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.
Proyecto «Pedidos» · paso 8 de 30
Tests del dominio
Proteger las reglas de negocio con tests rápidos que se lean como una especificación.
De qué va el proyecto
El backend de una tienda online: clientes, catálogo de productos y pedidos con sus líneas, pagos y envíos. Empieza como un proyecto Maven con records y termina como un sistema con API REST, seguridad, mensajería, contenedores y observabilidad. Cada paso construye sobre el anterior, así que trabaja siempre en el mismo repositorio.
Qué hacer
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.
Katas: ejercicios cortos
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.