Testing y calidad: la red de seguridad
En una entrevista, decir «sí, escribo tests» no puntúa. Lo que puntúa es saber qué nivel de test usar para cada cosa, por qué una suite lenta acaba desactivada, cómo se diseña un test que no se rompe en cada refactor, y por qué el 100 % de cobertura puede convivir con un sistema que no verifica nada. Este módulo recorre todo el camino: desde qué es un buen nombre de test hasta cómo montar un pipeline con Testcontainers, cobertura de mutaciones y puertas de calidad. Con Java 21, JUnit 5.11, Spring Boot 3.5, Mockito 5, AssertJ y Testcontainers 1.20.
Plan de los dos días
Objetivo: una suite propia con los cuatro niveles funcionandoFundamentos y herramientas (secciones 1 a 5)
Lee 1 y 2 con calma y aplica lo aprendido renombrando diez tests tuyos. Después JUnit 5 a fondo, AssertJ y Mockito, escribiendo código a la vez que lees. No pases a Spring sin dominar los parametrizados.
TDD y tests de la aplicación Spring (secciones 6 y 7)
Haz el ejercicio de TDD completo del apartado 6.2 sin mirar la solución. Luego monta los slices: @WebMvcTest, @DataJpaTest y un @SpringBootTest. Mide cuántos contextos crea tu suite.
Integración real y datos (secciones 8 y 9)
Testcontainers con PostgreSQL y Kafka, WireMock para el servicio externo, y un juego de builders para los datos. Aquí es donde se gana la fiabilidad de verdad.
Calidad, fiabilidad y CI (secciones 10 a 15)
ArchUnit, JaCoCo con umbral, PIT sobre un paquete pequeño y el pipeline de GitHub Actions. Cierra con las preguntas de entrevista en voz alta y la autoevaluación.
1 · Por qué y qué testear
Antes de aprender anotaciones hay que responder a una pregunta incómoda: ¿por qué dedicas la mitad de tu tiempo a escribir código que el cliente no ejecuta nunca? Si no sabes contestarla, escribirás tests por obligación, serán malos, se romperán constantemente y terminarás pensando que el testing es una pérdida de tiempo. Con razón, además, porque los tests malos sí son una pérdida de tiempo.
1.1 El coste de un fallo según cuándo se detecta
El argumento económico es el más sólido y el que mejor funciona en una entrevista. Un defecto no cuesta lo mismo según la fase en que aparece, y la diferencia no es del 20 %: es de órdenes de magnitud. Las cifras exactas varían según el estudio (el clásico es el de Barry Boehm, y NIST publicó estimaciones parecidas), pero la forma de la curva se repite en todos: exponencial.
| Fase en que se detecta | Coste relativo | Qué hay que hacer para arreglarlo | Quién se entera |
|---|---|---|---|
| Mientras escribes (compilador, IDE, test unitario en rojo) | 1× | Cambiar una línea. El contexto completo está en tu cabeza. | Nadie. |
| Suite local antes del commit | ~2× | Cambiar una línea y volver a ejecutar. Sigues en contexto. | Nadie. |
| CI de la pull request | ~5× | Cambio de contexto, entender el log de CI, nuevo commit, esperar el pipeline. | Tú y quien revisa. |
| Revisión de código | ~5–10× | Discusión, ida y vuelta, posible rediseño del enfoque. | El equipo. |
| QA o preproducción | ~15× | Reproducir, diagnosticar sin el contexto original, arreglar, volver a desplegar, volver a validar. | El equipo y QA. |
| Producción, detectado por métricas | ~50× | Guardia, diagnóstico bajo presión, hotfix, despliegue urgente, postmortem. | Todo el mundo. |
| Producción, detectado por el cliente | ~100× o incalculable | Lo anterior más soporte, corrección de datos corruptos, comunicación y pérdida de confianza. | El cliente, y a veces el regulador. |
Fíjate en cuál es el factor que multiplica: no es la dificultad técnica del arreglo (casi siempre es la misma línea), es la pérdida de contexto y el número de personas involucradas. Un test rápido que falla en tu portátil te devuelve el error mientras todavía recuerdas por qué escribiste esa condición. Ese es el valor real, y explica por qué la velocidad de la suite no es un capricho: una suite de 40 minutos empuja el descubrimiento del fallo dos columnas a la derecha en esa tabla.
1.2 Los cinco beneficios reales (y ninguno es «encontrar bugs»)
Sorprende, pero encontrar defectos nuevos es el beneficio menor de una suite automatizada. Los tests que escribes hoy pasan en verde hoy: no descubren nada. Su valor está en el futuro.
1 · Red de seguridad para refactorizar
Es el beneficio número uno. Refactorizar significa cambiar la estructura sin cambiar el comportamiento; sin una forma automática de comprobar que el comportamiento no ha cambiado, no estás refactorizando, estás reescribiendo y rezando. Los equipos sin tests no es que refactoricen mal: es que no refactorizan, y por eso su código se degrada hasta que alguien propone «reescribirlo todo desde cero».
2 · Documentación que no miente
Un test es la única documentación que falla cuando queda obsoleta. cuando_el_cupon_ha_caducado_no_se_aplica_descuento()
te dice qué hace el sistema mejor que tres párrafos en Confluence escritos hace dos años. Cuando
entres en un proyecto nuevo, los tests son el primer sitio donde mirar para entender las reglas de
negocio.
3 · Presión de diseño
Un código difícil de testear es un código con mal diseño: dependencias ocultas, responsabilidades mezcladas, estado global, constructores que hacen trabajo. El test es el primer cliente de tu API y te da feedback inmediato sobre su ergonomía. Si para probar una regla de negocio necesitas arrancar Spring y una base de datos, la regla está en el sitio equivocado.
4 · Velocidad de entrega y despliegue sin miedo
Las métricas DORA lo miden: los equipos con más frecuencia de despliegue y menor tasa de fallo de cambios son los que tienen pruebas automatizadas fiables. La entrega continua no es una herramienta, es una consecuencia de tener una suite en la que confías lo suficiente como para desplegar un viernes a las cinco.
5 · Reproducir un bug una sola vez
La forma correcta de arreglar un defecto de producción es escribir primero el test que lo reproduce (rojo), arreglarlo (verde) y dejar el test para siempre. Ese test es la garantía de que ese fallo concreto no volverá, y es la razón por la que una suite madura acumula valor con los años en lugar de envejecer.
Y un beneficio secundario: dormir
No es una broma. La diferencia entre un equipo que despliega con calma y uno que despliega con el estómago cerrado casi nunca está en el talento: está en si existe algo que compruebe automáticamente que lo importante sigue funcionando.
1.3 Qué NO merece la pena testear
Esta lista es tan importante como la anterior. Escribir tests sin criterio produce suites enormes, lentas y frágiles que no aportan confianza y sí generan trabajo en cada cambio. Un test tiene coste de escritura y, sobre todo, coste de mantenimiento perpetuo. Si no compra confianza, es pasivo, no activo.
| No testees… | Por qué | Qué hacer en su lugar |
|---|---|---|
Getters, setters, records y POJOs sin lógica |
Estás probando el compilador. Cero probabilidad de fallo, cien por cien de coste de mantenimiento. | Nada. Se prueban implícitamente en los tests que los usan. |
| Código de terceros (Spring, Hibernate, Jackson) | Ya tiene sus tests, hechos por gente que conoce el código. No es tu trabajo. | Prueba tu integración con él: tu consulta, tu mapeo, tu configuración. |
| Métodos privados, directamente | Son detalle de implementación. Testearlos con reflexión congela la estructura interna y bloquea el refactor. | Pruébalos a través del método público que los usa. Si no se puede, es que quieren ser otra clase. |
| Mapeos triviales de DTO a entidad campo a campo | Un test que copia el mapeo tiene el mismo error si el mapeo está mal. Es un espejo, no una comprobación. | Un test con usingRecursiveComparison que detecte campos olvidados; ahí sí hay valor. |
| Configuración estática de Spring, bean a bean | Que un bean exista no dice nada. Y el test es lentísimo en relación al riesgo cubierto. | Un único smoke test de que el contexto arranca, más tests de propiedades condicionales. |
| La interfaz de usuario, en profundidad, con Selenium | Frágil, lento y con mantenimiento desproporcionado. La relación coste/beneficio es terrible. | Dos o tres flujos críticos de extremo a extremo, y todo lo demás bajo la UI. |
| Logs y trazas, salvo casos concretos | No son comportamiento observable del negocio y cambian a menudo. | Excepción legítima: si un log es un requisito de auditoría, entonces sí es comportamiento y se prueba. |
| Un prototipo que vas a tirar en dos semanas | Si de verdad lo vas a tirar, la red de seguridad no compra nada. | Sé honesto: el 80 % de los prototipos «que se van a tirar» acaban en producción. |
calcularSubtotal()
antes que a aplicarIva()» te obliga a modificar el test cada vez que reorganizas el código,
incluso cuando el resultado no cambia. Ese test no protege: bloquea. La pregunta de oro antes de
escribir un test es: «si refactorizo esto sin cambiar lo que hace, ¿este test debería seguir en verde?».
Si la respuesta es no, el test está mal.
1.4 Pirámide, trofeo y panal: cómo repartir el esfuerzo
No hay una única forma correcta de repartir los tests entre niveles: depende de dónde vive el riesgo de tu sistema. Las tres formas clásicas son respuestas a tres tipos de sistema distintos, y saber explicar cuándo aplicar cada una es exactamente lo que separa una respuesta de junior de una de senior en la entrevista.
LA PIRÁMIDE (Mike Cohn, 2009) EL TROFEO (Kent C. Dodds) EL PANAL (Spotify)
╱╲ E2E: 5 ▁▁▁ E2E: pocos ╱╲ integrado
╱ ╲ ╱ ╲ ╱ ╲
╱────╲ integración: 50 ╱─────────╲ INTEGRACIÓN: muchos ╱ ████ ╲ ← el grueso:
╱ ╲ ╱───────────╲ ╲ ████ ╱ tests de
╱────────╲ ╱ unitarios ╲ ╲ ╱ servicio
╱ ╲ UNITARIOS: 500 ╱───────────────╲ ╲╱ unitarios: pocos
╱────────────╲ ╱ estático (TS) ╲
‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
Lógica de negocio rica. Mucho pegamento entre Microservicios pequeños,
Backend con dominio propio. librerías. Frontend. casi sin lógica interna.
| Forma | Idea central | Cuándo es la correcta | Su riesgo |
|---|---|---|---|
| Pirámide | Muchos tests rápidos y baratos abajo, pocos lentos y caros arriba. El coste y la fragilidad crecen con la altura. | Aplicaciones con lógica de negocio propia y rica: banca, seguros, logística, comercio. Es el caso habitual de un backend Java. | Si el sistema es «pegamento» (llamar a tres APIs y guardar), los unitarios prueban mocks y la integración real queda sin cubrir. |
| Trofeo | «Escribe tests. No demasiados. Sobre todo de integración.» El grueso está en el nivel que prueba varias piezas juntas. | Cuando el valor está en la conexión entre componentes y no en algoritmos: frontend, BFF, servicios CRUD, orquestadores. | Suites más lentas y diagnósticos menos precisos: cuando falla, hay que investigar más para saber dónde. |
| Panal | El grueso son tests de servicio (el microservicio completo con sus dependencias simuladas o en contenedor). | Microservicios muy pequeños donde la clase individual apenas tiene lógica y el riesgo está en los bordes. | Se puede convertir en «solo tests de integración», que son lentos y no señalan la causa. |
| El «cono de helado» (anti-patrón) | Muchos tests manuales y de UI arriba, pocos unitarios abajo. Nadie lo elige: se llega por inercia. | Nunca. | Suite de horas, intermitente, que nadie mira. Es el estado natural de un proyecto que no decide su estrategia. |
1.5 Vocabulario preciso: los nombres importan
«Test de integración» significa cosas distintas para cada equipo, y esa ambigüedad genera discusiones
inútiles. Fija el vocabulario en tu proyecto (en un TESTING.md) y úsalo con rigor. Estas son
las definiciones más aceptadas, con lo que implican en un proyecto Java.
| Tipo | Definición operativa | Alcance en Java/Spring | Tiempo objetivo | Qué NO puede tocar |
|---|---|---|---|---|
| Unitario | Prueba una unidad de comportamiento (normalmente una clase y sus colaboradores baratos) de forma aislada y en memoria. | JUnit 5 + AssertJ (+ Mockito si hace falta). Sin contexto de Spring. | < 10 ms cada uno; miles en pocos segundos | Red, disco, base de datos, reloj del sistema, hilos con esperas, otros tests. |
| Integración (estrecha) | Prueba tu código contra una dependencia externa real: la base de datos, la cola, un HTTP simulado a nivel de protocolo. | @DataJpaTest + Testcontainers, WireMock, @JsonTest. |
0,1–2 s cada uno tras el arranque inicial | Otros servicios de tu organización desplegados de verdad. |
| De componente / de servicio | Prueba toda tu aplicación desplegada en el proceso de test, con sus dependencias reales en contenedores y sus dependencias remotas simuladas. | @SpringBootTest(webEnvironment = RANDOM_PORT) + Testcontainers + WireMock. |
0,5–5 s cada uno | Servicios de terceros por internet, entornos compartidos. |
| De contrato | Verifica que dos servicios siguen entendiéndose, sin desplegarlos juntos. Cada lado ejecuta su mitad en su propio pipeline. | Pact, Spring Cloud Contract, validación de esquema Avro/JSON en el registro. | segundos | Nada externo: es la ventaja, precisamente. |
| Extremo a extremo (E2E) | Recorre un flujo de negocio completo a través de varios sistemas realmente desplegados, normalmente en un entorno compartido. | Playwright/Selenium, o llamadas HTTP contra preproducción. | decenas de segundos a minutos | Debe tocarlo todo: eso es lo que lo hace valioso y frágil a la vez. |
| De humo (smoke) | Comprobación mínima tras un despliegue: ¿arranca, responde y las dependencias críticas están conectadas? | curl a /actuator/health y a un endpoint de negocio de solo lectura. |
< 30 s en total | No debe escribir datos ni depender de datos concretos. |
| De regresión | No es un nivel: es un propósito. Cualquier test que exista para que un fallo ya corregido no vuelva. | El nivel más bajo que reproduzca el bug. | El de su nivel | — |
| De aceptación | Expresado en el lenguaje del negocio; define «terminado» para una historia de usuario. | Cucumber/Gherkin, o simplemente tests con @DisplayName en lenguaje de negocio. |
Variable | — |
1.6 El presupuesto de tiempo de la suite
La velocidad de la suite no es una cuestión estética: determina cuántas veces al día la ejecutas, y una suite que no se ejecuta no protege nada. La psicología del desarrollador es implacable con los tiempos de espera.
| Duración | Qué ocurre en la práctica | Consecuencia sobre la calidad |
|---|---|---|
| < 1 s | La ejecutas de forma continua, casi sin darte cuenta. Puedes hacer TDD con ciclos de segundos. | Máxima. El feedback es parte del acto de escribir código. |
| 1–10 s | La ejecutas después de cada cambio pequeño. Sigues en flujo. | Excelente. Este es el objetivo para la suite unitaria completa. |
| 10 s – 2 min | La ejecutas antes de cada commit, no en cada cambio. Empiezas a mirar el móvil. | Buena, si son los tests de integración y los unitarios van por separado. |
| 2–10 min | La ejecutas antes de subir la rama. Cambias de contexto mientras esperas, y volver cuesta. | Aceptable solo en CI. En local necesitas poder ejecutar un subconjunto. |
| 10–30 min | Dejas que CI la ejecute y sigues a otra cosa. Los fallos llegan cuando ya has olvidado el cambio. | Mala. Aparecen los «no la ejecuto, ya lo hará el pipeline» y los merges a ciegas. |
| > 30 min | La gente empieza a saltárselas, a marcar @Disabled y a reintentar los fallos hasta que pasan. |
Nula, y peor que nula: da falsa sensación de seguridad y consume tiempo real de todos. |
Un presupuesto realista para un microservicio Spring Boot de tamaño medio, y que puedes usar como objetivo concreto:
| Etapa | Contenido | Presupuesto | Cuándo se ejecuta |
|---|---|---|---|
| Compilación + unitarios | 800–2.000 tests sin Spring | < 30 s | En cada guardado o en cada commit, en local y en CI |
| Slices de Spring | 30–80 tests, 2 o 3 contextos cacheados | < 60 s | En cada commit |
| Integración con contenedores | 30–100 tests, contenedores reutilizados | 2–5 min | En cada pull request |
| Contrato | Verificación de pacts | < 1 min | En cada pull request |
| Mutación (PIT) | Solo paquetes de dominio | 5–15 min | Nocturna o semanal |
| E2E y carga | 3–8 flujos, escenario de carga base | 10–30 min | Nocturna, y antes de una release |
Thread.sleep y
los que crean un contenedor por clase. En la sección 14 verás cómo encontrarlos y arreglarlos, y en la 7
por qué la caché de contextos de Spring es el factor dominante en la mayoría de las suites lentas.
2 · Anatomía de un buen test
Un test es código que otra persona (tú, en seis meses, a las once de la noche, con un incidente abierto) va a leer en el peor momento posible. Su primera obligación no es ser inteligente: es ser obvio. Esta sección son las reglas que hacen que un test se pueda leer en cinco segundos y se pueda diagnosticar en treinta.
2.1 El nombre es la mitad del test
Cuando un test falla en CI, lo primero (y a veces lo único) que ves es su nombre. Si el nombre es
test1() o testCalcular(), tienes que abrir el código para saber qué se ha roto.
Si el nombre es lanzaExcepcionSiElCuponHaCaducado(), ya sabes qué comportamiento está
afectado antes de leer una sola línea. Un buen nombre responde a tres preguntas: qué se
prueba, bajo qué condición y qué se espera.
| Convención | Forma | Ejemplo | Valoración |
|---|---|---|---|
metodo_condicion_resultado |
Tres bloques separados por guiones bajos | aplicarCupon_cuponCaducado_lanzaExcepcion() |
Muy usada y clara. Su pega: incluye el nombre del método, así que un rename obliga a tocar los tests. |
should…When… |
Estilo inglés BDD | shouldRejectOrderWhenStockIsInsufficient() |
Legible, pero el «should» sobra en todos y los nombres se hacen largos. En un equipo español, mezclar idiomas suele salir mal. |
Frase en español con _ |
Comportamiento descrito como afirmación | un_pedido_sin_lineas_no_se_puede_confirmar() |
La mejor opción si el dominio se habla en español. Se lee de corrido y describe el negocio, no el método. |
@DisplayName + método corto |
Nombre técnico + descripción libre con espacios, tildes y símbolos | @DisplayName("un cupón caducado no descuenta nada") |
Excelente para el informe y para @Nested. Cuidado: si el @DisplayName y el nombre del método divergen, confunde más que ayuda. |
| Gherkin en el nombre | dado…cuando…entonces… |
dadoStockCero_cuandoSePide_entoncesSeRechaza() |
Muy explícito, pero verboso. Útil en tests de aceptación, excesivo en unitarios. |
// ❌ Nombres que no dicen nada: cuando fallan en CI hay que abrir el código
class CalculadoraDescuentoTest {
@Test void test1() { … }
@Test void testCalcular() { … }
@Test void testCalcularKo() { … }
@Test void happyPath() { … }
@Test void edgeCase2() { … } // ¿cuál era el caso límite 1?
}
// ✅ Nombres que describen comportamiento: el informe de CI ya es documentación
class CalculadoraDescuentoTest {
@Test
void sin_cupon_el_precio_no_cambia() { … }
@Test
void un_cupon_del_10_por_ciento_descuenta_sobre_el_subtotal_sin_iva() { … }
@Test
void un_cupon_caducado_no_aplica_descuento_y_no_lanza_excepcion() { … }
@Test
void los_descuentos_no_se_acumulan_por_encima_del_50_por_ciento() { … }
@Test
void el_importe_final_nunca_es_negativo_aunque_el_descuento_supere_el_total() { … }
}
2.2 Arrange-Act-Assert (y Given-When-Then)
Todo test tiene tres partes: preparar el escenario, ejecutar la acción y comprobar el resultado. Que esas tres partes sean visualmente evidentes permite leer el test en diagonal. Es la misma estructura con dos vocabularios: Arrange-Act-Assert viene del mundo de los tests unitarios; Given-When-Then del BDD y se usa cuando el test describe un escenario de negocio.
@Test
void un_cupon_caducado_no_aplica_descuento() {
// ARRANGE (given): construimos el mundo. Solo lo relevante, y nada más.
var reloj = Clock.fixed(Instant.parse("2026-03-15T10:00:00Z"), ZoneOffset.UTC);
var cupon = new Cupon("VERANO10", Porcentaje.de(10), LocalDate.parse("2026-03-01"));
var carrito = CarritoMother.conLinea("SKU-1", 2, euros("50.00")); // subtotal 100,00
var calculadora = new CalculadoraDescuento(reloj);
// ACT (when): UNA sola acción, la que da nombre al test.
var resultado = calculadora.aplicar(carrito, cupon);
// ASSERT (then): comprobamos el resultado observable, no cómo se ha llegado a él.
assertThat(resultado.descuento()).isEqualTo(euros("0.00"));
assertThat(resultado.total()).isEqualTo(euros("100.00"));
assertThat(resultado.motivoRechazo()).contains(MotivoRechazo.CUPON_CADUCADO);
}
| Síntoma en la estructura | Qué significa | Arreglo |
|---|---|---|
| El bloque arrange ocupa 40 líneas | La clase bajo prueba necesita demasiado para existir: mal diseño o falta de builders. | Extrae un Object Mother (sección 9) y considera simplificar el constructor. |
| Hay dos bloques act | Estás probando dos comportamientos: si falla el primero, nunca sabrás del segundo. | Divide en dos tests. Excepción: secuencias donde la segunda acción es el escenario (crear y luego cancelar). |
| Hay aserciones intercaladas con acciones | El test se ha convertido en un guion. Es difícil saber qué falló y por qué. | Reordena: preparar, actuar, comprobar. Si de verdad es un flujo, hazlo explícito con comentarios de fase. |
No hay ninguna aserción, solo verify |
Estás comprobando interacciones, no resultados. A veces es correcto (efectos de salida), a menudo no. | Pregúntate qué cambio observable produce la acción y aserta sobre eso. |
| El assert repite el cálculo del código de producción | Test espejo: si la fórmula está mal, el test también. No comprueba nada. | Escribe el valor esperado literal, calculado a mano o a partir de la especificación. |
assertThat(pedido.total()).isEqualTo(subtotal.multiply(new BigDecimal("1.21"))). Lo correcto
es assertThat(pedido.total()).isEqualTo(euros("121.00")), con el número escrito a mano. Un
valor literal es una segunda fuente de verdad; una fórmula copiada es la misma fuente dos veces.
2.3 Un solo motivo de fallo
«Una aserción por test» es una regla que se repite mucho y que, tomada al pie de la letra, produce tests ridículos. La formulación correcta es: un test debe fallar por un solo motivo. Puedes tener cinco aserciones si todas describen facetas del mismo comportamiento, siempre que al fallar veas todas las que fallan y no solo la primera.
// ❌ Un test, cinco comportamientos: cuando falla el primero, los otros cuatro
// quedan sin ejecutar. Y el nombre no puede describir lo que hace.
@Test
void testPedido() {
var pedido = new Pedido("P-1", cliente);
assertThat(pedido.estado()).isEqualTo(Estado.NUEVO);
pedido.agregar(linea);
assertThat(pedido.lineas()).hasSize(1);
pedido.confirmar();
assertThat(pedido.estado()).isEqualTo(Estado.CONFIRMADO);
pedido.cancelar();
assertThat(pedido.estado()).isEqualTo(Estado.CANCELADO);
assertThatThrownBy(pedido::confirmar).isInstanceOf(IllegalStateException.class);
}
// ✅ Varias aserciones sobre EL MISMO resultado, y todas se evalúan gracias a assertAll.
@Test
void al_confirmar_se_fija_la_fecha_el_estado_y_el_numero() {
var pedido = PedidoMother.conUnaLinea();
pedido.confirmar();
assertAll("pedido confirmado",
() -> assertThat(pedido.estado()).isEqualTo(Estado.CONFIRMADO),
() -> assertThat(pedido.confirmadoEn()).isEqualTo(AHORA),
() -> assertThat(pedido.numero()).matches("PED-\\d{8}-\\d{4}"),
() -> assertThat(pedido.eventos()).containsExactly(new PedidoConfirmado("P-1", AHORA)));
}
// ✅ O, mejor todavía en AssertJ, una sola aserción sobre el objeto completo:
@Test
void al_confirmar_el_pedido_queda_en_estado_confirmado_con_su_fecha() {
var pedido = PedidoMother.conUnaLinea();
pedido.confirmar();
assertThat(pedido)
.extracting(Pedido::estado, Pedido::confirmadoEn)
.containsExactly(Estado.CONFIRMADO, AHORA);
}
La diferencia práctica entre encadenar aserciones y usar assertAll (o las
aserciones blandas de AssertJ, sección 4) es el diagnóstico: con aserciones
encadenadas, arreglas un fallo, vuelves a ejecutar y descubres el siguiente; con
assertAll ves los tres fallos a la vez y entiendes el problema de una sola pasada. Cuando
el bucle de ejecución dura tres minutos en CI, esa diferencia son horas.
2.4 Independencia, orden y determinismo
Tres propiedades que se dan por supuestas hasta el día que fallan, y ese día te cuestan una tarde entera de investigación.
| Propiedad | Qué significa | Cómo se rompe | Cómo se garantiza |
|---|---|---|---|
| Independencia | Cada test puede ejecutarse solo, y su resultado no cambia. | Campos static mutables, datos que un test deja en la base, ficheros temporales, propiedades del sistema, caché de Spring con estado. |
Estado en campos de instancia (JUnit crea una instancia nueva por test), limpieza en @AfterEach, transacción con rollback, @TempDir. |
| Orden irrelevante | Ejecutados en cualquier orden, todos pasan. | Un test crea el usuario que el siguiente busca. Clásico y letal. | Cada test crea lo que necesita. Verifícalo con junit.jupiter.testmethod.order.default = org.junit.jupiter.api.MethodOrderer$Random. |
| Determinismo | Mismo código, mismo resultado, siempre. | Instant.now(), Math.random(), UUID.randomUUID() en aserciones, HashSet iterado, zona horaria, locale, red, concurrencia. |
Clock inyectado, semilla fija, comparaciones sin orden (containsExactlyInAnyOrder), zona y locale explícitos, Awaitility en vez de sleep. |
// ❌ Dependencia oculta entre tests a través de un campo estático.
// Ejecuta solo el segundo test y falla. Ejecuta en orden aleatorio y falla a veces.
class CatalogoTest {
static Catalogo catalogo = new Catalogo(); // ← compartido por TODOS los tests
@Test void a_se_puede_anadir_un_producto() {
catalogo.anadir(producto("SKU-1"));
assertThat(catalogo.tamano()).isEqualTo(1);
}
@Test void b_se_puede_buscar_por_sku() {
assertThat(catalogo.buscar("SKU-1")).isPresent(); // depende de que "a" haya corrido antes
}
}
// ✅ Estado por test. JUnit 5 crea una instancia nueva de la clase para cada @Test,
// así que un campo de instancia es automáticamente fresco.
class CatalogoTest {
private final Catalogo catalogo = new Catalogo();
@Test void se_puede_anadir_un_producto() {
catalogo.anadir(producto("SKU-1"));
assertThat(catalogo.tamano()).isEqualTo(1);
}
@Test void se_puede_buscar_un_producto_por_su_sku() {
catalogo.anadir(producto("SKU-1")); // este test se basta a sí mismo
assertThat(catalogo.buscar("SKU-1")).isPresent();
}
}
src/test/resources/junit-platform.properties
la línea junit.jupiter.testmethod.order.default=org.junit.jupiter.api.MethodOrderer$Random y
ejecuta la suite tres veces. Si el resultado varía, tienes acoplamiento entre tests. Es un experimento de
dos minutos que suele destapar problemas escondidos durante años. Cuando lo arregles, déjalo puesto:
convierte una clase de bug intermitente en un fallo reproducible.
2.5 Legible frente a DRY: en tests gana la legibilidad
En código de producción, eliminar duplicación es casi siempre correcto. En código de test, no. La razón es que las dos clases de código optimizan cosas distintas: el de producción optimiza para el cambio, el de test optimiza para el diagnóstico. Un test que delega su preparación en cuatro métodos de utilidad y dos jerarquías de herencia es imposible de leer sin abrir seis ficheros, y cuando falla no sabes qué estaba en juego. La duplicación en tests se tolera mucho mejor que la indirección.
// ❌ «DRY» llevado al extremo: para entender el test hay que reconstruir el estado
// mentalmente a partir de tres niveles de setUp. Nadie sabe qué es relevante.
abstract class BaseTest {
protected Pedido pedido;
protected Cliente cliente;
@BeforeEach void setUpBase() { cliente = crearClienteVip(); }
}
class PedidoServiceTest extends BaseTest {
@BeforeEach void setUp() { pedido = crearPedidoConTresLineasYCuponYEnvioExpres(cliente); }
@Test void el_total_incluye_el_descuento_vip() {
assertThat(servicio.total(pedido)).isEqualTo(euros("242.00"));
// ¿De dónde sale 242,00? Imposible saberlo sin leer dos métodos y una clase padre.
}
}
// ✅ Explícito en el propio test: lo relevante se ve, y lo irrelevante lo pone el builder.
class PedidoServiceTest {
@Test
void un_cliente_vip_obtiene_un_5_por_ciento_adicional() {
var cliente = ClienteMother.vip(); // lo relevante: es VIP
var pedido = PedidoMother.builder()
.cliente(cliente)
.conLinea("SKU-1", 2, euros("100.00")) // lo relevante: subtotal 200
.build(); // el resto, por defecto
var total = servicio.total(pedido);
assertThat(total).isEqualTo(euros("229.90")); // 200 − 5% VIP = 190 + 21% IVA
}
}
| Mecanismo de reutilización | Veredicto | Por qué |
|---|---|---|
| Builders y Object Mothers con valores por defecto | ✅ Recomendado | Ocultan lo irrelevante y dejan visible lo relevante. El test sigue siendo autoexplicativo. |
Aserciones personalizadas de dominio (assertThatPedido(...)) |
✅ Recomendado | Suben el nivel de abstracción y mejoran los mensajes de fallo. |
@BeforeEach con lo verdaderamente común (crear la clase bajo prueba) |
✅ Correcto | Poco y evidente. La regla: si un lector tiene que subir a mirarlo para entender el test, es demasiado. |
| Métodos de utilidad privados en la propia clase de test | 🟡 Con moderación | Bien si están al lado y tienen nombre claro. Mal si esconden aserciones o decisiones. |
| Herencia de clases base de test | ❌ Evítala | El estado llega de un sitio invisible. Usa composición, extensiones de JUnit o interfaces con @Nested. |
| Fixtures compartidos y mutables (un dataset global) | ❌ Evítalos | Acoplan todos los tests: cambiar un dato para arreglar uno rompe siete. Ver sección 9.5. |
| Bucles y condicionales dentro del test | ❌ Casi nunca | Un if en un test significa que son dos tests. Un bucle significa que quieres @ParameterizedTest. |
2.6 El test como especificación
Cuando los nombres describen comportamientos y los tests están agrupados por escenario, el informe de la suite se convierte en la especificación viva del sistema. Esto no es filosofía: es una salida real que puedes enseñar a un analista de negocio.
@DisplayName("Cálculo del importe de un pedido")
class ImportePedidoTest {
@Nested
@DisplayName("cuando el cliente no tiene ninguna promoción")
class SinPromocion {
@Test @DisplayName("el total es la suma de las líneas más el IVA")
void suma_lineas_mas_iva() { … }
@Test @DisplayName("el envío es gratis a partir de 50 €")
void envio_gratis_desde_50() { … }
@Test @DisplayName("por debajo de 50 € se cobran 4,95 € de envío")
void envio_cobrado_por_debajo_de_50() { … }
}
@Nested
@DisplayName("cuando el cliente es VIP")
class ClienteVip {
@Test @DisplayName("se aplica un 5 % adicional sobre el subtotal")
void cinco_por_ciento_adicional() { … }
@Test @DisplayName("el envío es siempre gratis, aunque el pedido sea de 1 €")
void envio_siempre_gratis() { … }
}
}
Cálculo del importe de un pedido
├─ cuando el cliente no tiene ninguna promoción
│ ├─ ✔ el total es la suma de las líneas más el IVA
│ ├─ ✔ el envío es gratis a partir de 50 €
│ └─ ✔ por debajo de 50 € se cobran 4,95 € de envío
└─ cuando el cliente es VIP
├─ ✔ se aplica un 5 % adicional sobre el subtotal
└─ ✔ el envío es siempre gratis, aunque el pedido sea de 1 €
Ese árbol es lo que ves en IntelliJ y en el informe HTML de Gradle. Leerlo contesta preguntas de negocio sin abrir el código, y las ausencias saltan a la vista: si en «cliente VIP» no hay ningún test sobre acumulación con cupones, es probable que ese caso no esté ni implementado ni decidido. Usar los tests para encontrar los huecos de la especificación es una de las técnicas más rentables que existen.
2.7 Qué hacer con los tests heredados ilegibles
Llegas a un proyecto con 1.400 tests, la mitad se llaman test17(), hay una clase base de
600 líneas y tres tests están @Disabled desde 2021. No los reescribas todos: no vas a
terminar y vas a romper cosas. Aplica una estrategia de mejora oportunista con reglas claras.
| Situación | Qué hacer | Por qué |
|---|---|---|
| Test con nombre malo pero que pasa y cubre algo real | Renómbralo cuando toques ese área. Un rename es seguro y gratuito. | Mejora incremental sin riesgo. La «regla del campista»: deja el sitio algo mejor que como lo encontraste. |
| Test que no aserta nada (solo ejecuta código) | Añade la aserción que falta o bórralo. No lo dejes. | Da cobertura falsa y confianza falsa, que es peor que no tener test. Ver sección 12.5. |
Test @Disabled sin fecha ni motivo |
Bórralo. Si alguien lo quiere, está en el historial. | Un test desactivado no protege y da la falsa impresión de cobertura. El código muerto en tests también es código muerto. |
| Test intermitente | Cuarentena con etiqueta, dueño y fecha límite; arréglalo o bórralo. Nunca reintentos como solución permanente. | Un test que falla al azar entrena al equipo a ignorar los rojos. Es el daño cultural más grave. Ver sección 14. |
| Clase base gigante de la que heredan 80 tests | No la toques de golpe. Extrae extensiones de JUnit 5 y ve migrando clase a clase. | Cambiar la base rompe 80 tests a la vez y nadie sabrá si el fallo es real. |
| Tests que tardan 40 s cada uno arrancando Spring entero | Mide primero (sección 14.6). Suele bastar con unificar la configuración para que la caché de contextos funcione. | Es la optimización con mejor relación esfuerzo/resultado en una suite Spring. A veces bajas de 20 min a 4. |
| Área crítica sin ningún test | Tests de caracterización (sección 15.5): escribe tests que documenten lo que hace ahora, aunque esté mal. | Necesitas la red antes de tocar nada. Primero congelas el comportamiento, luego lo cambias a propósito. |
3 · JUnit 5 de principio a fin
JUnit 5 (Jupiter) no es «JUnit 4 con nombres nuevos»: es una reescritura completa con un modelo de extensión distinto, tests parametrizados de primera clase, ejecución paralela y una separación limpia entre el motor y la plataforma. Conocerlo a fondo se nota en la calidad de tus tests y, en la entrevista, en que puedes responder «cómo harías X» sin dudar.
3.1 Arquitectura: Platform, Jupiter y Vintage
Lo primero que hay que entender es que «JUnit 5» son tres subproyectos distintos, y esa separación es la razón de que Maven, Gradle, IntelliJ, Cucumber, Spock, Testcontainers o ArchUnit puedan convivir en la misma ejecución.
| Subproyecto | Qué es | Artefacto | Quién lo usa |
|---|---|---|---|
| JUnit Platform | El cimiento: define el TestEngine SPI, descubre y filtra tests, y ofrece el
Launcher. No sabe nada de @Test. |
junit-platform-launcher, junit-platform-engine |
Los IDEs, Surefire, Failsafe, Gradle y la consola. Tú casi nunca directamente. |
| JUnit Jupiter | El modelo de programación nuevo: @Test, @Nested,
@ParameterizedTest, extensiones… más su motor de ejecución. |
junit-jupiter (agrega API, params y engine) |
Tú, en el 99 % de los casos. |
| JUnit Vintage | Un motor que ejecuta tests de JUnit 3 y 4 dentro de la Platform, para migrar sin big bang. | junit-vintage-engine |
Proyectos en migración. Quítalo cuando ya no queden tests antiguos. |
┌──────────────────────────────────────────────┐
IDE, Maven │ JUnit Platform │
Gradle, ───►│ Launcher · descubrimiento · filtros · report │
consola └───────┬───────────────┬──────────────┬────────┘
│ │ │ (TestEngine SPI)
┌──────▼─────┐ ┌──────▼──────┐ ┌────▼─────────────┐
│ Jupiter │ │ Vintage │ │ Spock, Cucumber, │
│ Engine │ │ Engine │ │ jqwik, ArchUnit… │
└──────┬─────┘ └──────┬──────┘ └──────────────────┘
│ │
@Test, @Nested… @Test de JUnit 4
(jupiter-api) (junit:junit 4.13.2)
mvn test, el 90 % de las veces el problema es que falta el engine adecuado en
el classpath, o que Surefire es demasiado antiguo para hablar con la Platform, o que el nombre
de la clase no encaja con el patrón de inclusión (*Test, Test*,
*Tests, *TestCase). Saber que hay una capa de descubrimiento por debajo
convierte una tarde perdida en una comprobación de dos minutos.
3.2 Dependencias: Maven y Gradle
Si usas Spring Boot, no declares versiones: spring-boot-starter-test ya trae JUnit 5,
AssertJ, Mockito, Hamcrest, JSONassert, JsonPath, XMLUnit y las utilidades de test de Spring, todas con
versiones compatibles gestionadas por el BOM.
<!-- Spring Boot 3.5.x: una sola dependencia y lo tienes todo -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<!-- Opcional pero recomendable: si usas solo AssertJ, fuera Hamcrest y sus imports ambiguos -->
<exclusion>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Testcontainers: gestión de versiones con su propio BOM -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.20.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<!-- Proyecto sin Spring Boot: JUnit 5 «a pelo» con su BOM -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>5.11.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId> <!-- api + params + engine -->
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.14.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build><plugins>
<!-- Surefire ejecuta *Test (unitarios). Failsafe ejecuta *IT (integración) en verify. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
<configuration>
<excludedGroups>slow,manual</excludedGroups> <!-- @Tag que no van en la suite rápida -->
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.5.2</version>
<executions>
<execution><goals><goal>integration-test</goal><goal>verify</goal></goals></execution>
</executions>
</plugin>
</plugins></build>
// build.gradle.kts — equivalente en Gradle
plugins {
java
id("org.springframework.boot") version "3.5.0"
id("io.spring.dependency-management") version "1.1.7"
}
java {
toolchain { languageVersion = JavaLanguageVersion.of(21) }
}
dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:postgresql")
testImplementation(platform("org.testcontainers:testcontainers-bom:1.20.4"))
}
tasks.test {
useJUnitPlatform {
excludeTags("slow", "manual") // la suite rápida del día a día
}
maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
testLogging {
events("failed", "skipped")
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
// Tarea separada para la integración: se ejecuta solo cuando se pide
val integrationTest by tasks.registering(Test::class) {
useJUnitPlatform { includeTags("integration") }
shouldRunAfter(tasks.test)
systemProperty("testcontainers.reuse.enable", "true")
}
3.3 El ciclo de vida y @TestInstance
Por defecto, JUnit 5 crea una instancia nueva de la clase de test para cada método
(Lifecycle.PER_METHOD). Esta decisión de diseño es lo que hace que los campos de instancia
sean automáticamente independientes entre tests, y es la razón por la que @BeforeAll y
@AfterAll tienen que ser static: se ejecutan cuando aún no hay instancia.
class CicloDeVidaTest {
@BeforeAll // static: una vez por clase, antes de todo
static void arrancarRecursoCaro() {
System.out.println("1 · @BeforeAll — arranca el servidor de prueba");
}
@BeforeEach // antes de CADA test, con instancia nueva
void prepararEscenario() {
System.out.println(" 2 · @BeforeEach — datos limpios");
}
@Test void primero() { System.out.println(" 3 · @Test primero"); }
@Test void segundo() { System.out.println(" 3 · @Test segundo"); }
@AfterEach
void limpiar() {
System.out.println(" 4 · @AfterEach — se ejecuta AUNQUE el test falle");
}
@AfterAll
static void pararRecursoCaro() {
System.out.println("5 · @AfterAll — libera el servidor");
}
}
/* Salida:
1 · @BeforeAll
2 · @BeforeEach ← instancia nueva de CicloDeVidaTest
3 · @Test primero
4 · @AfterEach
2 · @BeforeEach ← OTRA instancia nueva
3 · @Test segundo
4 · @AfterEach
5 · @AfterAll */
| Anotación | Cuándo se ejecuta | ¿Estático? | Uso típico | Trampa |
|---|---|---|---|---|
@BeforeAll |
Una vez, antes del primer test de la clase | Sí, salvo con PER_CLASS |
Arrancar un contenedor, cargar un fichero grande, abrir un pool | Si guardas estado mutable ahí, los tests dejan de ser independientes. |
@BeforeEach |
Antes de cada @Test, tras crear la instancia |
No | Instanciar la clase bajo prueba, limpiar tablas | Si crece más de 10 líneas, extrae builders: se vuelve invisible para el lector. |
@AfterEach |
Después de cada test, incluso si falla o lanza | No | Cerrar recursos, borrar datos, restaurar propiedades del sistema | Si el @AfterEach falla, el test se marca como fallido aunque el cuerpo pasara. |
@AfterAll |
Una vez, tras el último test | Sí, salvo con PER_CLASS |
Parar lo que arrancó @BeforeAll |
Con Testcontainers y campos static, no hace falta: Ryuk limpia al terminar la JVM. |
@TestInstance(PER_CLASS) |
Cambia el ciclo: una sola instancia para toda la clase | Permite @BeforeAll no estático |
Escenarios caros, @Nested con estado, tests dinámicos con fixtures |
Pierdes el aislamiento automático: el estado se arrastra entre tests. Úsalo a conciencia. |
// PER_CLASS: útil cuando la preparación es carísima y de solo lectura.
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ParsearCatalogoGrandeTest {
private List<Producto> catalogo; // ya no hace falta que sea static
@BeforeAll // ya no hace falta que sea static
void cargarUnaSolaVez() throws Exception {
catalogo = new CatalogoCsvParser().parse(Path.of("src/test/resources/catalogo-500k.csv"));
}
@Test void tiene_500000_productos() { assertThat(catalogo).hasSize(500_000); }
@Test void ningun_precio_es_negativo() { assertThat(catalogo).allMatch(p -> p.precio().signum() >= 0); }
@Test void los_sku_son_unicos() { assertThat(catalogo).extracting(Producto::sku).doesNotHaveDuplicates(); }
}
PER_CLASS: úsalo solo si el estado compartido es
inmutable. En cuanto un test modifique el fixture, has creado dependencia de
orden y un bug intermitente que aparecerá el día que se ejecute en paralelo. Si necesitas
mutar, vuelve a PER_METHOD y paga el coste, o usa una copia por test.
3.4 @DisplayName y generadores de nombres
@DisplayName("Servicio de pedidos ▸ confirmación")
class ConfirmarPedidoTest {
@Test
@DisplayName("✅ un pedido con líneas y stock suficiente pasa a CONFIRMADO")
void confirma_correctamente() { … }
@Test
@DisplayName("❌ sin stock se rechaza con 409 y no se cobra")
void rechaza_sin_stock() { … }
@ParameterizedTest(name = "importe {0} € → recargo {1} €")
@CsvSource({"10.00, 4.95", "49.99, 4.95", "50.00, 0.00", "120.00, 0.00"})
@DisplayName("el recargo de envío depende del importe")
void recargo_por_importe(BigDecimal importe, BigDecimal recargoEsperado) { … }
}
Si te da pereza escribir un @DisplayName por test, hay generadores automáticos que
convierten el nombre del método en una frase legible. Se activan globalmente y son la opción más rentable
para un proyecto entero.
# src/test/resources/junit-platform.properties
# Convierte "un_pedido_sin_lineas_no_se_puede_confirmar" en
# "un pedido sin lineas no se puede confirmar"
junit.jupiter.displayname.generator.default = \
org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores
# Otras opciones:
# $Standard → nombre del método tal cual, con paréntesis
# $Simple → nombre del método sin paréntesis
# $IndicativeSentences → concatena el nombre de la clase (y de las @Nested) con el del método,
# produciendo frases del tipo:
# "ConfirmarPedidoTest, cuando no hay stock, se rechaza con 409"
// Generador propio: útil si tienes una convención de equipo concreta.
public class NombresEnCastellano extends DisplayNameGenerator.ReplaceUnderscores {
@Override
public String generateDisplayNameForClass(Class<?> testClass) {
return super.generateDisplayNameForClass(testClass)
.replaceAll("Test$", "")
.replaceAll("([a-z])([A-Z])", "$1 $2"); // "ConfirmarPedido" → "Confirmar Pedido"
}
@Override
public String generateDisplayNameForMethod(Class<?> testClass, Method testMethod) {
String base = super.generateDisplayNameForMethod(testClass, testMethod);
return base.substring(0, 1).toUpperCase() + base.substring(1);
}
}
// Uso puntual en una clase concreta:
@DisplayNameGeneration(NombresEnCastellano.class)
class ConfirmarPedidoTest { … }
3.5 @Nested: agrupar por escenario
Las clases internas anotadas con @Nested heredan el @BeforeEach de la clase
externa y pueden añadir el suyo. Sirven para lo que en otros lenguajes son los describe
anidados: compartir el «dado que…» de un grupo de tests sin repetirlo y sin herencia.
@DisplayName("Carrito de la compra")
class CarritoTest {
private Carrito carrito; // el @BeforeEach externo se ejecuta también
// para los tests de las clases anidadas
@BeforeEach
void nuevoCarrito() {
carrito = new Carrito(ClienteMother.estandar(), RELOJ_FIJO);
}
@Nested
@DisplayName("cuando está vacío")
class Vacio {
@Test void el_total_es_cero() { assertThat(carrito.total()).isEqualTo(euros("0.00")); }
@Test void no_se_puede_confirmar() { assertThatThrownBy(carrito::confirmar)
.isInstanceOf(CarritoVacio.class); }
@Test void no_admite_cupones() { assertThat(carrito.aplicar(CUPON_10).aplicado()).isFalse(); }
}
@Nested
@DisplayName("cuando tiene tres líneas")
class ConTresLineas {
@BeforeEach // se ejecuta DESPUÉS del @BeforeEach externo
void llenar() {
carrito.anadir("SKU-1", 1, euros("30.00"));
carrito.anadir("SKU-2", 2, euros("10.00"));
carrito.anadir("SKU-3", 1, euros("50.00"));
}
@Test void el_total_suma_las_lineas() { assertThat(carrito.subtotal()).isEqualTo(euros("100.00")); }
@Test void el_envio_es_gratis() { assertThat(carrito.envio()).isEqualTo(euros("0.00")); }
@Nested
@DisplayName("y se elimina una línea")
class TrasEliminarUna {
@BeforeEach void eliminar() { carrito.eliminar("SKU-3"); }
@Test void el_subtotal_baja() { assertThat(carrito.subtotal()).isEqualTo(euros("50.00")); }
@Test void quedan_dos_lineas() { assertThat(carrito.lineas()).hasSize(2); }
}
}
}
Aspecto de @Nested | Comportamiento |
|---|---|
| Orden de los callbacks | @BeforeEach de fuera hacia dentro; @AfterEach de dentro hacia fuera. Igual que los constructores. |
@BeforeAll en una @Nested | No se permite con PER_METHOD (la clase interna no es estática). Con @TestInstance(PER_CLASS) sí. |
| Acceso al estado externo | Total: la instancia interna tiene referencia a la externa. De ahí que el campo carrito funcione. |
| Profundidad recomendada | Dos niveles. Con tres ya cuesta seguir qué preparación está activa; considera dividir la clase. |
| Extensiones | Las @ExtendWith de la clase externa se heredan en las anidadas. Muy útil con MockitoExtension. |
3.6 @Disabled, @Tag y selección de suites
// @Disabled SIEMPRE con motivo, dueño y fecha. Sin eso, es basura que engaña.
@Test
@Disabled("Bloqueado por PROY-4821: la pasarela de sandbox devuelve 502. Revisar antes del 2026-09-30 · @ana")
void cobra_con_tarjeta_3ds() { … }
// @Tag para separar la suite rápida de la lenta. Un solo tag por dimensión, y documentados.
@Tag("integration")
@Tag("slow")
class PedidoFlujoCompletoIT { … }
// Anotación compuesta: mejor que repetir tres anotaciones en 40 clases.
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Tag("integration")
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public @interface TestDeIntegracion { }
@TestDeIntegracion // una sola anotación en cada IT
class ConfirmarPedidoIT { … }
Las condiciones integradas evitan tener que escribir @Disabled a mano cuando lo que quieres
es que el test se salte en función del entorno:
@Test
@EnabledOnOs(OS.LINUX) // rutas de fichero dependientes del SO
void usa_rutas_posix() { … }
@Test
@EnabledOnJre(JRE.JAVA_21)
void aprovecha_los_hilos_virtuales() { … }
@Test
@EnabledForJreRange(min = JRE.JAVA_21)
void requiere_al_menos_java_21() { … }
@Test
@EnabledIfEnvironmentVariable(named = "CI", matches = "true")
void solo_en_ci_porque_necesita_docker_con_mucha_memoria() { … }
@Test
@DisabledIfEnvironmentVariable(named = "CI", matches = "true")
void abre_un_navegador_y_solo_tiene_sentido_en_local() { … }
@Test
@EnabledIfSystemProperty(named = "run.manual.tests", matches = "true")
void llama_al_sandbox_real_del_proveedor() { … }
@Test
@DisabledIf("dockerNoDisponible") // método booleano de la propia clase
void necesita_docker() { … }
static boolean dockerNoDisponible() {
return !org.testcontainers.DockerClientFactory.instance().isDockerAvailable();
}
# Selección por etiquetas desde la línea de órdenes
mvn test -Dgroups="unit" # solo los etiquetados unit
mvn test -DexcludedGroups="slow,integration" # la suite rápida
mvn verify -Dgroups="integration" # solo integración
gradle test --tests '*PedidoTest' # por patrón de nombre
gradle test -PincludeTags=integration # si lo has cableado en build.gradle.kts
# Expresiones de etiquetas: soportan and, or, not y paréntesis
mvn test -Dgroups="integration & !slow"
mvn test -Dgroups="(unit | contract) & !manual"
*Test lo que corre en cada commit y *IT
lo que corre en la pull request. Con Maven eso ya separa Surefire de Failsafe sin ninguna
etiqueta. Usa @Tag solo para dimensiones adicionales («slow», «manual», «flaky-quarantine»,
«security»). Un proyecto con quince etiquetas es un proyecto donde nadie sabe qué se ejecuta.
3.7 Las aserciones de JUnit: assertAll, assertThrows, assertTimeout
Aunque para el día a día usarás AssertJ (sección 4), hay tres aserciones de JUnit que no tienen equivalente directo y conviene dominar.
// 1 · assertAll: agrupa aserciones y las evalúa TODAS, reportando todos los fallos juntos.
@Test
void la_direccion_se_normaliza_completa() {
var dir = Direccion.parse(" c/ MAYOR 3, 4ºB — 28013 madrid ");
assertAll("dirección normalizada",
() -> assertEquals("Calle Mayor 3, 4ºB", dir.via()),
() -> assertEquals("28013", dir.codigoPostal()),
() -> assertEquals("Madrid", dir.municipio()),
() -> assertEquals("ES", dir.pais()));
}
/* Si fallan dos, el informe muestra los dos:
org.opentest4j.MultipleFailuresError: dirección normalizada (2 failures)
expected: "Calle Mayor 3, 4ºB" but was: "c/ MAYOR 3, 4ºB"
expected: "Madrid" but was: "madrid" */
// assertAll se puede anidar para agrupar por área
@Test
void la_respuesta_tiene_cabeceras_y_cuerpo_correctos() {
var respuesta = cliente.crearPedido(peticionValida());
assertAll(
() -> assertAll("estado y cabeceras",
() -> assertEquals(201, respuesta.status()),
() -> assertTrue(respuesta.headers().firstValue("Location").isPresent())),
() -> assertAll("cuerpo",
() -> assertNotNull(respuesta.body().id()),
() -> assertEquals("NUEVO", respuesta.body().estado())));
}
// 2 · assertThrows: devuelve la excepción para poder asertar sobre ella.
@Test
void un_iban_invalido_produce_un_error_con_el_campo_y_el_valor() {
var ex = assertThrows(DatoInvalido.class,
() -> new CuentaBancaria("ES00 0000 0000 0000 0000 0000"));
assertEquals("iban", ex.campo());
assertTrue(ex.getMessage().contains("dígito de control"));
assertNull(ex.getCause()); // no envolvemos excepciones por gusto
}
// assertDoesNotThrow: útil cuando el propio hecho de no fallar es el comportamiento.
@Test
void un_iban_con_espacios_y_minusculas_se_acepta() {
assertDoesNotThrow(() -> new CuentaBancaria("es91 2100 0418 4502 0005 1332"));
}
// ⚠️ Trampa clásica: NO metas más de una línea en el lambda. Si el fallo lo produce
// la preparación en lugar de la acción, el test pasa por el motivo equivocado.
@Test
void mal_planteado() {
assertThrows(CarritoVacio.class, () -> {
var carrito = new Carrito(null, null); // ← esto lanza NPE, no CarritoVacio…
carrito.confirmar(); // …y NPE es subclase de RuntimeException,
}); // así que si esperases esa, pasaría en falso
}
// 3 · Timeouts. Hay dos variantes y confundirlas causa tests intermitentes.
@Test
@Timeout(value = 500, unit = TimeUnit.MILLISECONDS) // falla si tarda más; NO interrumpe
void el_parseo_de_10000_lineas_es_rapido() { … }
@Test
void assertTimeout_deja_terminar_y_luego_falla() {
// Ejecuta en el MISMO hilo. Espera a que acabe y después comprueba el tiempo.
var resultado = assertTimeout(Duration.ofMillis(500), () -> parsear(FICHERO));
assertThat(resultado).hasSize(10_000);
}
@Test
void assertTimeoutPreemptively_corta_en_seco() {
// Ejecuta en OTRO hilo y aborta al vencer el plazo. Cuidado: al cambiar de hilo
// pierdes ThreadLocal (transacciones de Spring, MDC de logs, SecurityContext).
assertTimeoutPreemptively(Duration.ofSeconds(2), () -> clienteHttp.get("/lento"));
}
@Timeout con valores generosos
(10× lo esperado) y solo como red contra tests colgados, nunca para medir rendimiento. Para
medir rendimiento están JMH y las pruebas de carga (sección 13).
3.8 Suposiciones: abortar en lugar de fallar
Una assumption falsa no marca el test como fallido, sino como abortado. La diferencia importa: un fallo significa «hay un defecto»; un abortado significa «este test no aplica aquí». Confundirlos hace que CI se ponga rojo por motivos que no son defectos, o —peor— que un test importante se salte en silencio.
@Test
void usa_el_conector_nativo_de_postgres() {
assumeTrue(dockerDisponible(), "Docker no disponible: se omite el test de integración");
// … a partir de aquí solo se ejecuta si hay Docker
}
@Test
void el_calculo_del_cierre_usa_la_zona_de_madrid() {
assumingThat(ZoneId.systemDefault().equals(ZoneId.of("Europe/Madrid")),
() -> assertThat(servicio.cierre()).isEqualTo(LocalTime.of(23, 59)));
// El resto del test se ejecuta siempre; solo esa aserción es condicional.
}
@Test
void solo_en_ci() {
assumeFalse(System.getenv("CI") == null, "solo tiene sentido en CI");
…
}
| Mecanismo | Resultado si no se cumple | Cuándo usarlo |
|---|---|---|
assumeTrue(...) | Test abortado (amarillo). Se decide en tiempo de ejecución, dentro del test. | La condición depende de algo que solo se sabe ejecutando (¿hay Docker?, ¿responde el sandbox?). |
@EnabledIf… | Test deshabilitado (no se ejecuta ni se cuenta como abortado). | La condición se conoce antes de ejecutar: sistema operativo, versión de Java, variable de entorno. |
@Disabled | Test deshabilitado permanentemente. | Solo temporalmente y con motivo, dueño y fecha escritos. |
| Aserción normal | Test fallido (rojo). | Cuando la condición es lo que estás comprobando. |
assumeTrue mal puesto convierte un test en un
adorno. Si en CI Docker no está disponible por una mala configuración, tu suite de integración pasa
entera en amarillo y nadie se da cuenta durante meses. Contramedida: en CI, haz que el pipeline
falle si el número de tests abortados supera cero en las etapas donde no debería haber
ninguno.
3.9 Tests parametrizados a fondo
Es la característica de JUnit 5 que más reduce el código de test y la que peor se aprovecha. La idea: un método, muchos casos, un informe con una entrada por caso. Cada caso es un test independiente con su propio nombre, su propio ciclo de vida y su propio resultado.
// ── @ValueSource: un único parámetro, valores literales ──────────────────────
@ParameterizedTest(name = "«{0}» no es un código postal válido")
@ValueSource(strings = {"", " ", "1234", "123456", "abcde", "28-13", "00000"})
void rechaza_codigos_postales_invalidos(String cp) {
assertThatIllegalArgumentException().isThrownBy(() -> new CodigoPostal(cp));
}
@ParameterizedTest
@ValueSource(ints = {0, -1, -100, Integer.MIN_VALUE})
void la_cantidad_debe_ser_positiva(int cantidad) {
assertThatIllegalArgumentException().isThrownBy(() -> new Linea("SKU-1", cantidad, euros("1.00")));
}
// ⚠️ @ValueSource NO acepta null. Para incluirlo:
@ParameterizedTest
@NullSource // solo null
@EmptySource // "" para String, [] para arrays, colección vacía…
@ValueSource(strings = {" ", "\t", "\n"})
void el_nombre_en_blanco_se_rechaza(String nombre) {
assertThatThrownBy(() -> new Cliente(nombre)).isInstanceOf(DatoInvalido.class);
}
@ParameterizedTest
@NullAndEmptySource // atajo: null y vacío en una sola anotación
void tolera_ausencia_de_referencia(String referencia) {
assertThat(Referencia.parseOrDefault(referencia)).isEqualTo(Referencia.SIN_REFERENCIA);
}
// ── @CsvSource: varios parámetros por caso, en línea ─────────────────────────
@ParameterizedTest(name = "[{index}] {0} + IVA {1}% = {2} €")
@CsvSource({
"100.00, 21, 121.00",
"100.00, 10, 110.00",
"100.00, 4, 104.00",
" 0.01, 21, 0.01", // redondeo hacia abajo
" 0.03, 21, 0.04" // redondeo HALF_UP
})
void aplica_el_iva_con_redondeo_half_up(BigDecimal base, int tipo, BigDecimal esperado) {
assertThat(Iva.de(tipo).aplicar(base)).isEqualByComparingTo(esperado);
}
// Opciones útiles de @CsvSource
@ParameterizedTest
@CsvSource(delimiter = '|', quoteCharacter = '\'', nullValues = "NULO", textBlock = """
entrada | esperado | comentario
'ES91 2100 0418' | INVALIDO | demasiado corto
'ES9121000418450200051332' | VALIDO | sin espacios
'es91 2100 0418 4502 0005 1332' | VALIDO| minúsculas y espacios
NULO | INVALIDO | nulo
""")
void valida_ibanes(String entrada, Resultado esperado, String comentario) {
assertThat(Iban.validar(entrada)).as(comentario).isEqualTo(esperado);
}
// El textBlock ignora la primera línea si usas useHeadersInDisplayName = true,
// y entonces el informe muestra "entrada = 'ES91…', esperado = VALIDO".
// ── @CsvFileSource: los casos viven en un fichero ────────────────────────────
// src/test/resources/casos/tramos-irpf.csv
// base,retencionEsperada
// 12000.00,240.00
// 30000.00,4500.00
// 80000.00,20800.00
@ParameterizedTest
@CsvFileSource(resources = "/casos/tramos-irpf.csv", numLinesToSkip = 1)
void calcula_la_retencion_por_tramos(BigDecimal base, BigDecimal esperada) {
assertThat(Irpf.retencion(base)).isEqualByComparingTo(esperada);
}
// Ideal cuando los casos los aporta negocio en una hoja de cálculo: exportan a CSV,
// tú no tocas código y los 300 casos se ejecutan solos.
// ── @MethodSource: la fuente es un método estático que devuelve Stream ───────
@ParameterizedTest(name = "{0} → {1}")
@MethodSource("casosDeDescuento")
void calcula_el_descuento(Carrito carrito, Dinero descuentoEsperado) {
assertThat(calculadora.descuento(carrito)).isEqualTo(descuentoEsperado);
}
static Stream<Arguments> casosDeDescuento() {
return Stream.of(
arguments(CarritoMother.vacio(), euros("0.00")),
arguments(CarritoMother.con(euros("100.00")), euros("0.00")),
arguments(CarritoMother.con(euros("100.00")).conCupon(CUPON_10), euros("10.00")),
arguments(CarritoMother.vip().con(euros("100.00")), euros("5.00")),
arguments(CarritoMother.vip().con(euros("100.00")).conCupon(CUPON_10), euros("15.00"))
);
}
// @MethodSource sin nombre usa un método con el MISMO nombre que el test
@ParameterizedTest
@MethodSource
void transicionesValidas(Estado desde, Estado hasta) { … }
static Stream<Arguments> transicionesValidas() { … }
// Fuente en otra clase (compartida entre varias clases de test)
@ParameterizedTest
@MethodSource("com.ejemplo.test.CasosCompartidos#ibanesValidos")
void acepta_ibanes_validos(String iban) { … }
// ── @EnumSource: recorrer un enum, entero o filtrado ─────────────────────────
@ParameterizedTest
@EnumSource(Estado.class) // todos los valores
void todo_estado_tiene_descripcion_no_vacia(Estado estado) {
assertThat(estado.descripcion()).isNotBlank();
}
@ParameterizedTest
@EnumSource(value = Estado.class, names = {"CANCELADO", "DEVUELTO"})
void los_estados_finales_no_admiten_transiciones(Estado estado) {
assertThat(estado.siguientesPosibles()).isEmpty();
}
@ParameterizedTest
@EnumSource(value = Estado.class, names = {"CANCELADO", "DEVUELTO"}, mode = EnumSource.Mode.EXCLUDE)
void los_estados_no_finales_tienen_al_menos_una_transicion(Estado estado) {
assertThat(estado.siguientesPosibles()).isNotEmpty();
}
@ParameterizedTest
@EnumSource(value = Estado.class, names = "^EN_.*", mode = EnumSource.Mode.MATCH_ALL)
void los_estados_en_curso_son_cancelables(Estado estado) {
assertThat(estado.esCancelable()).isTrue();
}
// ── @ArgumentsSource: fuente propia y reutilizable ───────────────────────────
public class IbanesInvalidos implements ArgumentsProvider {
@Override
public Stream<? extends Arguments> provideArguments(ExtensionContext ctx) {
return Stream.of(
arguments("ES00 0000 0000 0000 0000 0000", "dígito de control"),
arguments("XX91 2100 0418 4502 0005 1332", "país desconocido"),
arguments("ES91 2100 0418", "longitud"),
arguments("ES91 2100 0418 4502 0005 133Z", "carácter no numérico"));
}
}
@ParameterizedTest(name = "{0} → error de {1}")
@ArgumentsSource(IbanesInvalidos.class)
void rechaza_ibanes_invalidos_con_el_motivo(String iban, String motivo) {
assertThatThrownBy(() -> new Iban(iban)).hasMessageContaining(motivo);
}
// Y con una anotación compuesta queda perfecto de leer:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@ParameterizedTest(name = "{0} → error de {1}")
@ArgumentsSource(IbanesInvalidos.class)
public @interface TestConIbanesInvalidos { }
Conversión y agregación de argumentos. JUnit convierte automáticamente el
String del CSV a muchos tipos (números, enum, LocalDate,
UUID, Duration, clases con constructor o método estático de un solo
String…). Cuando no basta, hay dos mecanismos:
// 1 · Conversión explícita de un argumento
public class ADinero extends SimpleArgumentConverter {
@Override
protected Object convert(Object source, Class<?> targetType) {
return Dinero.euros(new BigDecimal(source.toString().replace(',', '.')));
}
}
@ParameterizedTest
@CsvSource({"1.234,56 | 1234.56", "0,01 | 0.01"})
void parsea_importes_en_formato_espanol(@ConvertWith(ADinero.class) Dinero valor,
BigDecimal esperado) {
assertThat(valor.cantidad()).isEqualByComparingTo(esperado);
}
// Conversión de fechas con formato: ya viene de serie
@ParameterizedTest
@ValueSource(strings = {"15/03/2026", "01/01/2026"})
void acepta_fechas_en_formato_espanol(
@JavaTimeConversionPattern("dd/MM/yyyy") LocalDate fecha) {
assertThat(fecha.getYear()).isEqualTo(2026);
}
// 2 · Agregación: varios argumentos en un objeto, para no tener firmas de 7 parámetros
public class AgregarDireccion implements ArgumentsAggregator {
@Override
public Object aggregateArguments(ArgumentsAccessor a, ParameterContext ctx) {
return new Direccion(a.getString(0), a.getString(1), a.getString(2), a.getString(3));
}
}
@ParameterizedTest
@CsvSource({
"Calle Mayor 3, 28013, Madrid, ES",
"Rua Augusta 10, 1200-054, Lisboa, PT"
})
void formatea_la_direccion_segun_el_pais(@AggregateWith(AgregarDireccion.class) Direccion dir) {
assertThat(dir.formateada()).contains(dir.municipio());
}
// Alternativa sin clase extra: ArgumentsAccessor directamente
@ParameterizedTest
@CsvSource({"Calle Mayor 3, 28013, Madrid, ES"})
void con_accessor(ArgumentsAccessor args) {
var dir = new Direccion(args.getString(0), args.getString(1), args.getString(2), args.getString(3));
assertThat(dir.pais()).isEqualTo("ES");
}
Marcador en name | Qué imprime |
|---|---|
{index} | Número del caso, empezando en 1. |
{0}, {1}… | El argumento en esa posición, con su toString(). |
{arguments} | Todos los argumentos separados por comas. |
{argumentsWithNames} | Todos con el nombre del parámetro. Es el valor por defecto en JUnit 5.11. |
{displayName} | El @DisplayName del método, para componer. |
if según el parámetro, no es un test parametrizado: son varios tests con nombres
propios. El parametrizado brilla cuando la misma frase se cumple para muchos valores. Y ten
cuidado con el toString() de tus objetos de dominio: si no lo implementas, el informe se
llena de com.ejemplo.Carrito@1f2a3b y pierdes la mitad del valor. Un record te
lo da gratis.
3.10 Tests dinámicos con @TestFactory
Un @ParameterizedTest se resuelve en tiempo de compilación (los casos vienen de anotaciones);
un @TestFactory genera los tests en tiempo de ejecución. Es la herramienta
para cuando la lista de casos sale de un directorio, de una consulta o de un fichero de configuración.
class ContratosJsonTest {
// Genera un test por cada fichero JSON del directorio de ejemplos.
// Si mañana alguien añade un fichero, aparece un test nuevo sin tocar código.
@TestFactory
Stream<DynamicTest> cada_ejemplo_json_se_deserializa_y_vuelve_igual() throws IOException {
try (var ficheros = Files.list(Path.of("src/test/resources/ejemplos/pedidos"))) {
return ficheros
.filter(p -> p.toString().endsWith(".json"))
.map(p -> DynamicTest.dynamicTest(
"ida y vuelta de " + p.getFileName(),
() -> {
String json = Files.readString(p);
var dto = mapper.readValue(json, PedidoDto.class);
assertThatJson(mapper.writeValueAsString(dto)).isEqualTo(json);
}))
.toList().stream(); // materializamos antes de cerrar el stream de ficheros
}
}
// Contenedores dinámicos: agrupan tests dinámicos en un árbol
@TestFactory
Stream<DynamicContainer> cada_pais_tiene_sus_reglas_fiscales() {
return Stream.of("ES", "PT", "FR", "DE")
.map(pais -> DynamicContainer.dynamicContainer("país " + pais, Stream.of(
DynamicTest.dynamicTest("tiene tipo de IVA general",
() -> assertThat(Fiscalidad.de(pais).ivaGeneral()).isPositive()),
DynamicTest.dynamicTest("tiene formato de NIF",
() -> assertThat(Fiscalidad.de(pais).patronNif()).isNotNull()),
DynamicTest.dynamicTest("declara su moneda",
() -> assertThat(Fiscalidad.de(pais).moneda()).isNotNull()))));
}
}
@ParameterizedTest | @TestFactory | |
|---|---|---|
| Origen de los casos | Anotaciones o método estático | Cualquier código en tiempo de ejecución |
| Ciclo de vida por caso | Completo: @BeforeEach y @AfterEach se ejecutan | No se ejecutan por caso, solo una vez para la fábrica |
| Extensiones e inyección | Todo el soporte de Jupiter | Limitado: los DynamicTest son lambdas, no métodos de test |
| Cuándo usarlo | Casi siempre | Cuando los casos son datos externos y no se conocen al compilar |
3.11 Inyección de parámetros: TestInfo, TestReporter, @TempDir
En JUnit 5 los métodos de test pueden tener parámetros, resueltos por extensiones. Tres vienen de fábrica y son muy útiles.
class InyeccionTest {
// TestInfo: metadatos del test en ejecución. Útil para datos únicos por test.
@Test
@Tag("smoke")
@DisplayName("crea un usuario con un correo único")
void crea_usuario(TestInfo info) {
assertThat(info.getDisplayName()).isEqualTo("crea un usuario con un correo único");
assertThat(info.getTags()).containsExactly("smoke");
// Truco útil: derivar datos únicos del nombre del test evita colisiones
// sin recurrir a aleatoriedad (que rompería el determinismo).
String correo = info.getTestMethod().orElseThrow().getName() + "@ejemplo.test";
assertThat(servicio.crear(correo).id()).isNotNull();
}
// TestReporter: publica información en el informe de la Platform (visible en CI),
// en lugar de ensuciar la salida estándar con System.out.
@Test
void publica_metricas_en_el_informe(TestReporter reporter) {
long inicio = System.nanoTime();
var resultado = servicio.recalcularTodo();
reporter.publishEntry("filas", String.valueOf(resultado.filas()));
reporter.publishEntry("ms", String.valueOf((System.nanoTime() - inicio) / 1_000_000));
assertThat(resultado.errores()).isZero();
}
// @TempDir: directorio temporal creado y BORRADO automáticamente.
// Sustituye a File.createTempFile y a los @AfterEach que borran a mano.
@Test
void exporta_el_catalogo_a_csv(@TempDir Path directorio) throws IOException {
Path destino = directorio.resolve("catalogo.csv");
exportador.exportar(catalogo, destino);
assertThat(destino).exists()
.content(StandardCharsets.UTF_8)
.startsWith("sku;nombre;precio")
.contains("SKU-1;Teclado;49.90");
assertThat(Files.readAllLines(destino)).hasSize(101); // cabecera + 100 productos
}
// Un @TempDir estático se comparte por toda la clase (se borra en @AfterAll)
@TempDir static Path directorioDeClase;
}
3.12 Orden de ejecución: casi siempre, no
Que necesites ordenar los tests es normalmente un síntoma de acoplamiento. Pero hay dos casos legítimos: los tests de caracterización de un flujo largo que sería absurdo repetir, y los escenarios de aceptación que documentan una secuencia de negocio. Si lo haces, hazlo explícito.
// Caso legítimo: un escenario de negocio contado en pasos, donde repetir la
// preparación multiplicaría por diez el tiempo. Requiere PER_CLASS.
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@DisplayName("Ciclo de vida completo de un pedido (escenario)")
class CicloPedidoEscenarioIT {
private String pedidoId;
@Test @Order(1)
@DisplayName("1 · el cliente crea el pedido")
void crear() {
pedidoId = api.crearPedido(peticionValida()).id();
assertThat(pedidoId).isNotBlank();
}
@Test @Order(2)
@DisplayName("2 · el pago se confirma y el pedido pasa a PAGADO")
void pagar() {
api.pagar(pedidoId, TARJETA_VALIDA);
assertThat(api.obtener(pedidoId).estado()).isEqualTo("PAGADO");
}
@Test @Order(3)
@DisplayName("3 · el almacén lo marca como enviado y se emite el evento")
void enviar() {
api.enviar(pedidoId, "SEUR-1234");
assertThat(eventos.publicados()).extracting("tipo").contains("PedidoEnviado");
}
}
| Ordenador | Criterio | Comentario |
|---|---|---|
MethodOrderer.OrderAnnotation | @Order(n) explícito | El único razonable cuando el orden es intencionado. |
MethodOrderer.DisplayName | Alfabético por nombre visible | Determinista, cómodo para leer el informe. |
MethodOrderer.MethodName | Alfabético por nombre de método | El truco viejo de a_, b_… Evítalo: esconde el acoplamiento. |
MethodOrderer.Random | Aleatorio con semilla | Actívalo por defecto: detecta dependencias de orden. La semilla se imprime para reproducir. |
ClassOrderer.ClassName / OrderAnnotation / Random | Orden entre clases | ClassOrderer.Random más MethodOrderer.Random es el examen completo de independencia. |
3.13 Extensiones: el modelo que sustituye a runners y rules
En JUnit 4 tenías @RunWith (uno por clase) y @Rule. En JUnit 5 hay
un solo mecanismo, componible: la extensión. Puedes registrar tantas como quieras y
cada una engancha en los puntos del ciclo de vida que le interesan. Es lo que usan por debajo
SpringExtension, MockitoExtension y @Testcontainers.
| Punto de extensión | Interfaz | Para qué |
|---|---|---|
| Antes de todos los tests de la clase | BeforeAllCallback | Arrancar un recurso compartido (contenedor, servidor simulado). |
| Antes de cada test | BeforeEachCallback | Limpiar base de datos, fijar el reloj, resetear un stub. |
| Antes/después del método (dentro de los callbacks) | BeforeTestExecutionCallback, AfterTestExecutionCallback | Medir el tiempo exacto del cuerpo del test, sin la preparación. |
| Después de cada test y de la clase | AfterEachCallback, AfterAllCallback | Liberar recursos, volcar diagnósticos si falló. |
| Inyectar parámetros | ParameterResolver | Pasar al test un cliente HTTP configurado, un Clock, datos de prueba. |
| Decidir si se ejecuta | ExecutionCondition | Implementar tus propias anotaciones tipo @EnabledIfDockerDisponible. |
| Manejar excepciones | TestExecutionExceptionHandler | Traducir excepciones, o reintentar (con mucho cuidado). |
| Procesar la instancia de test | TestInstancePostProcessor | Inyectar en campos, como hace Mockito con @Mock. |
| Invocar el test | InvocationInterceptor | Envolver la ejecución: transacciones, SecurityContext, cambio de locale. |
/**
* Extensión útil de verdad: detecta y reporta los tests lentos, y falla si un test
* unitario supera el presupuesto. Es la mejor defensa contra la degradación silenciosa
* de la suite, porque el problema se detecta el día que se introduce y no un año después.
*/
public class PresupuestoDeTiempoExtension
implements BeforeTestExecutionCallback, AfterTestExecutionCallback {
private static final Namespace NS = Namespace.create(PresupuestoDeTiempoExtension.class);
private static final String CLAVE_INICIO = "inicio";
private static final long LIMITE_MS = Long.getLong("test.presupuesto.ms", 100L);
private static final List<String> LENTOS = Collections.synchronizedList(new ArrayList<>());
@Override
public void beforeTestExecution(ExtensionContext ctx) {
ctx.getStore(NS).put(CLAVE_INICIO, System.nanoTime());
}
@Override
public void afterTestExecution(ExtensionContext ctx) {
long inicio = ctx.getStore(NS).remove(CLAVE_INICIO, long.class);
long ms = (System.nanoTime() - inicio) / 1_000_000;
ctx.publishReportEntry("duracion_ms", String.valueOf(ms));
if (ms > LIMITE_MS) {
String id = ctx.getRequiredTestClass().getSimpleName()
+ "#" + ctx.getRequiredTestMethod().getName();
LENTOS.add(id + " → " + ms + " ms");
// En modo estricto, romper el build; por defecto, solo avisar.
if (Boolean.getBoolean("test.presupuesto.estricto")) {
throw new AssertionError(
"El test %s ha tardado %d ms (límite %d ms). Muévelo a integración o acelérralo."
.formatted(id, ms, LIMITE_MS));
}
System.err.printf("⚠️ test lento: %s (%d ms)%n", id, ms);
}
}
}
// Uso puntual
@ExtendWith(PresupuestoDeTiempoExtension.class)
class CalculadoraDescuentoTest { … }
// Otra extensión muy práctica: fijar el reloj para toda la clase e inyectarlo.
public class RelojFijoExtension implements BeforeEachCallback, ParameterResolver {
public static final Instant AHORA = Instant.parse("2026-03-15T10:00:00Z");
private static final Clock RELOJ = Clock.fixed(AHORA, ZoneId.of("Europe/Madrid"));
@Override
public void beforeEach(ExtensionContext ctx) {
RelojDelSistema.fijar(RELOJ); // si tienes un punto único de acceso
}
@Override
public boolean supportsParameter(ParameterContext p, ExtensionContext c) {
return p.getParameter().getType() == Clock.class;
}
@Override
public Object resolveParameter(ParameterContext p, ExtensionContext c) {
return RELOJ;
}
}
@ExtendWith(RelojFijoExtension.class)
class CaducidadCuponTest {
@Test
void un_cupon_que_caduca_hoy_sigue_siendo_valido(Clock reloj) { // ← inyectado
var cupon = new Cupon("X", Porcentaje.de(10), LocalDate.parse("2026-03-15"));
assertThat(cupon.vigenteEn(reloj)).isTrue();
}
}
// Registro de extensiones: cuatro formas, de menos a más control
// 1 · Declarativa por clase o método
@ExtendWith({MockitoExtension.class, PresupuestoDeTiempoExtension.class})
class MiTest { … }
// 2 · Programática con @RegisterExtension (permite configurar la instancia)
class MiOtroTest {
@RegisterExtension
static WireMockExtension wiremock = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.failOnUnmatchedRequests(true)
.build();
}
// 3 · Automática vía ServiceLoader, para aplicarla a TODA la suite sin tocar clases:
// src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension
// com.ejemplo.test.PresupuestoDeTiempoExtension
// y en junit-platform.properties:
// junit.jupiter.extensions.autodetection.enabled = true
// 4 · Herencia de anotación compuesta (la forma más limpia de compartir configuración)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@ExtendWith({MockitoExtension.class, RelojFijoExtension.class})
public @interface TestDeDominio { }
3.14 Ejecución paralela
JUnit 5 puede ejecutar tests en paralelo dentro de la misma JVM. Es la forma más rápida de recortar minutos de una suite grande… y la forma más rápida de destapar todos los acoplamientos que tenías escondidos. Actívalo de forma gradual.
# src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled = true
# Estrategia por defecto para clases y métodos:
# same_thread → secuencial (por defecto)
# concurrent → en paralelo
junit.jupiter.execution.parallel.mode.default = same_thread
junit.jupiter.execution.parallel.mode.default.classes = concurrent
# ↑ Configuración recomendada para empezar: clases en paralelo, métodos de cada clase
# en secuencia. Es el 80 % de la ganancia con el 20 % del riesgo.
# Paralelismo: dynamic (factor × núcleos), fixed (número exacto) o custom
junit.jupiter.execution.parallel.config.strategy = dynamic
junit.jupiter.execution.parallel.config.dynamic.factor = 1.0
# junit.jupiter.execution.parallel.config.strategy = fixed
# junit.jupiter.execution.parallel.config.fixed.parallelism = 4
// Control fino por clase o método
@Execution(ExecutionMode.CONCURRENT)
class TestsPurosTest { … } // sin estado compartido: paralelo sin miedo
@Execution(ExecutionMode.SAME_THREAD)
class TestsQueTocanUnFicheroTest { … } // fuerza secuencial
// @ResourceLock: declara qué recurso compartido usa cada test y JUnit se encarga
// de no ejecutar a la vez dos que se pisen.
class ConfiguracionGlobalTest {
@Test
@ResourceLock(value = Resources.SYSTEM_PROPERTIES, mode = ResourceAccessMode.READ_WRITE)
void cambia_una_propiedad_del_sistema() {
System.setProperty("app.modo", "prueba");
…
}
@Test
@ResourceLock(value = Resources.SYSTEM_PROPERTIES, mode = ResourceAccessMode.READ)
void lee_una_propiedad_del_sistema() { … } // varios READ pueden ir a la vez
@Test
@ResourceLock("tabla_pedido") // recurso propio, identificado por una cadena
void trunca_la_tabla_de_pedidos() { … }
}
// Recursos predefinidos: SYSTEM_PROPERTIES, SYSTEM_OUT, SYSTEM_ERR, LOCALE, TIME_ZONE.
| Riesgo al paralelizar | Síntoma | Solución |
|---|---|---|
Estado static mutable compartido | Fallos aleatorios que cambian de test en cada ejecución | Eliminar el estático o marcar @ResourceLock. Lo primero es mejor. |
| Propiedades del sistema, locale, zona horaria | Un test cambia la zona y otro, en paralelo, calcula mal | @ResourceLock(Resources.TIME_ZONE), o mejor: inyecta la zona en lugar de usar la global. |
| Misma tabla de base de datos | Violaciones de clave única, contadores incorrectos, interbloqueos | Datos únicos por test (prefijos), esquema por hilo, o @ResourceLock por tabla. |
| Puertos fijos | Address already in use | Puerto 0 / RANDOM_PORT / dynamicPort() siempre. |
Mocks estáticos (mockStatic) | Fugas entre hilos y errores extrañísimos | mockStatic es por hilo, pero mezclado con paralelismo es una fuente de dolor: rediseña. |
| Contextos de Spring | Consumo de memoria disparado, OOM en CI | Paralelizar solo la suite unitaria; los @SpringBootTest, en secuencia o en forks separados. |
MethodOrderer.Random y
ClassOrderer.Random y arregla lo que se rompa; (2) paraleliza solo clases
en la suite unitaria; (3) mide la ganancia real —si es menor del 30 %, quizá tu problema era otro—;
(4) solo entonces considera paralelizar métodos. Y no paralelices los tests de integración con
contenedores hasta que hayas resuelto el aislamiento de datos: es la receta perfecta para una suite
intermitente.
3.15 Migración desde JUnit 4
| JUnit 4 | JUnit 5 (Jupiter) | Nota de la migración |
|---|---|---|
org.junit.Test | org.junit.jupiter.api.Test | Cuidado con el import: el IDE a veces elige el equivocado y el test no se ejecuta ni avisa. |
@Before / @After | @BeforeEach / @AfterEach | Renombrado directo. |
@BeforeClass / @AfterClass | @BeforeAll / @AfterAll | Siguen siendo static salvo con @TestInstance(PER_CLASS). |
@Ignore | @Disabled | Aprovecha para añadir el motivo, que en JUnit 4 casi nadie ponía. |
@Category | @Tag | Cadenas en vez de clases marcadoras: más simple y filtrable desde la línea de órdenes. |
@RunWith(...) | @ExtendWith(...) | Y ahora puedes poner varias. @RunWith(SpringRunner.class) → @ExtendWith(SpringExtension.class), que ya viene implícito en @SpringBootTest. |
@Rule / @ClassRule | Extensión, o @RegisterExtension | Existe junit-jupiter-migrationsupport con @EnableRuleMigrationSupport para ExternalResource, Verifier y TemporaryFolder, pero solo como puente. |
TemporaryFolder | @TempDir | Mejor API y limpieza automática. |
@Test(expected = X.class) | assertThrows(X.class, …) | Mucho mejor: el expected pasaba si la excepción salía de cualquier línea, incluida la preparación. |
@Test(timeout = 500) | @Timeout(value = 500, unit = MILLISECONDS) | Y ahora tienes assertTimeout / assertTimeoutPreemptively para más control. |
ExpectedException (rule) | assertThrows | La rule estaba ya deprecada en JUnit 4.13. Adiós sin nostalgia. |
Assume.assumeTrue | Assumptions.assumeTrue | Mismo concepto, paquete nuevo. |
@Parameterized con @Parameters | @ParameterizedTest | Reescritura, pero el resultado es muchísimo más legible: se acabaron los constructores con siete parámetros y los campos por caso. |
@RunWith(Suite.class) | @Suite de junit-platform-suite | Aunque casi siempre es mejor filtrar por @Tag que mantener suites a mano. |
Assert.assertEquals(msg, exp, act) | assertEquals(exp, act, msg) | El mensaje pasa al final. Es el error de migración más común: compila y el mensaje acaba comparado como valor. |
# Estrategia de migración sin big bang, en cuatro pasos:
# 1 · Añade junit-jupiter y junit-vintage-engine. Los tests viejos siguen ejecutándose.
# Comprueba que el número total de tests ejecutados NO baja (es la trampa clásica:
# tests que dejan de ejecutarse en silencio porque falta un engine).
mvn test | grep "Tests run"
# 2 · Escribe TODO lo nuevo en JUnit 5. Prohíbe por convención (o con ArchUnit) los
# imports de org.junit.Test en código nuevo.
# 3 · Migra por módulos o paquetes, no test a test. OpenRewrite lo automatiza bastante bien:
mvn -U org.openrewrite.maven:rewrite-maven-plugin:run \
-Drewrite.activeRecipes=org.openrewrite.java.testing.junit5.JUnit4to5Migration
# 4 · Cuando no quede ningún org.junit.Test, elimina junit-vintage-engine y la
# dependencia junit:junit. Si algo dejó de ejecutarse, aquí se ve.
grep -rl "org.junit.Test" src/test/ | wc -l # debe dar 0
org.junit.Test, org.junit.Assert y org.junit.Ignore en
src/test. Sin esa red, en tres meses vuelven a aparecer, porque el autocompletado del IDE
los sigue ofreciendo y nadie mira los imports en la revisión.
4 · Aserciones expresivas: AssertJ y compañía
La aserción es donde el test dice qué espera. Y es donde se decide si, cuando falle a las tres de la
mañana, el mensaje te dará la respuesta o te obligará a añadir System.out.println. Una
biblioteca de aserciones buena no hace más comprobaciones: hace mejores mensajes de
fallo y permite expresar la intención con menos ruido.
4.1 Por qué AssertJ y no las aserciones de JUnit
Compara el mismo caso con las tres opciones. No es cuestión de gusto: mira el mensaje de fallo, que es lo único que verás cuando el test se ponga rojo.
List<String> skus = pedido.skus(); // devuelve ["SKU-1", "SKU-3"]
// 1 · JUnit puro: verboso, y el mensaje no dice qué faltaba
assertTrue(skus.contains("SKU-2"));
// → org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
// Inútil: ¿qué contenía la lista?
// 2 · Hamcrest: mejor mensaje, sintaxis anidada de dentro hacia fuera
assertThat(skus, hasItem("SKU-2"));
// → Expected: a collection containing "SKU-2" but: was "SKU-1", was "SKU-3"
// 3 · AssertJ: fluido, autocompletable y con el mensaje más informativo
assertThat(skus).contains("SKU-2");
// → java.lang.AssertionError:
// Expecting ArrayList:
// ["SKU-1", "SKU-3"]
// to contain:
// ["SKU-2"]
// but could not find the following element(s):
// ["SKU-2"]
| Criterio | JUnit Assertions | Hamcrest | AssertJ |
|---|---|---|---|
| Descubribilidad con el IDE | Baja: métodos estáticos sueltos | Baja: hay que conocer el matcher | Alta: escribes assertThat(x). y el autocompletado te ofrece solo lo aplicable al tipo |
| Mensajes de fallo | Pobres, salvo que los escribas a mano | Buenos | Excelentes, con formato multilínea y diferencias |
| Orden de lectura | Natural | Invertido y anidado | Natural y encadenado |
| Colecciones y objetos complejos | Muy limitado | Aceptable | Muy potente: extracting, flatExtracting, comparación recursiva |
| Excepciones | assertThrows, correcto | Torpe | assertThatThrownBy con encadenado sobre causa y mensaje |
| Aserciones propias del dominio | No | Sí, con esfuerzo | Sí, y muy natural (extender AbstractAssert) |
| Cuándo usar cada una | assertAll, assertThrows, assertTimeout: no tienen equivalente | Solo si ya está en el proyecto, o con MockMvc antiguo, que usa Hamcrest | Todo lo demás |
assertThat. Si importas los dos en la misma clase, el código compila pero con semántica
distinta y los errores son desconcertantes. Elige uno por proyecto, y si eliges AssertJ, excluye
Hamcrest del classpath de test como se muestra en la sección 3.2. Para los ResultMatcher
de MockMvc no hace falta Hamcrest: status(), jsonPath() y
content() son de Spring.
4.2 AssertJ sobre objetos y valores
import static org.assertj.core.api.Assertions.*;
// ── Básicos ──────────────────────────────────────────────────────────────────
assertThat(pedido.estado()).isEqualTo(Estado.CONFIRMADO);
assertThat(pedido.estado()).isIn(Estado.CONFIRMADO, Estado.PAGADO);
assertThat(pedido.numero()).isNotBlank().startsWith("PED-").hasSize(17);
assertThat(pedido.cliente()).isNotNull().isSameAs(cliente); // isSameAs = identidad, ==
assertThat(pedido.total()).isEqualTo(euros("121.00"));
// ── Números: ojo con BigDecimal ──────────────────────────────────────────────
assertThat(new BigDecimal("10.00")).isEqualByComparingTo("10.0"); // ✅ pasa: compareTo
assertThat(new BigDecimal("10.00")).isEqualTo(new BigDecimal("10.0")); // ❌ falla: equals mira la escala
assertThat(total).isPositive().isLessThan(euros("1000.00"));
assertThat(porcentaje).isBetween(0.0, 100.0);
assertThat(media).isCloseTo(3.14, within(0.01)); // tolerancia absoluta
assertThat(media).isCloseTo(3.14, withinPercentage(1)); // tolerancia relativa
// ── Cadenas ──────────────────────────────────────────────────────────────────
assertThat(mensaje)
.isNotEmpty()
.containsIgnoringCase("pedido")
.doesNotContain("null", "Exception")
.matches("Pedido \\w+ confirmado el \\d{2}/\\d{2}/\\d{4}");
assertThat(csv).hasLineCount(101).containsSubsequence("cabecera", "SKU-1", "SKU-2");
assertThat(texto).isEqualToIgnoringWhitespace("hola mundo");
assertThat(texto).isEqualToNormalizingNewlines("linea1\nlinea2");
// ── Optional ─────────────────────────────────────────────────────────────────
assertThat(repositorio.buscar("P-1")).isPresent().get().extracting(Pedido::estado)
.isEqualTo(Estado.NUEVO);
assertThat(repositorio.buscar("NO-EXISTE")).isEmpty();
assertThat(repositorio.buscar("P-1")).hasValueSatisfying(
p -> assertThat(p.lineas()).isNotEmpty());
assertThat(repositorio.buscar("P-1")).contains(pedidoEsperado);
// ── Booleanos con contexto: usa as() para que el fallo se entienda ────────────
assertThat(cupon.vigente())
.as("el cupón %s debería estar vigente el %s", cupon.codigo(), HOY)
.isTrue();
// → java.lang.AssertionError: [el cupón VERANO10 debería estar vigente el 2026-03-15]
// Expecting value to be true but was false
as() es la función más subestimada de AssertJ. Una aserción booleana sin descripción
produce el peor mensaje posible («expected true but was false»). Cada vez que escribas
.isTrue() o .isFalse(), pregúntate si un as("…") con los datos
relevantes te ahorraría tener que depurar. Casi siempre sí. Mejor todavía: en lugar de
assertThat(lista.isEmpty()).isTrue(), escribe assertThat(lista).isEmpty(),
que ya imprime el contenido.
4.3 Colecciones y mapas
List<Pedido> pedidos = repositorio.findByEstado(Estado.NUEVO);
// ── Tamaño y contenido ───────────────────────────────────────────────────────
assertThat(pedidos).isNotEmpty().hasSize(3);
assertThat(pedidos).hasSizeBetween(1, 5).hasSizeGreaterThan(0);
assertThat(pedidos).hasSameSizeAs(esperados);
// contains / containsExactly / containsExactlyInAnyOrder: elige a conciencia
assertThat(skus).contains("SKU-1", "SKU-2"); // están, puede haber más, en cualquier orden
assertThat(skus).containsOnly("SKU-1", "SKU-2"); // solo esos, orden libre, duplicados permitidos
assertThat(skus).containsExactly("SKU-1", "SKU-2"); // exactamente esos y en ESE orden
assertThat(skus).containsExactlyInAnyOrder("SKU-2", "SKU-1"); // exactamente esos, orden libre ← el más usado
assertThat(skus).containsSequence("SKU-1", "SKU-2"); // consecutivos y en orden
assertThat(skus).containsSubsequence("SKU-1", "SKU-3"); // en ese orden, no necesariamente juntos
assertThat(skus).doesNotContain("SKU-9").doesNotHaveDuplicates();
assertThat(skus).startsWith("SKU-1").endsWith("SKU-3");
// ── Predicados sobre todos los elementos ─────────────────────────────────────
assertThat(pedidos).allMatch(p -> p.total().esPositivo());
assertThat(pedidos).noneMatch(p -> p.lineas().isEmpty());
assertThat(pedidos).anyMatch(p -> p.cliente().esVip());
assertThat(pedidos).allSatisfy(p -> { // con mensajes de fallo mucho mejores
assertThat(p.numero()).startsWith("PED-");
assertThat(p.creadoEn()).isBefore(AHORA);
});
assertThat(pedidos).anySatisfy(p -> assertThat(p.total()).isGreaterThan(euros("100.00")));
assertThat(pedidos).satisfiesExactly( // uno por elemento, en orden
primero -> assertThat(primero.numero()).isEqualTo("PED-1"),
segundo -> assertThat(segundo.numero()).isEqualTo("PED-2"));
// ── Mapas ────────────────────────────────────────────────────────────────────
Map<String, Integer> stock = almacen.stockPorSku();
assertThat(stock).hasSize(3)
.containsKeys("SKU-1", "SKU-2")
.containsEntry("SKU-1", 10)
.containsOnlyKeys("SKU-1", "SKU-2", "SKU-3")
.doesNotContainKey("SKU-9")
.doesNotContainEntry("SKU-1", 0);
assertThat(stock).extractingByKey("SKU-1").isEqualTo(10);
assertThat(stock).allSatisfy((sku, unidades) -> assertThat(unidades).isNotNegative());
assertThat(stock).containsExactlyInAnyOrderEntriesOf(Map.of("SKU-1", 10, "SKU-2", 5, "SKU-3", 0));
// ── Arrays y streams ─────────────────────────────────────────────────────────
assertThat(new int[]{1, 2, 3}).containsExactly(1, 2, 3).hasSize(3);
assertThat(Stream.of("a", "b")).containsExactly("a", "b"); // consume el stream una vez
containsExactly cuando el
orden no está garantizado. Funciona en tu portátil y falla en CI, o funciona con Java 21 y falla con 25,
porque el orden de un HashSet, de un Map.values() o de una consulta SQL
sin ORDER BY no está definido. Regla: usa
containsExactlyInAnyOrder salvo que el orden sea parte del contrato; y si lo es, añade el
ORDER BY o el sorted() que lo garantice y deja un comentario diciendo que el
orden es intencionado.
4.4 extracting, filteredOn y comparación recursiva
Aquí está la potencia que hace que AssertJ merezca la pena. Estas tres familias de métodos convierten aserciones de veinte líneas con bucles en una sola expresión legible.
// ── extracting: aserta sobre una proyección de los elementos ─────────────────
assertThat(pedidos).extracting(Pedido::numero)
.containsExactly("PED-1", "PED-2", "PED-3");
// Varias propiedades a la vez, comparando con tuplas
assertThat(pedidos).extracting(Pedido::numero, Pedido::estado, Pedido::total)
.containsExactlyInAnyOrder(
tuple("PED-1", Estado.NUEVO, euros("121.00")),
tuple("PED-2", Estado.CONFIRMADO, euros("60.50")));
// Por nombre de propiedad (útil con tipos que no controlas; menos seguro al refactorizar)
assertThat(pedidos).extracting("cliente.nombre").contains("Ana", "Luis");
// Sobre un solo objeto
assertThat(pedido).extracting(Pedido::estado, Pedido::confirmadoEn)
.containsExactly(Estado.CONFIRMADO, AHORA);
// flatExtracting: aplana colecciones anidadas (todas las líneas de todos los pedidos)
assertThat(pedidos).flatExtracting(Pedido::lineas)
.extracting(Linea::sku)
.containsExactlyInAnyOrder("SKU-1", "SKU-2", "SKU-1");
// ── filteredOn: reduce antes de asertar ──────────────────────────────────────
assertThat(pedidos)
.filteredOn(p -> p.estado() == Estado.NUEVO)
.hasSize(2)
.extracting(Pedido::numero).containsExactly("PED-1", "PED-3");
assertThat(pedidos).filteredOn("estado", Estado.NUEVO).hasSize(2);
assertThat(pedidos).filteredOn(Pedido::esVip, true).isNotEmpty();
assertThat(pedidos).filteredOnAssertions(
p -> assertThat(p.total()).isGreaterThan(euros("100.00"))).hasSize(1);
// ── satisfies: aserciones agrupadas sobre un objeto sin romper la cadena ─────
assertThat(respuesta).satisfies(r -> {
assertThat(r.status()).isEqualTo(201);
assertThat(r.headers().get("Location")).isNotNull();
assertThat(r.body().id()).isNotBlank();
});
// satisfiesAnyOf: pasa si al menos uno de los bloques pasa (útil con comportamientos alternativos)
assertThat(resultado).satisfiesAnyOf(
r -> assertThat(r.estado()).isEqualTo("PAGADO"),
r -> assertThat(r.estado()).isEqualTo("PENDIENTE_3DS"));
// ── usingRecursiveComparison: comparar objetos campo a campo sin equals ──────
// Ideal para DTOs y entidades: detecta campos que se te han olvidado mapear.
var esperado = new PedidoDto("P-1", "C-1", Estado.NUEVO, euros("121.00"), List.of(
new LineaDto("SKU-1", 2, euros("50.00"))));
assertThat(servicio.obtener("P-1"))
.usingRecursiveComparison()
.isEqualTo(esperado);
// Ignorar lo que no puedes predecir (ids generados, fechas de auditoría)
assertThat(guardado)
.usingRecursiveComparison()
.ignoringFields("id", "creadoEn", "modificadoEn", "version")
.ignoringFieldsMatchingRegexes(".*\\.auditoria")
.isEqualTo(esperado);
// Comparadores por tipo o por campo: la solución elegante para BigDecimal y fechas
assertThat(guardado)
.usingRecursiveComparison()
.withComparatorForType(BigDecimal::compareTo, BigDecimal.class) // 10.00 == 10.0
.withEqualsForFields((Instant a, Instant b) ->
Duration.between(a, b).abs().toMillis() < 1000, "creadoEn")
.ignoringCollectionOrder() // listas sin orden garantizado
.ignoringExpectedNullFields() // solo compara lo que rellenas
.isEqualTo(esperado);
// Sobre colecciones enteras
assertThat(lista)
.usingRecursiveFieldByFieldElementComparatorIgnoringFields("id")
.containsExactlyInAnyOrderElementsOf(esperados);
// Comprobar que dos objetos NO son iguales por algún campo concreto
assertThat(actualizado)
.usingRecursiveComparison()
.comparingOnlyFields("estado", "total")
.isNotEqualTo(original);
usingRecursiveComparison por sí solo: los mapeos. Un
test que compara campo a campo escrito a mano no detecta que has olvidado mapear un campo
nuevo: simplemente no lo comprueba. La comparación recursiva sí, porque compara
todos los campos, incluidos los que se añadan mañana. Es la diferencia entre un test
que valida el pasado y uno que protege el futuro.
4.5 Excepciones con AssertJ
// Forma canónica: assertThatThrownBy + encadenado
assertThatThrownBy(() -> servicio.confirmar("NO-EXISTE"))
.isInstanceOf(PedidoNoEncontrado.class)
.hasMessage("No existe el pedido NO-EXISTE")
.hasMessageContaining("NO-EXISTE")
.hasMessageMatching("No existe el pedido \\w+-\\w+")
.hasNoCause();
// Con acceso a los campos de la excepción de dominio
assertThatThrownBy(() -> servicio.confirmar("P-1"))
.isInstanceOf(StockInsuficiente.class)
.asInstanceOf(InstanceOfAssertFactories.type(StockInsuficiente.class))
.satisfies(ex -> {
assertThat(ex.sku()).isEqualTo("SKU-1");
assertThat(ex.solicitado()).isEqualTo(5);
assertThat(ex.disponible()).isEqualTo(2);
});
// Excepciones estándar: atajos que leen muy bien
assertThatIllegalArgumentException().isThrownBy(() -> new Linea("SKU-1", 0, PRECIO))
.withMessageContaining("cantidad");
assertThatIllegalStateException().isThrownBy(pedido::confirmar);
assertThatNullPointerException().isThrownBy(() -> new Pedido(null, cliente));
assertThatExceptionOfType(TimeoutException.class).isThrownBy(() -> cliente.get("/lento"));
// Causa raíz: importantísimo cuando el framework envuelve tus excepciones
assertThatThrownBy(() -> repositorio.saveAndFlush(duplicado))
.isInstanceOf(DataIntegrityViolationException.class)
.hasRootCauseInstanceOf(SQLIntegrityConstraintViolationException.class)
.rootCause().hasMessageContaining("uk_pedido_referencia");
// Verificar que NO lanza
assertThatNoException().isThrownBy(() -> servicio.confirmar("P-1"));
assertThatCode(() -> servicio.confirmar("P-1")).doesNotThrowAnyException();
// Capturar para asertar en varios pasos
Throwable ex = catchThrowable(() -> servicio.confirmar("X"));
assertThat(ex).isInstanceOf(PedidoNoEncontrado.class);
// Versión tipada, más cómoda:
var noEncontrado = catchThrowableOfType(PedidoNoEncontrado.class, () -> servicio.confirmar("X"));
assertThat(noEncontrado.pedidoId()).isEqualTo("X");
isInstanceOf. Un test que solo comprueba el tipo de la excepción
pasa igual si el mensaje dice «error» que si dice «no se encontró el pedido P-1 del cliente C-3». Y el
mensaje es lo que verá quien opere el sistema. Aserta al menos sobre una parte del mensaje o sobre los
campos estructurados de tu excepción de dominio: es la forma de garantizar que los errores sean
diagnosticables en producción.
4.6 Aserciones blandas (soft assertions)
Equivalen al assertAll de JUnit, pero con la API de AssertJ: acumulan todos los fallos y los
reportan juntos al final. Se usan cuando estás verificando varias facetas del mismo resultado y quieres
ver el panorama completo en una sola ejecución.
// Opción 1 · Con try-with-resources (AssertJ 3.24+): se comprueba al cerrar
@Test
void la_respuesta_del_api_tiene_todos_los_campos() {
var dto = api.obtener("P-1");
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(dto.id()).isEqualTo("P-1");
softly.assertThat(dto.estado()).isEqualTo("NUEVO");
softly.assertThat(dto.total()).isEqualByComparingTo("121.00");
softly.assertThat(dto.lineas()).hasSize(2);
softly.assertThat(dto.creadoEn()).isNotNull();
} // aquí se lanza el error agrupado si algo falló
}
// Opción 2 · Con la extensión de JUnit 5: inyecta el SoftAssertions y comprueba solo
@ExtendWith(SoftAssertionsExtension.class)
class RespuestaApiTest {
@Test
void la_respuesta_tiene_todos_los_campos(SoftAssertions softly) {
var dto = api.obtener("P-1");
softly.assertThat(dto.id()).isEqualTo("P-1");
softly.assertThat(dto.estado()).isEqualTo("NUEVO");
softly.assertThat(dto.total()).isEqualByComparingTo("121.00");
}
}
// Opción 3 · Estilo funcional, sin variable
assertSoftly(softly -> {
softly.assertThat(dto.id()).isEqualTo("P-1");
softly.assertThat(dto.estado()).isEqualTo("NUEVO");
});
/* Mensaje de fallo agrupado:
org.assertj.core.api.SoftAssertionError:
The following 2 assertions failed:
1) expected: "NUEVO" but was: "CONFIRMADO"
at RespuestaApiTest.la_respuesta_tiene_todos_los_campos(RespuestaApiTest.java:24)
2) expected size: 2 but was: 3 in: [...]
at RespuestaApiTest.la_respuesta_tiene_todos_los_campos(RespuestaApiTest.java:26) */
assertThat(lista).hasSize(1) y después lista.get(0), con
aserciones blandas el segundo paso lanzará IndexOutOfBoundsException y perderás el mensaje
útil. En ese caso, aserción normal (que corta) primero, y blandas después. Y no las uses como excusa para
meter cinco comportamientos distintos en un test: siguen siendo cinco tests.
4.7 Aserciones personalizadas para tu dominio
Cuando repites el mismo grupo de aserciones en veinte tests, extráelas a una aserción de dominio. Ganas tres cosas: los tests se leen como frases de negocio, los mensajes de fallo hablan tu idioma y, si cambia la regla, se toca en un solo sitio.
public class PedidoAssert extends AbstractAssert<PedidoAssert, Pedido> {
private PedidoAssert(Pedido actual) {
super(actual, PedidoAssert.class);
}
public static PedidoAssert assertThatPedido(Pedido actual) {
return new PedidoAssert(actual);
}
public PedidoAssert estaConfirmado() {
isNotNull();
if (actual.estado() != Estado.CONFIRMADO) {
failWithMessage("Se esperaba que el pedido %s estuviese CONFIRMADO, pero está en %s",
actual.numero(), actual.estado());
}
if (actual.confirmadoEn() == null) {
failWithMessage("El pedido %s está CONFIRMADO pero no tiene fecha de confirmación",
actual.numero());
}
return this;
}
public PedidoAssert tieneTotal(String importeEsperado) {
isNotNull();
var esperado = Dinero.euros(importeEsperado);
if (actual.total().compareTo(esperado) != 0) {
failWithMessage("El total del pedido %s es %s pero se esperaba %s%n"
+ " Líneas: %s%n Descuentos: %s",
actual.numero(), actual.total(), esperado,
actual.lineas(), actual.descuentos()); // ← contexto para diagnosticar
}
return this;
}
public PedidoAssert haEmitido(Class<? extends EventoDominio> tipoEvento) {
isNotNull();
Assertions.assertThat(actual.eventos())
.as("eventos emitidos por el pedido %s", actual.numero())
.hasAtLeastOneElementOfType(tipoEvento);
return this;
}
public PedidoAssert noHaEmitidoNada() {
isNotNull();
Assertions.assertThat(actual.eventos())
.as("el pedido %s no debería haber emitido eventos", actual.numero())
.isEmpty();
return this;
}
}
// En el test: se lee como una especificación, y el fallo trae todo el contexto
@Test
void confirmar_un_pedido_valido_lo_deja_confirmado_y_emite_el_evento() {
var pedido = PedidoMother.conDosLineas();
pedido.confirmar(RELOJ);
assertThatPedido(pedido)
.estaConfirmado()
.tieneTotal("121.00")
.haEmitido(PedidoConfirmado.class);
}
// Punto de entrada único: una clase con todos los assertThat del proyecto.
// Un solo import estático en los tests y el IDE te ofrece todo.
public final class Assertions extends org.assertj.core.api.Assertions {
public static PedidoAssert assertThat(Pedido actual) { return PedidoAssert.assertThatPedido(actual); }
public static DineroAssert assertThat(Dinero actual) { return DineroAssert.assertThatDinero(actual); }
public static IbanAssert assertThat(Iban actual) { return IbanAssert.assertThatIban(actual); }
private Assertions() { }
}
// import static com.ejemplo.test.Assertions.*; ← y ya tienes AssertJ + lo tuyo
// AssertJ también puede generar estas clases por ti con assertj-assertions-generator-maven-plugin.
4.8 Fechas, tiempos y duraciones
// ── Comparaciones básicas ────────────────────────────────────────────────────
assertThat(pedido.creadoEn()).isEqualTo(Instant.parse("2026-03-15T10:00:00Z"));
assertThat(pedido.creadoEn()).isBefore(AHORA).isAfterOrEqualTo(AYER);
assertThat(pedido.creadoEn()).isBetween(INICIO_DEL_DIA, FIN_DEL_DIA);
assertThat(factura.fecha()).isToday(); // usa el reloj del sistema: cuidado
assertThat(vencimiento).isAfter(LocalDate.of(2026, 12, 31));
// ── Tolerancia: la clave para tests que no fallan por milisegundos ───────────
assertThat(guardado.creadoEn()).isCloseTo(AHORA, within(1, ChronoUnit.SECONDS));
assertThat(guardado.creadoEn()).isCloseTo(AHORA, byLessThan(500, ChronoUnit.MILLIS));
// Ignorar campos de precisión (útil cuando la BD trunca a milisegundos o a microsegundos)
assertThat(leidoDeLaBd.creadoEn()).isEqualToIgnoringNanos(escrito.creadoEn());
assertThat(fechaHora).isEqualToIgnoringSeconds(otraFechaHora);
// ── Duraciones y periodos ────────────────────────────────────────────────────
assertThat(Duration.between(inicio, fin)).isLessThan(Duration.ofSeconds(2));
assertThat(plazoEntrega).hasDays(3);
assertThat(Period.between(nacimiento, HOY).getYears()).isGreaterThanOrEqualTo(18);
// ── Zonas horarias: el error clásico ─────────────────────────────────────────
// 'timestamp with time zone' en la BD, OffsetDateTime en Java, UTC en la JVM.
// Y en el test, la zona SIEMPRE explícita:
var madrid = ZoneId.of("Europe/Madrid");
assertThat(pedido.creadoEn().atZone(madrid).getHour()).isEqualTo(11); // 10:00Z = 11:00 en marzo (CET)
(1) El que falla a medianoche: usa
LocalDate.now() dentro de la lógica.
Cura: inyecta Clock y usa Clock.fixed en el test.(2) El que falla el último día del mes o en año bisiesto: el caso límite existía y no se probó. Cura: test parametrizado con 29 de febrero, 31 de enero, cambio de año y cambio de hora (el 29 de marzo de 2026 a las 02:00 en España no existe).
(3) El que falla solo en CI: la JVM de CI está en UTC y tu portátil en
Europe/Madrid. Cura: fija -Duser.timezone=UTC y
-Duser.language=es -Duser.country=ES en la configuración de Surefire, para que local y CI
sean idénticos, y nunca dependas de la zona por defecto en la lógica.
<!-- Que local y CI se comporten igual: fija zona, locale y codificación -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>-Duser.timezone=UTC -Duser.language=es -Duser.country=ES -Dfile.encoding=UTF-8</argLine>
</configuration>
</plugin>
4.9 Aserciones sobre JSON
// ── 1 · JsonPath dentro de MockMvc (lo más habitual en Spring) ───────────────
mvc.perform(get("/api/v1/pedidos/P-1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value("P-1"))
.andExpect(jsonPath("$.estado").value("NUEVO"))
.andExpect(jsonPath("$.total").value(121.00))
.andExpect(jsonPath("$.lineas", hasSize(2)))
.andExpect(jsonPath("$.lineas[0].sku").value("SKU-1"))
.andExpect(jsonPath("$.lineas[*].sku").value(containsInAnyOrder("SKU-1", "SKU-2")))
.andExpect(jsonPath("$.password").doesNotExist()) // ← comprobación de seguridad
.andExpect(jsonPath("$.creadoEn").exists());
// ── 2 · JsonPath sobre una cadena, con AssertJ ───────────────────────────────
String json = respuesta.body();
assertThat(JsonPath.<String>read(json, "$.estado")).isEqualTo("NUEVO");
assertThat(JsonPath.<List<String>>read(json, "$.lineas[*].sku")).containsExactly("SKU-1", "SKU-2");
// ── 3 · JSONAssert: compara documentos completos ignorando orden y formato ───
JSONAssert.assertEquals("""
{
"id": "P-1",
"estado": "NUEVO",
"total": 121.00
}
""", json, JSONCompareMode.LENIENT); // LENIENT: permite campos extra en el actual
// STRICT: exige exactamente los mismos campos. Útil para detectar campos filtrados por error.
// STRICT_ORDER / NON_EXTENSIBLE: variantes intermedias.
// Con ignorado selectivo de campos impredecibles
JSONAssert.assertEquals(esperado, json, new CustomComparator(JSONCompareMode.LENIENT,
new Customization("id", (o1, o2) -> true),
new Customization("creadoEn", (o1, o2) -> true)));
// ── 4 · @JsonTest con JacksonTester: serialización pura, sin web ─────────────
@JsonTest
class PedidoDtoJsonTest {
@Autowired JacksonTester<PedidoDto> json;
@Test
void serializa_los_importes_como_numero_con_dos_decimales() throws Exception {
var dto = new PedidoDto("P-1", Estado.NUEVO, new BigDecimal("121.00"));
assertThat(json.write(dto)).hasJsonPathStringValue("$.id")
.extractingJsonPathNumberValue("$.total").isEqualTo(121.00);
assertThat(json.write(dto)).isEqualToJson("/ejemplos/pedido-nuevo.json");
}
@Test
void ignora_los_campos_desconocidos_al_deserializar() throws Exception {
var contenido = """
{ "id": "P-1", "estado": "NUEVO", "total": 121.00, "campoQueYaNoExiste": true }
""";
assertThat(json.parseObject(contenido).id()).isEqualTo("P-1");
}
}
| Herramienta | Úsala para | Ventaja | Inconveniente |
|---|---|---|---|
jsonPath() de MockMvc | Comprobar campos concretos de una respuesta | Preciso, buenos mensajes, sin ficheros extra | Verboso si compruebas veinte campos |
| JSONAssert | Comparar el documento completo | Ignora orden de claves y formato; modo estricto detecta campos de más | Los mensajes de fallo son menos claros con documentos grandes |
JacksonTester | Tests de serialización aislados | Rapidísimo, comprueba tu configuración real de Jackson | No prueba el binding del controlador |
| Aserciones sobre el objeto deserializado | Cuando el contrato es el objeto, no el texto | Refactor-seguro, tipado | No detecta cambios en nombres de campo JSON |
| Ficheros de ejemplo (approval) | Respuestas grandes y estables | El diff completo es facilísimo de revisar | Tienta a actualizar el fichero sin pensar cuando falla |
5 · Test doubles y Mockito
Un test double es cualquier cosa que pones en lugar de una dependencia real. Mockito es la biblioteca dominante en Java para crearlos, y también la herramienta que más daño hace cuando se usa sin criterio: una suite sobre-mockeada es más frágil que no tener tests, porque cada refactor obliga a reescribirla y da confianza en comportamientos que nunca se han ejecutado de verdad.
5.1 Los cinco tipos de doble, con ejemplo de cada uno
La taxonomía es de Gerard Meszaros y la popularizó Martin Fowler. Saber distinguirlos importa porque «mock» se usa coloquialmente para los cinco, y eso oculta que en la mayoría de los casos lo que necesitas no es un mock.
| Tipo | Qué es | Se usa para | Verifica interacciones |
|---|---|---|---|
| Dummy | Un objeto que se pasa pero nunca se usa. Podría ser null si el código lo permitiera. | Rellenar un parámetro obligatorio irrelevante para el caso. | No |
| Stub | Devuelve respuestas prefijadas. No tiene lógica ni recuerda nada. | Controlar lo que entra en la clase bajo prueba. | No |
| Spy | Envuelve un objeto real y registra las llamadas; puede delegar o interceptar. | Comprobar que se llamó a algo, manteniendo el comportamiento real. | Sí, a posteriori |
| Mock | Se programa con expectativas y falla si no se cumplen. | Verificar lo que sale: que se envió el correo, que se publicó el evento. | Sí, es su razón de ser |
| Fake | Implementación real pero simplificada: funciona de verdad, en memoria. | Sustituir un repositorio, una caché o un servicio completo sin infraestructura. | No: se aserta sobre su estado |
// ── DUMMY: existe para rellenar el hueco ─────────────────────────────────────
@Test
void el_constructor_valida_el_importe_antes_de_usar_el_notificador() {
Notificador dummy = mock(Notificador.class); // nunca se usa; podría ser (a, b) -> {}
assertThatIllegalArgumentException()
.isThrownBy(() -> new Transferencia(euros("-1.00"), dummy));
}
// ── STUB: controla la entrada ────────────────────────────────────────────────
@Test
void aplica_el_tipo_de_cambio_del_dia() {
TipoDeCambio stub = mock(TipoDeCambio.class);
when(stub.eurosPorDolar()).thenReturn(new BigDecimal("0.92")); // respuesta fija
var resultado = new Conversor(stub).aEuros(dolares("100.00"));
assertThat(resultado).isEqualTo(euros("92.00")); // aserto sobre el RESULTADO
}
// ── SPY: objeto real, con vigilancia ─────────────────────────────────────────
@Test
void la_cache_evita_la_segunda_consulta() {
var repositorioReal = new RepositorioEnMemoria(List.of(PRODUCTO));
var espia = spy(repositorioReal);
var cache = new CatalogoCacheado(espia);
cache.buscar("SKU-1");
cache.buscar("SKU-1");
verify(espia, times(1)).buscar("SKU-1"); // el comportamiento real se ejecutó, pero solo una vez
}
// ── MOCK: verifica la salida ─────────────────────────────────────────────────
@Test
void al_confirmar_se_notifica_al_cliente() {
Notificador notificador = mock(Notificador.class);
var servicio = new ServicioPedidos(repositorio, notificador);
servicio.confirmar("P-1");
verify(notificador).enviarConfirmacion("ana@ejemplo.com", "P-1"); // la SALIDA es el efecto
}
// ── FAKE: implementación de verdad, simplificada ─────────────────────────────
public class RepositorioPedidosEnMemoria implements RepositorioPedidos {
private final Map<String, Pedido> datos = new ConcurrentHashMap<>();
@Override public Optional<Pedido> buscar(String id) { return Optional.ofNullable(datos.get(id)); }
@Override public void guardar(Pedido pedido) { datos.put(pedido.id(), pedido); }
@Override public List<Pedido> porEstado(Estado e) {
return datos.values().stream().filter(p -> p.estado() == e).toList();
}
@Override public boolean existeReferencia(String r) {
return datos.values().stream().anyMatch(p -> r.equals(p.referencia()));
}
// Utilidades solo para tests
public void precargar(Pedido... pedidos) { Stream.of(pedidos).forEach(this::guardar); }
public int tamano() { return datos.size(); }
}
@Test
void confirmar_persiste_el_pedido_en_estado_confirmado() {
var repositorio = new RepositorioPedidosEnMemoria();
repositorio.precargar(PedidoMother.nuevo("P-1"));
var servicio = new ServicioPedidos(repositorio, notificador);
servicio.confirmar("P-1");
// Aserción sobre el ESTADO del fake: nada de verify, nada de when
assertThat(repositorio.buscar("P-1")).get()
.extracting(Pedido::estado).isEqualTo(Estado.CONFIRMADO);
}
5.2 Cuándo un fake gana a un mock
Esta es la decisión de diseño de tests con más impacto en la mantenibilidad de una suite, y casi nadie
la plantea explícitamente. Un repositorio en memoria cuesta 30 líneas escribirlo y ahorra
when(...).thenReturn(...) en cien tests.
Usa un mock cuando…
- La interacción es el comportamiento: enviar un correo, publicar un evento, cobrar en una pasarela. El efecto sale del sistema y no hay estado que consultar.
- Necesitas simular un fallo concreto:
thenThrow(new TimeoutException()). - La dependencia tiene uno o dos métodos y un fake no aportaría nada.
- Quieres verificar que algo no ocurrió:
verify(pasarela, never()).cobrar(any()).
Usa un fake cuando…
- La dependencia es un almacén: repositorio, caché, cola. Su semántica es «guarda y devuelve», y un mock te obliga a reimplementarla caso por caso.
- Muchos tests usan la misma dependencia con configuraciones distintas: el fake elimina la repetición.
- Quieres asertar sobre el estado final, que es más robusto ante refactors que verificar llamadas.
- La interfaz tiene más de cinco métodos y cada test necesita stubbear tres.
// ❌ El mismo test con mocks: seis líneas de preparación que reimplementan un Map
@Test
void con_mocks() {
when(repositorio.buscar("P-1")).thenReturn(Optional.of(pedido));
when(repositorio.existeReferencia("REF-1")).thenReturn(false);
doNothing().when(repositorio).guardar(any());
when(repositorio.porEstado(Estado.NUEVO)).thenReturn(List.of(pedido));
servicio.confirmar("P-1");
var captor = ArgumentCaptor.forClass(Pedido.class);
verify(repositorio).guardar(captor.capture());
assertThat(captor.getValue().estado()).isEqualTo(Estado.CONFIRMADO);
}
// ✅ El mismo test con un fake: se lee como el caso de negocio
@Test
void con_fake() {
repositorio.precargar(PedidoMother.nuevo("P-1"));
servicio.confirmar("P-1");
assertThat(repositorio.buscar("P-1")).get()
.extracting(Pedido::estado).isEqualTo(Estado.CONFIRMADO);
}
Map no valida
restricciones de unicidad, no falla por bloqueo optimista y no trunca cadenas a 255 caracteres. Dos
contramedidas: (1) escribe el fake con las invariantes importantes (que lance si la referencia está
duplicada, por ejemplo); (2) ten una batería de tests de contrato del repositorio que se
ejecute dos veces, contra el fake y contra la implementación JPA real con Testcontainers. Si las dos
pasan los mismos tests, el fake es fiable. Esto se hace con una interfaz de test y dos subclases, y es
una de las técnicas más elegantes que existen.
// Contrato de repositorio ejecutado contra las DOS implementaciones.
// Si el fake se desvía del real, un test se pone rojo.
interface ContratoRepositorioPedidos {
RepositorioPedidos repositorio();
@Test
default void guardar_y_recuperar_devuelve_el_mismo_pedido() {
var pedido = PedidoMother.nuevo("P-1");
repositorio().guardar(pedido);
assertThat(repositorio().buscar("P-1")).get()
.extracting(Pedido::referencia).isEqualTo(pedido.referencia());
}
@Test
default void buscar_lo_que_no_existe_devuelve_vacio() {
assertThat(repositorio().buscar("NO-EXISTE")).isEmpty();
}
@Test
default void no_se_admiten_dos_pedidos_con_la_misma_referencia() {
repositorio().guardar(PedidoMother.conReferencia("REF-1"));
assertThatThrownBy(() -> repositorio().guardar(PedidoMother.conReferencia("REF-1")))
.isInstanceOf(ReferenciaDuplicada.class);
}
}
class RepositorioEnMemoriaTest implements ContratoRepositorioPedidos {
private final RepositorioPedidosEnMemoria repo = new RepositorioPedidosEnMemoria();
@Override public RepositorioPedidos repositorio() { return repo; }
}
@DataJpaTest
@Testcontainers
@Tag("integration")
class RepositorioJpaTest implements ContratoRepositorioPedidos {
@Autowired RepositorioPedidosJpa repo;
@Override public RepositorioPedidos repositorio() { return repo; }
}
5.3 Mockito: creación de dobles e inyección
@ExtendWith(MockitoExtension.class) // ← la extensión: inicializa, verifica y limpia
class ServicioPedidosTest {
@Mock RepositorioPedidos repositorio; // doble vacío: todo devuelve null/0/empty
@Mock PasarelaPago pasarela;
@Mock Notificador notificador;
@Spy CalculadoraIva iva = new CalculadoraIva(); // objeto real vigilado
@Captor ArgumentCaptor<Pedido> capturaPedido;
@InjectMocks ServicioPedidos servicio; // construye el SUT inyectando los mocks
// Alternativa sin @InjectMocks (recomendada: explícita y refactor-segura)
// ServicioPedidos servicio;
// @BeforeEach void init() {
// servicio = new ServicioPedidos(repositorio, pasarela, notificador, RELOJ_FIJO);
// }
@Test
void confirmar_cobra_guarda_y_notifica() {
when(repositorio.buscar("P-1")).thenReturn(Optional.of(PedidoMother.nuevo("P-1")));
servicio.confirmar("P-1");
verify(pasarela).cobrar(euros("121.00"));
verify(repositorio).guardar(capturaPedido.capture());
assertThat(capturaPedido.getValue().estado()).isEqualTo(Estado.CONFIRMADO);
}
}
| Forma de crear el doble | Cuándo | Comentario |
|---|---|---|
@ExtendWith(MockitoExtension.class) + @Mock | Por defecto | Inicializa los campos, aplica modo estricto y valida el uso al terminar. La opción correcta. |
mock(Tipo.class) | Dobles locales de un solo test | Perfecto para un dummy o para un doble que solo usa un test. |
MockitoAnnotations.openMocks(this) en @BeforeEach | Cuando no puedes usar la extensión | Recuerda cerrar el AutoCloseable en @AfterEach o tendrás fugas de memoria. |
@InjectMocks | SUT con muchas dependencias | Cómodo pero silencioso: si añades una dependencia y olvidas el @Mock, se inyecta null y el fallo es un NPE oscuro. Preferible el constructor explícito. |
mock(Tipo.class, RETURNS_DEEP_STUBS) | Casi nunca | Permite a.getB().getC() sin configurar. Es un imán de train wrecks: si lo necesitas, arregla el diseño. |
mock(Tipo.class, CALLS_REAL_METHODS) | Casos raros con clases abstractas | Suele ser mejor un @Spy o una subclase de test. |
5.4 Stubbing: thenReturn, thenThrow, thenAnswer
// ── Valores de retorno ───────────────────────────────────────────────────────
when(repositorio.buscar("P-1")).thenReturn(Optional.of(pedido));
when(repositorio.buscar(anyString())).thenReturn(Optional.empty()); // caso general
// Llamadas consecutivas: primera vez una cosa, después otra
when(cliente.consultarEstado("P-1"))
.thenReturn(Estado.PENDIENTE)
.thenReturn(Estado.PENDIENTE)
.thenReturn(Estado.CONFIRMADO); // ideal para probar reintentos y sondeos
when(reloj.instant())
.thenReturn(T0, T0.plusSeconds(1), T0.plusSeconds(2)); // forma abreviada
// ── Excepciones ──────────────────────────────────────────────────────────────
when(pasarela.cobrar(any())).thenThrow(new PagoRechazado("fondos insuficientes"));
when(cliente.get(any())).thenThrow(SocketTimeoutException.class); // Mockito la instancia
// Para métodos void hay que invertir el orden: doX().when(mock).metodo()
doThrow(new PagoRechazado("fondos")).when(pasarela).cobrarVoid(any());
doNothing().when(auditor).registrar(any());
doAnswer(inv -> { throw new IllegalStateException(); }).when(auditor).registrar(any());
// ── thenAnswer: la respuesta depende de los argumentos ───────────────────────
when(repositorio.guardar(any(Pedido.class)))
.thenAnswer(inv -> {
Pedido p = inv.getArgument(0);
return p.conId(SECUENCIA.incrementAndGet()); // simula la asignación de id de la BD
});
when(conversor.convertir(any(), any()))
.thenAnswer(inv -> {
Dinero importe = inv.getArgument(0);
Moneda destino = inv.getArgument(1);
return new Dinero(importe.cantidad().multiply(TASAS.get(destino)), destino);
});
// Simular latencia (¡con cuidado: ralentiza la suite!)
when(cliente.get(any())).thenAnswer(inv -> {
Thread.sleep(50);
return RESPUESTA;
});
// ── doReturn: cuándo hace falta y por qué ────────────────────────────────────
// 1) Con @Spy, when(...) EJECUTA el método real antes de stubbearlo:
var espia = spy(new ServicioLento());
// when(espia.calcular()).thenReturn(42); // ❌ ejecuta calcular() de verdad (¡5 segundos!)
doReturn(42).when(espia).calcular(); // ✅ no lo ejecuta
// 2) Con métodos void que lanzan
doThrow(new IllegalStateException()).when(mock).metodoVoid();
// 3) Cuando el tipo de retorno no encaja en la firma genérica y el compilador protesta.
// ── Métodos por defecto de interfaces y valores de retorno por defecto ───────
// Un mock devuelve: null para objetos, 0 para números, false, colección VACÍA (no null)
// y Optional.empty(). Esto último evita muchos NPE, pero también oculta stubs olvidados.
5.5 Verificación: times, never, inOrder
// ── Número de invocaciones ───────────────────────────────────────────────────
verify(notificador).enviar(CORREO); // exactamente 1 vez (por defecto)
verify(notificador, times(3)).enviar(any());
verify(notificador, never()).enviar("jefe@ejemplo.com"); // ← comprobación muy valiosa
verify(notificador, atLeastOnce()).enviar(any());
verify(notificador, atLeast(2)).enviar(any());
verify(notificador, atMost(5)).enviar(any());
verify(repositorio, only()).guardar(any()); // esa y ninguna otra llamada al mock
// ── Nada más que lo verificado ───────────────────────────────────────────────
verify(pasarela).cobrar(euros("121.00"));
verifyNoMoreInteractions(pasarela); // falla si hubo alguna otra llamada
verifyNoInteractions(notificador); // el mock no se tocó en absoluto
// ── Orden entre llamadas y entre mocks ───────────────────────────────────────
var orden = inOrder(repositorio, pasarela, notificador);
orden.verify(repositorio).buscar("P-1");
orden.verify(pasarela).cobrar(any());
orden.verify(repositorio).guardar(any());
orden.verify(notificador).enviarConfirmacion(any(), any());
// Solo verifica el orden RELATIVO de lo indicado; puede haber otras llamadas entremedias.
// ── Con timeout, para código asíncrono ───────────────────────────────────────
verify(notificador, timeout(2000)).enviar(any()); // espera hasta 2 s
verify(notificador, timeout(2000).times(3)).enviar(any());
verify(notificador, after(500).never()).enviar(any()); // durante 500 ms NO debe llamarse
// Aun así, para asincronía prefiere Awaitility: mensajes de fallo mucho mejores.
verifyNoMoreInteractions es un arma de doble filo. Convierte cualquier llamada
adicional —incluso una nueva métrica o un log estructurado— en un test rojo. Úsalo solo cuando
«no hacer nada más» sea parte del contrato: por ejemplo, que un pago rechazado no genere
ninguna escritura. Para el resto de casos, verifica lo que te importa y nada más. El test que verifica
todo es el test que se rompe siempre.
5.6 ArgumentCaptor frente a assertArg
// ── Captor clásico: verificas, capturas y luego asertas ─────────────────────
@Test
void al_confirmar_se_guarda_el_pedido_con_fecha_y_estado() {
when(repositorio.buscar("P-1")).thenReturn(Optional.of(PedidoMother.nuevo("P-1")));
servicio.confirmar("P-1");
var captor = ArgumentCaptor.forClass(Pedido.class);
verify(repositorio).guardar(captor.capture());
assertThat(captor.getValue())
.extracting(Pedido::estado, Pedido::confirmadoEn)
.containsExactly(Estado.CONFIRMADO, AHORA);
}
// Varias capturas: getAllValues() devuelve la lista en orden de llamada
@Test
void se_notifica_a_los_tres_destinatarios_en_orden() {
servicio.notificarIncidencia(INCIDENCIA);
var captor = ArgumentCaptor.forClass(String.class);
verify(notificador, times(3)).enviar(captor.capture());
assertThat(captor.getAllValues())
.containsExactly("cliente@x.com", "soporte@x.com", "auditoria@x.com");
}
// ── assertArg (Mockito 5.3+): más conciso y con mejores mensajes ─────────────
@Test
void al_confirmar_se_guarda_el_pedido_correcto() {
when(repositorio.buscar("P-1")).thenReturn(Optional.of(PedidoMother.nuevo("P-1")));
servicio.confirmar("P-1");
verify(repositorio).guardar(assertArg(p -> {
assertThat(p.estado()).isEqualTo(Estado.CONFIRMADO);
assertThat(p.confirmadoEn()).isEqualTo(AHORA);
assertThat(p.total()).isEqualTo(euros("121.00"));
}));
}
// Ventaja sobre argThat(): si la aserción falla, ves POR QUÉ falla, no un genérico
// «Argument(s) are different». Ese detalle ahorra muchísimo tiempo de depuración.
| Técnica | Cuándo usarla | Mensaje de fallo |
|---|---|---|
verify(mock).metodo(valorConcreto) | Cuando puedes construir el valor esperado completo y tiene equals | Bueno: muestra las diferencias |
assertArg(...) | Cuando solo te importan algunos campos del argumento | El mejor: el mensaje es el de AssertJ |
ArgumentCaptor | Cuando necesitas el objeto para más comprobaciones o hay varias llamadas | Bueno, pero se aserta después de verify |
argThat(predicado) | Solo si necesitas emparejar durante el stubbing; evítalo para verificar | Malo: «Argument(s) are different» sin decir qué campo |
5.7 Matchers: la regla de no mezclar
// Matchers habituales
when(repositorio.buscar(anyString())).thenReturn(Optional.empty());
when(servicio.calcular(anyInt(), anyDouble())).thenReturn(0.0);
when(repositorio.buscarTodos(any(Pageable.class))).thenReturn(Page.empty());
when(mapa.get(eq("clave"))).thenReturn("valor");
when(validador.valida(argThat(p -> p.total().esPositivo()))).thenReturn(true);
verify(cache).put(startsWith("pedido:"), any());
verify(repositorio).eliminar(isNull());
verify(auditor).registrar(nullable(String.class), any()); // acepta null y no-null
// ⚠️ LA REGLA: si usas un matcher en una llamada, TODOS los argumentos deben ser matchers.
// ❌ InvalidUseOfMatchersException
verify(notificador).enviar("ana@ejemplo.com", any(Plantilla.class));
// ✅ envuelve el valor literal con eq()
verify(notificador).enviar(eq("ana@ejemplo.com"), any(Plantilla.class));
// ⚠️ anyString() NO empareja null (desde Mockito 2). Para null explícito:
verify(servicio).procesar(nullable(String.class));
// ⚠️ any() sí empareja null y cualquier tipo; any(Tipo.class) comprueba el tipo.
// En un método sobrecargado, usa siempre la variante tipada para evitar ambigüedad.
any() por comodidad: escribir when(repositorio.buscar(any())) es
tentador, pero convierte el test en algo más débil: pasaría igual si tu código buscase el pedido
equivocado. Usa el valor concreto siempre que lo conozcas
(when(repositorio.buscar("P-1"))). Como efecto secundario, con el modo estricto obtendrás un
PotentialStubbingProblem si el código llama con otro argumento, que es exactamente la señal
que quieres.
5.8 mockStatic y mockConstruction
Mockito puede interceptar métodos estáticos y constructores desde la versión 3.4, sin necesidad de PowerMock. Que se pueda no significa que se deba: casi siempre indican que hay una dependencia oculta que debería ser explícita. Aun así, en código heredado que no puedes cambiar son la diferencia entre tener test y no tenerlo.
// ── mockStatic: SIEMPRE con try-with-resources (el mock es por hilo) ─────────
@Test
void factura_con_la_fecha_del_sistema() {
try (var mockedInstant = mockStatic(Instant.class, CALLS_REAL_METHODS)) {
mockedInstant.when(Instant::now).thenReturn(Instant.parse("2026-03-15T10:00:00Z"));
var factura = GeneradorFacturasLegacy.generar(PEDIDO); // usa Instant.now() por dentro
assertThat(factura.fecha()).isEqualTo(LocalDate.parse("2026-03-15"));
} // ← al cerrar, el estático vuelve a su comportamiento normal. Si te lo saltas,
// contaminas TODOS los tests siguientes del mismo hilo.
}
// Verificar llamadas a un estático
try (var utils = mockStatic(FicheroUtils.class)) {
servicio.exportar();
utils.verify(() -> FicheroUtils.escribir(eq(Path.of("/tmp/export.csv")), anyString()));
}
// ── mockConstruction: intercepta los new de una clase concreta ───────────────
@Test
void el_servicio_legacy_usa_el_cliente_http_interno() {
try (var construidos = mockConstruction(ClienteHttpInterno.class,
(mock, contexto) -> when(mock.get(anyString())).thenReturn("{\"ok\":true}"))) {
var resultado = new ServicioLegacy().consultar("/estado"); // hace new ClienteHttpInterno()
assertThat(resultado).isEqualTo("{\"ok\":true}");
assertThat(construidos.constructed()).hasSize(1);
verify(construidos.constructed().get(0)).get("/estado");
}
}
mockStatic, intenta esto:(1)
Instant.now() / LocalDate.now() → inyecta un Clock.(2)
UUID.randomUUID() → inyecta un Supplier<UUID> o un
GeneradorIds.(3)
Math.random() → inyecta un Random con semilla o un
DoubleSupplier.(4)
Files.readString(...) → inyecta una interfaz LectorRecursos.(5) Un singleton estático → conviértelo en un bean inyectado.
Cada una de esas cinco refactorizaciones cuesta diez minutos, hace el código más testeable para siempre y elimina la dependencia de un mecanismo que se rompe con la ejecución en paralelo. Añade
mockito-inline solo si de verdad no puedes tocar el código.
5.9 Modo estricto y UnnecessaryStubbingException
Desde Mockito 2, MockitoExtension aplica Strictness.STRICT_STUBS, y es una de
las mejores decisiones de la biblioteca. Detecta dos clases de error que antes pasaban desapercibidas.
// 1 · UnnecessaryStubbingException: has configurado un stub que nadie usa.
// Suele significar que el test está probando otra cosa de la que crees,
// o que quedó basura de un refactor anterior.
@Test
void ejemplo_de_stub_innecesario() {
when(repositorio.buscar("P-1")).thenReturn(Optional.of(pedido));
when(pasarela.comisiones()).thenReturn(euros("1.00")); // ← el código nunca lo llama
servicio.confirmar("P-1");
verify(repositorio).guardar(any());
}
/* → org.mockito.exceptions.misusing.UnnecessaryStubbingException:
Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.
1. -> at ServicioPedidosTest.ejemplo_de_stub_innecesario(ServicioPedidosTest.java:42) */
// 2 · PotentialStubbingProblem: el código llamó al método con OTRO argumento.
// Es un fallo real disfrazado: tu código está pidiendo el pedido equivocado.
@Test
void ejemplo_de_argumento_inesperado() {
when(repositorio.buscar("P-1")).thenReturn(Optional.of(pedido));
servicio.confirmar("P-2"); // ← llama a buscar("P-2"), que no está stubbeado
}
/* → PotentialStubbingProblem: Strict stubbing argument mismatch.
Actual invocation has different arguments: repositorio.buscar("P-2")
Stubbed: repositorio.buscar("P-1") */
// Escapes, solo cuando estén justificados:
lenient().when(pasarela.comisiones()).thenReturn(euros("1.00")); // este stub concreto
@MockitoSettings(strictness = Strictness.LENIENT) // toda la clase (evítalo)
@Mock(lenient = true) Auditor auditor; // este mock concreto
LENIENT para «que deje de molestar». Cada excepción de modo estricto es
información: o hay código de test muerto, o hay un comportamiento distinto del que asumías. Los dos
únicos casos donde lenient() está justificado son un stub común en
@BeforeEach que solo usan algunos tests de la clase (y suele ser mejor moverlo a los tests
que lo usan) y algún @Nested que comparte preparación parcial.
5.10 BDDMockito: given/willReturn/then
import static org.mockito.BDDMockito.*;
@Test
void un_pago_rechazado_no_cambia_el_estado_del_pedido() {
// GIVEN
given(repositorio.buscar("P-1")).willReturn(Optional.of(PedidoMother.nuevo("P-1")));
willThrow(new PagoRechazado("fondos")).given(pasarela).cobrar(any());
// WHEN
var resultado = catchThrowable(() -> servicio.confirmar("P-1"));
// THEN
assertThat(resultado).isInstanceOf(PagoRechazado.class);
then(repositorio).should(never()).guardar(any());
then(notificador).shouldHaveNoInteractions();
}
// Tabla de equivalencias
// when(x).thenReturn(y) → given(x).willReturn(y)
// when(x).thenThrow(e) → given(x).willThrow(e)
// when(x).thenAnswer(a) → given(x).willAnswer(a)
// doThrow(e).when(m).metodo() → willThrow(e).given(m).metodo()
// doNothing().when(m).metodo() → willDoNothing().given(m).metodo()
// verify(m).metodo() → then(m).should().metodo()
// verify(m, never()).metodo() → then(m).should(never()).metodo()
// verifyNoInteractions(m) → then(m).shouldHaveNoInteractions()
Funcionalmente son idénticos. La ventaja de BDDMockito es la coherencia visual: el
bloque given del test usa given, el then usa then, y no
aparece la palabra when en el sitio equivocado (en Mockito clásico,
when(...) está en la fase arrange, lo cual confunde a quien empieza). Elige uno
por proyecto y sé consistente.
5.11 Lo que no se debe mockear
| No mockees… | Por qué | Alternativa |
|---|---|---|
Objetos de valor y entidades de tu dominio (Dinero, Pedido, Cliente) |
Son baratos de construir, no tienen efectos y su comportamiento es lo que quieres probar. Mockearlos oculta errores reales. | Constrúyelos de verdad, con un builder u Object Mother. |
Clases de la biblioteca estándar (String, List, Optional, Map) |
Estarías probando el JDK, y el mock se comportará distinto del real. | Usa la implementación real. Una ArrayList es más rápida que un mock. |
| La base de datos y el ORM | El 90 % de los fallos de persistencia están en el SQL generado, en el mapeo o en el flush. Un mock del repositorio no ve nada de eso. | Testcontainers con la base de datos real (sección 8). |
| Clientes HTTP y serialización | Los bugs viven en cabeceras, códigos de estado, tiempos de espera y JSON, no en la firma del método. | WireMock o MockWebServer: simulan el protocolo, no tu código. |
Tipos que no controlas y son complejos (EntityManager, KafkaTemplate, RestTemplate) |
Acabas replicando su semántica interna en stubs, y cuando cambien de versión tu test seguirá verde mintiendo. | Envuélvelos en una interfaz propia y mockea tu interfaz; o prueba contra lo real. |
| Métodos estáticos y constructores (si puedes evitarlo) | Indican dependencias ocultas y rompen la ejecución paralela. | Inyección de dependencias (ver 5.8 y 5.13). |
| El sistema bajo prueba, aunque sea parcialmente | Un @Spy del propio SUT con métodos stubbeados significa que estás probando una versión que no existe en producción. |
Extrae la parte que querías stubbear a una colaboradora y mockea esa. |
5.12 Sobre-mockeo: síntomas y cómo salir
// 🚩 El test que grita «mal diseño». Ocho líneas de mocks para probar una suma.
@Test
void calcular_el_total_del_pedido() {
when(repositorio.buscar("P-1")).thenReturn(Optional.of(pedido));
when(pedido.lineas()).thenReturn(List.of(linea1, linea2)); // ← mock de una entidad
when(linea1.subtotal()).thenReturn(euros("100.00")); // ← mock de un valor
when(linea2.subtotal()).thenReturn(euros("50.00"));
when(calculadoraIva.tipo(any())).thenReturn(21);
when(calculadoraIva.aplicar(any(), anyInt())).thenReturn(euros("181.50"));
when(servicioEnvio.coste(any())).thenReturn(euros("0.00"));
when(servicioDescuentos.aplicables(any())).thenReturn(List.of());
var total = servicio.total("P-1");
assertThat(total).isEqualTo(euros("181.50")); // ¡el resultado lo ha dicho un mock!
}
// Este test no comprueba NADA: el valor esperado se lo has dado tú al mock. Pasaría
// igual si la fórmula del IVA estuviera al revés.
| Síntoma | Qué revela | Salida |
|---|---|---|
| Más de 3 o 4 mocks en un test | La clase tiene demasiadas dependencias: hace demasiado. | Extrae la lógica pura a una clase de dominio sin dependencias y pruébala sin dobles. |
| El resultado esperado lo produce un mock | El test es tautológico. No verifica nada. | Usa objetos reales para el cálculo y mockea solo los bordes (E/S). |
when encadenados de tres niveles (a.getB().getC()) | Violación de la ley de Demeter: el código navega por estructuras ajenas. | Pasa el dato que necesitas, no el objeto que lo contiene («tell, don't ask»). |
| Cada refactor rompe veinte tests aunque el comportamiento no cambie | Los tests describen la implementación. | Reescríbelos asertando sobre el resultado. Con un fake suele desaparecer el problema. |
Solo hay verify y ninguna aserción de estado | Estás documentando llamadas, no comportamiento. | Pregunta: «¿qué cambia en el mundo?» y aserta sobre eso. |
| Mockeas tus propias entidades | El dominio es anémico o tiene dependencias que no debería tener. | Constrúyelas de verdad. Si no puedes, el diseño del dominio necesita trabajo. |
// ✅ La salida: separar el cálculo (puro, testeable sin dobles) de la orquestación.
// 1 · Dominio puro. Cero dependencias, cero mocks en su test.
public record Pedido(String id, List<Linea> lineas, Cliente cliente) {
public Dinero subtotal() {
return lineas.stream().map(Linea::subtotal).reduce(Dinero.CERO, Dinero::mas);
}
public Dinero total(TarifaIva tarifa, PoliticaEnvio envio, List<Descuento> descuentos) {
Dinero base = descuentos.stream().reduce(subtotal(), (d, x) -> x.aplicar(d), Dinero::mas);
return tarifa.aplicar(base).mas(envio.coste(base, cliente));
}
}
// Su test: legible, rápido, sin un solo mock, y comprueba la fórmula DE VERDAD.
@Test
void el_total_es_subtotal_menos_descuentos_mas_iva_mas_envio() {
var pedido = new Pedido("P-1", List.of(
new Linea("SKU-1", 2, euros("50.00")),
new Linea("SKU-2", 1, euros("50.00"))), ClienteMother.estandar());
var total = pedido.total(TarifaIva.GENERAL, PoliticaEnvio.ESTANDAR,
List.of(new DescuentoPorcentual(Porcentaje.de(10))));
// 150 − 10% = 135 → +21% IVA = 163,35 → +0 envío (>50) = 163,35
assertThat(total).isEqualTo(euros("163.35"));
}
// 2 · Orquestación. Aquí sí hay mocks, pero solo dos y solo de los bordes.
@Test
void confirmar_cobra_el_total_calculado_y_guarda() {
repositorio.precargar(PEDIDO_DE_150); // fake en memoria
servicio.confirmar("P-1");
verify(pasarela).cobrar(euros("163.35")); // solo verificamos el borde de salida
assertThat(repositorio.buscar("P-1")).get()
.extracting(Pedido::estado).isEqualTo(Estado.CONFIRMADO);
}
5.13 Diseño testeable: las cinco costuras que siempre necesitas
Casi todos los problemas de testabilidad se reducen a que el código coge cosas del entorno global en lugar de recibirlas. Estas cinco refactorizaciones resuelven el 90 % de los casos y todas siguen el mismo patrón: convertir una dependencia implícita en un parámetro explícito.
// ── 1 · EL TIEMPO: Clock, siempre ────────────────────────────────────────────
// ❌ Imposible de testear sin mockStatic; falla a medianoche y en fin de mes.
public class Suscripcion {
public boolean estaActiva() {
return LocalDate.now().isBefore(fin);
}
}
// ✅ El reloj entra por el constructor. En producción, Clock.systemUTC().
@Service
public class ServicioSuscripciones {
private final Clock reloj;
public ServicioSuscripciones(Clock reloj) { this.reloj = reloj; }
public boolean estaActiva(Suscripcion s) {
return LocalDate.now(reloj).isBefore(s.fin());
}
}
@Configuration
class RelojConfig {
@Bean Clock reloj() { return Clock.systemUTC(); } // producción
}
// En el test: control total, y puedes probar el instante exacto del vencimiento.
private static final Clock RELOJ = Clock.fixed(Instant.parse("2026-03-15T10:00:00Z"), ZoneOffset.UTC);
@ParameterizedTest
@CsvSource({"2026-03-14, false", "2026-03-15, false", "2026-03-16, true"})
void una_suscripcion_esta_activa_hasta_el_dia_anterior_al_fin(LocalDate fin, boolean activa) {
var servicio = new ServicioSuscripciones(RELOJ);
assertThat(servicio.estaActiva(new Suscripcion(fin))).isEqualTo(activa);
}
// Reloj mutable para tests que necesitan avanzar el tiempo:
public class RelojDeTest extends Clock {
private Instant ahora;
public RelojDeTest(Instant inicio) { this.ahora = inicio; }
public void avanzar(Duration d) { ahora = ahora.plus(d); }
@Override public Instant instant() { return ahora; }
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(ZoneId z) { return this; }
}
@Test
void el_token_caduca_a_los_15_minutos() {
var reloj = new RelojDeTest(Instant.parse("2026-03-15T10:00:00Z"));
var token = new ServicioTokens(reloj).emitir("ana");
reloj.avanzar(Duration.ofMinutes(14));
assertThat(token.vigenteEn(reloj)).isTrue();
reloj.avanzar(Duration.ofMinutes(2));
assertThat(token.vigenteEn(reloj)).isFalse();
}
// ── 2 · LOS IDENTIFICADORES ──────────────────────────────────────────────────
public interface GeneradorIds { String siguiente(); }
@Component
class GeneradorUuid implements GeneradorIds {
@Override public String siguiente() { return UUID.randomUUID().toString(); }
}
// En test: secuencia predecible → puedes asertar el id exacto
class GeneradorSecuencial implements GeneradorIds {
private int n = 0;
@Override public String siguiente() { return "ID-" + (++n); }
}
@Test
void el_primer_pedido_recibe_el_id_esperado() {
var servicio = new ServicioPedidos(repositorio, new GeneradorSecuencial(), RELOJ);
assertThat(servicio.crear(PETICION).id()).isEqualTo("ID-1");
}
// ── 3 · LA ALEATORIEDAD ──────────────────────────────────────────────────────
public class SelectorDeGanadores {
private final RandomGenerator aleatorio; // interfaz de Java 17+
public SelectorDeGanadores(RandomGenerator aleatorio) { this.aleatorio = aleatorio; }
…
}
// En producción: new SecureRandom() o RandomGenerator.getDefault()
// En test: new Random(42) → determinista y reproducible
// ── 4 · EL ENTORNO (variables, propiedades, ficheros) ───────────────────────
// ❌ System.getenv("API_KEY") esparcido por el código
// ✅ @ConfigurationProperties o un parámetro del constructor; en test, un objeto de configuración
// ── 5 · LAS ENTRADAS Y SALIDAS EXPLÍCITAS ───────────────────────────────────
// ❌ El método lee de un fichero, calcula y escribe en otro: no se puede probar el cálculo.
public void procesarFichero(Path entrada, Path salida) { … }
// ✅ Separa: leer → calcular (puro, testeable) → escribir.
public List<Movimiento> parsear(Reader entrada) { … } // testeable con StringReader
public Resumen resumir(List<Movimiento> movimientos) { … } // función pura: test trivial
public void escribir(Resumen resumen, Writer salida) { … } // testeable con StringWriter
public void procesarFichero(Path entrada, Path salida) { // 3 líneas de pegamento
try (var r = Files.newBufferedReader(entrada); var w = Files.newBufferedWriter(salida)) {
escribir(resumir(parsear(r)), w);
}
}
6 · TDD en la práctica
TDD (Test-Driven Development) no es «escribir tests»: es una técnica de diseño en la que el test se escribe antes del código porque obliga a decidir la interfaz antes de la implementación. El efecto secundario es una suite completa, pero el efecto principal es que el código nace testeable, con dependencias explícitas y en trozos pequeños. En entrevistas se pregunta muchísimo y casi siempre con la misma trampa: «¿haces TDD?». La respuesta interesante no es sí o no, es cuándo y por qué.
6.1 El ciclo rojo-verde-refactor
┌──────────────────────────────────────────────────────────┐
│ │
▼ │
┌─────────┐ escribe el test más pequeño ┌─────────┐ │
│ ROJO │ que falle por el motivo │ VERDE │ │
│ │ correcto │ │ │
│ 30 s a │ ────────────────────────────► │ 1-3 min │ │
│ 2 min │ hazlo pasar de la forma │ │ │
└─────────┘ más simple posible └────┬────┘ │
▲ │ │
│ ▼ │
│ ┌─────────────┐ │
└───────────────────────────────────│ REFACTOR │────────┘
siguiente comportamiento │ │
│ 1-5 min │
│ (en verde, │
│ siempre) │
└─────────────┘
REGLAS: 1. No escribas código de producción sin un test en rojo.
2. No escribas más test del necesario para fallar.
3. No escribas más código del necesario para pasar.
4. Refactoriza SOLO en verde, y sin añadir comportamiento.
| Fase | Qué haces | Qué NO haces | Cómo sabes que has terminado |
|---|---|---|---|
| Rojo | Escribes un test que describe el siguiente comportamiento pequeño. Lo ejecutas y compruebas que falla por el motivo correcto. | No escribes cinco tests de golpe. No pasas a implementar sin haber visto el rojo. | El test falla con el mensaje esperado (no con un NoSuchMethodError inesperado ni un NPE en la preparación). |
| Verde | Escribes la implementación más simple que haga pasar ese test, aunque sea devolver una constante. | No generalizas «porque ya sé cómo va a ser». No añades el if del caso siguiente. |
Todos los tests pasan. |
| Refactor | Eliminas duplicación, mejoras nombres, extraes métodos. Ejecutas los tests después de cada movimiento. | No añades funcionalidad. No cambias comportamiento. Si necesitas un test nuevo, es que no era refactor. | El código expresa la intención y los tests siguen verdes. |
6.2 Un ejemplo completo, iteración a iteración
Vamos a construir con TDD una calculadora de descuentos con estas reglas de negocio, que iremos descubriendo por orden. Verás el test y el código de producción en cada paso, incluidas las decisiones de diseño que aparecen solas.
Especificación (tal y como la daría negocio)
- Sin promociones, el importe a pagar es el subtotal del carrito.
- Un cupón de porcentaje descuenta ese porcentaje del subtotal.
- Un cupón caducado no descuenta nada, pero no es un error: se informa del motivo.
- Los clientes VIP tienen un 5 % adicional, acumulable con el cupón.
- El descuento total nunca puede superar el 50 % del subtotal.
- Los importes se redondean a dos decimales con half-up, y nunca son negativos.
Iteración 1 · El caso más simple: sin promociones
// 🔴 ROJO. Ni la clase existe todavía. El test define el nombre, la firma y el tipo
// de retorno: son tres decisiones de diseño que tomas AHORA, no al final.
class CalculadoraDescuentoTest {
@Test
void sin_promociones_se_paga_el_subtotal() {
var calculadora = new CalculadoraDescuento();
var carrito = new Carrito(euros("100.00"), Cliente.estandar());
var resultado = calculadora.calcular(carrito, Optional.empty());
assertThat(resultado.aPagar()).isEqualTo(euros("100.00"));
assertThat(resultado.descuento()).isEqualTo(euros("0.00"));
}
}
// No compila → es el primer rojo. Perfectamente válido: el compilador es tu primer test.
// 🟢 VERDE. Lo mínimo. Sí, devolver el subtotal tal cual es «trampa»: es lo correcto
// en TDD. Solo generalizamos cuando un test nos obligue.
public record ResultadoDescuento(Dinero aPagar, Dinero descuento) { }
public class CalculadoraDescuento {
public ResultadoDescuento calcular(Carrito carrito, Optional<Cupon> cupon) {
return new ResultadoDescuento(carrito.subtotal(), Dinero.CERO);
}
}
Iteración 2 · Un cupón de porcentaje
// 🔴 ROJO
@Test
void un_cupon_del_10_por_ciento_descuenta_el_10_por_ciento_del_subtotal() {
var carrito = new Carrito(euros("100.00"), Cliente.estandar());
var cupon = Cupon.porcentual("VERANO10", Porcentaje.de(10));
var resultado = calculadora.calcular(carrito, Optional.of(cupon));
assertThat(resultado.descuento()).isEqualTo(euros("10.00"));
assertThat(resultado.aPagar()).isEqualTo(euros("90.00"));
}
// Falla: descuento esperado 10.00, obtenido 0.00. Motivo correcto. 👍
// 🟢 VERDE, con lo mínimo
public ResultadoDescuento calcular(Carrito carrito, Optional<Cupon> cupon) {
Dinero descuento = cupon
.map(c -> carrito.subtotal().porcentaje(c.porcentaje()))
.orElse(Dinero.CERO);
return new ResultadoDescuento(carrito.subtotal().menos(descuento), descuento);
}
// 🔵 REFACTOR: el cálculo del porcentaje pertenece a Dinero, no a la calculadora.
// Y ya está ahí, así que el refactor consiste en NO haberlo puesto aquí. Bien.
public record Dinero(BigDecimal cantidad, Moneda moneda) {
public static final Dinero CERO = euros("0.00");
public Dinero porcentaje(Porcentaje p) {
return new Dinero(cantidad.multiply(p.comoFraccion())
.setScale(2, RoundingMode.HALF_UP), moneda);
}
public Dinero menos(Dinero otro) { … }
public Dinero mas(Dinero otro) { … }
}
Iteración 3 · El cupón caducado (aparece el reloj)
// 🔴 ROJO. Este test fuerza una decisión de diseño importante: la calculadora
// necesita saber la fecha. ¿De dónde la saca? El test te obliga a elegir, y la
// respuesta correcta es «se la inyectan», nunca LocalDate.now() por dentro.
@Test
void un_cupon_caducado_no_descuenta_e_informa_del_motivo() {
var reloj = Clock.fixed(Instant.parse("2026-03-15T10:00:00Z"), ZoneOffset.UTC);
var calculadora = new CalculadoraDescuento(reloj); // ← nueva firma
var carrito = new Carrito(euros("100.00"), Cliente.estandar());
var cupon = Cupon.porcentual("VERANO10", Porcentaje.de(10),
LocalDate.parse("2026-03-01")); // caducó hace dos semanas
var resultado = calculadora.calcular(carrito, Optional.of(cupon));
assertThat(resultado.descuento()).isEqualTo(euros("0.00"));
assertThat(resultado.aPagar()).isEqualTo(euros("100.00"));
assertThat(resultado.avisos()).containsExactly(Aviso.CUPON_CADUCADO);
}
// 🟢 VERDE
public record ResultadoDescuento(Dinero aPagar, Dinero descuento, List<Aviso> avisos) {
public static ResultadoDescuento sinDescuento(Dinero subtotal, Aviso... avisos) {
return new ResultadoDescuento(subtotal, Dinero.CERO, List.of(avisos));
}
}
public enum Aviso { CUPON_CADUCADO, CUPON_NO_ACUMULABLE, TOPE_DE_DESCUENTO_ALCANZADO }
public class CalculadoraDescuento {
private final Clock reloj;
public CalculadoraDescuento(Clock reloj) { this.reloj = reloj; }
public ResultadoDescuento calcular(Carrito carrito, Optional<Cupon> cupon) {
LocalDate hoy = LocalDate.now(reloj);
if (cupon.isEmpty()) {
return ResultadoDescuento.sinDescuento(carrito.subtotal());
}
if (cupon.get().haCaducadoEn(hoy)) {
return ResultadoDescuento.sinDescuento(carrito.subtotal(), Aviso.CUPON_CADUCADO);
}
Dinero descuento = carrito.subtotal().porcentaje(cupon.get().porcentaje());
return new ResultadoDescuento(carrito.subtotal().menos(descuento), descuento, List.of());
}
}
// Nota: los dos tests anteriores han dejado de compilar (falta el reloj en el constructor).
// Arreglarlos es parte del trabajo: extraemos una constante RELOJ y un @BeforeEach.
// 🔵 REFACTOR de los tests, que también son código y también se refactorizan.
class CalculadoraDescuentoTest {
private static final Clock RELOJ =
Clock.fixed(Instant.parse("2026-03-15T10:00:00Z"), ZoneOffset.UTC);
private static final LocalDate MANANA = LocalDate.parse("2026-03-16");
private static final LocalDate AYER = LocalDate.parse("2026-03-14");
private final CalculadoraDescuento calculadora = new CalculadoraDescuento(RELOJ);
private ResultadoDescuento calcular(Dinero subtotal, Cliente cliente, Cupon... cupon) {
return calculadora.calcular(new Carrito(subtotal, cliente),
cupon.length == 0 ? Optional.empty() : Optional.of(cupon[0]));
}
…
}
Iteración 4 · Cliente VIP y acumulación
// 🔴 ROJO — dos tests, porque son dos comportamientos distintos
@Test
void un_cliente_vip_obtiene_un_5_por_ciento_sin_cupon() {
var resultado = calcular(euros("100.00"), Cliente.vip());
assertThat(resultado.descuento()).isEqualTo(euros("5.00"));
assertThat(resultado.aPagar()).isEqualTo(euros("95.00"));
}
@Test
void el_descuento_vip_se_acumula_con_el_del_cupon() {
var cupon = Cupon.porcentual("VERANO10", Porcentaje.de(10), MANANA);
var resultado = calcular(euros("100.00"), Cliente.vip(), cupon);
assertThat(resultado.descuento()).isEqualTo(euros("15.00")); // 10 % + 5 %, sobre el subtotal
assertThat(resultado.aPagar()).isEqualTo(euros("85.00"));
}
// 🟢 VERDE. Ahora la estructura de ifs empieza a chirriar…
public ResultadoDescuento calcular(Carrito carrito, Optional<Cupon> cupon) {
LocalDate hoy = LocalDate.now(reloj);
Dinero subtotal = carrito.subtotal();
var avisos = new ArrayList<Aviso>();
Dinero descuento = Dinero.CERO;
if (cupon.isPresent()) {
if (cupon.get().haCaducadoEn(hoy)) {
avisos.add(Aviso.CUPON_CADUCADO);
} else {
descuento = descuento.mas(subtotal.porcentaje(cupon.get().porcentaje()));
}
}
if (carrito.cliente().esVip()) {
descuento = descuento.mas(subtotal.porcentaje(Porcentaje.de(5)));
}
return new ResultadoDescuento(subtotal.menos(descuento), descuento, List.copyOf(avisos));
}
// 🔵 REFACTOR. Los tests están en verde: podemos rediseñar con red.
// Extraemos el concepto que ha ido emergiendo: «regla de descuento».
public interface ReglaDescuento {
Optional<Dinero> aplicar(Carrito carrito, LocalDate hoy, Consumer<Aviso> avisos);
}
public record DescuentoPorCupon(Cupon cupon) implements ReglaDescuento {
@Override
public Optional<Dinero> aplicar(Carrito carrito, LocalDate hoy, Consumer<Aviso> avisos) {
if (cupon.haCaducadoEn(hoy)) {
avisos.accept(Aviso.CUPON_CADUCADO);
return Optional.empty();
}
return Optional.of(carrito.subtotal().porcentaje(cupon.porcentaje()));
}
}
public record DescuentoVip() implements ReglaDescuento {
private static final Porcentaje CINCO = Porcentaje.de(5);
@Override
public Optional<Dinero> aplicar(Carrito carrito, LocalDate hoy, Consumer<Aviso> avisos) {
return carrito.cliente().esVip()
? Optional.of(carrito.subtotal().porcentaje(CINCO))
: Optional.empty();
}
}
public class CalculadoraDescuento {
private final Clock reloj;
public CalculadoraDescuento(Clock reloj) { this.reloj = reloj; }
public ResultadoDescuento calcular(Carrito carrito, Optional<Cupon> cupon) {
LocalDate hoy = LocalDate.now(reloj);
var avisos = new ArrayList<Aviso>();
Dinero descuento = reglas(cupon).stream()
.map(r -> r.aplicar(carrito, hoy, avisos::add))
.flatMap(Optional::stream)
.reduce(Dinero.CERO, Dinero::mas);
return new ResultadoDescuento(
carrito.subtotal().menos(descuento), descuento, List.copyOf(avisos));
}
private List<ReglaDescuento> reglas(Optional<Cupon> cupon) {
var reglas = new ArrayList<ReglaDescuento>();
cupon.map(DescuentoPorCupon::new).ifPresent(reglas::add);
reglas.add(new DescuentoVip());
return reglas;
}
}
// Los cinco tests siguen en verde y no se ha tocado ninguno. ESA es la red de seguridad
// en acción: acabas de cambiar la arquitectura de la clase con la certeza de no romper nada.
Iteración 5 · El tope del 50 % (triangulación)
// 🔴 ROJO. Aquí conviene TRIANGULAR: un solo caso no distingue entre «devolver
// el 50 %» y «aplicar min(descuento, 50 %)». Con dos casos, sí.
@ParameterizedTest(name = "subtotal 100 €, cupón del {0}% → descuento {1} €")
@CsvSource({
"10, 15.00", // 10 % + 5 % VIP = 15 % → por debajo del tope
"40, 45.00", // 40 % + 5 % VIP = 45 % → justo por debajo
"45, 50.00", // 45 % + 5 % VIP = 50 % → justo en el tope
"60, 50.00", // 60 % + 5 % VIP = 65 % → recortado al 50 %
"90, 50.00" // extremo
})
void el_descuento_total_nunca_supera_el_50_por_ciento(int porcentajeCupon, BigDecimal esperado) {
var cupon = Cupon.porcentual("X", Porcentaje.de(porcentajeCupon), MANANA);
var resultado = calcular(euros("100.00"), Cliente.vip(), cupon);
assertThat(resultado.descuento().cantidad()).isEqualByComparingTo(esperado);
}
@Test
void al_alcanzar_el_tope_se_informa_con_un_aviso() {
var cupon = Cupon.porcentual("X", Porcentaje.de(60), MANANA);
var resultado = calcular(euros("100.00"), Cliente.vip(), cupon);
assertThat(resultado.avisos()).contains(Aviso.TOPE_DE_DESCUENTO_ALCANZADO);
}
// 🟢 VERDE
public class CalculadoraDescuento {
private static final Porcentaje TOPE = Porcentaje.de(50);
public ResultadoDescuento calcular(Carrito carrito, Optional<Cupon> cupon) {
LocalDate hoy = LocalDate.now(reloj);
var avisos = new ArrayList<Aviso>();
Dinero subtotal = carrito.subtotal();
Dinero bruto = reglas(cupon).stream()
.map(r -> r.aplicar(carrito, hoy, avisos::add))
.flatMap(Optional::stream)
.reduce(Dinero.CERO, Dinero::mas);
Dinero maximo = subtotal.porcentaje(TOPE);
Dinero neto = bruto;
if (bruto.esMayorQue(maximo)) {
neto = maximo;
avisos.add(Aviso.TOPE_DE_DESCUENTO_ALCANZADO);
}
return new ResultadoDescuento(subtotal.menos(neto), neto, List.copyOf(avisos));
}
}
Iteración 6 · Redondeo y no negatividad (los casos límite)
// 🔴 ROJO. Los casos que rompen los sistemas reales: céntimos y ceros.
@ParameterizedTest(name = "subtotal {0} €, cupón 33% → descuento {1} €")
@CsvSource({
"10.00, 3.30",
" 0.01, 0.00", // 0,0033 → redondea a 0,00
" 0.02, 0.01", // 0,0066 → redondea a 0,01 (HALF_UP)
" 1.00, 0.33",
" 1.50, 0.50" // 0,495 → 0,50 con HALF_UP (con HALF_EVEN daría 0,50 también; con FLOOR, 0,49)
})
void el_descuento_se_redondea_a_dos_decimales_half_up(BigDecimal subtotal, BigDecimal esperado) {
var cupon = Cupon.porcentual("X", Porcentaje.de(33), MANANA);
var resultado = calcular(euros(subtotal.toPlainString()), Cliente.estandar(), cupon);
assertThat(resultado.descuento().cantidad()).isEqualByComparingTo(esperado);
}
@Test
void el_importe_a_pagar_de_un_carrito_vacio_es_cero_y_no_negativo() {
var resultado = calcular(euros("0.00"), Cliente.vip(),
Cupon.porcentual("X", Porcentaje.de(90), MANANA));
assertThat(resultado.aPagar()).isEqualTo(euros("0.00"));
assertThat(resultado.aPagar().cantidad().signum()).isNotNegative();
}
@Test
void un_carrito_con_subtotal_negativo_es_un_error_de_programacion() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new Carrito(euros("-1.00"), Cliente.estandar()))
.withMessageContaining("subtotal");
}
Qué ha producido este ejercicio
- Trece tests que cubren todos los caminos y los casos límite, escritos antes del código y por tanto imposibles de «adaptar» a lo que el código hacía.
- Un
Clockinyectado, porque el tercer test lo exigió. Sin TDD, casi seguro habría unLocalDate.now()escondido. - Una abstracción
ReglaDescuentoque apareció en el refactor de la iteración 4, cuando había suficiente evidencia. No se diseñó por adelantado: emergió. La séptima regla de negocio que pida negocio se añade sin tocar la calculadora. - Un tipo de retorno rico (
ResultadoDescuentocon avisos) en lugar de unBigDecimalpelado, porque el test del cupón caducado necesitaba expresar «no descuenta, y este es el motivo». - Cero mocks. Todo el dominio se prueba con objetos reales, en microsegundos.
6.3 Triangulación y las tres estrategias para llegar al verde
Kent Beck describe tres formas de pasar de rojo a verde. Saber cuál usar en cada momento es la diferencia entre TDD fluido y TDD frustrante.
| Estrategia | En qué consiste | Cuándo usarla | Ejemplo |
|---|---|---|---|
| Fingir (fake it) | Devolver la constante que hace pasar el test, y generalizar en el siguiente ciclo. | Cuando no tienes claro el algoritmo y quieres arrancar. Da impulso. | return euros("100.00"); en la iteración 1. |
| Triangular | Añadir un segundo (y tercer) caso que obligue a generalizar, porque la constante ya no vale. | Cuando la implementación obvia podría ser un caso particular disfrazado. | El tope del 50 %: con un solo caso no distingues «devolver 50» de «aplicar el mínimo». |
| Implementación obvia | Escribir directamente la solución correcta. | Cuando el algoritmo es evidente y lo tienes en la cabeza. La mayoría de las veces. | subtotal.menos(descuento). |
assertThat(sumar(2, 2)).isEqualTo(4), la implementación return a * b; pasa. Con
un segundo caso sumar(2, 3) == 5, ya no. Cuando el resultado esperado es un número «bonito»
(0, 1, el mismo que la entrada, el doble), sospecha y añade otro caso.
6.4 Cuándo TDD ayuda y cuándo estorba
✅ TDD brilla cuando…
- Hay lógica con reglas: cálculos, validaciones, máquinas de estado, parsers, precios, impuestos, plazos. Los casos límite se descubren escribiendo tests.
- Corriges un bug: reproducirlo con un test rojo es la forma más rápida y segura de arreglarlo, y te llevas la garantía de no repetición.
- La API pública importa: escribir el test primero te obliga a diseñarla desde el punto de vista de quien la usa.
- El problema te supera: dividirlo en tests pequeños convierte «no sé por dónde empezar» en una lista de pasos de dos minutos.
- Refactorizas: cubres primero con tests, después cambias.
❌ TDD estorba cuando…
- Estás explorando: no sabes qué API quieres ni qué hace la librería. Haz un spike sin tests, tíralo y entonces hazlo con TDD.
- Es configuración o pegamento: un
@Bean, unDockerfile, un mapeo de campos. El test no aporta diseño. - El resultado es visual: maquetación, gráficas, informes en PDF. Necesitas verlo, no asertarlo (aunque las pruebas de snapshot ayudan después).
- Trabajas contra una API externa desconocida: primero descubre cómo responde de verdad, luego escribe los tests con WireMock.
- El coste de arrancar el test es enorme y no puedes reducirlo: ciclos de cinco minutos matan el flujo. Arregla eso primero.
6.5 TDD con base de datos
Es la objeción número uno: «TDD está muy bien para calcular el IVA, pero mi código habla con PostgreSQL». La respuesta tiene dos partes.
Primera: separa. La lógica de negocio no debería necesitar la base de datos. Si tu servicio lee, decide y escribe, el «decide» puede ser una función pura que recibe los datos ya leídos. Eso es TDD puro y rápido. Lo que queda —leer y escribir— se prueba con tests de integración, que también se pueden hacer primero, solo con un ciclo más lento.
// ❌ Todo mezclado: no hay forma de hacer TDD sobre la regla de negocio.
@Transactional
public void aplicarPenalizacionPorRetraso(String pedidoId) {
var pedido = repositorio.findById(pedidoId).orElseThrow();
if (pedido.getFechaEntrega().isBefore(LocalDate.now())
&& pedido.getEstado() == Estado.EN_TRANSITO
&& pedido.getCliente().getTipo() != Tipo.MAYORISTA) {
var dias = ChronoUnit.DAYS.between(pedido.getFechaEntrega(), LocalDate.now());
var penalizacion = pedido.getTotal().multiply(new BigDecimal("0.02"))
.multiply(BigDecimal.valueOf(Math.min(dias, 10)));
pedido.setPenalizacion(penalizacion);
repositorio.save(pedido);
}
}
// ✅ La decisión es una función pura sobre un objeto de dominio: TDD trivial.
public record Retraso(LocalDate fechaEntrega, Estado estado, TipoCliente tipo, Dinero total) {
private static final Porcentaje POR_DIA = Porcentaje.de(2);
private static final int DIAS_MAXIMOS = 10;
public Dinero penalizacionEn(LocalDate hoy) {
if (!fechaEntrega.isBefore(hoy) || estado != Estado.EN_TRANSITO
|| tipo == TipoCliente.MAYORISTA) {
return Dinero.CERO;
}
long dias = Math.min(ChronoUnit.DAYS.between(fechaEntrega, hoy), DIAS_MAXIMOS);
return total.porcentaje(POR_DIA).por(dias);
}
}
// Y el servicio queda como pegamento, con un test de integración corto:
@Transactional
public void aplicarPenalizacionPorRetraso(String pedidoId) {
var pedido = repositorio.findById(pedidoId).orElseThrow(() -> new PedidoNoEncontrado(pedidoId));
pedido.aplicarPenalizacion(pedido.retraso().penalizacionEn(LocalDate.now(reloj)));
}
Segunda: para lo que sí necesita la base de datos, TDD sigue funcionando, con estas condiciones prácticas:
| Problema | Solución |
|---|---|
| Arrancar el contenedor tarda 20 s en cada ejecución | Contenedor static + testcontainers.reuse.enable=true: se arranca una vez y se queda. Los ciclos bajan a 1–2 s. |
| Los datos de un test contaminan el siguiente | @Transactional en el test (rollback automático) o truncado en @BeforeEach. Ver sección 9.4. |
| No sé qué SQL va a generar Hibernate | Escribe el test primero con la aserción de negocio; después activa spring.jpa.show-sql y comprueba también el número de consultas. |
| La migración de esquema y el test se desincronizan | Ejecuta Flyway en el test contra el contenedor: pruebas el esquema real, migraciones incluidas. |
| El ciclo sigue siendo lento para TDD fino | Haz el ciclo rápido con el dominio puro y valida con integración cada pocos ciclos. No todo el TDD tiene que ser de un segundo. |
6.6 Refactorizar con red: el catálogo de movimientos seguros
Tener tests cambia lo que te atreves a hacer. Estos son los movimientos que se vuelven rutinarios cuando hay red, con el orden concreto de pasos para que en ningún momento la suite esté en rojo más de unos segundos.
| Refactor | Pasos seguros | Qué te protege el test |
|---|---|---|
| Extraer método | Selecciona, Ctrl+Alt+M, ejecuta tests. | Que no hayas dejado fuera una variable o cambiado el orden de evaluación. |
| Extraer clase | Crea la clase nueva delegando; mueve un método cada vez; ejecuta tras cada movimiento. | Que la delegación conserve exactamente el comportamiento. |
| Cambiar la firma de un método público | Añade la nueva sobrecarga → migra las llamadas una a una → borra la vieja. | Que ninguna llamada quede sin migrar y que la nueva sea equivalente. |
| Sustituir condicional por polimorfismo | Extrae cada rama a un método → crea las clases → sustituye el switch por una tabla o factoría. |
Que las ramas nuevas cubran todos los casos del switch original, incluido el default. |
Introducir un parámetro (inyectar Clock) |
Añade el constructor nuevo → el viejo delega con Clock.systemUTC() → migra usos → borra el viejo. |
Que el comportamiento con el reloj real sea idéntico. |
| Reemplazar la implementación de un algoritmo | Escribe la nueva junto a la vieja → un test compara las dos con muchas entradas → cambia la llamada → borra la vieja. | Que las dos coincidan en todos los casos, incluidos los que no habías pensado. |
| Strangler: sustituir un módulo entero | Interfaz común → implementación nueva → conmutador por configuración → comparación en producción (shadow) → retirada del viejo. | Que la implementación nueva pase los mismos tests de contrato que la vieja. |
7 · Tests de la aplicación Spring
Spring Boot trae la mejor infraestructura de test de cualquier framework de servidor, y también la mayor
cantidad de formas de hacerlo mal. El error dominante es poner @SpringBootTest en todo: la
suite pasa de veinte segundos a veinte minutos y nadie sabe por qué. Esta sección explica exactamente
qué hace cada anotación, cuánto cuesta y cómo elegir.
7.1 El mapa: qué anotación para qué
| Qué quieres probar | Anotación | Qué arranca | Coste típico |
|---|---|---|---|
| Una regla de negocio, un cálculo, una validación de dominio | Ninguna (JUnit puro) | Nada. new MiClase(...). |
< 5 ms |
| Un servicio con dependencias mockeadas | @ExtendWith(MockitoExtension.class) |
Nada de Spring. | < 20 ms |
| Serialización y deserialización JSON | @JsonTest |
Jackson con tu configuración real, módulos y ObjectMapper personalizado. |
~0,5 s (contexto pequeño) |
| Un controlador: rutas, binding, validación, errores, seguridad | @WebMvcTest |
Capa web sin servidor: MockMvc, @ControllerAdvice, Converter, filtros de seguridad. |
~1–2 s |
| Consultas, mapeos y esquema JPA | @DataJpaTest |
JPA, Hibernate, DataSource, transacción con rollback, TestEntityManager. |
~2–3 s + contenedor |
SQL a pelo con JdbcClient/JdbcTemplate |
@JdbcTest |
DataSource y JdbcTemplate, sin JPA. |
~1 s + contenedor |
| Un cliente HTTP que consume otra API | @RestClientTest |
RestClient/RestTemplate/WebClient con MockRestServiceServer. |
~1 s |
| Operaciones con Redis | @DataRedisTest |
RedisTemplate, repositorios de Redis, conversores. |
~1,5 s + contenedor |
| El flujo completo con dependencias reales | @SpringBootTest |
Toda la aplicación. Con webEnvironment puedes añadir servidor real. |
3–10 s el primero, luego cacheado |
| Que una autoconfiguración se active o no según condiciones | ApplicationContextRunner |
Un contexto mínimo por escenario, sin anotaciones. | ~50 ms cada uno |
snake_case, necesitas Jackson (@JsonTest). Si lo que puede fallar es que la
consulta devuelva un producto cartesiano, necesitas PostgreSQL (@DataJpaTest +
Testcontainers). Elige el nivel más bajo capaz de detectar ese fallo, nunca más arriba.
7.2 @SpringBootTest y sus webEnvironment
// MOCK (valor por defecto): contexto web simulado, sin servidor ni puerto.
// Usa MockMvc. Es el más rápido de los cuatro y sirve para el 90 % de los casos.
@SpringBootTest
@AutoConfigureMockMvc
class PedidoApiTest {
@Autowired MockMvc mvc;
}
// RANDOM_PORT: arranca Tomcat de verdad en un puerto libre. Necesario si quieres
// probar filtros de servlet, cabeceras reales, compresión, HTTP/2 o WebSockets.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PedidoApiIT {
@Autowired TestRestTemplate rest;
@LocalServerPort int puerto;
}
// DEFINED_PORT: usa el puerto de la configuración (8080). Evítalo: colisiona en CI
// y bloquea la ejecución en paralelo.
// NONE: sin entorno web aunque la aplicación sea web. Ideal para probar servicios,
// consumidores de Kafka o tareas programadas sin pagar el coste del servidor.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class ProcesadorEventosIT { }
// Opciones útiles de @SpringBootTest
@SpringBootTest(
classes = {ConfiguracionMinima.class, ServicioPedidos.class}, // contexto recortado a mano
properties = { // propiedades solo para este test
"app.reintentos.max=1",
"spring.jpa.hibernate.ddl-auto=none",
"logging.level.org.hibernate.SQL=DEBUG"
},
args = "--app.modo=prueba" // argumentos de línea de órdenes
)
@ActiveProfiles({"test", "sin-kafka"})
class ServicioPedidosIT { }
// ⚠️ Cada combinación distinta de classes/properties/profiles/mocks crea un CONTEXTO
// NUEVO que se cachea aparte. Es la causa número uno de suites lentas. Ver 7.3.
webEnvironment | Servidor | Cliente que usas | Cuándo |
|---|---|---|---|
MOCK | No (servlet simulado) | MockMvc, MockMvcTester, WebTestClient (con @AutoConfigureWebTestClient) | Por defecto. Más rápido y con mejores mensajes de fallo. |
RANDOM_PORT | Sí, puerto libre | TestRestTemplate, RestClient, WebTestClient con URL base | Cuando importa la pila HTTP real: filtros, cabeceras, TLS, tiempos de espera. |
DEFINED_PORT | Sí, puerto fijo | Igual | Casi nunca. Solo si algo externo tiene que conectarse a un puerto conocido. |
NONE | No | Ninguno | Tests de servicios, listeners, schedulers, procesos por lotes. |
RANDOM_PORT: el test se ejecuta en un hilo distinto del
servidor, así que @Transactional en el test no envuelve lo que hace la
aplicación. No hay rollback automático: los datos quedan en la base de datos y
contaminan el siguiente test. Con MOCK, en cambio, todo ocurre en el mismo hilo y el
rollback funciona. Si usas RANDOM_PORT, limpia explícitamente (ver sección 9.4).
7.3 La caché de contextos: por qué tu suite es lenta
Spring cachea los contextos de aplicación entre clases de test dentro de la misma ejecución de la JVM. Arrancar un contexto cuesta segundos; reutilizarlo, cero. La clave: la caché se indexa por una clave compuesta, y cualquier diferencia en esa clave produce un contexto nuevo.
| Elemento de la clave de caché | Ejemplo que crea un contexto nuevo |
|---|---|
Clases de configuración (classes, @ContextConfiguration) | Un test con classes = Foo.class y otro sin ello. |
Perfiles activos (@ActiveProfiles) | {"test"} frente a {"test","kafka"}. Incluso el orden importa si no usas ActiveProfilesResolver. |
Propiedades (properties, @TestPropertySource) | Añadir "app.x=1" en una sola clase de test. |
| Inicializadores de contexto | @ContextConfiguration(initializers = ...) distintos. |
@MockitoBean / @MockitoSpyBean | Cada conjunto distinto de beans mockeados es un contexto nuevo. Es la causa más frecuente y la menos conocida. |
Tipo de webEnvironment | MOCK frente a RANDOM_PORT. |
@DirtiesContext | No crea contexto: lo destruye, forzando a recrearlo en el siguiente test. Devastador para el tiempo. |
Jerarquías de contexto y @ContextCustomizer | Testcontainers con @DynamicPropertySource distinto por clase. |
# Diagnóstico: cuántos contextos crea tu suite y por qué
# Activa el log de la caché en src/test/resources/application.properties o logback-test.xml:
logging.level.org.springframework.test.context.cache = DEBUG
# En la salida verás líneas como:
# Spring test ApplicationContext cache statistics:
# [DefaultContextCache@1a2b size = 9, maxSize = 32, parentContextCount = 0,
# hitCount = 87, missCount = 9]
#
# missCount = número de contextos DISTINTOS creados. Si tienes 9 con 40 clases de test,
# estás pagando 9 arranques. Bajarlo a 2 o 3 suele recortar minutos.
# El tamaño máximo por defecto es 32; se puede subir, pero cada contexto consume memoria:
spring.test.context.cache.maxSize = 32
Receta para tener 2 o 3 contextos en toda la suite
- Una clase base (o anotación compuesta) por tipo de test, con la configuración
idéntica. Todos los
*ITheredan de la misma. - Los contenedores, en la clase base y
static, con@ServiceConnection. Así todas las clases comparten la misma configuración de propiedades y, por tanto, el mismo contexto. - Evita
propertiespor clase. Si necesitas variar una propiedad, mira si puedes cambiarla en tiempo de ejecución con un bean de configuración en lugar de crear un contexto. - Agrupa los
@MockitoBean: si tres clases mockean el mismo bean, declara el mock en la clase base y compártelo (recordando resetearlo). - Prohíbe
@DirtiesContextsalvo justificación escrita. Casi siempre esconde un test que no limpia lo que ensucia. - Ordena por configuración:
ClassOrdererpersonalizado que agrupe las clases con el mismo contexto reduce la presión de expulsión de la caché.
// La clase base que hace que TODA la suite de integración comparta un contexto.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
@Tag("integration")
public abstract class BaseIntegracionIT {
// static + un solo sitio: se arranca UNA vez para toda la suite
@Container
@ServiceConnection
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true);
@Container
@ServiceConnection
static final GenericContainer<?> REDIS =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379).withReuse(true);
@Autowired protected TestRestTemplate rest;
@Autowired private JdbcClient jdbc;
@BeforeEach
void limpiarBaseDeDatos() {
// Truncado rápido en lugar de recrear el esquema: milisegundos en vez de segundos
jdbc.sql("""
TRUNCATE TABLE linea_pedido, pedido, cliente RESTART IDENTITY CASCADE
""").update();
}
}
// Y cada test de integración solo añade lo suyo:
class ConfirmarPedidoIT extends BaseIntegracionIT {
@Test
void confirmar_un_pedido_lo_deja_pagado() { … }
}
@DirtiesContext, el asesino silencioso del tiempo de CI. Una sola clase con
@DirtiesContext obliga a recrear el contexto después de ella, y si el orden de
clases es desfavorable puedes acabar recreándolo varias veces. Si necesitas resetear estado, resetea
el estado: Mockito.reset(mock), limpiar la caché, truncar tablas, reiniciar un
contador. Destruir el contexto entero es matar una mosca con una excavadora. Busca
@DirtiesContext en tu proyecto hoy: cada aparición es probablemente entre 3 y 10 segundos
de CI en cada ejecución.
7.4 Slices: qué autoconfigura cada uno
Un slice arranca solo una porción del contexto. La lista de autoconfiguraciones que incluye cada
uno está en los ficheros spring.factories del starter de test, pero en la práctica
lo que necesitas saber es qué beans tienes disponibles y qué beans no.
| Slice | Incluye | NO incluye | Transacción |
|---|---|---|---|
@WebMvcTest |
@Controller, @ControllerAdvice, @JsonComponent, Converter, Filter, WebMvcConfigurer, HandlerMethodArgumentResolver, la configuración de Spring Security. |
@Service, @Component, @Repository, JPA, DataSource. Debes mockear las dependencias del controlador. |
No |
@DataJpaTest |
@Entity, repositorios de Spring Data, EntityManager, TestEntityManager, DataSource, Flyway/Liquibase. |
Controladores, servicios, @Component normales. |
Sí, con rollback automático al terminar cada test. |
@JdbcTest |
DataSource, JdbcTemplate, JdbcClient, NamedParameterJdbcTemplate, migraciones. |
JPA, Hibernate, repositorios de Spring Data JPA. | Sí, con rollback. |
@DataJdbcTest |
Lo de @JdbcTest más los repositorios de Spring Data JDBC. |
JPA. | Sí, con rollback. |
@JsonTest |
ObjectMapper con tu configuración, módulos, @JsonComponent, JacksonTester, JsonContentAssert. |
Todo lo demás. | No |
@RestClientTest |
RestClient.Builder, RestTemplateBuilder, WebClient.Builder, MockRestServiceServer, Jackson. |
Todo lo demás; debes indicar las clases a incluir. | No |
@DataRedisTest |
RedisTemplate, StringRedisTemplate, repositorios de Redis, conversores. |
Web, JPA. | No |
@DataMongoTest |
MongoTemplate, repositorios de Mongo, conversores. |
Web, JPA. | No (Mongo no tiene rollback sin réplicas) |
@WebFluxTest |
@Controller reactivos, WebTestClient, WebFluxConfigurer. |
Servicios, repositorios. | No |
@GraphQlTest |
Controladores GraphQL, GraphQlTester, esquema. |
Servicios, repositorios. | No |
// ── @WebMvcTest: la capa web, sin base de datos ni servicios reales ──────────
@WebMvcTest(PedidoController.class) // sin argumento incluye TODOS los controladores
class PedidoControllerTest {
@Autowired MockMvc mvc;
@Autowired ObjectMapper mapper;
@MockitoBean ServicioPedidos servicio; // las dependencias del controlador, mockeadas
@MockitoBean ServicioClientes clientes;
@Test
void get_devuelve_200_con_el_pedido() throws Exception {
when(servicio.buscar("P-1")).thenReturn(new PedidoDto("P-1", "NUEVO", euros("121.00")));
mvc.perform(get("/api/v1/pedidos/{id}", "P-1").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.id").value("P-1"))
.andExpect(jsonPath("$.total").value(121.00));
}
}
// ── @DataJpaTest con PostgreSQL real (nunca H2, ver 8.3) ────────────────────
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // ← no sustituir por H2
@Testcontainers
class RepositorioPedidosTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired RepositorioPedidosJpa repositorio;
@Autowired TestEntityManager em;
@Test
void busca_por_estado_con_el_cliente_ya_cargado() {
em.persist(PedidoEntityMother.con(Estado.PAGADO));
em.persist(PedidoEntityMother.con(Estado.NUEVO));
em.flush();
em.clear(); // ← imprescindible: vacía la caché de primer nivel
// para que la consulta vaya de verdad a la base de datos
var resultado = repositorio.findByEstadoConCliente(Estado.PAGADO);
assertThat(resultado).hasSize(1);
assertThat(Hibernate.isInitialized(resultado.get(0).getCliente())).isTrue(); // sin N+1
}
@Test
void la_referencia_es_unica_en_el_esquema() {
em.persistAndFlush(PedidoEntityMother.conReferencia("REF-1"));
assertThatThrownBy(() -> em.persistAndFlush(PedidoEntityMother.conReferencia("REF-1")))
.isInstanceOf(PersistenceException.class)
.rootCause().hasMessageContaining("uk_pedido_referencia");
}
}
// ── @JdbcTest para SQL escrito a mano ────────────────────────────────────────
@JdbcTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(InformeVentasDao.class) // el DAO no es un bean autoconfigurado: hay que importarlo
@Testcontainers
class InformeVentasDaoTest {
@Container @ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired InformeVentasDao dao;
@Test
@Sql("/datos/ventas-marzo.sql")
void agrupa_las_ventas_por_categoria_y_ordena_por_importe() {
var filas = dao.ventasPorCategoria(LocalDate.parse("2026-03-01"), LocalDate.parse("2026-03-31"));
assertThat(filas).extracting(FilaVentas::categoria)
.containsExactly("Informática", "Hogar", "Libros"); // el ORDER BY del SQL
}
}
// ── @RestClientTest: el cliente HTTP de un servicio externo ──────────────────
@RestClientTest(ClienteTarifas.class)
class ClienteTarifasTest {
@Autowired ClienteTarifas cliente;
@Autowired MockRestServiceServer servidor;
@Test
void parsea_la_respuesta_de_tarifas() {
servidor.expect(requestTo("https://tarifas.ejemplo.com/v1/eur-usd"))
.andExpect(method(HttpMethod.GET))
.andExpect(header("X-Api-Key", "clave-de-test"))
.andRespond(withSuccess("""
{ "par": "EUR/USD", "tasa": 1.0872, "fecha": "2026-03-15" }
""", MediaType.APPLICATION_JSON));
var tasa = cliente.eurosPorDolar();
assertThat(tasa).isEqualByComparingTo("1.0872");
servidor.verify();
}
@Test
void un_500_del_proveedor_se_traduce_a_excepcion_de_dominio() {
servidor.expect(requestTo("https://tarifas.ejemplo.com/v1/eur-usd"))
.andRespond(withServerError());
assertThatThrownBy(cliente::eurosPorDolar)
.isInstanceOf(ProveedorNoDisponible.class)
.hasMessageContaining("tarifas");
}
}
7.5 MockMvc a fondo
// ── Construcción de peticiones ───────────────────────────────────────────────
mvc.perform(get("/api/v1/pedidos"))
mvc.perform(get("/api/v1/pedidos/{id}", "P-1")) // variables de plantilla
mvc.perform(get("/api/v1/pedidos").param("estado", "NUEVO")
.param("page", "0")
.param("size", "20"))
mvc.perform(get("/api/v1/pedidos").queryParam("estado", "NUEVO", "PAGADO")) // repetido
mvc.perform(post("/api/v1/pedidos")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.header("Idempotency-Key", "abc-123")
.content("""
{ "clienteId": "C-1", "lineas": [ { "sku": "SKU-1", "cantidad": 2 } ] }
"""))
mvc.perform(put("/api/v1/pedidos/P-1").contentType(APPLICATION_JSON).content(cuerpo))
mvc.perform(patch("/api/v1/pedidos/P-1").contentType("application/merge-patch+json").content(parche))
mvc.perform(delete("/api/v1/pedidos/P-1"))
mvc.perform(multipart("/api/v1/pedidos/P-1/adjuntos")
.file(new MockMultipartFile("fichero", "factura.pdf",
MediaType.APPLICATION_PDF_VALUE, contenido)))
mvc.perform(get("/api/v1/pedidos").with(csrf())) // token CSRF válido
mvc.perform(get("/api/v1/pedidos").cookie(new Cookie("SESSION", "abc")))
mvc.perform(get("/api/v1/pedidos").locale(Locale.forLanguageTag("es-ES")))
// ── Aserciones sobre la respuesta ────────────────────────────────────────────
.andExpect(status().isOk()) // 200
.andExpect(status().isCreated()) // 201
.andExpect(status().isNoContent()) // 204
.andExpect(status().isBadRequest()) // 400
.andExpect(status().isUnauthorized()) // 401
.andExpect(status().isForbidden()) // 403
.andExpect(status().isNotFound()) // 404
.andExpect(status().isConflict()) // 409
.andExpect(status().isUnprocessableEntity()) // 422
.andExpect(status().is5xxServerError())
.andExpect(status().is(429))
.andExpect(header().string("Location", matchesPattern("/api/v1/pedidos/[A-Z0-9-]+")))
.andExpect(header().exists("ETag"))
.andExpect(header().longValue("Content-Length", 128))
.andExpect(header().doesNotExist("X-Powered-By"))
.andExpect(content().contentType("application/problem+json"))
.andExpect(content().string(containsString("confirmado")))
.andExpect(content().json(esperado, JsonCompareMode.STRICT)) // Spring 6.2+
.andExpect(jsonPath("$.lineas.length()").value(2))
.andExpect(jsonPath("$.lineas[?(@.sku == 'SKU-1')].cantidad").value(2))
.andExpect(cookie().httpOnly("SESSION", true))
.andExpect(redirectedUrl("/login"))
.andExpect(view().name("pedidos/detalle")) // vistas Thymeleaf
.andExpect(model().attributeExists("pedido"))
// ── Depuración y reutilización del resultado ──────────────────────────────────
.andDo(print()) // imprime petición y respuesta completas
.andDo(log()) // igual, pero al logger
.andReturn(); // MvcResult para seguir trabajando
// Encadenar peticiones: crear y luego leer usando la Location devuelta
var resultado = mvc.perform(post("/api/v1/pedidos").contentType(APPLICATION_JSON).content(cuerpo))
.andExpect(status().isCreated())
.andReturn();
String location = resultado.getResponse().getHeader("Location");
mvc.perform(get(location)).andExpect(status().isOk()).andExpect(jsonPath("$.estado").value("NUEVO"));
// ── Configuración global de MockMvc: no repitas en cada test ─────────────────
@WebMvcTest(PedidoController.class)
class PedidoControllerTest {
@Autowired MockMvc mvc;
// Alternativa con configuración a medida (por ejemplo, imprimir siempre en los fallos):
@TestConfiguration
static class ConfiguracionMockMvc {
@Bean MockMvcBuilderCustomizer personalizar() {
return builder -> builder
.alwaysDo(print()) // ← traza todas las peticiones
.alwaysExpect(header().doesNotExist("X-Powered-By"))
.defaultRequest(get("/").accept(MediaType.APPLICATION_JSON)
.characterEncoding(StandardCharsets.UTF_8));
}
}
}
// O construyendo el MockMvc a mano cuando quieres control total (sin contexto de Spring):
class PedidoControllerStandaloneTest {
private final ServicioPedidos servicio = mock(ServicioPedidos.class);
private final MockMvc mvc = MockMvcBuilders
.standaloneSetup(new PedidoController(servicio))
.setControllerAdvice(new ManejadorErroresGlobal())
.setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper()))
.setValidator(new LocalValidatorFactoryBean() {{ afterPropertiesSet(); }})
.alwaysDo(print())
.build();
// standaloneSetup arranca en milisegundos y NO crea contexto de Spring: buenísimo para
// muchos tests de controlador. La contrapartida: no prueba tu configuración web real
// (filtros, seguridad, resolvers registrados por autoconfiguración).
}
| Enfoque | Arranque | Prueba tu configuración web | Seguridad | Cuándo |
|---|---|---|---|---|
standaloneSetup | ~5 ms, sin contexto | No | No | Muchos tests de binding y lógica de controlador; suite ultrarrápida. |
@WebMvcTest | ~1–2 s (contexto cacheado) | Sí | Sí | Lo habitual: valida @ControllerAdvice, conversores y filtros reales. |
@SpringBootTest + @AutoConfigureMockMvc | 3–10 s el primero | Sí, entera | Sí | Cuando quieres el controlador contra los servicios y la base de datos reales. |
@SpringBootTest(RANDOM_PORT) + TestRestTemplate | Igual + servidor | Sí, y la pila HTTP | Sí | Cuando importa el HTTP real; el más lento y el más realista. |
7.6 MockMvcTester: la API moderna con AssertJ
Spring Framework 6.2 (Spring Boot 3.4+) introduce MockMvcTester, que envuelve
MockMvc con una API basada en AssertJ. Resuelve las tres pegas históricas de
MockMvc: el throws Exception obligatorio, los mensajes de fallo pobres de los
ResultMatcher y la dependencia de Hamcrest.
@WebMvcTest(PedidoController.class)
class PedidoControllerTesterTest {
@Autowired MockMvcTester mvc; // inyectado directamente
@MockitoBean ServicioPedidos servicio;
@Test
void get_devuelve_200_con_el_pedido() { // ← sin throws Exception
when(servicio.buscar("P-1")).thenReturn(PEDIDO_DTO);
assertThat(mvc.get().uri("/api/v1/pedidos/{id}", "P-1"))
.hasStatusOk()
.hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON)
.bodyJson()
.hasPathSatisfying("$.id", p -> p.assertThat().isEqualTo("P-1"))
.extractingPath("$.total").asNumber().isEqualTo(121.00);
}
@Test
void post_crea_el_pedido_y_devuelve_la_cabecera_location() {
assertThat(mvc.post().uri("/api/v1/pedidos")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{ "clienteId": "C-1", "lineas": [{ "sku": "SKU-1", "cantidad": 2 }] }
"""))
.hasStatus(HttpStatus.CREATED)
.hasHeader("Location", "/api/v1/pedidos/P-1")
.bodyJson().convertTo(PedidoDto.class)
.satisfies(dto -> assertThat(dto.estado()).isEqualTo("NUEVO"));
}
@Test
void deserializa_el_cuerpo_directamente_a_un_tipo() {
assertThat(mvc.get().uri("/api/v1/pedidos"))
.hasStatusOk()
.bodyJson().convertTo(InstanceOfAssertFactories.list(PedidoDto.class))
.hasSize(3)
.extracting(PedidoDto::estado).containsOnly("NUEVO");
}
@Test
void una_excepcion_no_manejada_se_puede_asertar_directamente() {
when(servicio.buscar("BOOM")).thenThrow(new IllegalStateException("kaboom"));
assertThat(mvc.get().uri("/api/v1/pedidos/BOOM"))
.hasFailed()
.failure().isInstanceOf(IllegalStateException.class)
.hasMessage("kaboom");
}
@Test
void tambien_permite_el_estilo_clasico_cuando_conviene() {
mvc.get().uri("/api/v1/pedidos/P-1")
.assertThat().hasStatusOk();
// Y se puede seguir usando MockMvc puro: mvc.getMockMvc() lo expone.
}
}
MockMvcTester? No hace falta de golpe: convive con
MockMvc en el mismo proyecto e incluso en la misma clase. Úsalo en los tests nuevos y
migra los antiguos cuando los toques. Las dos ventajas que se notan enseguida son la desaparición del
throws Exception (que obligaba a declararlo en absolutamente todos los métodos) y los
mensajes de fallo, que pasan de «Status expected:<200> but was:<400>» a incluir el cuerpo de
la respuesta, lo que ahorra un andDo(print()) cada vez.
7.7 WebTestClient y TestRestTemplate
// ── TestRestTemplate: el clásico para @SpringBootTest con RANDOM_PORT ────────
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PedidoApiIT {
@Autowired TestRestTemplate rest; // ya apunta a http://localhost:{puertoAleatorio}
@Test
void crear_y_leer_un_pedido() {
var creado = rest.postForEntity("/api/v1/pedidos", nuevaPeticion(), PedidoDto.class);
assertThat(creado.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(creado.getHeaders().getLocation()).isNotNull();
assertThat(creado.getBody().estado()).isEqualTo("NUEVO");
var leido = rest.getForEntity(creado.getHeaders().getLocation(), PedidoDto.class);
assertThat(leido.getBody().id()).isEqualTo(creado.getBody().id());
}
@Test
void un_404_no_lanza_excepcion_con_TestRestTemplate() {
// Diferencia clave con RestTemplate: TestRestTemplate NO lanza en 4xx/5xx,
// devuelve la respuesta para que puedas asertar sobre ella. Es lo que quieres en tests.
var respuesta = rest.getForEntity("/api/v1/pedidos/NO-EXISTE", ProblemDetail.class);
assertThat(respuesta.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
assertThat(respuesta.getBody().getTitle()).isEqualTo("Pedido no encontrado");
}
@Test
void con_autenticacion_basica() {
var respuesta = rest.withBasicAuth("admin", "secreto")
.getForEntity("/api/v1/admin/estadisticas", String.class);
assertThat(respuesta.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void con_cabeceras_a_medida() {
var cabeceras = new HttpHeaders();
cabeceras.setBearerAuth(TOKEN_DE_PRUEBA);
cabeceras.set("Idempotency-Key", "k-1");
var respuesta = rest.exchange("/api/v1/pedidos", HttpMethod.POST,
new HttpEntity<>(nuevaPeticion(), cabeceras), PedidoDto.class);
assertThat(respuesta.getStatusCode()).isEqualTo(HttpStatus.CREATED);
}
}
// ── WebTestClient: API fluida, funciona con y sin servidor real ──────────────
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient(timeout = "10s")
class PedidoApiWebTestClientIT {
@Autowired WebTestClient web;
@Test
void crear_un_pedido_devuelve_201_con_location() {
web.post().uri("/api/v1/pedidos")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(nuevaPeticion())
.exchange()
.expectStatus().isCreated()
.expectHeader().valueMatches("Location", "/api/v1/pedidos/.+")
.expectBody()
.jsonPath("$.estado").isEqualTo("NUEVO")
.jsonPath("$.total").isEqualTo(121.00)
.jsonPath("$.password").doesNotExist();
}
@Test
void listar_devuelve_una_lista_tipada() {
web.get().uri(uri -> uri.path("/api/v1/pedidos")
.queryParam("estado", "NUEVO")
.queryParam("size", 10).build())
.exchange()
.expectStatus().isOk()
.expectBodyList(PedidoDto.class)
.hasSize(3)
.value(lista -> assertThat(lista).extracting(PedidoDto::estado).containsOnly("NUEVO"));
}
@Test
void con_token_jwt_de_prueba() {
web.mutateWith(mockJwt().jwt(j -> j.claim("scope", "pedidos:leer")))
.get().uri("/api/v1/pedidos")
.exchange().expectStatus().isOk();
}
@Test
void consume_un_flujo_de_eventos_sse() {
var eventos = web.get().uri("/api/v1/pedidos/eventos")
.accept(MediaType.TEXT_EVENT_STREAM)
.exchange()
.expectStatus().isOk()
.returnResult(EventoDto.class)
.getResponseBody()
.take(3);
StepVerifier.create(eventos)
.expectNextMatches(e -> e.tipo().equals("PedidoCreado"))
.expectNextCount(2)
.verifyComplete();
}
}
| Cliente | Ventajas | Inconvenientes | Recomendación |
|---|---|---|---|
MockMvc / MockMvcTester | Rapidísimo, mismo hilo (transacciones funcionan), mensajes de fallo excelentes | No prueba la pila HTTP real | La opción por defecto para tests de la capa web |
TestRestTemplate | Simple, síncrono, no lanza en 4xx/5xx | API menos expresiva; RestTemplate está en mantenimiento | Bien para smoke tests de integración sencillos |
WebTestClient | API fluida, sirve para MVC y WebFlux, soporta SSE y streaming, integración con Spring Security de test | Requiere entender Reactor para los casos avanzados | La mejor opción con servidor real y para WebFlux |
RestClient (Spring 6.1+) | API moderna síncrona, la que usarás en producción | No tiene ayudas específicas de test | Útil si quieres probar exactamente el cliente que usa producción |
7.8 @MockitoBean y @MockitoSpyBean
Sustituyen a @MockBean y @SpyBean, deprecados desde Spring Boot
3.4 y ahora parte de Spring Framework 6.2 (paquete
org.springframework.test.context.bean.override.mockito). Reemplazan un bean del contexto por
un doble de Mockito.
@SpringBootTest
class ConfirmarPedidoIT {
@MockitoBean PasarelaPago pasarela; // sustituye el bean real por un mock
@MockitoSpyBean ServicioNotificaciones notificaciones; // envuelve el bean real y lo vigila
@MockitoBean(name = "clienteTarifasPrimario") ClienteTarifas tarifas; // por nombre de bean
@Autowired ServicioPedidos servicio;
@Test
void confirmar_cobra_en_la_pasarela_y_envia_el_correo_real() {
when(pasarela.cobrar(any())).thenReturn(new Cobro("TX-1", Estado.OK));
servicio.confirmar("P-1");
verify(pasarela).cobrar(euros("121.00"));
verify(notificaciones).enviarConfirmacion("ana@ejemplo.com", "P-1"); // y SÍ se ha enviado
}
}
// @MockitoBean también funciona en @Nested y se puede poner en campos de clases base.
// Los mocks se RESETEAN automáticamente después de cada test (MockReset.AFTER por defecto).
@MockitoBean(reset = MockReset.BEFORE) PasarelaPago pasarela; // o antes, si lo prefieres
@MockitoBean(reset = MockReset.NONE) Auditor auditor; // nunca (raro; cuidado con las fugas)
// Otras anotaciones de sustitución de beans de Spring 6.2, sin Mockito:
@TestBean Clock reloj; // ← se resuelve con un método estático de la clase
static Clock reloj() { return Clock.fixed(AHORA, UTC); }
@TestBean(methodName = "com.ejemplo.test.Relojes#fijo") Clock otroReloj;
@MockitoBean: cada conjunto distinto de beans
sustituidos crea un contexto de aplicación nuevo. Si tienes diez clases de
@SpringBootTest y cada una mockea un bean diferente, tendrás once contextos en la
caché y once arranques de la aplicación. La contramedida es declarar los mocks comunes en la clase base
compartida (o en la anotación compuesta) para que todas las clases coincidan en la clave de caché, y
reservar los mocks específicos para los pocos tests que de verdad los necesiten. Cuando puedas, mejor
todavía: sustituye la dependencia externa por WireMock o por un contenedor y no mockees nada, así el
contexto es siempre el mismo.
// ✅ Patrón recomendado: los mocks de dependencias externas, en una @TestConfiguration
// compartida, para que todos los tests de integración usen el MISMO contexto.
@TestConfiguration(proxyBeanMethods = false)
public class DependenciasExternasDeTest {
@Bean
@Primary
PasarelaPago pasarelaFalsa() {
return new PasarelaPagoEnMemoria(); // un FAKE, no un mock: sin resets ni stubs
}
@Bean
@Primary
Clock relojFijo() {
return Clock.fixed(Instant.parse("2026-03-15T10:00:00Z"), ZoneOffset.UTC);
}
}
@SpringBootTest
@Import(DependenciasExternasDeTest.class)
@ActiveProfiles("test")
abstract class BaseIT {
@Autowired protected PasarelaPagoEnMemoria pasarela; // el fake, con su API de inspección
@BeforeEach
void limpiarFakes() { pasarela.limpiar(); }
}
class ConfirmarPedidoIT extends BaseIT {
@Test
void confirmar_registra_un_cobro_por_el_total() {
servicio.confirmar("P-1");
assertThat(pasarela.cobros()).singleElement()
.extracting(Cobro::importe).isEqualTo(euros("121.00"));
}
}
7.9 Perfiles, propiedades y configuración de test
## src/test/resources/application.yaml — configuración base de TODOS los tests
spring:
main:
banner-mode: off # menos ruido en los logs de CI
jpa:
open-in-view: false
properties:
hibernate:
# Detecta N+1 en los tests: falla si una colección LAZY se inicializa fuera de sesión
enable_lazy_load_no_trans: false
generate_statistics: true
flyway:
clean-disabled: false # solo en test: permite recrear el esquema
task:
execution:
pool:
core-size: 1 # ejecución previsible de @Async en tests
logging:
level:
root: WARN
com.ejemplo: INFO
org.springframework.test.context.cache: DEBUG # ← diagnóstico de contextos
org.hibernate.SQL: OFF # súbelo a DEBUG cuando investigues
app:
pasarela:
url: http://localhost:${wiremock.server.port:0}
timeout: 200ms # timeouts cortos en test: los fallos son rápidos
reintentos:
max: 2
espera: 10ms # ¡no 2 s! o la suite tarda una eternidad
## src/test/resources/application-test.yaml — perfil "test"
## Se activa con @ActiveProfiles("test") y se SUMA a application.yaml
app:
features:
nuevo-motor-precios: true # activar flags que quieres probar
correo:
habilitado: false # nunca enviar correos de verdad
## src/test/resources/application-integracion.yaml — perfil para los *IT
spring:
jpa:
hibernate:
ddl-auto: validate # el esquema lo crea Flyway; validamos que coincide
kafka:
consumer:
auto-offset-reset: earliest # que el consumidor de test lea desde el principio
// ── @TestConfiguration: beans SOLO para tests, sin contaminar producción ─────
// Una clase estática anidada se aplica solo a esa clase de test (y sus @Nested):
@SpringBootTest
class ServicioPedidosIT {
@TestConfiguration(proxyBeanMethods = false)
static class Config {
@Bean Clock reloj() { return Clock.fixed(AHORA, ZoneOffset.UTC); }
@Bean GeneradorIds ids() { return new GeneradorSecuencial(); }
}
@Autowired ServicioPedidos servicio;
}
// Una clase @TestConfiguration de primer nivel NO se detecta automáticamente
// (a diferencia de @Configuration): hay que importarla explícitamente. Es a propósito.
@SpringBootTest
@Import({RelojDeTestConfig.class, PasarelaFalsaConfig.class})
class OtroServicioIT { }
// ── Sobrescribir propiedades: cuatro formas, de menos a más invasiva ─────────
@SpringBootTest(properties = "app.reintentos.max=1") // 1. inline, crea contexto propio
@TestPropertySource(properties = {"app.x=1", "app.y=2"}) // 2. igual, con más opciones
@TestPropertySource(locations = "classpath:test-especial.properties") // 3. desde fichero
@ActiveProfiles({"test", "integracion"}) // 4. por perfil (preferido)
// ⚠️ Las tres primeras crean un contexto nuevo por combinación. El perfil también,
// pero al menos se comparte entre todas las clases que usen el mismo conjunto.
// ── Registrar propiedades calculadas en tiempo de ejecución ──────────────────
@SpringBootTest
@Testcontainers
class ConPropiedadesDinamicasIT {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void propiedades(DynamicPropertyRegistry registro) {
registro.add("spring.datasource.url", postgres::getJdbcUrl);
registro.add("spring.datasource.username", postgres::getUsername);
registro.add("spring.datasource.password", postgres::getPassword);
registro.add("app.pasarela.url", () -> "http://localhost:" + wiremock.getPort());
}
// Nota: con @ServiceConnection (Boot 3.1+) esto ya NO hace falta para las
// dependencias soportadas (PostgreSQL, MySQL, Kafka, Redis, MongoDB, RabbitMQ,
// Elasticsearch, LocalStack…). Sigue siendo útil para propiedades propias.
}
// Spring Boot 3.4+ · alternativa sin static: DynamicPropertyRegistrar como bean
@TestConfiguration
class RegistroPropiedades {
@Bean
DynamicPropertyRegistrar urlDePasarela(WireMockServer wiremock) {
return registro -> registro.add("app.pasarela.url", wiremock::baseUrl);
}
}
| Necesidad | Mecanismo | ¿Contexto nuevo? |
|---|---|---|
| Configuración común a todos los tests | src/test/resources/application.yaml | No |
| Variar un grupo de propiedades entre familias de tests | Perfil + @ActiveProfiles | Uno por combinación de perfiles |
| Una propiedad puntual en una clase | @SpringBootTest(properties = …) | Sí (evítalo si puedes) |
| Valores que solo se conocen al arrancar (puertos de contenedores) | @ServiceConnection o @DynamicPropertySource | Compartido si está en la clase base |
| Sustituir un bean por un doble | @MockitoBean o @TestConfiguration con @Primary | Sí en el primer caso; compartido si el @Import es común |
| Cambiar un valor durante el test | Bean mutable de configuración, o @ConfigurationProperties con setters | No: es lo más barato |
7.10 ApplicationContextRunner: probar autoconfiguraciones
Si escribes una starter, una autoconfiguración o cualquier configuración condicional
(@ConditionalOnProperty, @ConditionalOnMissingBean,
@ConditionalOnClass), esta es la herramienta: crea contextos minúsculos, uno por escenario,
en decenas de milisegundos. Nada de @SpringBootTest.
class AutoConfiguracionAuditoriaTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(AuditoriaAutoConfiguration.class));
@Test
void por_defecto_la_auditoria_esta_activa_con_el_repositorio_jdbc() {
runner.run(contexto -> assertThat(contexto)
.hasSingleBean(ServicioAuditoria.class)
.hasSingleBean(RepositorioAuditoriaJdbc.class)
.doesNotHaveBean(RepositorioAuditoriaKafka.class));
}
@Test
void se_puede_desactivar_por_propiedad() {
runner.withPropertyValues("app.auditoria.enabled=false")
.run(contexto -> assertThat(contexto).doesNotHaveBean(ServicioAuditoria.class));
}
@Test
void si_el_usuario_define_su_propio_repositorio_el_nuestro_no_se_crea() {
runner.withUserConfiguration(RepositorioPropioConfig.class)
.run(contexto -> assertThat(contexto)
.hasSingleBean(RepositorioAuditoria.class)
.getBean(RepositorioAuditoria.class)
.isInstanceOf(RepositorioAuditoriaPropio.class));
}
@Test
void sin_kafka_en_el_classpath_se_usa_el_repositorio_jdbc() {
runner.withClassLoader(new FilteredClassLoader(KafkaTemplate.class))
.withPropertyValues("app.auditoria.destino=kafka")
.run(contexto -> assertThat(contexto).hasSingleBean(RepositorioAuditoriaJdbc.class));
}
@Test
void una_configuracion_invalida_falla_al_arrancar_con_un_mensaje_util() {
runner.withPropertyValues("app.auditoria.retencion-dias=-1")
.run(contexto -> assertThat(contexto)
.hasFailed()
.getFailure()
.rootCause()
.hasMessageContaining("retencion-dias")
.hasMessageContaining("debe ser mayor que 0"));
}
@Test
void las_propiedades_se_enlazan_con_los_valores_por_defecto_correctos() {
runner.run(contexto -> {
var props = contexto.getBean(AuditoriaProperties.class);
assertThat(props.retencionDias()).isEqualTo(90);
assertThat(props.destino()).isEqualTo(Destino.JDBC);
assertThat(props.excluir()).containsExactly("/actuator/**");
});
}
}
// Variantes según el tipo de aplicación:
// new WebApplicationContextRunner() → contexto web servlet
// new ReactiveWebApplicationContextRunner() → contexto web reactivo
NullPointerException a las tres semanas. Añade @Validated y
constraints de Bean Validation a tus @ConfigurationProperties, y escribe un test
con ApplicationContextRunner que compruebe que un valor inválido impide el
arranque con el mensaje esperado. Cuesta cinco minutos y evita incidentes.
7.11 Tests de seguridad
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
@WebMvcTest(PedidoController.class)
class SeguridadPedidosTest {
@Autowired MockMvc mvc;
@MockitoBean ServicioPedidos servicio;
// ── 1 · Sin autenticación: el caso que más se olvida probar ──────────────
@Test
void sin_autenticacion_devuelve_401() throws Exception {
mvc.perform(get("/api/v1/pedidos"))
.andExpect(status().isUnauthorized());
}
// ── 2 · Con usuario y roles ──────────────────────────────────────────────
@Test
@WithMockUser(username = "ana", roles = "CLIENTE")
void un_cliente_puede_ver_sus_pedidos() throws Exception {
mvc.perform(get("/api/v1/pedidos")).andExpect(status().isOk());
}
@Test
@WithMockUser(username = "ana", roles = "CLIENTE")
void un_cliente_no_puede_borrar_pedidos() throws Exception {
mvc.perform(delete("/api/v1/pedidos/P-1").with(csrf()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = {"ADMIN", "AUDITOR"})
void un_admin_puede_borrar() throws Exception {
mvc.perform(delete("/api/v1/pedidos/P-1").with(csrf()))
.andExpect(status().isNoContent());
}
// ── 3 · Con authorities en lugar de roles (¡no es lo mismo!) ─────────────
@Test
@WithMockUser(authorities = "SCOPE_pedidos:escribir") // roles añade el prefijo ROLE_
void con_el_scope_correcto_puede_crear() throws Exception { … }
// ── 4 · Usuario cargado de tu UserDetailsService real ────────────────────
@Test
@WithUserDetails(value = "ana@ejemplo.com", userDetailsServiceBeanName = "usuariosService")
void con_el_usuario_real_de_la_base_de_datos() throws Exception { … }
// ── 5 · Anotación compuesta propia: legible y sin repetir ────────────────
@Test
@ComoClienteVip
void un_vip_ve_el_precio_con_descuento() throws Exception { … }
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@WithMockUser(username = "vip@ejemplo.com", roles = {"CLIENTE", "VIP"})
public @interface ComoClienteVip { }
// ── Tests con JWT y OAuth2 resource server ───────────────────────────────────
import static org.springframework.security.test.web.servlet.request
.SecurityMockMvcRequestPostProcessors.*;
@Test
void con_un_jwt_de_prueba_y_los_claims_que_quieras() throws Exception {
mvc.perform(get("/api/v1/pedidos").with(jwt().jwt(builder -> builder
.subject("ana")
.claim("scope", "pedidos:leer pedidos:escribir")
.claim("cliente_id", "C-1")
.claim("email", "ana@ejemplo.com")
.issuer("https://idp.ejemplo.com")
.audience(List.of("pedidos-api")))))
.andExpect(status().isOk());
}
@Test
void sin_el_scope_necesario_devuelve_403() throws Exception {
mvc.perform(post("/api/v1/pedidos")
.with(jwt().jwt(j -> j.claim("scope", "pedidos:leer"))) // falta escribir
.with(csrf())
.contentType(APPLICATION_JSON).content(cuerpo))
.andExpect(status().isForbidden());
}
@Test
void con_authorities_derivadas_del_token() throws Exception {
mvc.perform(get("/api/v1/admin/informes")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
.andExpect(status().isOk());
}
// Otros post-processors útiles
mvc.perform(get("/x").with(user("ana").roles("CLIENTE"))); // usuario ad hoc
mvc.perform(get("/x").with(anonymous())); // anónimo explícito
mvc.perform(get("/x").with(httpBasic("ana", "secreto"))); // Basic real
mvc.perform(get("/x").with(opaqueToken().attributes(a -> a.put("scope", "leer")))); // token opaco
mvc.perform(get("/x").with(oidcLogin().idToken(t -> t.claim("email", "a@b.c")))); // OIDC
mvc.perform(post("/x").with(csrf())); // token CSRF válido
mvc.perform(post("/x").with(csrf().useInvalidToken())); // ← probar que CSRF PROTEGE
// ── @PreAuthorize en la capa de servicio: se prueba con contexto de seguridad ─
@SpringBootTest
class AutorizacionServicioIT {
@Autowired ServicioPedidos servicio;
@Test
@WithMockUser(roles = "CLIENTE")
void un_cliente_no_puede_forzar_el_estado_de_un_pedido() {
assertThatThrownBy(() -> servicio.forzarEstado("P-1", Estado.PAGADO))
.isInstanceOf(AccessDeniedException.class);
}
@Test
void sin_contexto_de_seguridad_tambien_se_deniega() {
assertThatThrownBy(() -> servicio.forzarEstado("P-1", Estado.PAGADO))
.isInstanceOf(AuthenticationCredentialsNotFoundException.class);
}
}
// ── El test de seguridad más valioso: la matriz completa endpoint × rol ──────
// Un parametrizado que recorre TODOS los endpoints y TODOS los roles evita el
// agujero clásico: el endpoint nuevo que nadie protegió.
@WebMvcTest
class MatrizDeAutorizacionTest {
@Autowired MockMvc mvc;
@MockitoBean ServicioPedidos servicio;
@ParameterizedTest(name = "{0} {1} como {2} → {3}")
@CsvSource({
"GET, /api/v1/pedidos, , 401",
"GET, /api/v1/pedidos, CLIENTE, 200",
"GET, /api/v1/pedidos, ADMIN, 200",
"POST, /api/v1/pedidos, CLIENTE, 201",
"POST, /api/v1/pedidos, LECTOR, 403",
"DELETE, /api/v1/pedidos/P-1, CLIENTE, 403",
"DELETE, /api/v1/pedidos/P-1, ADMIN, 204",
"GET, /api/v1/admin/metricas, CLIENTE, 403",
"GET, /api/v1/admin/metricas, ADMIN, 200",
"GET, /actuator/health, , 200",
"GET, /actuator/env, , 401",
"GET, /actuator/env, ADMIN, 200"
})
void la_matriz_de_autorizacion_se_respeta(String metodo, String ruta, String rol, int esperado)
throws Exception {
var peticion = request(HttpMethod.valueOf(metodo), ruta)
.contentType(APPLICATION_JSON).content(cuerpoMinimoValido(ruta)).with(csrf());
if (rol != null && !rol.isBlank()) {
peticion = peticion.with(user("u").roles(rol));
}
mvc.perform(peticion).andExpect(status().is(esperado));
}
}
(1) El endpoint nuevo sin proteger: la matriz de arriba, o mejor, un test que enumere los
@RequestMapping con reflexión y falle si alguno no aparece en la
matriz. Así el test se rompe cuando alguien añade un endpoint y se olvida de decidir su autorización.(2) IDOR (Insecure Direct Object Reference): «el usuario A pide el pedido de B». Debe devolver 404, no 403 (para no revelar existencia). Uno por cada recurso con identificador en la ruta.
(3) Fuga de datos en la respuesta:
jsonPath("$.password").doesNotExist(),
$.iban, $..tokenInterno. Un campo añadido a la entidad se filtra al DTO con
más frecuencia de la que parece. Ver el módulo 10 · Seguridad.
7.12 Tests de validación de DTOs
public record CrearPedidoRequest(
@NotBlank(message = "el cliente es obligatorio")
String clienteId,
@NotEmpty(message = "el pedido debe tener al menos una línea")
@Size(max = 100, message = "máximo 100 líneas por pedido")
@Valid
List<LineaRequest> lineas,
@Email(message = "el correo no tiene un formato válido")
String correoAviso,
@Future(message = "la fecha de entrega debe ser futura")
LocalDate entregaDeseada,
@Pattern(regexp = "[A-Z0-9]{4,20}", message = "el cupón solo admite mayúsculas y dígitos")
String cupon) { }
public record LineaRequest(
@NotBlank String sku,
@Positive @Max(999) int cantidad) { }
// ── 1 · Validación pura, sin Spring: rapidísima y exhaustiva ────────────────
class CrearPedidoRequestValidacionTest {
private static Validator validador;
@BeforeAll
static void init() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
validador = factory.getValidator();
}
}
@Test
void una_peticion_valida_no_produce_violaciones() {
assertThat(validador.validate(peticionValida())).isEmpty();
}
@ParameterizedTest(name = "clienteId = «{0}» → violación")
@NullAndEmptySource
@ValueSource(strings = {" ", "\t"})
void el_cliente_es_obligatorio(String clienteId) {
var peticion = peticionValida().conCliente(clienteId);
assertThat(validador.validate(peticion))
.singleElement()
.satisfies(v -> {
assertThat(v.getPropertyPath()).hasToString("clienteId");
assertThat(v.getMessage()).isEqualTo("el cliente es obligatorio");
});
}
@Test
void las_violaciones_de_las_lineas_incluyen_el_indice_en_la_ruta() {
var peticion = peticionValida().conLineas(
new LineaRequest("SKU-1", 2),
new LineaRequest("", 0)); // dos errores en la línea 1
assertThat(validador.validate(peticion))
.extracting(v -> v.getPropertyPath().toString())
.containsExactlyInAnyOrder("lineas[1].sku", "lineas[1].cantidad");
}
@Test
void el_maximo_de_lineas_es_100() {
var peticion = peticionValida().conLineas(
IntStream.range(0, 101).mapToObj(i -> new LineaRequest("SKU-" + i, 1))
.toArray(LineaRequest[]::new));
assertThat(validador.validate(peticion))
.extracting(ConstraintViolation::getMessage)
.containsExactly("máximo 100 líneas por pedido");
}
}
// ── 2 · Validación a través del controlador: comprueba el formato del error ──
@WebMvcTest(PedidoController.class)
class ValidacionPedidoControllerTest {
@Autowired MockMvc mvc;
@MockitoBean ServicioPedidos servicio;
@Test
void un_cuerpo_invalido_devuelve_400_con_los_errores_por_campo() throws Exception {
mvc.perform(post("/api/v1/pedidos").contentType(APPLICATION_JSON).content("""
{ "clienteId": "", "lineas": [], "correoAviso": "no-es-un-correo" }
"""))
.andExpect(status().isBadRequest())
.andExpect(content().contentType("application/problem+json"))
.andExpect(jsonPath("$.title").value("Petición inválida"))
.andExpect(jsonPath("$.status").value(400))
.andExpect(jsonPath("$.errores.clienteId").value("el cliente es obligatorio"))
.andExpect(jsonPath("$.errores.lineas").value("el pedido debe tener al menos una línea"))
.andExpect(jsonPath("$.errores.correoAviso").exists())
.andExpect(jsonPath("$.stackTrace").doesNotExist()); // ← nunca filtres la traza
}
@Test
void un_json_malformado_devuelve_400_y_no_500() throws Exception {
mvc.perform(post("/api/v1/pedidos").contentType(APPLICATION_JSON).content("{ esto no es json"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.title").value("Cuerpo de la petición ilegible"));
}
@Test
void un_tipo_incorrecto_en_un_parametro_devuelve_400() throws Exception {
mvc.perform(get("/api/v1/pedidos").param("size", "muchos"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.detail").value(containsString("size")));
}
@Test
void un_valor_no_valido_de_enum_devuelve_400_con_los_valores_admitidos() throws Exception {
mvc.perform(get("/api/v1/pedidos").param("estado", "INVENTADO"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.detail").value(containsString("NUEVO")));
}
}
// ── 3 · Validadores personalizados: pruébalos como código normal ────────────
class ValidadorNifTest {
private final ValidadorNif validador = new ValidadorNif();
@ParameterizedTest
@ValueSource(strings = {"12345678Z", "X1234567L", "B12345674"})
void acepta_nif_nie_y_cif_validos(String documento) {
assertThat(validador.isValid(documento, null)).isTrue();
}
@ParameterizedTest
@ValueSource(strings = {"12345678A", "1234567", "AAAAAAAAA", ""})
void rechaza_documentos_invalidos(String documento) {
assertThat(validador.isValid(documento, null)).isFalse();
}
@Test
void null_se_considera_valido_porque_de_eso_se_encarga_NotNull() {
assertThat(validador.isValid(null, null)).isTrue();
}
}
7.13 Tests del manejo de errores y ProblemDetail
// El manejador global (RFC 9457: application/problem+json)
@RestControllerAdvice
public class ManejadorErroresGlobal extends ResponseEntityExceptionHandler {
@ExceptionHandler(PedidoNoEncontrado.class)
ProblemDetail noEncontrado(PedidoNoEncontrado ex) {
var problema = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problema.setTitle("Pedido no encontrado");
problema.setType(URI.create("https://api.ejemplo.com/errores/pedido-no-encontrado"));
problema.setProperty("pedidoId", ex.pedidoId());
problema.setProperty("timestamp", Instant.now());
return problema;
}
@ExceptionHandler(StockInsuficiente.class)
ProblemDetail stock(StockInsuficiente ex) {
var problema = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
problema.setTitle("Stock insuficiente");
problema.setProperty("sku", ex.sku());
problema.setProperty("solicitado", ex.solicitado());
problema.setProperty("disponible", ex.disponible());
return problema;
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders h, HttpStatusCode s, WebRequest r) {
var problema = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problema.setTitle("Petición inválida");
problema.setProperty("errores", ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(FieldError::getField,
e -> Objects.requireNonNullElse(e.getDefaultMessage(), "inválido"),
(a, b) -> a)));
return ResponseEntity.badRequest().body(problema);
}
}
@WebMvcTest(PedidoController.class)
class ManejoErroresTest {
@Autowired MockMvcTester mvc;
@MockitoBean ServicioPedidos servicio;
@Test
void un_pedido_inexistente_devuelve_404_con_problem_detail_completo() {
when(servicio.buscar("X")).thenThrow(new PedidoNoEncontrado("X"));
assertThat(mvc.get().uri("/api/v1/pedidos/X"))
.hasStatus(HttpStatus.NOT_FOUND)
.hasContentType("application/problem+json")
.bodyJson()
.extractingPath("$.title").isEqualTo("Pedido no encontrado");
}
@Test
void el_conflicto_de_stock_devuelve_409_con_los_datos_para_el_cliente() throws Exception {
when(servicio.confirmar("P-1")).thenThrow(new StockInsuficiente("SKU-1", 5, 2));
assertThat(mvc.post().uri("/api/v1/pedidos/P-1/confirmacion"))
.hasStatus(HttpStatus.CONFLICT)
.bodyJson().satisfies(json -> {
assertThat(json.extractingPath("$.sku").isEqualTo("SKU-1"));
assertThat(json.extractingPath("$.solicitado").isEqualTo(5));
assertThat(json.extractingPath("$.disponible").isEqualTo(2));
});
}
@Test
void un_error_inesperado_devuelve_500_SIN_filtrar_detalles_internos() {
when(servicio.buscar(any())).thenThrow(new RuntimeException(
"jdbc:postgresql://prod-db-01:5432/pedidos?user=app&password=s3cr3t0"));
assertThat(mvc.get().uri("/api/v1/pedidos/P-1"))
.hasStatus5xxServerError()
.bodyJson().satisfies(json -> {
// El cliente recibe un mensaje genérico; el detalle va al log con un id de traza
assertThat(json.extractingPath("$.detail").asString())
.doesNotContain("password", "jdbc", "prod-db-01");
assertThat(json.extractingPath("$.traceId").asString()).isNotBlank();
});
}
}
ExcepcionDeDominio y falla si alguna no aparece en la tabla de
correspondencias. Sin eso, la excepción nueva que alguien añade el mes que viene devolverá un 500 con la
traza completa, y nadie se dará cuenta hasta que lo vea un cliente.
7.14 Eventos, @Async y Awaitility
// ── Eventos de aplicación: @RecordApplicationEvents (Spring 5.3.3+) ─────────
@SpringBootTest
@RecordApplicationEvents
class EventosPedidoIT {
@Autowired ServicioPedidos servicio;
@Autowired ApplicationEvents eventos;
@Test
void confirmar_publica_exactamente_un_evento_PedidoConfirmado() {
servicio.confirmar("P-1");
assertThat(eventos.stream(PedidoConfirmado.class))
.singleElement()
.extracting(PedidoConfirmado::pedidoId).isEqualTo("P-1");
assertThat(eventos.stream(PedidoCancelado.class)).isEmpty();
assertThat(eventos.stream().count()).isEqualTo(3); // incluye eventos del framework
}
}
// ── Listeners: probarlos aislados es mucho más rápido que a través del evento ─
class EnviarCorreoAlConfirmarTest {
private final Notificador notificador = mock(Notificador.class);
private final EnviarCorreoAlConfirmar listener = new EnviarCorreoAlConfirmar(notificador);
@Test
void envia_el_correo_de_confirmacion_al_recibir_el_evento() {
listener.al(new PedidoConfirmado("P-1", "ana@ejemplo.com", AHORA));
verify(notificador).enviarConfirmacion("ana@ejemplo.com", "P-1");
}
}
// ── @TransactionalEventListener: el evento se procesa DESPUÉS del commit ────
// Si el test es @Transactional y hace rollback, el listener NUNCA se ejecuta.
// Ese es el fallo más desconcertante de esta familia de tests.
@SpringBootTest
class EventoTrasCommitIT {
@Autowired ServicioPedidos servicio;
@Autowired TransactionTemplate tx;
@MockitoSpyBean ProyeccionPedidos proyeccion;
@Test
void la_proyeccion_se_actualiza_tras_el_commit() {
// ⚠️ SIN @Transactional en el test: dejamos que el commit ocurra de verdad
tx.executeWithoutResult(status -> servicio.confirmar("P-1"));
await().atMost(Duration.ofSeconds(2))
.untilAsserted(() -> verify(proyeccion).actualizar("P-1"));
}
}
// ── Awaitility: la forma correcta de esperar en tests ────────────────────────
import static org.awaitility.Awaitility.*;
// ❌ Thread.sleep: si es corto, falla en CI; si es largo, la suite tarda una eternidad.
@Test
void mal() throws Exception {
servicio.procesarAsincrono("P-1");
Thread.sleep(2000); // ← 2 s SIEMPRE, incluso si tarda 50 ms
assertThat(repositorio.buscar("P-1").get().estado()).isEqualTo(Estado.PROCESADO);
}
// ✅ Awaitility: sondea hasta que se cumple, con tope. Termina en 50 ms si va rápido.
@Test
void bien() {
servicio.procesarAsincrono("P-1");
await().atMost(Duration.ofSeconds(5))
.pollInterval(Duration.ofMillis(50))
.pollDelay(Duration.ZERO)
.untilAsserted(() -> assertThat(repositorio.buscar("P-1"))
.get().extracting(Pedido::estado).isEqualTo(Estado.PROCESADO));
}
// Variantes útiles
await().until(() -> cola.tamano() == 0); // condición booleana
await().until(cola::tamano, equalTo(0)); // supplier + matcher
await().atMost(2, SECONDS).until(() -> contador.get() >= 3);
await().during(Duration.ofSeconds(1)) // que se MANTENGA cierto
.atMost(Duration.ofSeconds(5))
.until(() -> servicio.estable());
await().pollInSameThread() // sin hilo extra: preserva
.untilAsserted(() -> verify(proyeccion).actualizar("P-1")); // ThreadLocals y transacciones
await().ignoreExceptionsInstanceOf(EmptyResultDataAccessException.class)
.untilAsserted(() -> assertThat(dao.buscar("P-1")).isNotNull());
// Configuración global, en una clase base de test
@BeforeAll
static void configurarAwaitility() {
Awaitility.setDefaultTimeout(Duration.ofSeconds(5));
Awaitility.setDefaultPollInterval(Duration.ofMillis(50));
Awaitility.setDefaultPollDelay(Duration.ZERO);
}
// ── @Async: tres estrategias, de mejor a peor ───────────────────────────────
// 1 · ✅ LA MEJOR: prueba el método SIN @Async, llamándolo directamente.
// La anotación es infraestructura de Spring; su comportamiento ya está probado.
class ProcesadorPedidosTest {
@Test
void procesa_el_pedido_y_actualiza_el_estado() {
procesador.procesar("P-1"); // llamada directa, síncrona, determinista
assertThat(repositorio.buscar("P-1").get().estado()).isEqualTo(Estado.PROCESADO);
}
}
// 2 · ✅ Ejecutor síncrono en los tests: @Async se convierte en llamada directa.
@TestConfiguration
class EjecutorSincronoConfig {
@Bean("taskExecutor")
@Primary
Executor ejecutorSincrono() {
return new SyncTaskExecutor(); // ejecuta en el hilo que llama
}
}
// Con esto, un @SpringBootTest se vuelve determinista y no necesita Awaitility.
// Contrapartida: no pruebas el comportamiento concurrente real (que rara vez es lo que quieres probar aquí).
// 3 · 🟡 Con el ejecutor real + Awaitility: necesario si lo asíncrono ES el comportamiento
// (por ejemplo, que el endpoint responda 202 sin esperar al procesamiento).
@Test
void el_endpoint_responde_202_inmediatamente_y_procesa_despues() throws Exception {
long inicio = System.nanoTime();
mvc.perform(post("/api/v1/pedidos/P-1/procesamiento"))
.andExpect(status().isAccepted());
assertThat(Duration.ofNanos(System.nanoTime() - inicio)).isLessThan(Duration.ofMillis(500));
await().atMost(Duration.ofSeconds(5)).untilAsserted(() ->
assertThat(repositorio.buscar("P-1")).get()
.extracting(Pedido::estado).isEqualTo(Estado.PROCESADO));
}
| Situación asíncrona | Técnica recomendada |
|---|---|
@Async en un método propio | Prueba el método directamente; o SyncTaskExecutor en el contexto de test. |
@Scheduled | Extrae el cuerpo a un método público y pruébalo. Para el cron, un test de CronExpression.parse(...) que compruebe la próxima ejecución. |
@TransactionalEventListener(AFTER_COMMIT) | Test sin @Transactional, TransactionTemplate explícito y Awaitility. |
| Consumidor de Kafka o RabbitMQ | Contenedor real, publicar el mensaje y esperar con Awaitility a que cambie el estado observable. |
CompletableFuture | assertThat(futuro).succeedsWithin(Duration.ofSeconds(2)).isEqualTo(esperado) (AssertJ). |
WebClient / Mono / Flux | StepVerifier, o .block(Duration) en tests sencillos. |
| Hilos virtuales y tareas en paralelo | CountDownLatch para sincronizar, y ver la sección 13.5. |
Thread.sleep en un test es siempre un error. No hay excepciones prácticas. Si es
demasiado corto, el test es intermitente en una máquina cargada de CI; si es lo bastante largo para ser
fiable, has añadido segundos a cada ejecución. Y una suite con cincuenta sleep(500) son
veinticinco segundos de nada. Sustitúyelo siempre por Awaitility, por un ejecutor síncrono, por un
CountDownLatch o por un reloj controlado. Añade una regla de ArchUnit que prohíba
Thread.sleep en src/test y verás cuántos aparecen.
8 · Integración con dependencias reales
Aquí es donde una suite deja de mentir. Un test que sustituye PostgreSQL por H2, Kafka por una cola en memoria y el proveedor de pagos por un mock puede estar todo en verde mientras producción arde: los fallos de integración viven precisamente en las diferencias que has eliminado. Testcontainers y WireMock son las dos herramientas que cierran ese hueco sin sacrificar la reproducibilidad.
8.1 Testcontainers: por qué y cómo
| Problema del enfoque tradicional | Cómo lo resuelve Testcontainers |
|---|---|
| «En mi máquina funciona»: cada desarrollador tiene su PostgreSQL con su versión y sus datos. | La versión de la imagen está en el código. Todos ejecutan exactamente lo mismo, en local y en CI. |
| Base de datos compartida en un servidor: dos personas ejecutando tests a la vez se pisan. | Un contenedor por ejecución, aislado, con puerto aleatorio. |
H2 en memoria: dialecto distinto, sin jsonb, sin SKIP LOCKED, sin la misma semántica de aislamiento. | El motor real, con sus tipos, funciones, índices y bloqueos. |
| Hay que documentar cómo montar el entorno de test. | git clone y mvn verify. Lo único necesario es Docker. |
| Los contenedores quedan colgados si el test falla. | El contenedor sidecar Ryuk los elimina al terminar la JVM, incluso si el proceso muere de forma abrupta. |
// ── Uso básico: JUnit 5 + @Testcontainers ────────────────────────────────────
@SpringBootTest
@Testcontainers // activa el ciclo de vida gestionado por la extensión
class PedidoIT {
// static → se arranca UNA vez para toda la clase (@BeforeAll) y se para al final
// no-static → se arranca y se para en CADA test. Casi nunca es lo que quieres.
@Container
@ServiceConnection // Boot 3.1+: configura url, usuario y contraseña solos
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
}
// ── Sin @ServiceConnection (o para propiedades propias) ─────────────────────
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", postgres::getJdbcUrl);
r.add("spring.datasource.username", postgres::getUsername);
r.add("spring.datasource.password", postgres::getPassword);
}
// ── Ciclo de vida manual: máximo control, contenedor único para TODA la suite ─
public abstract class ContenedoresCompartidos {
static final PostgreSQLContainer<?> POSTGRES;
static final KafkaContainer KAFKA;
static { // se ejecuta una vez por JVM
POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true);
KAFKA = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.7.1"))
.withReuse(true);
Startables.deepStart(POSTGRES, KAFKA).join(); // arranque en PARALELO: ahorra segundos
}
// Sin @Container y sin stop(): los contenedores viven mientras viva la JVM y Ryuk limpia.
}
8.2 Los contenedores que vas a usar
// ── PostgreSQL ───────────────────────────────────────────────────────────────
@Container @ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("pedidos")
.withUsername("app")
.withPassword("secreto")
.withInitScript("db/init-extensiones.sql") // se ejecuta al arrancar
.withCommand("postgres", "-c", "fsync=off", // ← 2-5× más rápido en tests
"-c", "full_page_writes=off",
"-c", "synchronous_commit=off",
"-c", "log_min_duration_statement=100")
.withReuse(true);
// ── Kafka (KRaft, sin ZooKeeper) ─────────────────────────────────────────────
@Container @ServiceConnection
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.7.1"));
// Alternativa oficial más ligera: org.testcontainers.kafka.KafkaContainer("apache/kafka:3.8.0")
// ── Redis (imagen genérica + @ServiceConnection por nombre) ──────────────────
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
// ── MongoDB ──────────────────────────────────────────────────────────────────
@Container @ServiceConnection
static MongoDBContainer mongo = new MongoDBContainer("mongo:7"); // con réplica: soporta transacciones
// ── LocalStack: AWS en local (S3, SQS, SNS, DynamoDB, KMS…) ──────────────────
@Container
static LocalStackContainer localstack = new LocalStackContainer(
DockerImageName.parse("localstack/localstack:3.8"))
.withServices(Service.S3, Service.SQS);
@DynamicPropertySource
static void aws(DynamicPropertyRegistry r) {
r.add("spring.cloud.aws.endpoint", () -> localstack.getEndpoint().toString());
r.add("spring.cloud.aws.region.static", localstack::getRegion);
r.add("spring.cloud.aws.credentials.access-key", localstack::getAccessKey);
r.add("spring.cloud.aws.credentials.secret-key", localstack::getSecretKey);
}
// ── Contenedor genérico: cualquier imagen, con espera explícita ──────────────
@Container
static GenericContainer<?> keycloak = new GenericContainer<>("quay.io/keycloak/keycloak:26.0")
.withExposedPorts(8080)
.withEnv("KEYCLOAK_ADMIN", "admin")
.withEnv("KEYCLOAK_ADMIN_PASSWORD", "admin")
.withCopyFileToContainer(
MountableFile.forClasspathResource("keycloak/realm-pedidos.json"),
"/opt/keycloak/data/import/realm.json")
.withCommand("start-dev", "--import-realm")
.waitingFor(Wait.forHttp("/realms/pedidos/.well-known/openid-configuration")
.forStatusCode(200)
.withStartupTimeout(Duration.ofMinutes(2)));
// ── Docker Compose: cuando ya tienes el fichero y son muchos servicios ───────
@Container
static ComposeContainer entorno = new ComposeContainer(new File("src/test/resources/compose-test.yaml"))
.withExposedService("postgres", 5432, Wait.forListeningPort())
.withExposedService("kafka", 9092)
.withLocalCompose(true);
| Estrategia de espera | Cuándo usarla |
|---|---|
Wait.forListeningPort() | Por defecto en GenericContainer. Insuficiente: el puerto puede estar abierto antes de que el servicio acepte trabajo. |
Wait.forHttp("/health").forStatusCode(200) | Servicios HTTP. La opción más fiable. |
Wait.forLogMessage(".*ready to accept.*", 1) | Cuando el servicio escribe una línea inequívoca al estar listo. |
Wait.forHealthcheck() | Si la imagen define HEALTHCHECK. Lo mejor cuando existe. |
Wait.forSuccessfulCommand("pg_isready") | Comprobación desde dentro del contenedor. |
.waitingFor(...).withStartupTimeout(Duration) | Siempre: sin tope, un fallo de arranque cuelga CI hasta el timeout del job. |
8.3 Rendimiento, reutilización y CI
# ~/.testcontainers.properties (fichero LOCAL del desarrollador, no del repositorio)
testcontainers.reuse.enable=true
# Con reuse activado y .withReuse(true) en el contenedor:
# · el contenedor NO se elimina al terminar la JVM
# · la siguiente ejecución lo reutiliza si la configuración es idéntica (hash de la definición)
# · el arranque pasa de ~8 s a ~0 s
# · Ryuk se desactiva para esos contenedores: los limpias tú con `docker rm -f`
#
# ⚠️ Reutilizar significa que los DATOS persisten entre ejecuciones. Es imprescindible
# limpiar en @BeforeEach (truncado) o tendrás tests que pasan solos y fallan en grupo.
#
# En CI NO se activa: cada job arranca limpio y el reuse no aporta nada.
| Técnica | Ahorro típico | Detalle |
|---|---|---|
Contenedores static en una clase base compartida | De N arranques a 1 | La medida con más impacto. Combínala con un solo contexto de Spring (7.3). |
Startables.deepStart(...) | 30–50 % del arranque | Arranca PostgreSQL, Kafka y Redis en paralelo en lugar de en secuencia. |
Imágenes -alpine o slim | 1–3 s de descarga y arranque | Y menos ancho de banda en CI. Fija siempre la etiqueta: nunca latest. |
fsync=off y compañía en PostgreSQL | 2–5× en escrituras | Absolutamente seguro en tests: perder datos al morir el contenedor es lo esperado. |
tmpfs para el directorio de datos | 1,5–3× | .withTmpFs(Map.of("/var/lib/postgresql/data", "rw")) |
| Truncar en vez de recrear el esquema | De ~1 s a ~5 ms por test | TRUNCATE ... RESTART IDENTITY CASCADE de todas las tablas. |
| Caché de imágenes en CI | 10–60 s por job | Cachea ~/.docker o usa un registro mirror propio; evita el límite de descargas de Docker Hub. |
testcontainers.reuse.enable en local | 5–15 s por ejecución | Solo en local. Cambia por completo la experiencia de TDD con base de datos. |
## Docker en CI · GitHub Actions: los runners de Linux ya traen Docker, no hay que hacer nada
jobs:
integration:
runs-on: ubuntu-latest # ✅ Docker disponible
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '21', cache: maven }
- run: mvn -B verify -Pintegration
env:
TESTCONTAINERS_RYUK_DISABLED: "false" # en CI déjalo activo
# Evita el límite de descargas anónimas de Docker Hub:
TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX: ${{ vars.MIRROR_REGISTRY }}
## ⚠️ Casos problemáticos conocidos:
## · runners de macOS en GitHub Actions: NO traen Docker. Usa Linux para la integración.
## · Kubernetes con Docker-in-Docker: necesita privilegios; alternativa, Testcontainers Cloud.
## · Podman: funciona configurando DOCKER_HOST al socket de Podman y
## TESTCONTAINERS_RYUK_PRIVILEGED=true.
## · Contenedor de build sin socket: monta /var/run/docker.sock o usa un runner con Docker.
8.4 Alternativas embebidas y sus falsos positivos
| Alternativa | Qué gana | Qué NO detecta (falsos positivos reales) |
|---|---|---|
| H2 en modo PostgreSQL | Arranque en ~200 ms, sin Docker. | Tipos (jsonb, arrays, inet, rangos), funciones (generate_series, to_char, ILIKE), SKIP LOCKED, ON CONFLICT con WHERE, CTE recursivas complejas, ventanas con FILTER, colaciones, comportamiento de índices, EXPLAIN, semántica real de REPEATABLE READ, y las migraciones Flyway con SQL específico de PostgreSQL. |
| HSQLDB / Derby | Igual que H2. | Lo mismo, y con menos compatibilidad todavía. |
spring-kafka-test (@EmbeddedKafka) |
Sin Docker; útil si el equipo no puede usarlo. | Versión del broker distinta de producción, configuración de listeners y ACL, comportamiento real de reequilibrado, compactación, y la interacción con el registro de esquemas. |
Redis embebido (embedded-redis) |
Arranque rápido. | Proyectos abandonados, versiones antiguas de Redis, sin soporte de modules ni de ACL. No lo uses. |
Mongo embebido (flapdoodle) |
Sin Docker, descarga el binario. | Descarga por internet en cada CI limpio (lento y frágil), y sin réplica no hay transacciones. |
string_agg, o porque LIMIT con
FOR UPDATE se comporta distinto, o porque una columna jsonb se creó como
varchar. Un test verde que no detecta un fallo real es peor que no tener
test: consume tiempo y genera confianza injustificada. Si tu única razón para usar H2 es la
velocidad, mide primero: con contenedor estático y reutilización, la diferencia en una suite de 60 tests
de persistencia suele ser de segundos, no de minutos.
8.5 WireMock: servicios HTTP externos
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ClienteTarifasIT {
@RegisterExtension
static WireMockExtension wiremock = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort()
.notifier(new ConsoleNotifier(true))) // traza los matches
.failOnUnmatchedRequests(true) // ← si tu código pide algo no simulado, falla
.build();
@DynamicPropertySource
static void propiedades(DynamicPropertyRegistry r) {
r.add("app.tarifas.url", wiremock::baseUrl);
}
@Autowired ClienteTarifas cliente;
@Test
void parsea_la_respuesta_correcta() {
wiremock.stubFor(get(urlPathEqualTo("/v1/eur-usd"))
.withHeader("X-Api-Key", equalTo("clave-de-test"))
.willReturn(okJson("""
{ "par": "EUR/USD", "tasa": 1.0872, "fecha": "2026-03-15" }
""")));
assertThat(cliente.eurosPorDolar()).isEqualByComparingTo("1.0872");
wiremock.verify(1, getRequestedFor(urlPathEqualTo("/v1/eur-usd")));
}
// ── Simular los fallos que de verdad ocurren ─────────────────────────────
@Test
void un_503_del_proveedor_se_traduce_a_excepcion_de_dominio() {
wiremock.stubFor(get(anyUrl()).willReturn(aResponse()
.withStatus(503)
.withHeader("Retry-After", "30")
.withBody("Service Unavailable")));
assertThatThrownBy(cliente::eurosPorDolar).isInstanceOf(ProveedorNoDisponible.class);
}
@Test
void una_respuesta_lenta_provoca_timeout_y_no_cuelga_la_peticion() {
wiremock.stubFor(get(anyUrl()).willReturn(okJson("{}").withFixedDelay(3000)));
assertThatThrownBy(cliente::eurosPorDolar)
.isInstanceOf(ProveedorNoDisponible.class)
.hasRootCauseInstanceOf(SocketTimeoutException.class);
}
@Test
void una_conexion_cortada_a_media_respuesta_se_gestiona() {
wiremock.stubFor(get(anyUrl())
.willReturn(aResponse().withFault(Fault.MALFORMED_RESPONSE_CHUNK)));
// Otras faults: EMPTY_RESPONSE, RANDOM_DATA_THEN_CLOSE, CONNECTION_RESET_BY_PEER
assertThatThrownBy(cliente::eurosPorDolar).isInstanceOf(ProveedorNoDisponible.class);
}
@Test
void un_json_inesperado_no_produce_un_500_sino_un_error_controlado() {
wiremock.stubFor(get(anyUrl()).willReturn(okJson("""
{ "par": "EUR/USD", "tasa": "no-es-un-numero" }
""")));
assertThatThrownBy(cliente::eurosPorDolar).isInstanceOf(RespuestaInvalida.class);
}
}
// ── Escenarios con estado: probar reintentos y circuit breaker ───────────────
@Test
void reintenta_dos_veces_y_tiene_exito_a_la_tercera() {
wiremock.stubFor(get("/v1/eur-usd").inScenario("fallo-transitorio")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse().withStatus(503))
.willSetStateTo("primer-fallo"));
wiremock.stubFor(get("/v1/eur-usd").inScenario("fallo-transitorio")
.whenScenarioStateIs("primer-fallo")
.willReturn(aResponse().withStatus(503))
.willSetStateTo("segundo-fallo"));
wiremock.stubFor(get("/v1/eur-usd").inScenario("fallo-transitorio")
.whenScenarioStateIs("segundo-fallo")
.willReturn(okJson("{ \"tasa\": 1.0872 }")));
assertThat(cliente.eurosPorDolar()).isEqualByComparingTo("1.0872");
wiremock.verify(3, getRequestedFor(urlPathEqualTo("/v1/eur-usd")));
}
@Test
void tras_cinco_fallos_el_circuit_breaker_abre_y_deja_de_llamar() {
wiremock.stubFor(get(anyUrl()).willReturn(aResponse().withStatus(500)));
for (int i = 0; i < 5; i++) {
assertThatThrownBy(cliente::eurosPorDolar).isInstanceOf(ProveedorNoDisponible.class);
}
wiremock.resetRequests();
// Con el circuito abierto, la llamada falla RÁPIDO y sin salir a la red
assertThatThrownBy(cliente::eurosPorDolar).isInstanceOf(CallNotPermittedException.class);
wiremock.verify(0, getRequestedFor(anyUrl()));
// Y tras la ventana de espera, vuelve a probar (half-open)
await().atMost(Duration.ofSeconds(3)).untilAsserted(() -> {
assertThatThrownBy(cliente::eurosPorDolar).isInstanceOf(ProveedorNoDisponible.class);
wiremock.verify(moreThan(0), getRequestedFor(anyUrl()));
});
}
// ── Emparejamiento de peticiones: verificar lo que TÚ envías ──────────────────
@Test
void envia_el_cuerpo_y_las_cabeceras_esperadas() {
wiremock.stubFor(post(urlPathEqualTo("/v1/cobros")).willReturn(okJson("{\"id\":\"TX-1\"}")));
cliente.cobrar(euros("121.00"), "C-1", "idem-key-1");
wiremock.verify(postRequestedFor(urlPathEqualTo("/v1/cobros"))
.withHeader("Content-Type", containing("application/json"))
.withHeader("Idempotency-Key", equalTo("idem-key-1"))
.withHeader("Authorization", matching("Bearer .+"))
.withRequestBody(matchingJsonPath("$.importe", equalTo("121.00")))
.withRequestBody(matchingJsonPath("$.moneda", equalTo("EUR")))
.withRequestBody(equalToJson("""
{ "importe": "121.00", "moneda": "EUR", "clienteId": "C-1" }
""", true, true))); // ignoreArrayOrder, ignoreExtraElements
}
// ── Stubs en ficheros: el mapeo vive en JSON, no en Java ─────────────────────
// src/test/resources/mappings/tarifas-ok.json → definición del stub
// src/test/resources/__files/tarifas-ok-body.json → cuerpo de la respuesta
// Con @WireMockTest se cargan solos. Útil cuando los ejemplos los aporta el proveedor.
@WireMockTest(httpPort = 0) // anotación de JUnit 5 de WireMock, sin @RegisterExtension
class ConWireMockTest {
@Test
void usa_los_stubs_del_classpath(WireMockRuntimeInfo info) {
var cliente = new ClienteTarifas(info.getHttpBaseUrl(), "clave-de-test");
assertThat(cliente.eurosPorDolar()).isNotNull();
}
}
8.6 MockWebServer y otras opciones
// MockWebServer (de OkHttp): más ligero que WireMock, ideal para tests unitarios
// de un cliente HTTP. Sin emparejamiento: encolas respuestas en orden.
class ClienteTarifasMockWebServerTest {
private MockWebServer servidor;
private ClienteTarifas cliente;
@BeforeEach
void arrancar() throws IOException {
servidor = new MockWebServer();
servidor.start();
cliente = new ClienteTarifas(servidor.url("/").toString(), "clave");
}
@AfterEach
void parar() throws IOException { servidor.shutdown(); }
@Test
void parsea_la_tasa() throws Exception {
servidor.enqueue(new MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("{ \"tasa\": 1.0872 }"));
assertThat(cliente.eurosPorDolar()).isEqualByComparingTo("1.0872");
var peticion = servidor.takeRequest();
assertThat(peticion.getPath()).isEqualTo("/v1/eur-usd");
assertThat(peticion.getHeader("X-Api-Key")).isEqualTo("clave");
}
@Test
void gestiona_un_corte_de_conexion() {
servidor.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START));
assertThatThrownBy(cliente::eurosPorDolar).isInstanceOf(ProveedorNoDisponible.class);
}
}
| Herramienta | Nivel | Fuerte en | Úsala cuando |
|---|---|---|---|
MockRestServiceServer (@RestClientTest) | Intercepta el ClientHttpRequestFactory: no hay red real | Rapidez, integración perfecta con Spring | Test unitario de un cliente de Spring; el más rápido |
| MockWebServer | Servidor HTTP real en un puerto | Ligero, control del socket, fallos de red | Cliente HTTP de cualquier librería, tests de bajo nivel |
| WireMock | Servidor HTTP real, con emparejamiento y escenarios | Stubs complejos, escenarios con estado, latencia, record & replay, verificación rica | Tests de integración del servicio completo; simular varios proveedores |
| WireMock en contenedor | Contenedor Docker | Aislamiento total, compartido con otros lenguajes | Cuando el stub lo comparten varios equipos o servicios |
Mockear tu cliente (@MockitoBean) | Java, sin HTTP | Trivial de escribir | Solo cuando el HTTP ya está probado en otro test; nunca como única cobertura |
8.7 Probar los fallos: la mitad que nadie prueba
Casi todas las suites tienen el «camino feliz» de las integraciones cubierto y ninguno de los fallos. Y los incidentes de producción vienen justo de ahí. Esta es la lista mínima de escenarios adversos que deberías tener por cada dependencia externa.
| Escenario | Cómo simularlo | Qué debe hacer tu código |
|---|---|---|
| Timeout de conexión | Contenedor parado, o IP no enrutable | Fallar rápido (timeout de 1–2 s, no el de por defecto del sistema operativo) y con excepción propia |
| Timeout de lectura | withFixedDelay(N) mayor que tu read timeout | Cortar, no colgar el hilo; propagar un error entendible |
| 500 / 503 del proveedor | withStatus(503) | Reintentar si es idempotente, con retroceso exponencial y jitter; abrir el circuito tras N fallos |
429 con Retry-After | withStatus(429).withHeader("Retry-After","2") | Respetar la cabecera; no martillear |
| JSON inesperado o campo nuevo | Cuerpo con tipos cambiados o campos extra | Ignorar campos desconocidos; fallar de forma controlada ante tipos imposibles |
| Respuesta vacía o HTML de un proxy | Fault.EMPTY_RESPONSE, cuerpo text/html | No lanzar NullPointerException; error de dominio |
| Conexión cortada a mitad | Fault.MALFORMED_RESPONSE_CHUNK | Tratarlo como fallo transitorio |
| Base de datos caída durante la transacción | postgres.stop() en mitad del test | No dejar datos a medias; propagar y no reintentar dentro de la transacción |
| Base de datos lenta / pool agotado | pg_sleep, o pool de tamaño 1 con dos hilos | Timeout de adquisición del pool, no espera infinita |
| Kafka no disponible al publicar | kafka.stop() | Patrón outbox: la transacción de negocio no debe depender del broker |
| Mensaje duplicado | Publicar el mismo evento dos veces | Idempotencia por clave; el segundo no produce efecto |
| Mensaje envenenado | Publicar un JSON no deserializable | Ir a la dead letter queue sin bloquear la partición |
// Parar y arrancar un contenedor a mitad de test: probar la recuperación de verdad
@Test
void tras_recuperarse_la_base_de_datos_el_servicio_vuelve_a_funcionar() {
assertThat(servicio.contarPedidos()).isZero();
postgres.stop();
assertThatThrownBy(() -> servicio.contarPedidos())
.isInstanceOf(DataAccessResourceFailureException.class);
postgres.start();
// El pool tiene que reconectar solo. Si no lo hace, tienes un incidente esperando a ocurrir.
await().atMost(Duration.ofSeconds(30))
.untilAsserted(() -> assertThat(servicio.contarPedidos()).isZero());
}
// Con Toxiproxy se simula degradación de red sin parar nada: latencia, ancho de banda, cortes
@Container
static ToxiproxyContainer toxiproxy = new ToxiproxyContainer("ghcr.io/shopify/toxiproxy:2.11.0")
.withNetwork(red);
@Test
void con_500_ms_de_latencia_adicional_el_timeout_salta_y_el_fallback_responde() throws Exception {
var proxy = toxiproxy.getProxy(postgres, 5432);
proxy.toxics().latency("lenta", ToxicDirection.DOWNSTREAM, 500).setJitter(100);
assertThat(servicio.consultarConFallback("P-1")).isEqualTo(RESPUESTA_DEGRADADA);
proxy.toxics().get("lenta").remove();
}
9 · Datos de prueba
Los datos son la parte de los tests que más envejece y la que más los rompe. Un cambio en el constructor de una entidad puede tocar doscientos tests; un dataset compartido convierte «arreglar un test» en «romper otros siete». Estas técnicas son la diferencia entre una suite que se mantiene sola y una que consume una tarde por cada cambio de modelo.
9.1 Builders y el patrón Object Mother
// ── El BUILDER de test: valores por defecto válidos + solo lo relevante explícito ──
public class PedidoBuilder {
// Todos los valores por defecto forman un objeto VÁLIDO y determinista.
private String referencia = "PED-20260315-0001";
private Cliente cliente = ClienteMother.estandar();
private Estado estado = Estado.NUEVO;
private Instant creadoEn = Instant.parse("2026-03-15T10:00:00Z");
private final List<Linea> lineas = new ArrayList<>();
public static PedidoBuilder unPedido() { return new PedidoBuilder(); }
public PedidoBuilder referencia(String r) { this.referencia = r; return this; }
public PedidoBuilder de(Cliente c) { this.cliente = c; return this; }
public PedidoBuilder en(Estado e) { this.estado = e; return this; }
public PedidoBuilder creadoEn(String iso) { this.creadoEn = Instant.parse(iso); return this; }
public PedidoBuilder conLinea(String sku, int cantidad, String precio) {
lineas.add(new Linea(sku, cantidad, Dinero.euros(precio)));
return this;
}
public PedidoBuilder conLineasPorImporte(String total) { // atajo frecuente
return conLinea("SKU-1", 1, total);
}
public Pedido build() {
if (lineas.isEmpty()) conLinea("SKU-1", 1, "100.00"); // un pedido válido por defecto
return new Pedido(referencia, cliente, estado, List.copyOf(lineas), creadoEn);
}
}
// En el test se lee como una frase y SOLO menciona lo que importa al caso:
var pedido = unPedido().de(ClienteMother.vip())
.conLinea("SKU-9", 3, "10.00")
.build();
// ── El OBJECT MOTHER: nombres de escenarios de negocio ───────────────────────
public final class PedidoMother {
public static Pedido nuevo() { return unPedido().build(); }
public static Pedido nuevo(String referencia) { return unPedido().referencia(referencia).build(); }
public static Pedido confirmado() { return unPedido().en(Estado.CONFIRMADO).build(); }
public static Pedido pagadoDe(String importe) {
return unPedido().en(Estado.PAGADO).conLineasPorImporte(importe).build();
}
public static Pedido conEnvioGratis() { return unPedido().conLineasPorImporte("60.00").build(); }
public static Pedido queNoAlcanzaEnvioGratis(){ return unPedido().conLineasPorImporte("20.00").build(); }
public static Pedido deVipConCupon() {
return unPedido().de(ClienteMother.vip()).conCupon(CuponMother.vigente10()).build();
}
private PedidoMother() { }
}
| Patrón | Cuándo | Ventaja | Riesgo |
|---|---|---|---|
| Builder | Cuando cada test necesita variar campos distintos | Flexible; los valores por defecto absorben los cambios de constructor | Puede acabar teniendo cincuenta métodos si no lo cuidas |
| Object Mother | Cuando hay escenarios de negocio repetidos con nombre propio | Los tests hablan el idioma del dominio | Si cada test añade su método, crece sin control. Máxima: 10–15 escenarios por entidad |
| Los dos juntos | Casi siempre | El mother para los escenarios comunes, el builder para variaciones puntuales | — |
Constantes estáticas (static final Pedido PEDIDO = …) | Solo con objetos inmutables | Rapidísimo y muy legible | Si el objeto es mutable, has creado estado compartido: la peor clase de acoplamiento |
@Builder de Lombok en la entidad | Cuando la entidad ya lo tiene | Cero código extra | No tiene valores por defecto de test: cada test debe rellenar todo, y un campo nuevo rompe todos |
9.2 @Sql y scripts de datos
@DataJpaTest
@Testcontainers
class InformesTest {
// A nivel de clase: se ejecuta antes de CADA test de la clase
@Test
@Sql(scripts = {"/datos/limpiar.sql", "/datos/catalogo-base.sql", "/datos/ventas-marzo.sql"})
void las_ventas_por_categoria_se_ordenan_por_importe() { … }
// Antes y después, con fase explícita
@Test
@Sql(scripts = "/datos/pedidos-pendientes.sql",
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "/datos/limpiar.sql",
executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
void procesa_los_pedidos_pendientes() { … }
// SQL en línea (Spring 6.1+): útil para una o dos sentencias
@Test
@Sql(statements = {
"INSERT INTO cliente (id, nombre, tipo) VALUES ('C-1', 'Ana', 'VIP')",
"INSERT INTO pedido (id, cliente_id, estado, total) VALUES ('P-1', 'C-1', 'NUEVO', 121.00)"
})
void un_cliente_vip_ve_el_precio_con_descuento() { … }
// Configuración del ejecutor: separador, comentarios, tolerancia a errores
@Test
@Sql(scripts = "/datos/carga-masiva.sql",
config = @SqlConfig(separator = ";;", commentPrefix = "--",
errorMode = SqlConfig.ErrorMode.CONTINUE_ON_ERROR,
transactionMode = SqlConfig.TransactionMode.ISOLATED))
void carga_masiva() { … }
}
// Spring 6.1+ · BEFORE_TEST_CLASS y AFTER_TEST_CLASS: carga costosa una sola vez
@Sql(scripts = "/datos/catalogo-500k.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS)
class CatalogoGrandeTest { … }
| Forma de cargar datos | Pros | Contras | Recomendación |
|---|---|---|---|
@Sql con scripts | Rápido, controlas el SQL exacto, sirve para casos que JPA no puede crear | Se desincroniza del modelo; nadie lo lee al revisar | Para datos de referencia (catálogos, tablas maestras) y para casos de datos «imposibles» |
TestEntityManager.persist con builders | Refactor-seguro, legible, en el mismo fichero que el test | Más lento con muchos registros | Por defecto para los datos del caso concreto |
| Llamar al API de la aplicación | Datos coherentes por construcción; ejercita el flujo real | Lento; un fallo en la creación tumba tests de otra cosa | Para tests de extremo a extremo, no para preparar escenarios |
data.sql / import.sql global | Cero esfuerzo | Fixture compartido: acopla todos los tests | Solo para datos de referencia inmutables |
Migraciones Flyway de test (db/testdata) | Se versiona con el esquema | Mismo problema de acoplamiento | Solo catálogos |
9.3 Aislamiento entre tests: cuatro estrategias
| Estrategia | Cómo | Velocidad | Limitación |
|---|---|---|---|
| Transacción con rollback | @Transactional en el test (implícito en @DataJpaTest) |
⚡ La más rápida: nada que borrar | No funciona con RANDOM_PORT (otro hilo), ni con código que hace REQUIRES_NEW, ni con @TransactionalEventListener(AFTER_COMMIT). Y oculta bugs de flush: usa em.flush() explícito. |
Truncado en @BeforeEach |
TRUNCATE t1, t2, … RESTART IDENTITY CASCADE |
⚡ Muy rápida (milisegundos) | Hay que mantener la lista de tablas (se puede generar consultando el catálogo). El commit ocurre de verdad, así que los listeners se ejecutan: normalmente es lo que quieres. |
| Datos únicos por test | Prefijo derivado del nombre del test o de un contador | ⚡ Sin coste de limpieza | La base crece durante la ejecución; los count(*) globales dejan de servir. Imprescindible si paralelizas. |
| Esquema o base por test/clase | CREATE SCHEMA + search_path, o base nueva |
🐢 Lenta (crear esquema y migrar) | Aislamiento perfecto y paralelización trivial. Reserva para casos donde el resto falla. |
// Truncado genérico: descubre las tablas solo, así no hay lista que mantener
@Component
public class LimpiadorBaseDeDatos {
private final JdbcClient jdbc;
private List<String> tablas;
public LimpiadorBaseDeDatos(JdbcClient jdbc) { this.jdbc = jdbc; }
@PostConstruct
void descubrirTablas() {
tablas = jdbc.sql("""
SELECT quote_ident(table_schema) || '.' || quote_ident(table_name)
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
AND table_name NOT IN ('flyway_schema_history')
""").query(String.class).list();
}
public void limpiar() {
if (tablas.isEmpty()) return;
jdbc.sql("TRUNCATE TABLE " + String.join(", ", tablas) + " RESTART IDENTITY CASCADE").update();
}
}
// En la clase base de integración:
@BeforeEach
void limpiar() { limpiador.limpiar(); }
// Alternativa: la extensión de JUnit hace lo mismo sin ensuciar las clases de test
public class LimpiarBaseDeDatosExtension implements BeforeEachCallback {
@Override
public void beforeEach(ExtensionContext ctx) {
SpringExtension.getApplicationContext(ctx)
.getBean(LimpiadorBaseDeDatos.class).limpiar();
}
}
@Transactional en los tests: como todo ocurre en una transacción que
nunca hace commit, Hibernate puede no ejecutar el INSERT hasta el final, y así los
fallos de restricciones, de triggers o de flush no aparecen. Además la caché de primer
nivel te devuelve el objeto que acabas de guardar sin ir a la base de datos, de modo que un mapeo
incorrecto pasa desapercibido. Contramedida: em.flush() y em.clear() después de
preparar los datos, siempre. Es una línea y detecta una familia entera de bugs.
9.4 Fixtures compartidos y el acoplamiento que crean
-- ❌ El fichero que empieza pequeño y acaba siendo intocable: src/test/resources/data.sql
INSERT INTO cliente (id, nombre, tipo, saldo) VALUES
('C-1', 'Ana', 'VIP', 1000.00),
('C-2', 'Luis', 'ESTANDAR', 500.00),
('C-3', 'Marta', 'ESTANDAR', 0.00);
INSERT INTO pedido (id, cliente_id, estado, total) VALUES
('P-1', 'C-1', 'NUEVO', 121.00),
('P-2', 'C-1', 'PAGADO', 60.50),
('P-3', 'C-2', 'CANCELADO', 30.00);
-- … dos años después: 400 filas y 180 tests que dependen de ellas.
-- Cambiar el saldo de C-3 a 10,00 para arreglar un test rompe otros cinco.
-- Nadie sabe qué test depende de qué fila. Nadie se atreve a borrar nada.
| Síntoma del fixture compartido | Coste real |
|---|---|
| «Si cambio este dato, se rompen cinco tests que no tienen nada que ver» | Los cambios se paralizan; se añaden filas nuevas en vez de tocar las existentes. |
Los tests dicen assertThat(clientes).hasSize(3) | Añadir un cliente para un test nuevo rompe los que cuentan. |
Hay que leer el data.sql para entender un test | El test deja de ser autoexplicativo; el diagnóstico de un fallo pasa de un minuto a veinte. |
| El fichero tiene comentarios del tipo «no borrar, lo usa PedidoServiceTest» | Documentación de un acoplamiento que no debería existir. |
La salida gradual, sin reescribir todo de golpe: (1) congela el fixture: prohíbe añadirle filas nuevas; (2) los tests nuevos crean sus datos con builders; (3) cada vez que toques un test antiguo, migra sus datos al propio test; (4) cuando una fila se quede sin usuarios, bórrala. Distingue siempre entre datos de referencia (países, tipos de IVA, categorías: inmutables y compartirlos está bien) y datos del caso (clientes, pedidos: siempre locales al test).
9.5 Datos aleatorios: útiles y peligrosos
// ── Generadores tipo Instancio / EasyRandom: rellenan todo el objeto ─────────
@Test
void el_mapeo_no_pierde_ningun_campo() {
// Instancio rellena TODOS los campos con valores aleatorios: si añades un campo
// nuevo y no lo mapeas, este test lo detecta. Es su mejor caso de uso.
var entidad = Instancio.create(PedidoEntity.class);
var dto = mapeador.aDto(entidad);
var vuelta = mapeador.aEntidad(dto);
assertThat(vuelta).usingRecursiveComparison()
.ignoringFields("version", "creadoEn")
.isEqualTo(entidad);
}
// Con control sobre los campos que importan:
var pedido = Instancio.of(PedidoEntity.class)
.set(field(PedidoEntity::getEstado), Estado.NUEVO)
.generate(field(PedidoEntity::getTotal), gen -> gen.math().bigDecimal().min(ONE).max(TEN))
.withSeed(42) // ← determinista y reproducible
.create();
// ── Aleatoriedad determinista para tests basados en propiedades ──────────────
@Test
void el_total_siempre_es_mayor_o_igual_que_el_subtotal_sin_descuento() {
var aleatorio = new Random(20260315); // semilla FIJA: reproducible
for (int i = 0; i < 1000; i++) {
var subtotal = euros(BigDecimal.valueOf(aleatorio.nextInt(1, 100_000), 2));
var pedido = unPedido().conLineasPorImporte(subtotal.toString()).build();
assertThat(pedido.total()).isGreaterThanOrEqualTo(subtotal);
}
}
// ── jqwik: property-based testing de verdad, con reducción del contraejemplo ─
@Property(tries = 1000)
void el_descuento_nunca_supera_el_50_por_ciento(
@ForAll @BigRange(min = "0.01", max = "100000") BigDecimal subtotal,
@ForAll @IntRange(min = 0, max = 100) int porcentajeCupon) {
var resultado = calculadora.calcular(carritoDe(subtotal), cuponDe(porcentajeCupon));
assertThat(resultado.descuento().cantidad())
.isLessThanOrEqualTo(subtotal.multiply(new BigDecimal("0.5")));
}
// Cuando falla, jqwik REDUCE el caso al contraejemplo más pequeño posible
// (por ejemplo subtotal = 0.01, cupón = 51) y te lo imprime. Vale oro para
// invariantes matemáticas, parsers y serialización de ida y vuelta.
(1) Tests intermitentes: si la semilla cambia en cada ejecución, un fallo puede no reproducirse. Regla: semilla fija siempre, y si usas property-based testing, que la herramienta imprima la semilla del fallo para poder fijarla.
(2) Aserciones vacías: con datos aleatorios no puedes escribir el valor esperado literal, así que la tentación es asertar tautologías. Regla: los datos aleatorios sirven para invariantes («nunca negativo», «ida y vuelta idéntica»), no para casos concretos.
(3) Datos absurdos: un generador puede producir un pedido con −3 líneas y una fecha del año 1400, y hacerte perder una tarde con un fallo que no puede ocurrir. Regla: acota los generadores al dominio válido.
9.6 Datos de producción anonimizados
Copiar la base de producción a preproducción es lo más tentador y lo más peligroso que se hace en testing. Cubre casos reales que nunca imaginarías, y a la vez es un problema legal serio: en la Unión Europea, tratar datos personales para pruebas requiere base jurídica, y el RGPD no admite «los necesitábamos para testear» como justificación (ver el módulo 10 · Seguridad).
| Técnica | Qué hace | Conserva utilidad | Riesgo de reidentificación |
|---|---|---|---|
| Anonimización (irreversible) | Sustituye por valores sintéticos coherentes: nombres de un diccionario, IBAN válidos falsos, correos @invalid. | Alta si mantienes formatos y distribuciones | Bajo, si eliminas también los cuasi-identificadores (código postal + fecha de nacimiento + sexo identifican a mucha gente) |
| Pseudonimización (reversible con clave) | Cifra o hashea con clave separada. | Alta | Sigue siendo dato personal según el RGPD: mismas obligaciones |
| Enmascarado parcial | ****1234, a***@ejemplo.com. | Media | Medio: los patrones pueden identificar |
| Submuestreo + generación sintética | Aprender las distribuciones y generar datos nuevos. | Media-alta | Muy bajo. Es la opción recomendada |
| Copia directa | Nada. | Máxima | Inaceptable. Y el entorno de test suele tener menos controles que producción, con lo que has multiplicado la superficie de exposición |
9.7 Snapshots y approval testing
// La idea: en vez de escribir el resultado esperado, se aprueba una vez y se
// compara con lo aprobado. Ideal para salidas grandes y estables.
class InformeMensualApprovalTest {
@Test
void el_informe_de_marzo_no_cambia() {
String informe = generador.generar(datosDeMarzo());
// Compara con src/test/resources/approved/InformeMensualApprovalTest.
// el_informe_de_marzo_no_cambia.approved.txt
// Si difiere, escribe un .received.txt y falla mostrando el diff.
Approvals.verify(informe);
}
@Test
void el_json_de_la_respuesta_es_estable() {
Approvals.verifyJson(mapper.writeValueAsString(respuestaCompleta()));
}
}
✅ Buenos usos
- Informes, facturas, correos y plantillas: el resultado es largo y el diff es facilísimo de revisar.
- Respuestas JSON completas de una API pública: detectas cualquier cambio de contrato, incluidos los accidentales.
- Tests de caracterización de código heredado: capturas lo que hace ahora sin entenderlo.
- Salida generada (código, SQL, YAML, esquemas OpenAPI).
❌ Riesgos
- La aprobación automática: cuando el test falla, es tentador copiar el
.receivedsobre el.approvedsin leerlo. Ahí el test deja de proteger. Regla de equipo: el cambio de un fichero aprobado se revisa igual que un cambio de código. - Datos no deterministas (fechas, ids, orden de mapas) hacen fallar el snapshot cada vez: hay que normalizarlos antes de comparar.
- No explican por qué el valor es correcto: sin un nombre bueno, el fichero es opaco.
- Un cambio legítimo pequeño puede producir un diff de mil líneas.
10 · Tests de contrato y de API
10.1 Por qué los tests de integración no bastan entre equipos
Tienes dos servicios de equipos distintos: pedidos consume catalogo. Las
opciones «obvias» fallan las dos.
| Enfoque | Problema |
|---|---|
Mockear catalogo con WireMock |
Tus tests pasan con tu idea de la respuesta. Si catalogo renombra un campo, tus tests siguen verdes y la integración se rompe en producción. Los stubs son suposiciones no verificadas. |
| Desplegar los dos y probar de verdad (E2E) | Necesitas un entorno con las dos versiones correctas, coordinación entre equipos, y el pipeline de uno se rompe por culpa del otro. Con cinco servicios es logísticamente imposible y con veinte, absurdo. |
El contract testing resuelve el dilema: el consumidor declara lo que espera y el proveedor verifica en su propio pipeline que lo cumple. Nadie despliega nada junto y, aun así, la incompatibilidad se detecta antes del merge.
CONSUMIDOR (pedidos) BROKER PROVEEDOR (catalogo)
──────────────────── ──────── ────────────────────
1. Test con el mock de Pact
"cuando pida GET /productos/SKU-1
espero 200 con {sku, nombre, precio}"
│
│ genera pedidos-catalogo.json
▼
2. Publica el pacto ───────────────► almacena
│
│ 3. El pipeline de catalogo
│ descarga los pactos
▼
verifica ◄──── 4. Ejecuta cada interacción
│ contra el proveedor REAL
│ y comprueba la respuesta
│
6. can-i-deploy ◄─── resultado ◄─────┘ 5. Publica el resultado
Si catalogo rompe el contrato, SU pipeline se pone rojo antes de desplegar.
Si pedidos necesita un campo nuevo, cambia el pacto y catalogo se entera al verificar.
10.2 Pact: flujo completo
// ══ LADO CONSUMIDOR (servicio pedidos) ══════════════════════════════════════
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "catalogo", pactVersion = PactSpecVersion.V4)
class ClienteCatalogoPactTest {
@Pact(consumer = "pedidos")
RequestResponsePact productoExistente(PactDslWithProvider builder) {
return builder
.given("el producto SKU-1 existe y está activo") // ← estado del proveedor
.uponReceiving("una petición del producto SKU-1")
.path("/api/v1/productos/SKU-1")
.method("GET")
.headers(Map.of("Accept", "application/json"))
.willRespondWith()
.status(200)
.headers(Map.of("Content-Type", "application/json"))
// Se describe la FORMA, no los valores exactos: así el proveedor
// puede devolver cualquier nombre y el contrato sigue cumpliéndose.
.body(new PactDslJsonBody()
.stringType("sku", "SKU-1")
.stringType("nombre", "Teclado mecánico")
.decimalType("precio", 49.90)
.booleanType("activo", true)
.stringMatcher("categoria", "INFORMATICA|HOGAR|LIBROS", "INFORMATICA"))
.toPact();
}
@Pact(consumer = "pedidos")
RequestResponsePact productoInexistente(PactDslWithProvider builder) {
return builder
.given("el producto SKU-NOEXISTE no existe")
.uponReceiving("una petición de un producto inexistente")
.path("/api/v1/productos/SKU-NOEXISTE").method("GET")
.willRespondWith().status(404)
.toPact();
}
@Test
@PactTestFor(pactMethod = "productoExistente")
void mapea_la_respuesta_del_catalogo(MockServer servidor) {
var cliente = new ClienteCatalogo(servidor.getUrl());
var producto = cliente.buscar("SKU-1").orElseThrow();
assertThat(producto.sku()).isEqualTo("SKU-1");
assertThat(producto.precio()).isEqualByComparingTo("49.90");
}
@Test
@PactTestFor(pactMethod = "productoInexistente")
void un_404_se_traduce_a_optional_vacio(MockServer servidor) {
assertThat(new ClienteCatalogo(servidor.getUrl()).buscar("SKU-NOEXISTE")).isEmpty();
}
}
// ══ LADO PROVEEDOR (servicio catalogo) ══════════════════════════════════════
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Provider("catalogo")
@PactBroker(url = "https://pact.ejemplo.com",
authentication = @PactBrokerAuth(token = "${PACT_BROKER_TOKEN}"))
@VersionSelector
class CatalogoPactVerificationTest {
@LocalServerPort int puerto;
@MockitoBean RepositorioProductos repositorio;
@BeforeEach
void destino(PactVerificationContext contexto) {
contexto.setTarget(new HttpTestTarget("localhost", puerto));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verificarPacto(PactVerificationContext contexto) {
contexto.verifyInteraction(); // ejecuta CADA interacción de CADA consumidor
}
// Un @State por cada "given" declarado por los consumidores.
// Si falta uno, la verificación falla: es la forma de saber qué esperan de ti.
@State("el producto SKU-1 existe y está activo")
void sku1Existe() {
when(repositorio.buscar("SKU-1")).thenReturn(Optional.of(
new Producto("SKU-1", "Teclado mecánico", euros("49.90"), Categoria.INFORMATICA, true)));
}
@State("el producto SKU-NOEXISTE no existe")
void skuNoExiste() {
when(repositorio.buscar("SKU-NOEXISTE")).thenReturn(Optional.empty());
}
}
# ══ EN CI ═══════════════════════════════════════════════════════════════════
# 1 · Consumidor: genera y publica los pactos
mvn test -Dtest='*PactTest'
mvn pact:publish \
-Dpact.publish.consumer.version=$GIT_SHA \
-Dpact.publish.consumer.branch=$GIT_BRANCH \
-Dpact.broker.url=https://pact.ejemplo.com
# 2 · Proveedor: verifica los pactos de todos sus consumidores
mvn test -Dtest='*PactVerificationTest' \
-Dpact.provider.version=$GIT_SHA \
-Dpact.verifier.publishResults=true
# 3 · La puerta de despliegue: ¿es compatible con lo que hay desplegado?
pact-broker can-i-deploy \
--pacticipant pedidos --version $GIT_SHA \
--to-environment production \
--retry-while-unknown 30 --retry-interval 10
# Devuelve código 0 solo si TODAS las combinaciones consumidor/proveedor están verificadas.
# Es lo que convierte Pact en una puerta de calidad real y no en un informe que nadie mira.
# 4 · Y al desplegar de verdad, se registra:
pact-broker record-deployment --pacticipant pedidos --version $GIT_SHA --environment production
10.3 Spring Cloud Contract: el enfoque del proveedor
// src/test/resources/contracts/pedidos/debeDevolverElProducto.groovy
// El contrato lo escribe el PROVEEDOR (o se acuerda por pull request).
Contract.make {
description "devuelve el producto SKU-1 cuando existe"
request {
method GET()
url "/api/v1/productos/SKU-1"
headers { accept applicationJson() }
}
response {
status OK()
headers { contentType applicationJson() }
body(
sku: "SKU-1",
nombre: $(anyNonBlankString()),
precio: $(anyNumber()),
activo: true
)
}
}
// El plugin de Maven genera:
// · un test JUnit que verifica al proveedor (se ejecuta en su build)
// · un artefacto stubs .jar publicado al repositorio
// El consumidor usa los stubs con @AutoConfigureStubRunner, sin escribir nada.
// Consumidor: usa los stubs publicados por el proveedor, sin inventarse nada
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@AutoConfigureStubRunner(
ids = "com.ejemplo:catalogo:+:stubs:8090", // + = última versión
stubsMode = StubRunnerProperties.StubsMode.REMOTE,
repositoryRoot = "https://nexus.ejemplo.com/repository/maven-releases")
class ClienteCatalogoStubTest {
@Autowired ClienteCatalogo cliente;
@Test
void mapea_la_respuesta_real_del_proveedor() {
assertThat(cliente.buscar("SKU-1")).isPresent();
}
}
| Pact | Spring Cloud Contract | |
|---|---|---|
| Dirección | Consumer-driven: el consumidor declara lo que necesita | Provider-driven (o acordado): el contrato vive con el proveedor |
| Distribución | Broker con estados por entorno y can-i-deploy | Artefactos stubs.jar en el repositorio Maven |
| Poliglotismo | Excelente (JS, Python, Go, .NET, JVM…) | JVM sobre todo; hay soporte de otros, más limitado |
| Mensajería | Sí (mensajes asíncronos) | Sí, muy integrado con Spring Cloud Stream |
| Ventaja principal | El can-i-deploy como puerta de despliegue y la matriz de compatibilidad | Los stubs se generan solos y el consumidor no escribe nada |
| Cuándo elegirlo | Equipos y lenguajes heterogéneos, muchos consumidores, despliegue independiente | Ecosistema Spring homogéneo, el proveedor quiere controlar el contrato |
10.4 Verificar el esquema OpenAPI contra la implementación
Si publicas un openapi.yaml, tienes un segundo contrato que también se desincroniza. Hay dos
direcciones y las dos merecen un test.
// ── A · La implementación cumple el esquema (validación de respuestas) ───────
class ConformidadOpenApiTest {
private static final OpenApiInteractionValidator VALIDADOR = OpenApiInteractionValidator
.createForSpecificationUrl("src/main/resources/static/openapi.yaml")
.build();
@Test
void la_respuesta_de_get_pedido_cumple_el_esquema_publicado() throws Exception {
var resultado = mvc.perform(get("/api/v1/pedidos/P-1")).andReturn();
var informe = VALIDADOR.validateResponse("/api/v1/pedidos/{id}", Method.GET,
SimpleResponse.Builder.ok()
.withContentType("application/json")
.withBody(resultado.getResponse().getContentAsString())
.build());
assertThat(informe.hasErrors())
.as(() -> "violaciones del esquema: " + informe)
.isFalse();
}
// Aún mejor: un filtro que valide TODAS las respuestas de TODOS los tests de API.
// Se instala una vez y protege para siempre; cualquier campo nuevo sin documentar falla.
}
// ── B · El esquema no rompe a los clientes (comparación entre versiones) ─────
// En CI, con openapi-diff:
// docker run --rm -v $PWD:/spec openapitools/openapi-diff:latest \
// /spec/openapi-main.yaml /spec/openapi-rama.yaml --fail-on-incompatible
// Falla si eliminas un endpoint, quitas un campo de una respuesta, añades un
// parámetro obligatorio, restringes un enum o cambias un tipo. Esos son
// EXACTAMENTE los cambios que rompen a los consumidores.
// ── C · El esquema se genera desde el código, no al contrario ────────────────
// Con springdoc-openapi, un test que congela el esquema generado:
@SpringBootTest(webEnvironment = RANDOM_PORT)
class EsquemaOpenApiTest {
@Test
void el_esquema_publicado_coincide_con_el_generado() {
String generado = rest.getForObject("/v3/api-docs", String.class);
// approval test: si cambia, se revisa el diff a conciencia
Approvals.verifyJson(generado);
}
}
10.5 Compatibilidad de esquemas en mensajería
En un sistema de eventos, el contrato es el esquema del mensaje, y el problema es más duro que en HTTP: los mensajes son persistentes, los consumidores son muchos y desconocidos, y puede que alguien lea hoy un evento escrito hace seis meses.
| Modo de compatibilidad | Permite | Cuándo usarlo |
|---|---|---|
BACKWARD (por defecto) | Borrar campos opcionales y añadir campos con valor por defecto. Un consumidor nuevo lee datos viejos. | Lo normal: actualizas consumidores primero, luego productores. |
FORWARD | Añadir campos y borrar campos con valor por defecto. Un consumidor viejo lee datos nuevos. | Cuando no controlas cuándo se actualizan los consumidores. |
FULL | La intersección de las dos: solo añadir o quitar campos opcionales con valor por defecto. | Eventos públicos con muchos consumidores. La opción prudente. |
*_TRANSITIVE | Lo anterior comprobado contra todas las versiones anteriores, no solo la última. | Cuando hay mensajes históricos que se reprocesan. Lo más seguro. |
NONE | Todo. | Nunca en producción. |
# Puerta de calidad en CI: comprobar la compatibilidad ANTES de fusionar
curl -s -X POST \
-H "Content-Type: application/vnd.schemaregistry.v1+json" \
--data @esquema-nuevo.json \
https://schema-registry.ejemplo.com/compatibility/subjects/pedidos.confirmado-value/versions/latest \
| jq -e '.is_compatible == true'
# Con el plugin de Maven de Confluent:
mvn io.confluent:kafka-schema-registry-maven-plugin:test-compatibility
mvn io.confluent:kafka-schema-registry-maven-plugin:register # solo tras el merge
// Test de compatibilidad hacia atrás sin registro: los mensajes históricos se
// siguen deserializando. Guarda ejemplos reales de cada versión en el repositorio.
class CompatibilidadEventosTest {
@ParameterizedTest(name = "el evento de la versión {0} sigue siendo legible")
@ValueSource(strings = {"v1", "v2", "v3"})
void los_eventos_historicos_se_deserializan(String version) throws Exception {
String json = Files.readString(
Path.of("src/test/resources/eventos/pedido-confirmado-" + version + ".json"));
var evento = mapper.readValue(json, PedidoConfirmado.class);
assertThat(evento.pedidoId()).isNotBlank();
assertThat(evento.confirmadoEn()).isNotNull();
// Los campos añadidos después toman su valor por defecto, no null inesperado
assertThat(evento.canal()).isNotNull();
}
@Test
void el_evento_actual_es_legible_por_un_consumidor_de_la_version_anterior() throws Exception {
String actual = mapper.writeValueAsString(EVENTO_ACTUAL);
var comoLoVeElConsumidorViejo = mapper.readValue(actual, PedidoConfirmadoV2.class);
assertThat(comoLoVeElConsumidorViejo.pedidoId()).isEqualTo(EVENTO_ACTUAL.pedidoId());
}
}
11 · Tests de arquitectura y calidad estática
Las reglas de arquitectura que solo viven en un documento o en la cabeza del arquitecto se incumplen sistemáticamente, y no por mala fe: nadie recuerda un acuerdo de hace dos años cuando tiene prisa. Un test que falla en la pull request, en cambio, se cumple siempre. Convertir decisiones de diseño en tests ejecutables es una de las mejores inversiones que puedes hacer en un proyecto.
11.1 ArchUnit: la arquitectura como test
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.3.0</version>
<scope>test</scope>
</dependency>
@AnalyzeClasses(packages = "com.ejemplo.pedidos",
importOptions = {ImportOption.DoNotIncludeTests.class,
ImportOption.DoNotIncludeJars.class})
class ArquitecturaTest {
// ── 1 · CAPAS: la regla que más valor aporta ─────────────────────────────
@ArchTest
static final ArchRule capas = layeredArchitecture().consideringAllDependencies()
.layer("Web").definedBy("..web..", "..controller..")
.layer("Aplicacion").definedBy("..application..", "..service..")
.layer("Dominio").definedBy("..domain..")
.layer("Infraestructura").definedBy("..infrastructure..", "..persistence..")
.whereLayer("Web").mayNotBeAccessedByAnyLayer()
.whereLayer("Aplicacion").mayOnlyBeAccessedByLayers("Web")
.whereLayer("Dominio").mayOnlyBeAccessedByLayers("Web", "Aplicacion", "Infraestructura")
.whereLayer("Infraestructura").mayOnlyBeAccessedByLayers("Aplicacion");
// ── 2 · El dominio no conoce el framework (hexagonal de verdad) ──────────
@ArchTest
static final ArchRule el_dominio_es_puro =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage(
"org.springframework..",
"jakarta.persistence..",
"jakarta.servlet..",
"com.fasterxml.jackson..",
"org.hibernate..")
.because("el dominio debe poder compilarse y probarse sin el framework; "
+ "si depende de Spring, no puedes cambiar de framework ni testearlo rápido");
// ── 3 · Dependencias prohibidas entre módulos ────────────────────────────
@ArchTest
static final ArchRule los_controladores_no_usan_repositorios =
noClasses().that().haveSimpleNameEndingWith("Controller")
.should().dependOnClassesThat().haveSimpleNameEndingWith("Repository")
.because("saltarse la capa de aplicación esconde las reglas de negocio "
+ "y deja la autorización sin un sitio donde vivir");
@ArchTest
static final ArchRule nadie_usa_util_logging =
noClasses().should().accessClassesThat().resideInAPackage("java.util.logging..");
@ArchTest
static final ArchRule prohibido_system_out =
noClasses().should().callMethod(System.class, "currentTimeMillis")
.orShould().accessField(System.class, "out")
.orShould().accessField(System.class, "err")
.because("usa un logger y un Clock inyectado");
// ── 4 · Nomenclatura y ubicación ─────────────────────────────────────────
@ArchTest
static final ArchRule los_controladores_se_llaman_Controller =
classes().that().areAnnotatedWith(RestController.class)
.should().haveSimpleNameEndingWith("Controller")
.andShould().resideInAPackage("..web..");
@ArchTest
static final ArchRule los_servicios_son_interfaces_o_estan_en_application =
classes().that().areAnnotatedWith(Service.class)
.should().resideInAPackage("..application..");
@ArchTest
static final ArchRule las_excepciones_de_dominio_heredan_de_la_base =
classes().that().haveSimpleNameEndingWith("NoEncontrado")
.or().haveSimpleNameEndingWith("Invalido")
.should().beAssignableTo(ExcepcionDeDominio.class);
// ── 5 · Anotaciones obligatorias ─────────────────────────────────────────
@ArchTest
static final ArchRule los_metodos_de_escritura_son_transaccionales =
methods().that().areDeclaredInClassesThat().resideInAPackage("..application..")
.and().haveNameStartingWith("crear")
.or().haveNameStartingWith("actualizar")
.or().haveNameStartingWith("eliminar")
.should().beAnnotatedWith(Transactional.class);
@ArchTest
static final ArchRule las_entidades_jpa_tienen_constructor_sin_argumentos =
classes().that().areAnnotatedWith(Entity.class)
.should(new ArchCondition<JavaClass>("tener constructor sin argumentos") {
@Override
public void check(JavaClass clase, ConditionEvents eventos) {
boolean tiene = clase.getConstructors().stream()
.anyMatch(c -> c.getRawParameterTypes().isEmpty());
if (!tiene) {
eventos.add(SimpleConditionEvent.violated(clase,
clase.getName() + " no tiene constructor sin argumentos"));
}
}
});
// ── 6 · Ciclos: el enemigo silencioso de la modularidad ──────────────────
@ArchTest
static final ArchRule sin_ciclos_entre_modulos =
slices().matching("com.ejemplo.pedidos.(*)..").should().beFreeOfCycles();
// ── 7 · Reglas «higiénicas» que vienen de fábrica ────────────────────────
@ArchTest
static final ArchRule sin_acceso_a_campos_estaticos_mutables =
GeneralCodingRules.NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS;
@ArchTest
static final ArchRule sin_excepciones_genericas =
GeneralCodingRules.NO_CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONS;
@ArchTest
static final ArchRule sin_join_point_de_jodatime =
GeneralCodingRules.NO_CLASSES_SHOULD_USE_JODATIME;
@ArchTest
static final ArchRule sin_field_injection =
noFields().should().beAnnotatedWith(Autowired.class)
.because("la inyección por constructor hace las dependencias explícitas "
+ "y permite instanciar la clase en un test sin Spring");
}
// ── Reglas sobre el propio código de test: muy rentables ─────────────────────
@AnalyzeClasses(packages = "com.ejemplo.pedidos", importOptions = ImportOption.OnlyIncludeTests.class)
class ArquitecturaDeLosTestsTest {
@ArchTest
static final ArchRule nada_de_junit4 =
noClasses().should().dependOnClassesThat().resideInAnyPackage("org.junit", "org.junit.runners..")
.because("el proyecto está migrado a JUnit 5; los imports de JUnit 4 "
+ "compilan pero los tests NO se ejecutan");
@ArchTest
static final ArchRule prohibido_thread_sleep =
noClasses().should().callMethod(Thread.class, "sleep", long.class)
.because("usa Awaitility: Thread.sleep hace la suite lenta e intermitente");
@ArchTest
static final ArchRule prohibidas_las_aserciones_de_hamcrest =
noClasses().should().accessClassesThat().resideInAPackage("org.hamcrest..")
.because("el proyecto usa AssertJ; mezclar los dos assertThat confunde");
@ArchTest
static final ArchRule los_tests_de_integracion_se_llaman_IT =
classes().that().areAnnotatedWith(SpringBootTest.class)
.should().haveSimpleNameEndingWith("IT")
.because("Failsafe los ejecuta por nombre; si acaban en Test, "
+ "se ejecutan en la suite rápida y la vuelven lenta");
@ArchTest
static final ArchRule prohibido_disabled_sin_motivo =
methods().that().areAnnotatedWith(Disabled.class)
.should(new ArchCondition<JavaMethod>("tener un motivo escrito") {
@Override public void check(JavaMethod m, ConditionEvents e) {
String valor = m.getAnnotationOfType(Disabled.class).value();
if (valor == null || valor.isBlank()) {
e.add(SimpleConditionEvent.violated(m,
m.getFullName() + " está @Disabled sin motivo"));
}
}
});
}
FreezingArchRule. Congela las violaciones actuales en un fichero de «deuda declarada» y el
test solo falla con violaciones nuevas. Así la regla empieza a proteger desde el primer
día sin bloquear el desarrollo, y la lista de violaciones congeladas se va reduciendo sola cada vez que
alguien limpia algo. Escrito:
FreezingArchRule.freeze(el_dominio_es_puro), con el almacén en
archunit_store/.
11.2 Análisis estático: quién detecta qué
| Herramienta | Momento | Detecta | Falsos positivos | Veredicto |
|---|---|---|---|---|
| Error Prone | Durante la compilación | Bugs reales de patrones conocidos: equals entre tipos incompatibles, formato mal en String.format, Optional.get() sin comprobar, resultado ignorado, comparación de referencias de Integer. |
Muy pocos | Instálalo hoy. Rompe la compilación con errores que serían bugs, y eso es lo que quieres. |
| NullAway | Compilación (sobre Error Prone) | NullPointerException potenciales, con anotaciones @Nullable. |
Pocos, una vez anotado | Excelente relación coste/beneficio en código nuevo o en módulos concretos. |
SpotBugs (+ find-sec-bugs) |
Sobre el bytecode, tras compilar | Recursos sin cerrar, sincronización dudosa, hashCode sin equals, inyección SQL, criptografía débil. |
Algunos: hay que mantener un fichero de exclusiones | Útil, sobre todo find-sec-bugs. Activa solo las categorías con señal. |
| PMD | Sobre el AST | Complejidad ciclomática, código muerto, métodos y clases enormes, duplicación (CPD). | Muchos si activas todo | Selecciona un conjunto pequeño de reglas y trátalas como avisos, no como errores. |
| Checkstyle | Sobre el texto | Estilo: nombres, imports, orden de modificadores, longitud de línea. | N/A | Sustitúyelo por Spotless para el formato y quédate con Checkstyle solo para lo que Spotless no cubre. |
| Spotless | En el build | Formato. Y lo arregla, no solo lo señala. | N/A | Imprescindible. El formato no se discute en las revisiones: se aplica. |
| SonarQube / SonarCloud | En CI | Agrega todo lo anterior más deuda técnica, duplicación, cobertura y hotspots de seguridad. | Bastantes en las reglas de «mantenibilidad» | Muy útil si configuras la puerta de calidad sobre código nuevo. |
<!-- Error Prone + NullAway: la combinación con mejor retorno ---------------- -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>-XDcompilePolicy=simple</arg>
<arg>--should-stop=ifError=FLOW</arg>
<arg>-Xplugin:ErrorProne
-Xep:NullAway:ERROR
-XepOpt:NullAway:AnnotatedPackages=com.ejemplo
-Xep:MissingOverride:ERROR
-Xep:UnusedVariable:ERROR
-Xep:ReturnValueIgnored:ERROR</arg>
</compilerArgs>
<annotationProcessorPaths>
<path>
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_core</artifactId>
<version>2.36.0</version>
</path>
<path>
<groupId>com.uber.nullaway</groupId>
<artifactId>nullaway</artifactId>
<version>0.12.1</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<!-- Spotless: formatea y falla si alguien no lo aplicó -->
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.44.0</version>
<configuration>
<java>
<palantirJavaFormat/>
<removeUnusedImports/>
<importOrder><order>java,javax,jakarta,org,com,</order></importOrder>
<formatAnnotations/>
</java>
</configuration>
<executions>
<execution><phase>validate</phase><goals><goal>check</goal></goals></execution>
</executions>
</plugin>
<!-- mvn spotless:apply lo arregla todo. Ponlo también en un hook de pre-commit. -->
11.3 Revisión de código con criterios
Los tests automáticos no cubren todo. La revisión humana debe centrarse en lo que una máquina no puede juzgar, y para eso hay que sacarle de encima todo lo que sí puede: formato (Spotless), estilo (Checkstyle), bugs de patrón (Error Prone), arquitectura (ArchUnit) y cobertura (JaCoCo).
Sobre el código de producción
- ¿Los nombres dicen la verdad? ¿Se entiende el por qué sin preguntar?
- ¿Los casos límite están tratados o simplemente no ocurren en el camino feliz?
- ¿Los errores son diagnosticables? ¿El mensaje incluye los identificadores necesarios?
- ¿Hay una decisión de diseño implícita que debería estar escrita (un ADR)?
- ¿Qué pasa si esto se ejecuta dos veces? ¿Es idempotente?
Sobre los tests (la mitad que se salta)
- ¿El nombre describe el comportamiento? ¿Se entiende el fallo sin abrir el código?
- ¿Fallaría si invirtiera la condición del código? (el test del test)
- ¿El valor esperado es un literal o lo calcula la misma fórmula que se prueba?
- ¿Hay
Thread.sleep,now(),randomo dependencia de orden? - ¿Está en el nivel correcto, o es un
@SpringBootTestpara probar una suma? - ¿Verifica comportamiento o implementación? ¿Sobreviviría a un refactor?
12 · Cobertura y calidad de los tests
12.1 Qué mide cada tipo de cobertura
| Métrica | Qué cuenta | Fuerza | Debilidad |
|---|---|---|---|
Instrucciones (INSTRUCTION) | Bytecodes ejecutados. Es la métrica más fina de JaCoCo. | No depende del formato del código | Difícil de interpretar; poco intuitiva |
Líneas (LINE) | Líneas con al menos una instrucción ejecutada. | Intuitiva y la más usada | Una línea con a && b cuenta como cubierta aunque b nunca se evalúe |
Ramas (BRANCH) | Cada salida de cada if, switch, ?: y cada operando de &&/||. | La más útil. Detecta el else que nunca probaste | No cubre combinaciones de condiciones |
Complejidad (COMPLEXITY) | Caminos independientes cubiertos frente al total. | Señala los métodos con muchos caminos sin probar | Métrica agregada, difícil de accionar |
| Métodos y clases | Métodos o clases con alguna ejecución. | Detecta código completamente sin tocar | Muy gruesa |
| Mutación (PIT) | Cambios artificiales en el código que los tests detectan. | La única que mide si los tests VERIFICAN algo | Lenta y con «mutantes equivalentes» que no se pueden matar |
// Ejemplo que ilustra la diferencia entre línea y rama
public Dinero calcularEnvio(Dinero subtotal, boolean esVip) {
if (subtotal.esMayorQue(euros("50.00")) || esVip) { // ← 4 ramas: 2 por cada operando
return Dinero.CERO;
}
return euros("4.95");
}
// Un solo test con subtotal=60 y esVip=false:
// cobertura de LÍNEAS → 100 % (las dos líneas se ejecutan)
// cobertura de RAMAS → 50 % (nunca se evalúa esVip; nunca se ve el false||false)
// mutantes SUPERVIVIENTES → cambiar || por && sobrevive; cambiar > por >= sobrevive
// Hacen falta cuatro casos: (60,false) (40,true) (40,false) y el límite exacto (50,false).
12.2 JaCoCo: configuración y umbrales que rompen el build
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<!-- 1 · Instrumenta la JVM de los tests -->
<execution>
<id>prepare</id>
<goals><goal>prepare-agent</goal></goals>
</execution>
<!-- 2 · Informe HTML/XML tras los tests unitarios -->
<execution>
<id>informe</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
<!-- 3 · Informe combinado de unitarios + integración -->
<execution>
<id>prepare-it</id>
<goals><goal>prepare-agent-integration</goal></goals>
</execution>
<execution>
<id>informe-total</id>
<phase>verify</phase>
<goals><goal>report-aggregate</goal></goals>
</execution>
<!-- 4 · LA PARTE QUE IMPORTA: umbrales distintos por paquete -->
<execution>
<id>comprobar-umbrales</id>
<phase>verify</phase>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<!-- El dominio: exigente, porque es donde viven las reglas de negocio -->
<rule>
<element>PACKAGE</element>
<includes><include>com.ejemplo.pedidos.domain.*</include></includes>
<limits>
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>0.90</minimum></limit>
<limit><counter>BRANCH</counter><value>COVEREDRATIO</value><minimum>0.85</minimum></limit>
</limits>
</rule>
<!-- El resto: umbral moderado y global -->
<rule>
<element>BUNDLE</element>
<limits>
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>0.70</minimum></limit>
</limits>
</rule>
<!-- Ninguna clase por debajo del 50 %: detecta la clase nueva sin tests -->
<rule>
<element>CLASS</element>
<excludes>
<exclude>*Application</exclude>
<exclude>*Config</exclude>
<exclude>*Properties</exclude>
<exclude>*Dto</exclude>
<exclude>*_</exclude> <!-- metamodelo de JPA generado -->
<exclude>*MapperImpl</exclude> <!-- MapStruct generado -->
</excludes>
<limits>
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>0.50</minimum></limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
<configuration>
<excludes>
<exclude>**/generated/**</exclude>
<exclude>**/*MapperImpl.class</exclude>
<exclude>**/PedidosApplication.class</exclude>
</excludes>
</configuration>
</plugin>
| Exclusión | ¿Legítima? | Motivo |
|---|---|---|
| Código generado (MapStruct, Lombok, metamodelo JPA, clientes de OpenAPI) | ✅ Sí | No lo escribes tú y el generador ya está probado. |
La clase @SpringBootApplication con solo el main | ✅ Sí | Una línea que el smoke test del contexto ya ejercita. |
DTOs y records sin lógica | ✅ Sí | Testearlos no aporta; sesgan la métrica al alza o a la baja sin significado. |
Clases de configuración (@Configuration) | 🟡 Depende | Si tienen @Conditional o lógica, pruébalas con ApplicationContextRunner. |
| «Este paquete es difícil de testear» | ❌ No | Ese es exactamente el paquete que necesita tests. Excluirlo es esconder el problema. |
Bloques catch «que no pueden ocurrir» | ❌ No | Si de verdad no puede ocurrir, borra el catch. Si puede, pruébalo. |
12.3 Por qué el 100 % es una meta engañosa
// Este test da 100 % de cobertura de línea y NO COMPRUEBA NADA.
@Test
void cobertura_del_100_por_ciento_sin_verificar_nada() {
calculadora.calcular(carritoDe(euros("100.00")), CUPON_10);
calculadora.calcular(carritoDe(euros("0.00")), null);
calculadora.calcular(carritoDe(euros("1000.00")), CUPON_CADUCADO);
// Sin una sola aserción. Toda la clase «cubierta». El informe en verde.
// Si mañana el descuento se calcula al revés, este test sigue pasando.
}
| Nivel de cobertura | Qué significa realmente | Recomendación |
|---|---|---|
| < 40 % | No hay red de seguridad. Refactorizar es imposible sin miedo. | Empieza por el dominio y por los bugs que aparezcan (sección 15.5). |
| 60–70 % | Lo típico y razonable como media global de un proyecto con DTOs, configuración y pegamento. | Objetivo global sensato. Mira la distribución, no la media. |
| 85–95 % en el dominio | Las reglas de negocio están cubiertas. Es lo que de verdad importa. | El objetivo que hay que fijar. Umbral por paquete, no global. |
| 100 % global | Casi siempre significa tests escritos para la métrica: getters, toString, ramas imposibles. | Contraproducente: el coste de mantener el último 10 % supera su valor y enseña a escribir tests decorativos. |
| La cobertura baja tras un cambio | La señal más útil de todas: se ha añadido código sin tests. | Configura la puerta sobre el diff, no sobre el total. |
12.4 Cobertura de mutaciones con PIT
La idea es brillante por lo simple: modificar tu código de producción a propósito y comprobar si algún test se pone rojo. Si no se pone, ese cambio (ese «mutante») ha sobrevivido, y eso significa que ningún test verifica ese comportamiento. Es la única métrica que mide la calidad de las aserciones, no la del recorrido.
| Mutador | Qué cambia | Qué destapa cuando sobrevive |
|---|---|---|
CONDITIONALS_BOUNDARY | < ↔ <=, > ↔ >= | No has probado el límite exacto. El fallo off-by-one más frecuente del mundo. |
NEGATE_CONDITIONALS | == ↔ !=, invierte la condición | Solo has probado una rama, o no asertas sobre el resultado. |
MATH | + ↔ -, * ↔ / | El resultado del cálculo no se comprueba (o es un test espejo). |
INCREMENTS | i++ ↔ i-- | Los bucles y contadores no se verifican. |
VOID_METHOD_CALLS | Elimina la llamada a un método void | El efecto secundario no se comprueba: puedes borrar el save() y nadie se queja. |
RETURN_VALS / EMPTY_RETURNS | Devuelve null, 0, "", lista vacía | El valor de retorno no se aserta. |
REMOVE_CONDITIONALS | Hace la condición siempre cierta o siempre falsa | La guarda no está probada: puedes borrar el if de validación. |
EXPERIMENTAL_SWITCH | Cambia las ramas del switch | Faltan casos de la máquina de estados. |
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.17.0</version>
<dependencies>
<dependency>
<groupId>org.pitest</groupId>
<artifactId>pitest-junit5-plugin</artifactId>
<version>1.2.1</version>
</dependency>
</dependencies>
<configuration>
<!-- SOLO el dominio: PIT es caro y en configuración no aporta nada -->
<targetClasses>
<param>com.ejemplo.pedidos.domain.*</param>
<param>com.ejemplo.pedidos.application.*</param>
</targetClasses>
<targetTests><param>com.ejemplo.pedidos.*Test</param></targetTests>
<excludedTestClasses><param>com.ejemplo.pedidos.*IT</param></excludedTestClasses>
<mutators><mutator>STRONGER</mutator></mutators> <!-- DEFAULTS, STRONGER o ALL -->
<threads>4</threads>
<timeoutFactor>2.0</timeoutFactor>
<timestampedReports>false</timestampedReports>
<outputFormats><param>HTML</param><param>XML</param></outputFormats>
<mutationThreshold>75</mutationThreshold> <!-- rompe el build por debajo -->
<coverageThreshold>85</coverageThreshold>
<!-- SCM mode: mutar solo lo que ha cambiado. Convierte PIT de 20 min a 30 s -->
<features><feature>+GIT(from[HEAD~1])</feature></features>
</configuration>
</plugin>
# Ejecución
mvn org.pitest:pitest-maven:mutationCoverage
open target/pit-reports/index.html
# Solo lo que ha cambiado respecto a main (la forma de usarlo en CI)
mvn org.pitest:pitest-maven:scmMutationCoverage -Dinclude=ADDED,MODIFIED -DanalyseLastCommit=true
# Interpretación del informe
# Line Coverage 85 % ← lo que ya te decía JaCoCo
# Mutation Coverage 62 % ← el dato nuevo: el 38 % de los cambios pasa desapercibido
# Test Strength 71 % ← mutantes matados / mutantes CUBIERTOS por algún test
# (separa «no hay test» de «el test no comprueba»)
#
# Estados de un mutante:
# KILLED ✅ algún test falló: bien
# SURVIVED ❌ ningún test lo detectó: falta una aserción
# NO_COVERAGE ⚪ ningún test ejecuta esa línea: falta el test entero
# TIMED_OUT 🟡 el mutante provocó un bucle infinito → cuenta como matado
# NON_VIABLE 🟡 el bytecode mutado no es válido → se descarta
# RUN_ERROR 🟡 error de infraestructura: revisa la configuración
// EJEMPLO REAL de mutante supervivientes y cómo matarlos
public Dinero envio(Dinero subtotal) {
return subtotal.esMayorOIgualQue(euros("50.00")) ? Dinero.CERO : euros("4.95");
}
// Test inicial (100 % de línea, 100 % de rama):
@ParameterizedTest
@CsvSource({"60.00, 0.00", "40.00, 4.95"})
void el_envio_es_gratis_a_partir_de_50(BigDecimal subtotal, BigDecimal esperado) { … }
// PIT informa: CONDITIONALS_BOUNDARY survived (cambió >= por >)
// → nunca has probado exactamente 50,00. Con 60 y 40, > y >= dan lo mismo.
// Se mata añadiendo el límite:
@CsvSource({"60.00, 0.00", "50.00, 0.00", "49.99, 4.95", "40.00, 4.95"})
// ↑ estos dos casos matan el mutante y son EXACTAMENTE los que
// detectarían el bug real «el envío no es gratis con 50 € justos»
12.5 Tests que no aseveran nada y otras basuras
| Patología | Cómo detectarla | Qué hacer |
|---|---|---|
| Test sin aserciones | Regla de SonarQube java:S2699; o buscar métodos @Test sin assert/verify. | Añadir la aserción o borrar el test. Ojo: los tests de «no lanza excepción» son legítimos, pero deben decirlo con assertDoesNotThrow. |
| Test espejo (repite la fórmula) | Revisión de código, y PIT: los mutantes MATH sobreviven. | Sustituir la fórmula por el valor literal esperado. |
Aserción tautológica (assertThat(x).isEqualTo(x), isNotNull() sobre algo que nunca es nulo) | PIT: sobreviven casi todos los mutantes de la clase. | Asertar sobre el valor concreto. |
| Test que nunca ha estado en rojo | Rompe el código a propósito (invierte una condición) y comprueba que el test falla. | Si no falla, el test no prueba lo que crees. Bórralo o arréglalo. |
| Test duplicado en tres niveles | El mismo caso en unitario, slice e integración. | Quedarse con el nivel más bajo que detecte el fallo. |
Test @Disabled desde hace años | rg "@Disabled" src/test | wc -l | Borrar. Está en el historial de git. |
| Test que solo comprueba que no explota | Ejecuta un método y no mira nada. | Puede ser útil como smoke test, pero entonces nómbralo así y ponlo en su sitio. |
# Auditoría rápida de la salud de tu suite: cinco órdenes y un diagnóstico
rg -c "@Test" src/test --stats | tail -3 # cuántos tests hay
rg -l "@Disabled" src/test # tests desactivados
rg -n "Thread.sleep" src/test # esperas fijas
rg -n "Instant.now\(\)|LocalDate.now\(\)" src/main # dependencias del reloj sin Clock
rg -n "@SpringBootTest" src/test -l | wc -l # cuántas clases arrancan la app entera
rg -n "@MockBean|@SpyBean" src/test # anotaciones deprecadas (Boot 3.4+)
rg -n "@DirtiesContext" src/test # asesinos del tiempo de CI
rg -L "assert|verify|assertThat" --glob '*Test.java' src/test # ficheros sin ninguna aserción
13 · Rendimiento, carga y otros tipos de prueba
13.1 Microbenchmarks con JMH
Medir el rendimiento de un trozo de código Java a mano es casi siempre incorrecto, y por
razones que no son evidentes. La JVM optimiza en tiempo de ejecución (JIT), elimina código cuyo
resultado no se usa, hace inlining, desenrolla bucles y ejecuta el recolector de basura cuando
le conviene. Un bucle con System.nanoTime() mide una mezcla de todo eso.
// ❌ Cómo NO medir. Este código puede dar cualquier número, incluido 0 ns.
long inicio = System.nanoTime();
for (int i = 0; i < 1_000_000; i++) {
calculadora.total(pedido); // ← el JIT puede ELIMINAR la llamada: nadie usa el resultado
}
long ns = System.nanoTime() - inicio;
System.out.println("media: " + ns / 1_000_000 + " ns");
/* Cinco errores en seis líneas:
1. Sin warm-up: las primeras miles de iteraciones se ejecutan interpretadas.
2. Dead code elimination: el resultado no se usa, el JIT puede borrar el cuerpo.
3. Constant folding: si «pedido» es constante, el JIT puede precalcular el resultado.
4. Una sola JVM y una sola muestra: sin idea de la varianza.
5. El GC y la CPU compartida meten ruido que no se separa de la señal. */
// ✅ Con JMH: los cinco problemas resueltos por diseño
@BenchmarkMode(Mode.AverageTime) // también Throughput, SampleTime, SingleShotTime
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark) // el estado se comparte entre iteraciones
@Fork(value = 3, jvmArgs = {"-Xms1g", "-Xmx1g"}) // 3 JVM distintas: separa el ruido de la JVM
@Warmup(iterations = 5, time = 1) // 5 s calentando: deja que el JIT compile
@Measurement(iterations = 10, time = 1) // 10 mediciones de 1 s
public class TotalPedidoBenchmark {
private Pedido pedidoPequeno;
private Pedido pedidoGrande;
private CalculadoraDescuento calculadora;
@Setup(Level.Trial) // una vez por fork
public void preparar() {
calculadora = new CalculadoraDescuento(Clock.systemUTC());
pedidoPequeno = unPedido().conLinea("SKU-1", 1, "10.00").build();
pedidoGrande = unPedido().conLineas(500).build();
}
@Benchmark
public Dinero pedidoPequeno() {
return calculadora.calcular(pedidoPequeno, CUPON).aPagar(); // devolver evita la eliminación
}
@Benchmark
public void pedidoGrandeConBlackhole(Blackhole bh) {
bh.consume(calculadora.calcular(pedidoGrande, CUPON)); // consumir explícitamente
}
@Benchmark
@OperationsPerInvocation(500) // normaliza por línea
public void porLinea(Blackhole bh) {
pedidoGrande.lineas().forEach(l -> bh.consume(l.subtotal()));
}
}
# Ejecución (JMH se empaqueta como un jar aparte, no en la suite de tests)
mvn clean package -Pjmh
java -jar target/benchmarks.jar TotalPedidoBenchmark -rf json -rff resultados.json
# Interpretación de la salida
# Benchmark Mode Cnt Score Error Units
# TotalPedidoBenchmark.pequeno avgt 30 142,318 ± 3,201 ns/op
# TotalPedidoBenchmark.grande avgt 30 71204,5 ± 892,4 ns/op
# ↑ ↑
# 30 muestras intervalo de confianza al 99,9 %
#
# ⚠️ REGLA DE ORO: si dos alternativas difieren menos que la suma de sus errores,
# NO has demostrado que una sea más rápida. «142 ± 3» y «144 ± 4» son iguales.
#
# Perfiladores integrados, imprescindibles para entender el por qué:
java -jar target/benchmarks.jar -prof gc # asignaciones por operación
java -jar target/benchmarks.jar -prof perfasm # ensamblador generado por el JIT
java -jar target/benchmarks.jar -prof async:libPath=libasyncProfiler.so # flame graphs
StringBuilder frente a concatenación en
bucle, un mapa frente a otro. No es para medir el rendimiento de tu aplicación: eso son
pruebas de carga. Y no metas benchmarks en la suite de tests: son lentísimos (minutos por benchmark) y no
son deterministas. Van en un módulo o perfil aparte, y se ejecutan a mano o en una tarea nocturna.
13.2 Pruebas de carga: k6 y Gatling
// k6 · carga.js — escenario con rampa y umbrales que hacen fallar el pipeline
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { Trend, Rate } from 'k6/metrics';
const latenciaCrear = new Trend('latencia_crear_pedido');
const erroresNegocio = new Rate('errores_negocio');
export const options = {
scenarios: {
// Carga constante: el escenario base de referencia
nominal: {
executor: 'constant-arrival-rate',
rate: 50, timeUnit: '1s', // 50 peticiones por segundo, llegue lo que llegue
duration: '5m',
preAllocatedVUs: 50, maxVUs: 200,
},
// Rampa: encontrar el punto de saturación
rampa: {
executor: 'ramping-arrival-rate',
startTime: '5m',
startRate: 10, timeUnit: '1s',
preAllocatedVUs: 50, maxVUs: 500,
stages: [
{ target: 50, duration: '2m' },
{ target: 200, duration: '3m' },
{ target: 500, duration: '3m' }, // aquí normalmente empieza a doler
{ target: 0, duration: '1m' },
],
},
},
// LOS UMBRALES son lo que convierte esto en un test y no en un informe
thresholds: {
'http_req_failed': ['rate<0.001'], // menos del 0,1 % de errores
'http_req_duration{scenario:nominal}': ['p(95)<300', 'p(99)<800'],
'latencia_crear_pedido': ['p(95)<250'],
'errores_negocio': ['rate<0.01'],
'checks': ['rate>0.99'],
},
};
export default function () {
group('crear y confirmar pedido', () => {
const cuerpo = JSON.stringify({ clienteId: `C-${__VU}`, lineas: [{ sku: 'SKU-1', cantidad: 2 }] });
const params = { headers: { 'Content-Type': 'application/json',
'Authorization': `Bearer ${__ENV.TOKEN}`,
'Idempotency-Key': `${__VU}-${__ITER}` } };
const creado = http.post(`${__ENV.BASE_URL}/api/v1/pedidos`, cuerpo, params);
latenciaCrear.add(creado.timings.duration);
const ok = check(creado, {
'estado 201': r => r.status === 201,
'devuelve Location': r => !!r.headers['Location'],
'responde en < 500ms': r => r.timings.duration < 500,
});
erroresNegocio.add(!ok);
if (ok) {
http.post(`${__ENV.BASE_URL}${creado.headers['Location']}/confirmacion`, null, params);
}
sleep(1);
});
}
# Ejecución local y en CI
k6 run -e BASE_URL=https://pre.ejemplo.com -e TOKEN=$TOKEN carga.js
k6 run --out json=resultado.json --summary-export=resumen.json carga.js
# k6 devuelve código de salida 99 si algún umbral se incumple → el pipeline falla
# Salida resumida
# http_req_duration..: avg=87.2ms min=12ms med=71ms max=1.2s p(90)=142ms p(95)=198ms
# http_req_failed....: 0.02% ✓ 12 ✗ 59988
# ✓ estado 201
# ✗ responde en < 500ms → 99.31% (falla el umbral de checks: rate>0.99 justo)
| Concepto | Qué significa y por qué importa |
|---|---|
| Media (avg) | Casi inútil por sí sola: oculta la cola. Con 95 peticiones de 50 ms y 5 de 5 s, la media es 300 ms y parece aceptable, cuando el 5 % de tus usuarios ha esperado cinco segundos. |
| Mediana (p50) | La experiencia del usuario típico. |
| p95 / p99 | Lo que hay que vigilar. El p99 es 1 de cada 100 peticiones; con 10 llamadas internas por página, el p99 de cada una afecta al 10 % de las páginas. |
| Closed model (usuarios virtuales) | N usuarios que esperan la respuesta antes de la siguiente petición. Si el sistema se ralentiza, la carga baja: se autolimita y oculta el problema. |
| Open model (tasa de llegada) | X peticiones por segundo, responda o no. Es lo que hace el mundo real y lo que destapa el colapso. constant-arrival-rate en k6. |
| Ramp-up | Subir la carga poco a poco. Sin él mides el arranque en frío (JIT sin calentar, pools vacíos, cachés frías) en lugar del régimen estable. |
| Punto de saturación | El punto donde la latencia se dispara sin que crezca el rendimiento. Es el número que debes conocer para dimensionar y para configurar el autoescalado. |
| Coordinated omission | El sesgo de no contar las peticiones que no se enviaron porque el cliente estaba esperando. Es la razón por la que el modelo abierto es más honesto. |
| Herramienta | Lenguaje del escenario | Fuerte en | Cuándo elegirla |
|---|---|---|---|
| k6 | JavaScript | Ligero (Go), umbrales de primera clase, buen soporte en CI, salida a Prometheus/Grafana | Opción por defecto hoy: el escenario se escribe en 20 líneas. |
| Gatling | Scala, Java o Kotlin | Informes HTML excelentes, DSL muy expresivo, se integra en el build Maven | Equipos JVM que quieren los escenarios en el mismo repositorio y en Java. |
| JMeter | XML / GUI | Muchísimos protocolos, ecosistema enorme | Legado, o cuando necesitas JDBC/JMS/FTP. Los planes en XML se versionan mal. |
| Locust | Python | Escenarios con lógica compleja | Equipos con Python. |
| wrk / hey / oha | Línea de órdenes | Medir un endpoint en diez segundos | Sondeo rápido, no como prueba formal. |
13.3 Resistencia, estrés y caos
| Tipo | Qué busca | Cómo | Qué encuentra en la práctica |
|---|---|---|---|
| Carga | Comportamiento bajo la carga esperada | Tasa nominal durante 5–15 min | Latencias por encima del objetivo, consultas lentas, pool infradimensionado |
| Estrés | Dónde se rompe y cómo se rompe | Rampa hasta el colapso | Si degrada con elegancia (429, colas) o si cae en cascada; si se recupera solo al bajar la carga |
| Resistencia (soak) | Degradación con el tiempo | Carga moderada durante 4–24 h | Fugas de memoria, conexiones que no se devuelven, ficheros abiertos, cachés sin límite, tablas que crecen sin purgar, fragmentación |
| Pico (spike) | Reacción a un salto brusco | De 10 a 500 rps en 10 s | Timeouts en cascada, cold start del autoescalado, thundering herd sobre una caché vacía |
| Caos | Resiliencia ante fallos de infraestructura | Matar pods, cortar red, añadir latencia (Toxiproxy, LitmusChaos) | Reintentos ausentes, readiness probes mal configuradas, pérdida de mensajes, dependencias declaradas «opcionales» que en realidad son críticas |
| Volumen | Comportamiento con muchos datos | Base con 100× los datos actuales | Consultas que pasan de 10 ms a 10 s, planes de ejecución que cambian, paginación por OFFSET que se degrada |
13.4 Pruebas de concurrencia
// ── Reproducir una condición de carrera con CountDownLatch ──────────────────
// El truco: todos los hilos esperan en la misma barrera y salen a la vez, lo que
// maximiza la probabilidad de solapamiento. Sin la barrera, el primer hilo suele
// acabar antes de que arranque el segundo y el bug no aparece.
@Test
void dos_compras_simultaneas_del_ultimo_articulo_solo_una_tiene_exito() throws Exception {
almacen.reponer("SKU-1", 1);
int hilos = 2;
var salida = new CountDownLatch(1);
var terminados = new CountDownLatch(hilos);
var exitos = new AtomicInteger();
var fallos = new AtomicInteger();
try (var ejecutor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < hilos; i++) {
ejecutor.submit(() -> {
try {
salida.await(); // ← todos esperan aquí
servicio.comprar("SKU-1", 1);
exitos.incrementAndGet();
} catch (StockInsuficiente e) {
fallos.incrementAndGet();
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
terminados.countDown();
}
});
}
salida.countDown(); // ← salen a la vez
assertThat(terminados.await(10, TimeUnit.SECONDS)).isTrue();
}
assertThat(exitos.get()).isEqualTo(1);
assertThat(fallos.get()).isEqualTo(1);
assertThat(almacen.stock("SKU-1")).isZero(); // ni negativo ni sobrante
}
// ── Ejecutar el mismo test muchas veces: la carrera es probabilística ───────
@RepeatedTest(value = 50, name = "intento {currentRepetition} de {totalRepetitions}")
void el_contador_es_exacto_con_100_hilos() throws Exception { … }
// ── Awaitility para condiciones que deben MANTENERSE ─────────────────────────
@Test
void el_procesador_no_duplica_mensajes_bajo_concurrencia() {
publicarNVeces(EVENTO, 100);
await().atMost(Duration.ofSeconds(10))
.until(() -> repositorio.contar() == 100);
// Y que se MANTENGA: si hubiera duplicados tardíos, este bloque los detecta
await().during(Duration.ofSeconds(2))
.atMost(Duration.ofSeconds(5))
.until(() -> repositorio.contar() == 100);
}
| Herramienta | Para qué |
|---|---|
CountDownLatch / CyclicBarrier / Phaser | Sincronizar la salida de N hilos para maximizar el solapamiento. La técnica básica y suficiente en el 80 % de los casos. |
@RepeatedTest + ejecución paralela | Aumentar las oportunidades de que la carrera aparezca. Una carrera que ocurre 1 de cada 1.000 veces necesita muchas repeticiones. |
| jcstress | El estándar de oro para verificar el modelo de memoria: comprueba todos los resultados posibles de dos hilos que interactúan y detecta reordenamientos. Para autores de estructuras concurrentes. Ver el módulo 03 · Concurrencia. |
| Lincheck (JetBrains) | Genera escenarios concurrentes y comprueba la linealizabilidad de una estructura de datos. Muy potente y sorprendentemente fácil de usar. |
ThreadSanitizer / -XX:+UseZGC con estrés | Cambiar el GC y el número de núcleos altera el timing y saca carreras escondidas. |
| Hilos virtuales en tests | Executors.newVirtualThreadPerTaskExecutor() permite lanzar miles de tareas concurrentes sin agotar la memoria. Ojo: si tu código usa synchronized alrededor de E/S en Java 21, los hilos se fijan (pinning) y el test puede colgarse; eso también es un hallazgo válido. |
13.5 End-to-end de UI y accesibilidad
// Playwright para Java: la opción moderna. Más rápido y estable que Selenium.
// Su gran ventaja: espera automáticamente a que el elemento sea accionable,
// lo que elimina la mayoría de los sleeps y de los tests intermitentes.
@Test
void un_cliente_puede_completar_una_compra() {
try (var playwright = Playwright.create()) {
var navegador = playwright.chromium().launch();
var pagina = navegador.newPage();
pagina.navigate(baseUrl + "/catalogo");
pagina.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Añadir al carrito"))
.first().click();
pagina.getByRole(AriaRole.LINK, new GetByRoleOptions().setName("Ir al carrito")).click();
// Localizadores por ROL y TEXTO VISIBLE, no por CSS ni XPath:
// sobreviven a los cambios de maquetación y prueban la accesibilidad de paso.
assertThat(pagina.getByTestId("total")).hasText("121,00 €");
pagina.getByLabel("Cupón").fill("VERANO10");
pagina.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Aplicar")).click();
assertThat(pagina.getByTestId("total")).hasText("108,90 €");
pagina.getByRole(AriaRole.BUTTON, new GetByRoleOptions().setName("Pagar")).click();
assertThat(pagina.getByRole(AriaRole.HEADING,
new GetByRoleOptions().setName("Pedido confirmado"))).isVisible();
}
}
// Accesibilidad automatizada con axe-core (detecta ~30-40 % de los problemas reales):
// contraste insuficiente, imágenes sin alt, campos sin etiqueta, orden de
// encabezados, roles ARIA incorrectos, foco no visible.
// El resto requiere revisión manual con lector de pantalla y navegación por teclado.
| Regla para los E2E de UI | Por qué |
|---|---|
| Máximo 3–8 flujos, los que dan dinero | Cada test E2E cuesta entre 10 y 60 s y se rompe con cualquier cambio de maquetación. Su coste de mantenimiento es entre 10 y 50 veces el de un test unitario. |
Localizadores por rol, etiqueta o data-testid | Los selectores CSS o XPath se rompen con cada rediseño. Los roles y etiquetas son parte del contrato de accesibilidad. |
Nunca sleep: espera por condición | Playwright lo hace solo. En Selenium, WebDriverWait con ExpectedConditions. |
| Datos propios y creados por API | Preparar el escenario clicando es lento y frágil. Crea por API, verifica por UI. |
| Vídeo, capturas y traza al fallar | Un E2E que falla en CI sin artefactos es imposible de diagnosticar. Playwright graba traza completa con un flag. |
| Ejecución nocturna o antes de la release, no en cada commit | Su tiempo y su inestabilidad no encajan en el ciclo rápido. |
13.6 Smoke tests tras el despliegue y testing en producción
#!/usr/bin/env bash
# smoke.sh — se ejecuta TRAS cada despliegue. Si falla, se revierte automáticamente.
set -euo pipefail
BASE=${1:?falta la URL base}
VERSION_ESPERADA=${2:?falta la versión}
echo "▸ 1/5 salud general"
curl -fsS --max-time 5 "$BASE/actuator/health" | jq -e '.status == "UP"'
echo "▸ 2/5 la versión desplegada es la esperada"
curl -fsS "$BASE/actuator/info" | jq -e --arg v "$VERSION_ESPERADA" '.build.version == $v'
echo "▸ 3/5 dependencias críticas conectadas"
curl -fsS "$BASE/actuator/health" \
| jq -e '.components.db.status == "UP" and .components.kafka.status == "UP"'
echo "▸ 4/5 un endpoint de negocio de SOLO LECTURA responde"
curl -fsS --max-time 3 -H "Authorization: Bearer $TOKEN_SMOKE" \
"$BASE/api/v1/productos/SKU-CANARIO" | jq -e '.sku == "SKU-CANARIO"'
echo "▸ 5/5 latencia dentro del objetivo"
LAT=$(curl -fsS -o /dev/null -w '%{time_total}' "$BASE/api/v1/productos/SKU-CANARIO")
awk -v l="$LAT" 'BEGIN { exit !(l < 0.5) }' || { echo "latencia $LAT s > 0,5 s"; exit 1; }
echo "✅ smoke OK"
# Reglas: sin escrituras, sin datos de clientes reales, menos de 30 s en total,
# y con un usuario y unos datos «canario» creados a propósito para esto.
| Técnica en producción | Qué permite | Riesgo y mitigación |
|---|---|---|
| Feature flags | Desplegar código apagado y activarlo para un 1 % de usuarios. Separa despliegue de liberación. | Deuda de flags zombis: cada flag nace con fecha de caducidad y dueño. Y hay que testear las dos ramas. |
| Despliegue canario | Enviar el 5 % del tráfico a la versión nueva y comparar métricas de error y latencia. | Necesita métricas por versión y reversión automática, o no sirve de nada. |
| Blue-green | Conmutar el 100 % del tráfico de golpe, con reversión instantánea. | Requiere compatibilidad de esquema en las dos direcciones (expand-contract). |
| Shadow traffic (espejo) | Duplicar el tráfico real a la versión nueva sin devolver su respuesta. Comparas resultados con datos reales y volumen real. | Los efectos secundarios: si la versión espejo escribe, cobra o envía correos, has duplicado todo. Solo con lecturas o con efectos desviados. |
| Monitorización sintética | Un robot ejecuta el flujo crítico cada minuto desde varias regiones. | Datos canarios que hay que limpiar; alerta si el propio robot falla. |
| Alertas basadas en SLO | Detectar la degradación antes de que la reporte un cliente. Es la red final. | Alertar sobre síntomas del usuario (latencia, tasa de error), no sobre CPU. |
14 · Tests fiables y suite rápida
14.1 Por qué aparecen los tests intermitentes
Un test intermitente (flaky) es el que pasa y falla con el mismo código. Es la patología más destructiva de una suite, y no por el tiempo perdido: por el daño cultural. Cuando el equipo aprende que un rojo puede no significar nada, deja de mirar los rojos, y a partir de ahí la suite ya no protege. Estas son las nueve causas, con el arreglo de cada una.
| Causa | Síntoma característico | Diagnóstico | Solución |
|---|---|---|---|
| 1 · Tiempo y esperas | Falla en CI (máquina lenta) y nunca en local | Busca Thread.sleep, @Timeout ajustado, aserciones de duración |
Awaitility con tope generoso; timeouts 10× lo esperado; nunca asertar tiempos como comportamiento |
| 2 · Orden de ejecución | Pasa la clase completa, falla el test suelto (o al revés) | MethodOrderer$Random + ClassOrderer$Random; ejecutar el test aislado |
Cada test crea lo que necesita; eliminar estado static mutable |
| 3 · Estado compartido | Falla el segundo, el tercero… pero no el primero | Campos static, singletons, caché de Spring, ThreadLocal sin limpiar |
Estado en campos de instancia; @AfterEach que limpie; MockReset |
| 4 · Concurrencia | Falla 1 de cada 20, sin patrón | @RepeatedTest(100) y ejecución paralela para reproducirlo |
Sincronizar con latches; verificar invariantes en lugar de secuencias; arreglar la carrera real (suele ser un bug de producción) |
| 5 · Red y servicios externos | Falla en rachas, coincidiendo con incidencias ajenas | El log muestra DNS, timeouts o 5xx de un dominio externo | Ningún test debe salir a internet. WireMock o contenedor. Si es inevitable, etiquétalo manual y fuera de la suite |
| 6 · Puertos y recursos del sistema | Address already in use, ficheros bloqueados |
Falla al ejecutar en paralelo o dos veces seguidas | Puerto 0 / RANDOM_PORT; @TempDir; nombres únicos por test |
| 7 · Aleatoriedad | Falla con datos concretos que no puedes reproducir | Math.random(), UUID.randomUUID(), generadores sin semilla |
Semilla fija; que la herramienta imprima la semilla del fallo para fijarla |
| 8 · Zona horaria, locale y codificación | Falla solo en CI, o solo en verano, o solo el día del cambio de hora | Comparar TimeZone.getDefault() y Locale.getDefault() en local y CI |
-Duser.timezone=UTC -Duser.language=es -Duser.country=ES -Dfile.encoding=UTF-8 en Surefire; nunca depender de los valores por defecto |
| 9 · Orden no determinista de colecciones | containsExactly falla con los mismos elementos en otro orden |
HashSet, HashMap, consulta SQL sin ORDER BY, streams paralelos |
containsExactlyInAnyOrder, o garantizar el orden con ORDER BY/sorted() |
# Cómo cazar un intermitente: repetir hasta que falle, y capturar el contexto
for i in $(seq 1 50); do
echo "── intento $i ──"
mvn -q test -Dtest=PedidoServiceTest -Dsurefire.rerunFailingTestsCount=0 || {
echo "❌ falló en el intento $i"; break;
}
done
# Con orden aleatorio y semilla registrada, para poder reproducir
mvn test -Djunit.jupiter.testmethod.order.default='org.junit.jupiter.api.MethodOrderer$Random' \
-Djunit.jupiter.execution.order.random.seed=20260315
# Ejecutar UN test aislado: si falla solo, hay dependencia de otro test
mvn test -Dtest='PedidoServiceTest#el_total_incluye_el_iva'
# Ejecutar la suite dos veces en la misma JVM: destapa estado que no se limpia
mvn test -Dsurefire.rerunFailingTestsCount=0 -DforkCount=1 -DreuseForks=true
14.2 Tolerancia cero y cuarentena
La política que funciona, en cinco reglas
- Un test intermitente es un bug con prioridad alta, no una molestia. Puede estar señalando una condición de carrera real en producción: los intermitentes de concurrencia son bugs de verdad el 80 % de las veces.
- Nadie reejecuta el pipeline «a ver si pasa» sin abrir un ticket. Esa costumbre es exactamente lo que hace crónico el problema.
- Cuarentena con caducidad: se mueve a
@Tag("cuarentena"), se saca de la suite bloqueante, y se le pone dueño y fecha límite (dos semanas). Si llega la fecha sin arreglo, se borra. Un test en cuarentena indefinida es peor que ninguno. - Los reintentos automáticos, solo como medida temporal y visible. Si usas
surefire.rerunFailingTestsCount, que el informe liste los tests que necesitaron reintento y que ese número sea una métrica del equipo con objetivo cero. - Métrica en el panel: número de intermitentes activos y días en cuarentena. Lo que no se mide, se normaliza.
// Cuarentena explícita y autodocumentada
@Tag("cuarentena")
@Disabled("""
FLAKY-142 · falla ~1/15 en CI por una carrera al publicar el evento.
Sospecha: el @TransactionalEventListener se ejecuta antes del commit en algunos casos.
Dueño: @carlos · Límite: 2026-04-15 · Si no está arreglado ese día, SE BORRA.
""")
@Test
void la_proyeccion_se_actualiza_tras_confirmar() { … }
// En CI, la suite bloqueante excluye la cuarentena; una tarea nocturna la ejecuta
// y publica el porcentaje de fallo, que es el dato que necesitas para arreglarla.
14.3 Control del tiempo
// Resumen de las tres formas de controlar el tiempo, de mejor a peor
// 1 · ✅ Clock inyectado (ver 5.13). La única forma correcta en código nuevo.
// 2 🟡 Extensión de JUnit que fija un reloj global, si tienes un punto único de acceso.
// 3 ❌ mockStatic(Instant.class): último recurso en legado, incompatible con paralelismo.
// Fijar la zona y el locale de TODA la suite, para que local y CI sean idénticos:
@BeforeAll
static void entornoDeterminista() {
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
Locale.setDefault(Locale.forLanguageTag("es-ES"));
}
// Mejor todavía: en la configuración de Surefire, para que aplique desde el arranque
// de la JVM y no dependa del orden de las clases (ver 4.8).
// Los casos límite temporales que hay que probar SIEMPRE si tu dominio usa fechas:
@ParameterizedTest(name = "{0}")
@CsvSource({
"2026-02-28, fin de febrero en año no bisiesto",
"2024-02-29, 29 de febrero en año bisiesto",
"2026-01-31, fin de mes largo (¿+1 mes = 28 de febrero?)",
"2026-12-31, fin de año",
"2026-03-29, cambio a horario de verano en España (no existen las 02:30)",
"2026-10-25, cambio a horario de invierno (las 02:30 existen DOS veces)"
})
void los_calculos_de_plazos_funcionan_en_las_fechas_criticas(LocalDate fecha, String caso) { … }
14.4 Encontrar y arreglar los tests lentos
# 1 · ¿Dónde se va el tiempo? Surefire escribe un XML por clase con la duración
grep -h 'testsuite ' target/surefire-reports/*.xml \
| sed -E 's/.*name="([^"]+)".*time="([0-9.]+)".*/\2 \1/' \
| sort -rn | head -20
# Salida típica y reveladora:
# 38.412 com.ejemplo.PedidoFlujoCompletoIT ← un contexto propio de Spring
# 31.008 com.ejemplo.InformesIT ← otro contexto propio
# 12.550 com.ejemplo.ProcesadorAsincronoTest ← Thread.sleep
# 0.412 com.ejemplo.CalculadoraDescuentoTest ← 87 tests aquí dentro
# 2 · Tests individuales más lentos (Gradle genera el HTML; con Maven, del XML)
grep -h '0.5C true
| Optimización | Ahorro típico | Riesgo |
|---|---|---|
| Unificar la configuración para tener 2 o 3 contextos de Spring (7.3) | El mayor de todos: minutos | Bajo. Es una refactorización de anotaciones. |
Eliminar @DirtiesContext | 3–10 s por aparición y ejecución | Hay que sustituirlo por limpieza de estado real. |
Contenedores static compartidos + arranque en paralelo | 10–60 s | Exige limpiar datos entre tests. |
Sustituir Thread.sleep por Awaitility | Todo el tiempo de espera sobrante | Ninguno. Solo ventajas. |
Bajar un test de @SpringBootTest a @WebMvcTest, o a JUnit puro | 1–8 s por test | Comprobar que sigue cubriendo lo mismo. |
| Paralelizar clases en la suite unitaria | 2–4× en la parte unitaria | Destapa acoplamientos; hazlo después de arreglar el orden. |
| Dividir la suite en varios jobs de CI (sharding) | Lineal con el número de jobs | Coste de runners y de agregar informes. |
| Mover a nocturno lo que no da feedback útil por commit (PIT, E2E, carga) | Minutos en cada pull request | El feedback llega más tarde: solo para lo que tolera esa demora. |
| Caché de dependencias en CI | 30–120 s por job | Ninguno; invalídala con el hash del pom.xml. |
15 · Preguntas frecuentes
Batería rápida para comprobar que el módulo quedó asimilado. Si no puedes responder en voz alta, vuelve a la sección citada.
¿Qué idea de este módulo explicaría primero en una entrevista?
La que conecta el problema de negocio con la solución técnica y sus contrapartidas. No recites APIs: cuenta un caso, una decisión y qué descartaste.
¿Cómo sé si lo he entendido de verdad?
Si puedes escribir un ejemplo mínimo de memoria, explicar el fallo típico y decir cuándo no usar la técnica. La checklist del final de cada sección es el listón.
¿Qué debo practicar con teclado y no solo leer?
Todo lo que tenga bloque de código en el módulo: cópialo, rómpelo, mídelo. La lectura sin ejecución no fija el contrato de equals, un plan de ejecución o un probe de Kubernetes.
¿Cómo relaciono este módulo con el proyecto final?
Cada concepto debe aparecer en el repositorio del módulo 13 (Cafetería Tech / MiniShop): un commit, un test o una decisión documentada. Si no aparece, no cuenta como aprendido.
¿Qué preguntas trampa debo anticipar?
Las que piden el por qué y el cuándo no. Prepárate a decir “depende” seguido de dos criterios medibles, no de una preferencia estética.
¿Cuánto tiempo debo dedicarle a este módulo en el plan?
El que indica el badge de la cabecera. Si vas corto de días, prioriza las secciones marcadas como críticas en el índice y los ejercicios numerados; deja el resto para el repaso del fin de semana.
¿Qué hago si un ejemplo no compila con mi versión?
Comprueba Java 21+ y Spring Boot 3.x. Las APIs nuevas (virtual threads, RestClient, ProblemDetail) no están en Java 8 ni en Spring Boot 2. Ajusta o sube versión; no “arregles” degradando el ejemplo.
¿Debo memorizar flags, anotaciones y comandos?
Memoriza el mapa mental y tres ejemplos. Los flags exactos se consultan; lo que se evalúa es saber cuál buscar y por qué lo necesitas.
¿Cómo evito estudiar en modo pasivo?
Cierra el HTML y escribe de memoria: un test, una entidad, un Dockerfile o una respuesta de entrevista de 90 segundos. Luego contrasta. Ese ciclo es el 70 % teclado del plan.
¿Qué enlazo con otros módulos?
Usa el aside y los enlaces internos. Persistencia remite a SQL (06) y a Spring Boot (04); despliegue a microservicios (08) y seguridad (10). No dupliques: profundiza donde el plan te manda.
¿Cómo demuestro esto en el CV o en GitHub?
Con un commit claro, un test que falle sin el arreglo, y una línea en el README del proyecto (“detectamos N+1 / OOMKilled / … y lo medimos”). Evidencia > adjetivos.
Si solo me queda una hora, ¿qué hago?
Lee la sección de errores comunes, responde tres FAQ en voz alta y marca dos ejercicios como hechos solo si los has ejecutado. Mejor poco sólido que mucho subrayado.
16 · Ejercicios y retos
Autoevaluación
17 · Resumen y recursos
Qué debes recordar
- El por qué manda sobre la lista de APIs.
- Mide antes de optimizar; los síntomas engañan.
- Documenta decisiones y contrapartidas en el proyecto.
- Los tests y la observabilidad cierran el aprendizaje.
Siguiente paso
- Completa las checklists marcadas arriba.
- Pasa al módulo siguiente solo con los ejercicios 1–3 hechos.
- Anota dudas para el simulacro del módulo 12.