Plan de estudio Java 2026
Módulo 07 muy preguntado Días 13–14 ≈ 7 h de estudio activo

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.

Progreso de este módulo0 / 0
Cómo leer este módulo: las secciones 1 y 2 son las que más te van a cambiar la forma de trabajar (y las que casi nadie estudia: se aprenden a base de sufrir suites malas). Las secciones 3 a 6 son el dominio de las herramientas: JUnit, AssertJ, Mockito y TDD. Las 7 a 9 son testing de una aplicación Spring de verdad, con base de datos y colas. Las 10 a 13 abren el abanico: contratos, arquitectura, cobertura de mutaciones y rendimiento. Las 14 y 15 son las que distinguen a alguien que ha mantenido una suite grande en producción. Si solo tienes dos horas, lee 1, 2, 7 y 14.
Regla que resume el módulo: un test existe para que puedas cambiar el código con confianza. Todo lo que sirva a ese fin (rapidez, determinismo, buenos mensajes de fallo, independencia de la implementación) es una buena práctica; todo lo que lo estorbe (mocks de todo, aserciones sobre detalles internos, esperas fijas, datos compartidos) es deuda disfrazada de rigor.

Plan de los dos días

Objetivo: una suite propia con los cuatro niveles funcionando
Día 13 · mañana
Fundamentos 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.

Día 13 · tarde
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.

Día 14 · mañana
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.

Día 14 · tarde
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 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 detectaCoste relativoQué hay que hacer para arreglarloQuién se entera
Mientras escribes (compilador, IDE, test unitario en rojo) 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.

Ojo con el argumento «no hay tiempo para tests»: el tiempo no desaparece, se desplaza. Sin tests lo pagas en depuración manual, en bugs de regresión, en despliegues con miedo y en refactors que nunca se hacen porque nadie se atreve. La pregunta correcta no es «¿tengo tiempo de escribir tests?», sino «¿tengo tiempo de no escribirlos y arreglar lo de después?».

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.
El anti-patrón más caro: testear la implementación en lugar del comportamiento. Un test que verifica «se llamó al método 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.
FormaIdea centralCuándo es la correctaSu 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.
La síntesis práctica para un backend Spring: el criterio no es «cuántos de cada» sino «cada comportamiento se prueba una sola vez, en el nivel más bajo capaz de detectar su fallo». Las reglas de negocio, en tests unitarios sin Spring. La serialización, la validación y el mapeo HTTP, en slices. Las consultas SQL, contra PostgreSQL real. Y dos o tres flujos completos de extremo a extremo para comprobar que todo está bien enchufado. Duplicar el mismo caso en tres niveles no da tres veces más confianza: da tres veces más trabajo de mantenimiento.

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.

TipoDefinición operativaAlcance en Java/SpringTiempo objetivoQué 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
La discusión eterna sobre «unidad»: hay dos escuelas. La solitaria (Londres) aísla la clase bajo prueba con dobles para todos sus colaboradores; el test señala con precisión quirúrgica dónde está el fallo, pero se acopla a la estructura. La social (Detroit, o «clásica») considera unidad a un grupo de clases que colaboran y solo usa dobles en los bordes de proceso (base de datos, red, reloj); los tests son más robustos ante refactors, pero un fallo puede hacer caer varios a la vez. En un dominio con objetos de valor y agregados, la escuela social produce suites mucho más mantenibles: no mockees tus propias entidades. Reserva los dobles para lo que sale del proceso.

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ónQué ocurre en la prácticaConsecuencia 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:

EtapaContenidoPresupuestoCuándo se ejecuta
Compilación + unitarios800–2.000 tests sin Spring< 30 sEn cada guardado o en cada commit, en local y en CI
Slices de Spring30–80 tests, 2 o 3 contextos cacheados< 60 sEn cada commit
Integración con contenedores30–100 tests, contenedores reutilizados2–5 minEn cada pull request
ContratoVerificación de pacts< 1 minEn cada pull request
Mutación (PIT)Solo paquetes de dominio5–15 minNocturna o semanal
E2E y carga3–8 flujos, escenario de carga base10–30 minNocturna, y antes de una release
Mide antes de optimizar. Casi siempre el 90 % del tiempo se lo comen un 5 % de los tests: los que arrancan un contexto de Spring distinto, los que esperan con 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ónFormaEjemploValoració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() { … }
}
Prueba del algodón: lee en voz alta la lista de nombres de tests de una clase. Si suena a especificación de negocio («sin cupón el precio no cambia; un cupón caducado no aplica descuento…»), los nombres son buenos. Si suena a inventario de métodos («test calcular, test calcular con nulo, test calcular dos»), te falta pensar en comportamientos. Muchos IDEs y el informe de Surefire generan esa lista por ti: mírala de vez en cuando como si fuera documentación, porque lo es.

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 estructuraQué significaArreglo
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.
El test espejo, el fallo más frecuente y más invisible: nunca calcules el resultado esperado con la misma lógica que estás probando. Este test pasa siempre, incluso si el IVA está mal: 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.

PropiedadQué significaCómo se rompeCó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();
    }
}
Detecta la dependencia de orden hoy mismo: añade a 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ónVeredictoPor 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ónQué hacerPor 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.
Regla de oro para el legacy: no abras una pull request titulada «mejorar los tests». No la revisará nadie y creará conflictos con todo el equipo. En cambio, cada vez que toques una clase, arregla los tests de esa clase como parte del cambio. En seis meses la suite es otra, y nadie ha tenido que aprobar un proyecto de limpieza.

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.

SubproyectoQué esArtefactoQuié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)
Por qué importa esta separación en la práctica: cuando un test «no aparece» al ejecutar 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ónCuándo se ejecuta¿Estático?Uso típicoTrampa
@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(); }
}
La regla con 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 @NestedComportamiento
Orden de los callbacks@BeforeEach de fuera hacia dentro; @AfterEach de dentro hacia fuera. Igual que los constructores.
@BeforeAll en una @NestedNo se permite con PER_METHOD (la clase interna no es estática). Con @TestInstance(PER_CLASS) sí.
Acceso al estado externoTotal: la instancia interna tiene referencia a la externa. De ahí que el campo carrito funcione.
Profundidad recomendadaDos niveles. Con tres ya cuesta seguir qué preparación está activa; considera dividir la clase.
ExtensionesLas @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"
Convención que funciona: nombra *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"));
}
Los timeouts como aserción de rendimiento son una fuente número uno de tests intermitentes. Una máquina de CI compartida puede ser cinco veces más lenta que tu portátil, y un GC en el momento equivocado dispara cualquier umbral ajustado. Usa @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");
    …
}
MecanismoResultado si no se cumpleCuá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.
@DisabledTest deshabilitado permanentemente.Solo temporalmente y con motivo, dueño y fecha escritos.
Aserción normalTest fallido (rojo).Cuando la condición es lo que estás comprobando.
Peligro de las suposiciones: un 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 nameQué 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.
Cuándo NO parametrizar: si los casos requieren aserciones distintas, o si el cuerpo del test se llena de 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 casosAnotaciones o método estáticoCualquier código en tiempo de ejecución
Ciclo de vida por casoCompleto: @BeforeEach y @AfterEach se ejecutanNo se ejecutan por caso, solo una vez para la fábrica
Extensiones e inyecciónTodo el soporte de JupiterLimitado: los DynamicTest son lambdas, no métodos de test
Cuándo usarloCasi siempreCuando 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");
    }
}
OrdenadorCriterioComentario
MethodOrderer.OrderAnnotation@Order(n) explícitoEl único razonable cuando el orden es intencionado.
MethodOrderer.DisplayNameAlfabético por nombre visibleDeterminista, cómodo para leer el informe.
MethodOrderer.MethodNameAlfabético por nombre de métodoEl truco viejo de a_, b_… Evítalo: esconde el acoplamiento.
MethodOrderer.RandomAleatorio con semillaActívalo por defecto: detecta dependencias de orden. La semilla se imprime para reproducir.
ClassOrderer.ClassName / OrderAnnotation / RandomOrden entre clasesClassOrderer.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ónInterfazPara qué
Antes de todos los tests de la claseBeforeAllCallbackArrancar un recurso compartido (contenedor, servidor simulado).
Antes de cada testBeforeEachCallbackLimpiar base de datos, fijar el reloj, resetear un stub.
Antes/después del método (dentro de los callbacks)BeforeTestExecutionCallback, AfterTestExecutionCallbackMedir el tiempo exacto del cuerpo del test, sin la preparación.
Después de cada test y de la claseAfterEachCallback, AfterAllCallbackLiberar recursos, volcar diagnósticos si falló.
Inyectar parámetrosParameterResolverPasar al test un cliente HTTP configurado, un Clock, datos de prueba.
Decidir si se ejecutaExecutionConditionImplementar tus propias anotaciones tipo @EnabledIfDockerDisponible.
Manejar excepcionesTestExecutionExceptionHandlerTraducir excepciones, o reintentar (con mucho cuidado).
Procesar la instancia de testTestInstancePostProcessorInyectar en campos, como hace Mockito con @Mock.
Invocar el testInvocationInterceptorEnvolver 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 paralelizarSíntomaSolución
Estado static mutable compartidoFallos aleatorios que cambian de test en cada ejecuciónEliminar el estático o marcar @ResourceLock. Lo primero es mejor.
Propiedades del sistema, locale, zona horariaUn 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 datosViolaciones de clave única, contadores incorrectos, interbloqueosDatos únicos por test (prefijos), esquema por hilo, o @ResourceLock por tabla.
Puertos fijosAddress already in usePuerto 0 / RANDOM_PORT / dynamicPort() siempre.
Mocks estáticos (mockStatic)Fugas entre hilos y errores extrañísimosmockStatic es por hilo, pero mezclado con paralelismo es una fuente de dolor: rediseña.
Contextos de SpringConsumo de memoria disparado, OOM en CIParalelizar solo la suite unitaria; los @SpringBootTest, en secuencia o en forks separados.
Orden recomendado para paralelizar sin sufrir: (1) activa 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 4JUnit 5 (Jupiter)Nota de la migración
org.junit.Testorg.junit.jupiter.api.TestCuidado con el import: el IDE a veces elige el equivocado y el test no se ejecuta ni avisa.
@Before / @After@BeforeEach / @AfterEachRenombrado directo.
@BeforeClass / @AfterClass@BeforeAll / @AfterAllSiguen siendo static salvo con @TestInstance(PER_CLASS).
@Ignore@DisabledAprovecha para añadir el motivo, que en JUnit 4 casi nadie ponía.
@Category@TagCadenas 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 / @ClassRuleExtensión, o @RegisterExtensionExiste junit-jupiter-migrationsupport con @EnableRuleMigrationSupport para ExternalResource, Verifier y TemporaryFolder, pero solo como puente.
TemporaryFolder@TempDirMejor 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)assertThrowsLa rule estaba ya deprecada en JUnit 4.13. Adiós sin nostalgia.
Assume.assumeTrueAssumptions.assumeTrueMismo concepto, paquete nuevo.
@Parameterized con @Parameters@ParameterizedTestReescritura, 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-suiteAunque 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
Regla de ArchUnit para evitar la recaída (ver sección 11): añade un test que prohíba 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"]
CriterioJUnit AssertionsHamcrestAssertJ
Descubribilidad con el IDEBaja: métodos estáticos sueltosBaja: hay que conocer el matcherAlta: escribes assertThat(x). y el autocompletado te ofrece solo lo aplicable al tipo
Mensajes de falloPobres, salvo que los escribas a manoBuenosExcelentes, con formato multilínea y diferencias
Orden de lecturaNaturalInvertido y anidadoNatural y encadenado
Colecciones y objetos complejosMuy limitadoAceptableMuy potente: extracting, flatExtracting, comparación recursiva
ExcepcionesassertThrows, correctoTorpeassertThatThrownBy con encadenado sobre causa y mensaje
Aserciones propias del dominioNoSí, con esfuerzoSí, y muy natural (extender AbstractAssert)
Cuándo usar cada unaassertAll, assertThrows, assertTimeout: no tienen equivalenteSolo si ya está en el proyecto, o con MockMvc antiguo, que usa HamcrestTodo lo demás
Cuidado con mezclar: tanto Hamcrest como AssertJ exponen un método estático llamado 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
El error de aserción más frecuente en colecciones: usar 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);
El caso de uso que justifica 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");
No te quedes en 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)  */
Cuándo NO usar aserciones blandas: cuando una aserción es precondición de la siguiente. Si compruebas 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)
Los tres tests que fallan por fechas, y su cura:
(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 paraVentajaInconveniente
jsonPath() de MockMvcComprobar campos concretos de una respuestaPreciso, buenos mensajes, sin ficheros extraVerboso si compruebas veinte campos
JSONAssertComparar el documento completoIgnora orden de claves y formato; modo estricto detecta campos de másLos mensajes de fallo son menos claros con documentos grandes
JacksonTesterTests de serialización aisladosRapidísimo, comprueba tu configuración real de JacksonNo prueba el binding del controlador
Aserciones sobre el objeto deserializadoCuando el contrato es el objeto, no el textoRefactor-seguro, tipadoNo detecta cambios en nombres de campo JSON
Ficheros de ejemplo (approval)Respuestas grandes y establesEl diff completo es facilísimo de revisarTienta 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.

TipoQué esSe usa paraVerifica interacciones
DummyUn 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
StubDevuelve respuestas prefijadas. No tiene lógica ni recuerda nada.Controlar lo que entra en la clase bajo prueba.No
SpyEnvuelve un objeto real y registra las llamadas; puede delegar o interceptar.Comprobar que se llamó a algo, manteniendo el comportamiento real.Sí, a posteriori
MockSe 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
FakeImplementació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);
}
El riesgo del fake: que se aparte del comportamiento real. Tu 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 dobleCuándoComentario
@ExtendWith(MockitoExtension.class) + @MockPor defectoInicializa los campos, aplica modo estricto y valida el uso al terminar. La opción correcta.
mock(Tipo.class)Dobles locales de un solo testPerfecto para un dummy o para un doble que solo usa un test.
MockitoAnnotations.openMocks(this) en @BeforeEachCuando no puedes usar la extensiónRecuerda cerrar el AutoCloseable en @AfterEach o tendrás fugas de memoria.
@InjectMocksSUT con muchas dependenciasCó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 nuncaPermite 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 abstractasSuele 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écnicaCuándo usarlaMensaje de fallo
verify(mock).metodo(valorConcreto)Cuando puedes construir el valor esperado completo y tiene equalsBueno: muestra las diferencias
assertArg(...)Cuando solo te importan algunos campos del argumentoEl mejor: el mensaje es el de AssertJ
ArgumentCaptorCuando necesitas el objeto para más comprobaciones o hay varias llamadasBueno, pero se aserta después de verify
argThat(predicado)Solo si necesitas emparejar durante el stubbing; evítalo para verificarMalo: «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.
Sobre 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");
    }
}
Antes de usar 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
No pongas 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íntomaQué revelaSalida
Más de 3 o 4 mocks en un testLa 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 mockEl 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 cambieLos 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 estadoEstás documentando llamadas, no comportamiento.Pregunta: «¿qué cambia en el mundo?» y aserta sobre eso.
Mockeas tus propias entidadesEl 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);
    }
}
El patrón que subyace a las cinco: núcleo funcional, cáscara imperativa («functional core, imperative shell»). Empuja las decisiones y los cálculos hacia funciones puras que reciben datos y devuelven datos; deja los efectos —leer, escribir, llamar, publicar— en una capa fina exterior. El núcleo se prueba sin un solo doble, a velocidad de microsegundos y con tests que sobreviven a los refactors. La cáscara necesita mocks o integración, pero es pequeña y estable. Si tu servicio de 300 líneas necesita seis mocks, este es el rediseño que estás buscando.

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.
FaseQué hacesQué NO hacesCó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.
Ver el rojo no es opcional. Es el paso que más se salta y el que más valor aporta: un test que nunca has visto fallar puede estar mal escrito, apuntar a otro sitio o tener una aserción que se cumple siempre. La cantidad de tests inútiles que existen en el mundo porque nadie los vio en rojo es enorme. Truco: cuando escribas un test para código ya existente, rómpelo a propósito (invierte una condición, cambia un número) y comprueba que se pone rojo. Si no se pone, el test es decorativo.

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)

  1. Sin promociones, el importe a pagar es el subtotal del carrito.
  2. Un cupón de porcentaje descuenta ese porcentaje del subtotal.
  3. Un cupón caducado no descuenta nada, pero no es un error: se informa del motivo.
  4. Los clientes VIP tienen un 5 % adicional, acumulable con el cupón.
  5. El descuento total nunca puede superar el 50 % del subtotal.
  6. 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 Clock inyectado, porque el tercer test lo exigió. Sin TDD, casi seguro habría un LocalDate.now() escondido.
  • Una abstracción ReglaDescuento que 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 (ResultadoDescuento con avisos) en lugar de un BigDecimal pelado, 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.

EstrategiaEn qué consisteCuándo usarlaEjemplo
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).
Regla práctica sobre la triangulación: triangula cuando el valor esperado del test podría satisfacerse «por casualidad». Un ejemplo canónico: si pruebas 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, un Dockerfile, 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.
La respuesta honesta en la entrevista: «Hago TDD siempre que estoy escribiendo lógica de negocio o corrigiendo un bug, porque ahí es donde el diseño y los casos límite se benefician. Para configuración, exploración de librerías nuevas o maquetación escribo el código primero y los tests después, cubriendo lo que merece la pena. Lo que no negocio es el resultado: el comportamiento acaba cubierto por tests antes de hacer merge». Esa respuesta demuestra criterio; un «sí, siempre» dogmático suele delatar que no se ha practicado de verdad.

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:

ProblemaSolución
Arrancar el contenedor tarda 20 s en cada ejecuciónContenedor 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 HibernateEscribe 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 desincronizanEjecuta Flyway en el test contra el contenedor: pruebas el esquema real, migraciones incluidas.
El ciclo sigue siendo lento para TDD finoHaz 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.

RefactorPasos segurosQué 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.
Nunca refactorices y cambies comportamiento en el mismo commit. Si el test se pone rojo, no sabrás si es porque el refactor tenía un fallo o porque el comportamiento nuevo es distinto. La disciplina es: un commit de refactor con todos los tests verdes y sin tocar ni un test; después, un commit con el comportamiento nuevo y sus tests. Un revisor puede aprobar el primero en treinta segundos («no cambia nada, los tests lo prueban») y centrarse en el segundo.

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 probarAnotaciónQué arrancaCoste 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
La pregunta que resuelve el 80 % de las dudas: «¿qué es lo que puede fallar aquí?». Si lo que puede fallar es una fórmula, no necesitas Spring. Si lo que puede fallar es que el JSON tenga 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.
webEnvironmentServidorCliente que usasCuándo
MOCKNo (servlet simulado)MockMvc, MockMvcTester, WebTestClient (con @AutoConfigureWebTestClient)Por defecto. Más rápido y con mejores mensajes de fallo.
RANDOM_PORTSí, puerto libreTestRestTemplate, RestClient, WebTestClient con URL baseCuando importa la pila HTTP real: filtros, cabeceras, TLS, tiempos de espera.
DEFINED_PORTSí, puerto fijoIgualCasi nunca. Solo si algo externo tiene que conectarse a un puerto conocido.
NONENoNingunoTests de servicios, listeners, schedulers, procesos por lotes.
Diferencia crítica con 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 / @MockitoSpyBeanCada conjunto distinto de beans mockeados es un contexto nuevo. Es la causa más frecuente y la menos conocida.
Tipo de webEnvironmentMOCK frente a RANDOM_PORT.
@DirtiesContextNo crea contexto: lo destruye, forzando a recrearlo en el siguiente test. Devastador para el tiempo.
Jerarquías de contexto y @ContextCustomizerTestcontainers 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

  1. Una clase base (o anotación compuesta) por tipo de test, con la configuración idéntica. Todos los *IT heredan de la misma.
  2. 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.
  3. Evita properties por 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.
  4. Agrupa los @MockitoBean: si tres clases mockean el mismo bean, declara el mock en la clase base y compártelo (recordando resetearlo).
  5. Prohíbe @DirtiesContext salvo justificación escrita. Casi siempre esconde un test que no limpia lo que ensucia.
  6. Ordena por configuración: ClassOrderer personalizado 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.

SliceIncluyeNO incluyeTransacció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).
}
EnfoqueArranquePrueba tu configuración webSeguridadCuándo
standaloneSetup~5 ms, sin contextoNoNoMuchos tests de binding y lógica de controlador; suite ultrarrápida.
@WebMvcTest~1–2 s (contexto cacheado)Lo habitual: valida @ControllerAdvice, conversores y filtros reales.
@SpringBootTest + @AutoConfigureMockMvc3–10 s el primeroSí, enteraCuando quieres el controlador contra los servicios y la base de datos reales.
@SpringBootTest(RANDOM_PORT) + TestRestTemplateIgual + servidorSí, y la pila HTTPCuando 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.
    }
}
¿Migrar todo a 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();
    }
}
ClienteVentajasInconvenientesRecomendación
MockMvc / MockMvcTesterRapidísimo, mismo hilo (transacciones funcionan), mensajes de fallo excelentesNo prueba la pila HTTP realLa opción por defecto para tests de la capa web
TestRestTemplateSimple, síncrono, no lanza en 4xx/5xxAPI menos expresiva; RestTemplate está en mantenimientoBien para smoke tests de integración sencillos
WebTestClientAPI fluida, sirve para MVC y WebFlux, soporta SSE y streaming, integración con Spring Security de testRequiere entender Reactor para los casos avanzadosLa mejor opción con servidor real y para WebFlux
RestClient (Spring 6.1+)API moderna síncrona, la que usarás en producciónNo 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;
El coste oculto de @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);
    }
}
NecesidadMecanismo¿Contexto nuevo?
Configuración común a todos los testssrc/test/resources/application.yamlNo
Variar un grupo de propiedades entre familias de testsPerfil + @ActiveProfilesUno 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 @DynamicPropertySourceCompartido si está en la clase base
Sustituir un bean por un doble@MockitoBean o @TestConfiguration con @PrimarySí en el primer caso; compartido si el @Import es común
Cambiar un valor durante el testBean mutable de configuración, o @ConfigurationProperties con settersNo: 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
Úsalo también para tests de propiedades y de validación de configuración. Un error de configuración que se detecta al arrancar (y con un mensaje claro) es infinitamente mejor que un 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));
    }
}
Los tres tests de seguridad que casi nadie escribe y que más valen:
(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();
                });
    }
}
Un test de errores que merece la pena tener: uno parametrizado que recorra todas tus excepciones de dominio y compruebe que cada una tiene su manejador, con el estado HTTP correcto y sin filtrar información sensible. Se puede automatizar con reflexión: busca todas las subclases de tu 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íncronaTécnica recomendada
@Async en un método propioPrueba el método directamente; o SyncTaskExecutor en el contexto de test.
@ScheduledExtrae 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 RabbitMQContenedor real, publicar el mensaje y esperar con Awaitility a que cambie el estado observable.
CompletableFutureassertThat(futuro).succeedsWithin(Duration.ofSeconds(2)).isEqualTo(esperado) (AssertJ).
WebClient / Mono / FluxStepVerifier, o .block(Duration) en tests sencillos.
Hilos virtuales y tareas en paraleloCountDownLatch 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 tradicionalCó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 esperaCuá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écnicaAhorro típicoDetalle
Contenedores static en una clase base compartidaDe N arranques a 1La medida con más impacto. Combínala con un solo contexto de Spring (7.3).
Startables.deepStart(...)30–50 % del arranqueArranca PostgreSQL, Kafka y Redis en paralelo en lugar de en secuencia.
Imágenes -alpine o slim1–3 s de descarga y arranqueY menos ancho de banda en CI. Fija siempre la etiqueta: nunca latest.
fsync=off y compañía en PostgreSQL2–5× en escriturasAbsolutamente seguro en tests: perder datos al morir el contenedor es lo esperado.
tmpfs para el directorio de datos1,5–3×.withTmpFs(Map.of("/var/lib/postgresql/data", "rw"))
Truncar en vez de recrear el esquemaDe ~1 s a ~5 ms por testTRUNCATE ... RESTART IDENTITY CASCADE de todas las tablas.
Caché de imágenes en CI10–60 s por jobCachea ~/.docker o usa un registro mirror propio; evita el límite de descargas de Docker Hub.
testcontainers.reuse.enable en local5–15 s por ejecuciónSolo 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

AlternativaQué ganaQué 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.
La historia que se repite en todos los equipos: los tests pasan en H2, se despliega, y la consulta falla en producción porque usaba 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);
    }
}
HerramientaNivelFuerte enÚsala cuando
MockRestServiceServer (@RestClientTest)Intercepta el ClientHttpRequestFactory: no hay red realRapidez, integración perfecta con SpringTest unitario de un cliente de Spring; el más rápido
MockWebServerServidor HTTP real en un puertoLigero, control del socket, fallos de redCliente HTTP de cualquier librería, tests de bajo nivel
WireMockServidor HTTP real, con emparejamiento y escenariosStubs complejos, escenarios con estado, latencia, record & replay, verificación ricaTests de integración del servicio completo; simular varios proveedores
WireMock en contenedorContenedor DockerAislamiento total, compartido con otros lenguajesCuando el stub lo comparten varios equipos o servicios
Mockear tu cliente (@MockitoBean)Java, sin HTTPTrivial de escribirSolo 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.

EscenarioCómo simularloQué debe hacer tu código
Timeout de conexiónContenedor parado, o IP no enrutableFallar rápido (timeout de 1–2 s, no el de por defecto del sistema operativo) y con excepción propia
Timeout de lecturawithFixedDelay(N) mayor que tu read timeoutCortar, no colgar el hilo; propagar un error entendible
500 / 503 del proveedorwithStatus(503)Reintentar si es idempotente, con retroceso exponencial y jitter; abrir el circuito tras N fallos
429 con Retry-AfterwithStatus(429).withHeader("Retry-After","2")Respetar la cabecera; no martillear
JSON inesperado o campo nuevoCuerpo con tipos cambiados o campos extraIgnorar campos desconocidos; fallar de forma controlada ante tipos imposibles
Respuesta vacía o HTML de un proxyFault.EMPTY_RESPONSE, cuerpo text/htmlNo lanzar NullPointerException; error de dominio
Conexión cortada a mitadFault.MALFORMED_RESPONSE_CHUNKTratarlo como fallo transitorio
Base de datos caída durante la transacciónpostgres.stop() en mitad del testNo dejar datos a medias; propagar y no reintentar dentro de la transacción
Base de datos lenta / pool agotadopg_sleep, o pool de tamaño 1 con dos hilosTimeout de adquisición del pool, no espera infinita
Kafka no disponible al publicarkafka.stop()Patrón outbox: la transacción de negocio no debe depender del broker
Mensaje duplicadoPublicar el mismo evento dos vecesIdempotencia por clave; el segundo no produce efecto
Mensaje envenenadoPublicar un JSON no deserializableIr 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();
}
Cómo priorizar si no tienes tiempo para todos: por cada dependencia externa, escribe primero tres tests: el camino feliz, el timeout y el error 5xx. Con esos tres cubres la mayoría de los incidentes reales, porque son los que activan tus reintentos, tus timeouts y tus fallbacks, que casi siempre están configurados pero nunca probados. Un timeout mal configurado (o ausente) es la causa más frecuente de que un fallo en un proveedor tumbe tu servicio entero.

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ónCuándoVentajaRiesgo
BuilderCuando cada test necesita variar campos distintosFlexible; los valores por defecto absorben los cambios de constructorPuede acabar teniendo cincuenta métodos si no lo cuidas
Object MotherCuando hay escenarios de negocio repetidos con nombre propioLos tests hablan el idioma del dominioSi cada test añade su método, crece sin control. Máxima: 10–15 escenarios por entidad
Los dos juntosCasi siempreEl mother para los escenarios comunes, el builder para variaciones puntuales
Constantes estáticas (static final Pedido PEDIDO = …)Solo con objetos inmutablesRapidísimo y muy legibleSi el objeto es mutable, has creado estado compartido: la peor clase de acoplamiento
@Builder de Lombok en la entidadCuando la entidad ya lo tieneCero código extraNo tiene valores por defecto de test: cada test debe rellenar todo, y un campo nuevo rompe todos
Regla de oro de los datos de prueba: lo que aparece en el test es lo que importa al caso. Todo lo demás lo pone el builder. Si un test menciona el código postal del cliente para probar un cálculo de IVA, quien lo lea perderá tiempo preguntándose si el código postal es relevante. La legibilidad de un test se mide en «¿qué de esto puedo ignorar?»: cuanto menos, mejor.

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 datosProsContrasRecomendación
@Sql con scriptsRápido, controlas el SQL exacto, sirve para casos que JPA no puede crearSe desincroniza del modelo; nadie lo lee al revisarPara datos de referencia (catálogos, tablas maestras) y para casos de datos «imposibles»
TestEntityManager.persist con buildersRefactor-seguro, legible, en el mismo fichero que el testMás lento con muchos registrosPor defecto para los datos del caso concreto
Llamar al API de la aplicaciónDatos coherentes por construcción; ejercita el flujo realLento; un fallo en la creación tumba tests de otra cosaPara tests de extremo a extremo, no para preparar escenarios
data.sql / import.sql globalCero esfuerzoFixture compartido: acopla todos los testsSolo para datos de referencia inmutables
Migraciones Flyway de test (db/testdata)Se versiona con el esquemaMismo problema de acoplamientoSolo catálogos

9.3 Aislamiento entre tests: cuatro estrategias

EstrategiaCómoVelocidadLimitació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();
    }
}
La trampa de @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 compartidoCoste 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 testEl 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.
Los tres riesgos de los datos aleatorios, y su regla:
(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écnicaQué haceConserva utilidadRiesgo 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 distribucionesBajo, 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.AltaSigue siendo dato personal según el RGPD: mismas obligaciones
Enmascarado parcial****1234, a***@ejemplo.com.MediaMedio: los patrones pueden identificar
Submuestreo + generación sintéticaAprender las distribuciones y generar datos nuevos.Media-altaMuy bajo. Es la opción recomendada
Copia directaNada.MáximaInaceptable. Y el entorno de test suele tener menos controles que producción, con lo que has multiplicado la superficie de exposición
Lo que sí puedes llevarte de producción sin datos personales: el volumen y la forma. Genera un millón de pedidos sintéticos con la misma distribución de líneas por pedido, la misma cardinalidad de clientes y el mismo sesgo temporal. Con eso detectas los problemas de rendimiento y los planes de ejecución degenerados, que es el 90 % de lo que buscabas al querer la copia de producción. Añade además un fichero de casos raros extraídos y anonimizados a mano: los nombres con apóstrofos y eñes, las direcciones de 200 caracteres, el pedido de 500 líneas, el importe de siete cifras. Ese fichero vale más que un millón de filas medias.

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 .received sobre el .approved sin 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.

EnfoqueProblema
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();
    }
}
PactSpring Cloud Contract
DirecciónConsumer-driven: el consumidor declara lo que necesitaProvider-driven (o acordado): el contrato vive con el proveedor
DistribuciónBroker con estados por entorno y can-i-deployArtefactos stubs.jar en el repositorio Maven
PoliglotismoExcelente (JS, Python, Go, .NET, JVM…)JVM sobre todo; hay soporte de otros, más limitado
MensajeríaSí (mensajes asíncronos)Sí, muy integrado con Spring Cloud Stream
Ventaja principalEl can-i-deploy como puerta de despliegue y la matriz de compatibilidadLos stubs se generan solos y el consumidor no escribe nada
Cuándo elegirloEquipos y lenguajes heterogéneos, muchos consumidores, despliegue independienteEcosistema 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 compatibilidadPermiteCuá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.
FORWARDAñadir campos y borrar campos con valor por defecto. Un consumidor viejo lee datos nuevos.Cuando no controlas cuándo se actualizan los consumidores.
FULLLa 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.
*_TRANSITIVELo anterior comprobado contra todas las versiones anteriores, no solo la última.Cuando hay mensajes históricos que se reprocesan. Lo más seguro.
NONETodo.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());
    }
}
La regla de oro del versionado, en una frase: añade, no cambies. Añadir un campo opcional es seguro; renombrarlo, cambiarle el tipo o quitarlo, no. Cuando de verdad tienes que cambiar algo, el patrón es expand and contract: (1) añade el campo nuevo y publica los dos a la vez; (2) migra a los consumidores, uno a uno, a su ritmo; (3) cuando ninguno use el viejo —y lo sabes porque lo has instrumentado con una métrica—, retíralo. Ese paso 3 es el que casi nadie hace y por eso los esquemas acumulan campos zombis durante años.

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"));
                            }
                        }
                    });
}
Cómo introducir ArchUnit en un proyecto que ya incumple las reglas: usa 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é

HerramientaMomentoDetectaFalsos positivosVeredicto
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. -->
El error clásico con SonarQube: activar la puerta de calidad sobre todo el código en un proyecto con diez años de historia. El resultado es 8.000 issues, un build rojo permanente y un equipo que aprende a ignorar Sonar. La configuración correcta es la puerta sobre código nuevo (clean as you code): 0 bugs y 0 vulnerabilidades nuevas, 80 % de cobertura en las líneas nuevas, 3 % máximo de duplicación nueva. La deuda existente se reduce sola conforme se toca el código, y el equipo tiene un objetivo alcanzable en cada pull request.

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(), random o dependencia de orden?
  • ¿Está en el nivel correcto, o es un @SpringBootTest para 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étricaQué cuentaFuerzaDebilidad
Instrucciones (INSTRUCTION)Bytecodes ejecutados. Es la métrica más fina de JaCoCo.No depende del formato del códigoDifícil de interpretar; poco intuitiva
Líneas (LINE)Líneas con al menos una instrucción ejecutada.Intuitiva y la más usadaUna 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 probasteNo cubre combinaciones de condiciones
Complejidad (COMPLEXITY)Caminos independientes cubiertos frente al total.Señala los métodos con muchos caminos sin probarMétrica agregada, difícil de accionar
Métodos y clasesMétodos o clases con alguna ejecución.Detecta código completamente sin tocarMuy gruesa
Mutación (PIT)Cambios artificiales en el código que los tests detectan.La única que mide si los tests VERIFICAN algoLenta 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)🟡 DependeSi tienen @Conditional o lógica, pruébalas con ApplicationContextRunner.
«Este paquete es difícil de testear»❌ NoEse es exactamente el paquete que necesita tests. Excluirlo es esconder el problema.
Bloques catch «que no pueden ocurrir»❌ NoSi 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 coberturaQué significa realmenteRecomendació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 dominioLas reglas de negocio están cubiertas. Es lo que de verdad importa.El objetivo que hay que fijar. Umbral por paquete, no global.
100 % globalCasi 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 cambioLa 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.
La respuesta de entrevista sobre cobertura: «La cobertura mide qué código se ejecuta, no qué comportamiento se verifica: son cosas distintas y confundirlas produce tests decorativos. Yo la uso como detector de ausencias —qué no está tocado en absoluto— y pongo umbrales altos solo en el dominio. Para saber si los tests verifican algo de verdad, uso cobertura de mutaciones en los paquetes críticos. Y la métrica que de verdad me importa es cuántos defectos llegan a producción».

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.

MutadorQué cambiaQué 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ónSolo has probado una rama, o no asertas sobre el resultado.
MATH+-, */El resultado del cálculo no se comprueba (o es un test espejo).
INCREMENTSi++i--Los bucles y contadores no se verifican.
VOID_METHOD_CALLSElimina la llamada a un método voidEl efecto secundario no se comprueba: puedes borrar el save() y nadie se queja.
RETURN_VALS / EMPTY_RETURNSDevuelve null, 0, "", lista vacíaEl valor de retorno no se aserta.
REMOVE_CONDITIONALSHace la condición siempre cierta o siempre falsaLa guarda no está probada: puedes borrar el if de validación.
EXPERIMENTAL_SWITCHCambia las ramas del switchFaltan 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»
El coste de PIT y cómo hacerlo viable: PIT ejecuta la suite una vez por mutante que toca código cubierto. En un dominio mediano son miles de mutantes y puede tardar de diez a treinta minutos. Tres medidas lo vuelven práctico: (1) limítalo al dominio, nunca a todo el proyecto; (2) excluye los tests de integración de la lista de tests que PIT ejecuta (son lentísimos y matan pocos mutantes); (3) usa el modo incremental o SCM para mutar solo lo cambiado en la pull request. Con eso pasa a treinta segundos y puede ser una puerta de calidad de verdad.

12.5 Tests que no aseveran nada y otras basuras

PatologíaCómo detectarlaQué hacer
Test sin asercionesRegla 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 rojoRompe 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 nivelesEl mismo caso en unitario, slice e integración.Quedarse con el nivel más bajo que detecte el fallo.
Test @Disabled desde hace añosrg "@Disabled" src/test | wc -lBorrar. Está en el historial de git.
Test que solo comprueba que no explotaEjecuta 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
Cuándo usar JMH y cuándo no. JMH es para comparar alternativas de implementación de un trozo pequeño y caliente: dos algoritmos de parseo, 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)
ConceptoQué 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 / p99Lo 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-upSubir 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ónEl 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 omissionEl 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.
HerramientaLenguaje del escenarioFuerte enCuándo elegirla
k6JavaScriptLigero (Go), umbrales de primera clase, buen soporte en CI, salida a Prometheus/GrafanaOpción por defecto hoy: el escenario se escribe en 20 líneas.
GatlingScala, Java o KotlinInformes HTML excelentes, DSL muy expresivo, se integra en el build MavenEquipos JVM que quieren los escenarios en el mismo repositorio y en Java.
JMeterXML / GUIMuchísimos protocolos, ecosistema enormeLegado, o cuando necesitas JDBC/JMS/FTP. Los planes en XML se versionan mal.
LocustPythonEscenarios con lógica complejaEquipos con Python.
wrk / hey / ohaLínea de órdenesMedir un endpoint en diez segundosSondeo rápido, no como prueba formal.

13.3 Resistencia, estrés y caos

TipoQué buscaCómoQué encuentra en la práctica
CargaComportamiento bajo la carga esperadaTasa nominal durante 5–15 minLatencias por encima del objetivo, consultas lentas, pool infradimensionado
EstrésDónde se rompe y cómo se rompeRampa hasta el colapsoSi degrada con elegancia (429, colas) o si cae en cascada; si se recupera solo al bajar la carga
Resistencia (soak)Degradación con el tiempoCarga moderada durante 4–24 hFugas 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 bruscoDe 10 a 500 rps en 10 sTimeouts en cascada, cold start del autoescalado, thundering herd sobre una caché vacía
CaosResiliencia ante fallos de infraestructuraMatar 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
VolumenComportamiento con muchos datosBase con 100× los datos actualesConsultas que pasan de 10 ms a 10 s, planes de ejecución que cambian, paginación por OFFSET que se degrada
La prueba de resistencia es la que más incidentes evita y la que menos gente hace. Casi todas las fugas de memoria, los pools agotados y las cachés sin límite son invisibles en un test de cinco minutos y tumban el servicio al tercer día. Una prueba de soak de cuatro horas cada fin de semana, con métricas de memoria, conexiones y descriptores de fichero, detecta el problema antes que tu cliente. No necesitas carga alta: necesitas tiempo.

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);
}
HerramientaPara qué
CountDownLatch / CyclicBarrier / PhaserSincronizar 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 paralelaAumentar las oportunidades de que la carrera aparezca. Una carrera que ocurre 1 de cada 1.000 veces necesita muchas repeticiones.
jcstressEl 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ésCambiar el GC y el número de núcleos altera el timing y saca carreras escondidas.
Hilos virtuales en testsExecutors.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.
Un test de concurrencia que pasa no demuestra ausencia de carreras. Demuestra que en esta ejecución, en esta máquina, con este planificador, no ocurrió. Por eso las pruebas de concurrencia se complementan siempre con razonamiento (¿qué protege este estado?, ¿hay happens-before?) y con diseño defensivo (inmutabilidad, restricciones en la base de datos, operaciones atómicas). La regla práctica: la corrección de la concurrencia se garantiza con diseño; el test sirve para reproducir un bug conocido y para evitar su regresión.

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 UIPor qué
Máximo 3–8 flujos, los que dan dineroCada 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-testidLos 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ónPlaywright lo hace solo. En Selenium, WebDriverWait con ExpectedConditions.
Datos propios y creados por APIPreparar el escenario clicando es lento y frágil. Crea por API, verifica por UI.
Vídeo, capturas y traza al fallarUn 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 commitSu 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ónQué permiteRiesgo y mitigación
Feature flagsDesplegar 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 canarioEnviar 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-greenConmutar 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éticaUn 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 SLODetectar 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.

CausaSíntoma característicoDiagnósticoSolució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

  1. 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.
  2. Nadie reejecuta el pipeline «a ver si pasa» sin abrir un ticket. Esa costumbre es exactamente lo que hace crónico el problema.
  3. 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.
  4. 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.
  5. 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ónAhorro típicoRiesgo
Unificar la configuración para tener 2 o 3 contextos de Spring (7.3)El mayor de todos: minutosBajo. Es una refactorización de anotaciones.
Eliminar @DirtiesContext3–10 s por aparición y ejecuciónHay que sustituirlo por limpieza de estado real.
Contenedores static compartidos + arranque en paralelo10–60 sExige limpiar datos entre tests.
Sustituir Thread.sleep por AwaitilityTodo el tiempo de espera sobranteNinguno. Solo ventajas.
Bajar un test de @SpringBootTest a @WebMvcTest, o a JUnit puro1–8 s por testComprobar que sigue cubriendo lo mismo.
Paralelizar clases en la suite unitaria2–4× en la parte unitariaDestapa acoplamientos; hazlo después de arreglar el orden.
Dividir la suite en varios jobs de CI (sharding)Lineal con el número de jobsCoste de runners y de agregar informes.
Mover a nocturno lo que no da feedback útil por commit (PIT, E2E, carga)Minutos en cada pull requestEl feedback llega más tarde: solo para lo que tolera esa demora.
Caché de dependencias en CI30–120 s por jobNinguno; invalídala con el hash del pom.xml.
El orden correcto para acelerar una suite lenta: (1) mide y ordena por tiempo, no adivines; (2) cuenta los contextos de Spring, porque casi siempre ahí está la mitad del problema; (3) elimina las esperas fijas; (4) baja de nivel los tests que estén demasiado arriba en la pirámide; (5) solo entonces paraleliza y divide en CI. Paralelizar antes de arreglar lo anterior es multiplicar la potencia sin arreglar la fuga: gastas más recursos para el mismo problema, y encima destapas todos los acoplamientos a la vez.

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.
Cierre: no intentes dominar todo el módulo de una sentada. Domina el núcleo, demuéstralo con código y vuelve a las secciones avanzadas cuando el proyecto te las exija.