La lección anterior terminó con un límite claro. EstacionService se pudo probar sin Spring porque existía EstacionRepositorioEnMemoria, un fake escrito a mano en el módulo 2. Pero AlquilerService —la clase que concentra las reglas de negocio de CicloUrbana— depende de cuatro repositorios JPA, del SelectorTarifa, del Clock, de RedProperties y del publicador de eventos de Spring. Escribir un doble a mano para cada uno serían cientos de líneas de código que nadie mantendría, y muchos de esos tipos son interfaces generadas por Spring Data que ni siquiera podemos implementar cómodamente.
Mockito resuelve exactamente eso: genera dobles al vuelo para cualquier interfaz o clase, permite programar qué devuelven y comprobar cómo se les llamó. En esta lección veremos por qué hacen falta los dobles, cómo se crean, cómo se programan sus respuestas, cómo funcionan los matchers de argumentos y por qué su regla de todo-o-nada produce un error desconcertante, cómo verificar interacciones sin caer en pruebas frágiles, cómo capturar los argumentos con ArgumentCaptor, qué son los spies y por qué casi siempre son un olor de diseño, qué te está diciendo la UnnecessaryStubbingException y cuándo un fake sigue siendo mejor que cinco líneas de stubbing. Y lo aplicaremos a la prueba completa del AlquilerService, con sus escenarios de bicicleta no disponible, estación llena, duración excedida y cálculo del importe.
Contenido
- Por qué hacen falta dobles
- Tres formas de crear un mock
- Programar respuestas: el stubbing
- Matchers de argumentos
- Verificación de interacciones
ArgumentCaptor- Spies: qué son y por qué casi nunca
- Strictness y
UnnecessaryStubbingException - Simulación de estáticos y constructores
- El caso central:
AlquilerServiceTest - Mock o fake: cuándo cada uno
- Qué no se debe simular
- Errores Comunes y Consejos
- Ejercicios
- Por qué hacen falta dobles
Tres motivos, y los tres se ven en CicloUrbana:
Aislar la unidad. Si AlquilerServiceTest usara repositorios reales, un fallo podría venir de la consulta, del mapeo, de la transacción o de la lógica. Con dobles, si la prueba falla, el error está en AlquilerService. Ese es todo el valor de una prueba unitaria.
Evitar recursos lentos o externos. Base de datos, red, sistema de ficheros, servicios de terceros. Cada uno multiplica el tiempo por mil y añade una razón para que la prueba falle sin que el código esté mal.
Forzar escenarios difíciles o imposibles. Este es el motivo menos evidente y el más valioso. ¿Cómo pruebas que AlquilerService reacciona bien cuando el repositorio lanza una DataAccessException por un fallo de conexión? ¿O cuando SelectorTarifa recibe un nombre de tarifa que no existe? Provocarlo de verdad es difícil; con un doble, es una línea:
Mockito 5 viene incluido en spring-boot-starter-test. No se declara ninguna dependencia adicional, y hacerlo con una versión propia es la vía rápida a un conflicto en el classpath (06-01).
- Tres formas de crear un mock
// 1. Programática: útil dentro de un método concreto
EstacionRepositorio repositorio = Mockito.mock(EstacionRepositorio.class);
// 2. Con anotaciones + extensión: la forma habitual del curso
@ExtendWith(MockitoExtension.class)
class AlquilerServiceTest {
@Mock private BicicletaRepositorio bicicletaRepositorio;
@Mock private AlquilerRepositorio alquilerRepositorio;
@Mock private SelectorTarifa selectorTarifa;
private AlquilerService servicio; // se construye a mano en @BeforeEach
@BeforeEach
void prepararServicio() {
servicio = new AlquilerService(bicicletaRepositorio, alquilerRepositorio,
selectorTarifa, /* ... */);
}
}@ExtendWith(MockitoExtension.class) es lo que hace que los campos @Mock se inicialicen antes de cada prueba —usando el mismo mecanismo de resolución de parámetros que vimos en 06-02— y lo que activa las comprobaciones de strictness del apartado 8. Sin la extensión, los campos anotados quedan a null y la prueba falla con un NullPointerException que despista.
La tercera forma es @InjectMocks, y merece una advertencia:
Parece cómodo, pero tiene tres riesgos reales:
| Riesgo | Qué ocurre |
|---|---|
| Fallo silencioso | Si falta un @Mock para una dependencia, Mockito inyecta null sin avisar; el fallo aparece como NullPointerException dentro del código de producción |
| Ambigüedad por tipo | Con dos dependencias del mismo tipo, Mockito resuelve por nombre de campo, y un renombrado inocente cambia qué se inyecta dónde |
| Oculta el problema de diseño | Si construir la clase a mano resulta incómodo por tener ocho dependencias, eso es información que @InjectMocks esconde |
La política del curso: construir el objeto con new en el @BeforeEach. Es posible precisamente porque en 02-02 decidimos inyectar por constructor: la clase se puede instanciar en dos líneas sin ningún artefacto mágico. Y cuando esas dos líneas empiecen a ocupar diez, la prueba te estará diciendo que AlquilerService tiene demasiadas responsabilidades.
Por defecto, un mock devuelve valores vacíos: null para objetos, 0 para números, false para booleanos, colecciones vacías para List o Set y Optional.empty() para Optional. Esto último es muy práctico: el caso «no encontrado» de cualquier findById no necesita stubbing.
- Programar respuestas: el stubbing
// Respuesta fija
when(bicicletaRepositorio.findById(1L)).thenReturn(Optional.of(bicicletaRB0142));
// Lanzar una excepción
when(alquilerRepositorio.save(any())).thenThrow(new DataAccessResourceFailureException("caída"));
// Respuestas consecutivas: la primera llamada devuelve una cosa, la segunda otra
when(bicicletaRepositorio.findById(1L))
.thenReturn(Optional.of(bicicletaRB0142))
.thenReturn(Optional.empty()); // y de la tercera en adelante, esta
// thenAnswer: la respuesta depende de los argumentos recibidos
when(alquilerRepositorio.save(any(Alquiler.class))).thenAnswer(invocacion -> {
Alquiler recibido = invocacion.getArgument(0);
recibido.asignarId(99L); // simula lo que hace la base de datos
return recibido;
});thenAnswer es la herramienta para el caso más común de todos en Spring Data: save devuelve la entidad con el identificador ya asignado. Sin él, el save simulado devolvería null y el código de producción fallaría por un motivo que no tiene nada que ver con lo que se está probando.
Existe una segunda sintaxis, con el verbo delante:
doReturn(Optional.of(bicicletaRB0142)).when(bicicletaRepositorio).findById(1L);
doThrow(new EstacionLlenaException("Plaza Mayor", 24)).when(estacionService).anclar(1L);
doNothing().when(publicadorEventos).publishEvent(any());| Estilo | Cuándo usarlo |
|---|---|
when(...).thenReturn(...) |
Por defecto: es más legible y comprueba tipos en compilación |
doReturn(...).when(...) |
Obligatorio con métodos void, con spies y cuando el método real no debe ejecutarse |
La razón de la excepción es mecánica: when(mock.metodoVoid()) no compila, porque void no es una expresión. Y con un spy, when(spy.metodo()) ejecuta el método real antes de programarlo, con los efectos secundarios que eso conlleve. Con doReturn la llamada nunca ocurre.
- Matchers de argumentos
Un stub con un valor literal solo responde a ese valor exacto. Los matchers generalizan:
| Matcher | Coincide con |
|---|---|
any() |
Cualquier cosa, incluido null |
any(Alquiler.class), anyLong(), anyString() |
Cualquier valor no nulo del tipo |
eq(1L) |
Ese valor exacto, en un contexto que ya usa matchers |
argThat(a -> a.getImporte().compareTo(TEN) > 0) |
Lo que cumpla el predicado |
isNull() / isNotNull() |
Nulo o no nulo |
anyList(), anyMap(), anySet() |
Colecciones no nulas |
La regla de todo-o-nada. Si un método recibe varios argumentos y usas un matcher en uno, debes usar matchers en todos:
// ❌ Mezcla: falla en ejecución
when(repo.buscarPorEstacionYEstado(1L, any())).thenReturn(List.of());
// ✅ Todos matchers: eq() envuelve el literal
when(repo.buscarPorEstacionYEstado(eq(1L), any(EstadoBicicleta.class))).thenReturn(List.of());El error que produce la primera forma es célebre por lo mal que se lee:
org.mockito.exceptions.misusing.InvalidUseOfMatchersException: Invalid use of argument matchers! 2 matchers expected, 1 recorded
La causa está en la implementación: los matchers no son valores, sino que se registran en una pila interna al evaluarse. Mockito cuenta los registrados y los compara con el número de argumentos; si no cuadran, no puede saber cuál era cuál. La consecuencia práctica es que el error a veces aparece en la línea siguiente, en una prueba que no tiene nada que ver, lo que hace perder un buen rato. Si ves un InvalidUseOfMatchersException en un sitio absurdo, busca la mezcla en el stubbing anterior.
Prefiere valores literales cuando conozcas el argumento. when(repo.findById(1L)) es más específico y más informativo que when(repo.findById(anyLong())): si el código de producción empieza a pedir el identificador 2, quieres que la prueba te lo diga, no que siga pasando.
- Verificación de interacciones
Hasta aquí hemos usado los mocks como stubs: fuentes de datos. También sirven para comprobar que se les llamó:
verify(alquilerRepositorio).save(any(Alquiler.class)); // exactamente una vez
verify(alquilerRepositorio, times(2)).save(any());
verify(alquilerRepositorio, never()).delete(any()); // no debe ocurrir
verify(bicicletaRepositorio, atLeastOnce()).findById(1L);
verify(bicicletaRepositorio, atMost(3)).findById(anyLong());
verify(selectorTarifa, only()).calcular("estandar", DOS_HORAS); // esta y ninguna más
// Orden entre varios mocks
InOrder orden = inOrder(bicicletaRepositorio, alquilerRepositorio, publicadorEventos);
orden.verify(bicicletaRepositorio).save(any());
orden.verify(alquilerRepositorio).save(any());
orden.verify(publicadorEventos).publishEvent(any(AlquilerIniciado.class));
verifyNoMoreInteractions(alquilerRepositorio); // nada más que lo ya verificado
verifyNoInteractions(usuarioRepositorio); // no se tocó en absolutoY ahora la advertencia, que es lo más importante del apartado. Verificar interacciones acopla la prueba a cómo está escrito el método, no a qué hace. Una prueba llena de verify se rompe con cada refactorización, aunque el comportamiento no cambie, y entonces el equipo empieza a ver las pruebas como un impuesto en lugar de una red.
| Verifica cuando... | No verifiques cuando... |
|---|---|
| La interacción es el efecto observable: se publicó el evento, se envió el correo, se guardó la entidad | Ya has comprobado el resultado devuelto: verificar además cómo se calculó es redundante |
Hay que comprobar que algo no ocurrió: never() sobre un borrado |
El mock es solo una fuente de datos: findById ya se «verificó» al usarse su respuesta |
| El orden importa de verdad para la corrección | El orden es un detalle de implementación |
verifyNoMoreInteractions merece una mención aparte: parece riguroso y en la práctica produce las pruebas más frágiles de la suite, porque cualquier llamada añadida —un log, una consulta de apoyo— la rompe. Resérvalo para los casos en que «no tocar nada más» sea la regla de negocio.
ArgumentCaptor
ArgumentCaptorCuando el objeto que interesa se pasa a un colaborador en lugar de devolverse, hay que capturarlo. Es exactamente el caso de AlquilerService.iniciar: construye un Alquiler internamente y se lo pasa a save.
@Test
void guardaElAlquilerConLaBicicletaLaEstacionYLaHoraDeInicio() {
// Preparar
when(bicicletaRepositorio.findById(1L)).thenReturn(Optional.of(bicicletaRB0142));
when(alquilerRepositorio.save(any(Alquiler.class))).thenAnswer(i -> i.getArgument(0));
// Actuar
servicio.iniciar(new IniciarAlquilerRequest(1L, 7L, 1L));
// Comprobar: se captura el objeto que recibió el repositorio
ArgumentCaptor<Alquiler> capturador = ArgumentCaptor.forClass(Alquiler.class);
verify(alquilerRepositorio).save(capturador.capture());
Alquiler guardado = capturador.getValue();
assertSoftly(c -> {
c.assertThat(guardado.getBicicleta().getMatricula()).isEqualTo("RB-0142");
c.assertThat(guardado.getEstacionOrigen().getId()).isEqualTo(1L);
c.assertThat(guardado.getInicio()).isEqualTo(AHORA_EN_RIBALTA);
c.assertThat(guardado.getFin()).isNull();
c.assertThat(guardado.getEstado()).isEqualTo(EstadoAlquiler.EN_CURSO);
});
}Con @Captor sobre un campo se evita la línea forClass, y con getAllValues() se recuperan todas las capturas cuando el método se llamó varias veces.
Captor o argThat: argThat(a -> a.getEstado() == EN_CURSO) es más corto, pero cuando falla solo dice «no hubo ninguna invocación que coincidiera», sin enseñar qué llegó realmente. El ArgumentCaptor produce un mensaje de fallo con el valor concreto. Para asertos, captor; para seleccionar entre varias llamadas, argThat.
- Spies: qué son y por qué casi nunca
Un spy envuelve un objeto real: por defecto todos sus métodos se ejecutan de verdad, y solo los que programes se sustituyen.
@Spy private SelectorTarifa selectorTarifa = new SelectorTarifa(List.of(new TarifaEstandar()));
@Test
void usaLaTarifaRealSalvoParaElCasoQueSeSimula() {
doReturn(new BigDecimal("99.00")).when(selectorTarifa).calcular("estandar", DOS_HORAS);
// ^ doReturn obligatorio: con when(...) se ejecutaría el cálculo real primero
}Por qué son un olor de diseño. La necesidad de simular parte de una clase significa casi siempre que esa clase hace dos cosas: la que quieres probar y la que quieres evitar. La solución correcta es extraer la segunda a un colaborador e inyectarlo, que es lo que hicimos con CalculadoraTarifa en 02-02 y con Clock en 02-01. Un spy es un parche sobre un problema estructural.
Sus dos usos defendibles: código heredado que no puedes reestructurar todavía, y clases de terceros de las que solo quieres alterar un método. Fuera de ahí, cuando te encuentres escribiendo @Spy, para y mira la clase.
- Strictness y
UnnecessaryStubbingException
UnnecessaryStubbingExceptionMockitoExtension aplica por defecto el modo STRICT_STUBS, que es una de las mejores decisiones de Mockito 5:
| Modo | Comportamiento |
|---|---|
LENIENT |
Todo permitido; los stubs no usados se ignoran en silencio |
WARN |
Avisa por consola |
STRICT_STUBS (por defecto) |
Falla si hay stubs no usados o llamadas con argumentos que ningún stub cubre |
El error típico:
org.mockito.exceptions.misusing.UnnecessaryStubbingException: Unnecessary stubbings detected. 1. -> at AlquilerServiceTest.rechazaBicicletaEnMantenimiento(AlquilerServiceTest.java:64)
Qué te está diciendo, que casi nunca es «quita esa línea». Hay tres causas posibles, en orden de gravedad:
- El código no llega donde creías. Programaste
estacionRepositorio.findByIdy la validación de la bicicleta corta antes. El stub sobra porque la prueba no está probando lo que pensabas: es un hallazgo, no una molestia. - La prueba arrastra preparación de otra. Se copió un
@BeforeEachcon cinco stubs y este caso solo usa dos. Mueve al@BeforeEachúnicamente lo común. - El código cambió y la prueba no. Es la señal que quieres: hay stubbing muerto.
Si un stub debe existir aunque no siempre se use, lenient().when(...) lo exime, y @MockitoSettings(strictness = Strictness.LENIENT) desactiva el modo en la clase entera. Usa lo primero con moderación y lo segundo casi nunca: apagar el aviso pierde la información.
- Simulación de estáticos y constructores
Desde Mockito 3.4 se pueden simular métodos estáticos, y desde la 3.5 la construcción de objetos:
try (MockedStatic<LocalDateTime> reloj = mockStatic(LocalDateTime.class)) {
reloj.when(LocalDateTime::now).thenReturn(LocalDateTime.of(2026, 3, 14, 10, 0));
// ... dentro del try, LocalDateTime.now() devuelve ese valor
} // fuera del try, todo vuelve a la normalidad
try (MockedConstruction<Random> generador = mockConstruction(Random.class,
(simulado, contexto) -> when(simulado.nextInt(100)).thenReturn(42))) {
// cualquier new Random() creado aquí dentro es un mock
}Dos detalles técnicos importantes: el mock estático solo afecta al hilo actual y debe cerrarse, de ahí el try con recursos; si se escapa, contamina las pruebas siguientes con fallos imposibles de atribuir.
Y ahora la parte que importa: casi siempre es la solución equivocada. Compara las dos formas de probar el cálculo de la duración de un alquiler:
Con mockStatic(LocalDateTime.class) |
Con el Clock inyectado (02-01) |
|---|---|
Requiere try con recursos y sintaxis especial |
Clock.fixed(...) en el constructor |
| Solo afecta al hilo actual: sorpresas con código concurrente | Sin efectos globales |
Se rompe si el código pasa a usar Instant.now() |
Sigue funcionando |
| Instrumentación en tiempo de ejecución, más lenta | Cero coste |
| Oculta que el diseño está acoplado al reloj del sistema | Hace explícita la dependencia |
La conclusión es la de 06-02 dicha desde el otro lado: inyecta lo que no es determinista en lugar de simularlo. mockStatic es la herramienta para código heredado que no puedes cambiar hoy, no una alternativa de diseño.
- El caso central:
AlquilerServiceTest
AlquilerServiceTestTodo lo anterior, aplicado a la clase más importante de CicloUrbana.
package com.ciclourbana.alquileres;
@ExtendWith(MockitoExtension.class)
@DisplayName("AlquilerService · reglas de alquiler de la red de Ribalta")
class AlquilerServiceTest {
private static final Instant AHORA = Instant.parse("2026-03-14T09:00:00Z");
@Mock private BicicletaRepositorio bicicletaRepositorio;
@Mock private EstacionRepositorio estacionRepositorio;
@Mock private UsuarioRepositorio usuarioRepositorio;
@Mock private AlquilerRepositorio alquilerRepositorio;
@Mock private SelectorTarifa selectorTarifa;
@Mock private ApplicationEventPublisher publicadorEventos;
private AlquilerService servicio;
@BeforeEach
void prepararServicio() {
// Construcción explícita: sin @InjectMocks, gracias a la inyección
// por constructor de 02-02. El reloj, congelado (06-02).
servicio = new AlquilerService(bicicletaRepositorio, estacionRepositorio,
usuarioRepositorio, alquilerRepositorio, selectorTarifa,
publicadorEventos, Clock.fixed(AHORA, ZoneOffset.UTC),
RedPropertiesDePrueba.pordefecto());
}
@Nested
@DisplayName("al iniciar un alquiler")
class AlIniciar {
@Test
void guardaElAlquilerYPublicaElEventoAlquilerIniciado() {
when(bicicletaRepositorio.findById(1L))
.thenReturn(Optional.of(BicicletasDePrueba.rb0142Disponible()));
when(alquilerRepositorio.save(any(Alquiler.class)))
.thenAnswer(i -> i.getArgument(0)); // como haría la base de datos
servicio.iniciar(new IniciarAlquilerRequest(1L, 7L, 1L));
// La publicación del evento SÍ merece verify: es un efecto observable
verify(publicadorEventos).publishEvent(any(AlquilerIniciado.class));
}
@Test
void rechazaLaBicicletaCuandoLaBateriaEstaPorDebajoDelUmbral() {
when(bicicletaRepositorio.findById(1L))
.thenReturn(Optional.of(BicicletasDePrueba.conBateria(12))); // umbral 20
assertThatThrownBy(() -> servicio.iniciar(new IniciarAlquilerRequest(1L, 7L, 1L)))
.isInstanceOf(BicicletaNoDisponibleException.class)
.hasMessageContaining("RB-0142");
// Nada debe haberse guardado ni publicado: aquí el never() es la regla
verify(alquilerRepositorio, never()).save(any());
verifyNoInteractions(publicadorEventos);
}
@Test
void devuelveRecursoNoEncontradoCuandoLaBicicletaNoExiste() {
// Sin stubbing: un mock devuelve Optional.empty() por defecto
assertThatThrownBy(() -> servicio.iniciar(new IniciarAlquilerRequest(99L, 7L, 1L)))
.isInstanceOf(RecursoNoEncontradoException.class);
}
}
@Nested
@DisplayName("al finalizar un alquiler")
class AlFinalizar {
@Test
void calculaElImporteConLaTarifaDelUsuarioYLoGuarda() {
Alquiler enCurso = AlquileresDePrueba.enCursoDesde(AHORA.minus(Duration.ofMinutes(30)));
when(alquilerRepositorio.findById(5L)).thenReturn(Optional.of(enCurso));
when(estacionRepositorio.findById(2L))
.thenReturn(Optional.of(EstacionesDePrueba.estacionNorteConHuecos()));
when(selectorTarifa.calcular("estandar", Duration.ofMinutes(30)))
.thenReturn(new BigDecimal("4.10"));
AlquilerResponse respuesta = servicio.finalizar(5L, new FinalizarAlquilerRequest(2L));
assertThat(respuesta.importe()).isEqualByComparingTo("4.10");
assertThat(respuesta.estado()).isEqualTo(EstadoAlquiler.FINALIZADO);
assertThat(respuesta.duracionMinutos()).isEqualTo(30);
}
@Test
void rechazaLaDevolucionCuandoLaEstacionDeDestinoEstaLlena() {
when(alquilerRepositorio.findById(5L))
.thenReturn(Optional.of(AlquileresDePrueba.enCursoDesde(AHORA)));
when(estacionRepositorio.findById(1L))
.thenReturn(Optional.of(EstacionesDePrueba.plazaMayorLlena()));
assertThatThrownBy(() -> servicio.finalizar(5L, new FinalizarAlquilerRequest(1L)))
.isInstanceOf(EstacionLlenaException.class)
.hasFieldOrPropertyWithValue("codigo", "ESTACION_LLENA");
}
@Test
void aplicaRecargoCuandoSeSuperaLaDuracionMaximaDeDosHoras() {
Alquiler largo = AlquileresDePrueba.enCursoDesde(AHORA.minus(Duration.ofHours(3)));
when(alquilerRepositorio.findById(5L)).thenReturn(Optional.of(largo));
when(estacionRepositorio.findById(2L))
.thenReturn(Optional.of(EstacionesDePrueba.estacionNorteConHuecos()));
when(selectorTarifa.calcular(eq("estandar"), any(Duration.class)))
.thenReturn(new BigDecimal("22.10"));
AlquilerResponse respuesta = servicio.finalizar(5L, new FinalizarAlquilerRequest(2L));
assertThat(respuesta.importe()).isEqualByComparingTo("27.10"); // + 5 € de recargo
}
}
}Cuatro decisiones que conviene señalar, porque son el resumen práctico de la lección:
- El
Clockcongelado en el@BeforeEachhace que «treinta minutos» y «tres horas» sean afirmaciones exactas, no aproximaciones dependientes de cuándo se ejecute la suite. selectorTarifaestá simulado, no es real. La corrección del cálculo de tarifas ya la probamos en 06-02 con las pruebas parametrizadas; aquí lo que se prueba es queAlquilerServicepide la tarifa correcta y usa el resultado, incluido el recargo que suma por su cuenta.- Se verifica poco y se afirma mucho. Solo hay tres
verifyen toda la clase, y los tres corresponden a efectos observables reales: publicar el evento, no guardar nada tras un rechazo y no tocar el publicador. El resto son asertos sobre el valor devuelto. - El caso «no encontrado» no necesita stubbing, porque el valor por defecto de un mock que devuelve
OptionalesOptional.empty(). Menos código y más claro.
- Mock o fake: cuándo cada uno
CicloUrbana tiene un fake escrito a mano, EstacionRepositorioEnMemoria, y ahora también mocks. No compiten: sirven para cosas distintas.
| Mock (Mockito) | Fake (implementación en memoria) | |
|---|---|---|
| Coste inicial | Cero | Escribir y mantener una clase |
| Preparación por prueba | Stubbing explícito en cada una | guardar(...) y ya está |
| Comportamiento | Solo lo programado | Coherente: lo que guardas, lo lees |
| Verificar interacciones | Sí | No |
| Legibilidad con muchos casos | Cae en picado | Se mantiene |
| Riesgo | Stubs que mienten sobre el comportamiento real | El fake se desvía de la implementación real |
La regla práctica: si una prueba necesita más de tres o cuatro líneas de stubbing para preparar el mismo repositorio, o si el escenario consiste en «guardar algo y luego leerlo», el fake es mejor. Cuatro when encadenados para simular una secuencia de lecturas son ilegibles; un repositorio.guardar(plazaMayor()) no.
Y al revés: si solo necesitas que un método devuelva un valor y quieres comprobar que se llamó, escribir una clase entera es un desperdicio.
El riesgo del fake es real y hay que nombrarlo: puede divergir de la implementación de verdad. EstacionRepositorioEnMemoria filtra con stream().filter(...), mientras que el repositorio JPA genera SQL con reglas distintas de ordenación y de mayúsculas. Por eso el fake nunca sustituye a las pruebas de 06-04 y 06-05 sobre la base de datos real: es una herramienta para probar la lógica que lo usa, no para probar la persistencia.
- Qué no se debe simular
| No simules | Por qué | Qué hacer |
|---|---|---|
Tipos que no posees (Jackson, JJWT, el EntityManager) |
Tu mock congela un contrato que el tercero puede cambiar; la prueba pasa y producción falla | Envuélvelo en una interfaz propia y simula esa; o prueba la integración de verdad (06-04) |
Objetos de valor (Estacion, BigDecimal, Duration, record de DTO) |
Son baratos de construir y su comportamiento es el que quieres probar | new, o una fábrica EstacionesDePrueba |
| La clase bajo prueba | Un spy parcial sobre ella prueba tu simulación, no tu código | Extrae el colaborador problemático |
| Todo, indiscriminadamente | Una prueba en la que todo es mock verifica que Mockito funciona | Usa objetos reales cuando sean baratos y deterministas |
El primer punto tiene un ejemplo doloroso en CicloUrbana: simular JwtParser de JJWT para probar ServicioJwt produciría una prueba que pasa siempre, porque lo que puede fallar de verdad —la firma, la caducidad, el emisor— está justo en la parte simulada. La prueba correcta de ServicioJwt es la de 06-02: JJWT real, Clock inyectado.
Errores Comunes y Consejos
Olvidar @ExtendWith(MockitoExtension.class). Los campos @Mock quedan a null y el fallo aparece dentro del código de producción, lejos de la causa.
Mezclar matchers y literales. InvalidUseOfMatchersException, a menudo señalando la línea equivocada. Envuelve los literales con eq(...).
when(spy.metodo()) sobre un spy. Ejecuta el método real antes de programarlo. Con spies, siempre doReturn(...).when(spy).metodo().
Simular métodos final, static o private sin querer. Mockito 5 usa el inline mock maker por defecto y ya puede con final, pero un método private sigue siendo inalcanzable: si necesitas simularlo, es que debería ser una clase aparte.
Sobre-verificar. Una prueba con ocho verify y ningún aserto sobre el resultado no comprueba comportamiento, comprueba una transcripción del método. Se romperá en la primera refactorización.
Silenciar la UnnecessaryStubbingException con lenient() sin leerla. Es el consejo más importante de la lección: ese error es información, y muchas veces te está diciendo que la prueba no ejecuta el camino que crees.
Stubs que mienten. when(repo.save(any())).thenReturn(alquilerConImporte) hace que la prueba pase aunque el servicio nunca calcule el importe: el valor lo pusiste tú. Devuelve siempre lo que devolvería el colaborador real —con thenAnswer(i -> i.getArgument(0)) para los save— y afirma sobre lo que produce el código.
Consejo: prepara en el @BeforeEach solo lo que usan todas las pruebas. Lo demás va en cada prueba, donde se lee junto a lo que verifica. Con STRICT_STUBS, además, lo contrario falla.
Consejo: nombra los datos, no los mocks. BicicletasDePrueba.conBateria(12) dice por qué ese valor importa; bicicleta1 no dice nada.
Ejercicios
Ejercicio 1
Escribe una prueba de AlquilerService.iniciar que verifique, usando ArgumentCaptor, que el evento AlquilerIniciado publicado lleva el identificador del alquiler recién guardado y el instante del Clock inyectado, y no Instant.now() del sistema. Explica por qué esta prueba fallaría si alguien sustituyera el Clock por una llamada estática, y por qué eso es exactamente lo que se quiere.
Ejercicio 2
SeguridadAlquileres.esPropietario(idAlquiler, usuario) de 05-05 consulta alquilerRepositorio.existsByIdAndUsuarioId. Escribe su clase de prueba con Mockito cubriendo cuatro casos: es el propietario, no lo es, idAlquiler nulo y usuario nulo. Presta atención a un detalle: en dos de los cuatro casos el repositorio no debe consultarse en absoluto. Verifícalo y razona por qué ese verify sí está justificado.
Ejercicio 3
Esta prueba tiene cinco defectos de los vistos en la lección. Enuméralos y reescríbela:
@ExtendWith(MockitoExtension.class)
class AlquilerServiceTest {
@Mock AlquilerRepositorio alquilerRepositorio;
@Mock BicicletaRepositorio bicicletaRepositorio;
@Mock SelectorTarifa selectorTarifa;
@InjectMocks AlquilerService servicio;
@Test
void test() {
when(bicicletaRepositorio.findById(anyLong())).thenReturn(Optional.of(new Bicicleta()));
when(selectorTarifa.calcular(anyString(), any())).thenReturn(new BigDecimal("4.1"));
when(alquilerRepositorio.save(any())).thenReturn(new Alquiler());
servicio.iniciar(new IniciarAlquilerRequest(1L, 7L, 1L));
verify(bicicletaRepositorio).findById(anyLong());
verify(alquilerRepositorio).save(any());
verify(alquilerRepositorio, times(1)).save(any());
}
}Soluciones
Solución 1
@Test
void publicaElEventoConElIdDelAlquilerYLaHoraDelRelojInyectado() {
when(bicicletaRepositorio.findById(1L))
.thenReturn(Optional.of(BicicletasDePrueba.rb0142Disponible()));
when(alquilerRepositorio.save(any(Alquiler.class))).thenAnswer(invocacion -> {
Alquiler recibido = invocacion.getArgument(0);
recibido.asignarId(99L); // lo que hace la secuencia de PostgreSQL
return recibido;
});
ArgumentCaptor<AlquilerIniciado> capturador =
ArgumentCaptor.forClass(AlquilerIniciado.class);
servicio.iniciar(new IniciarAlquilerRequest(1L, 7L, 1L));
verify(publicadorEventos).publishEvent(capturador.capture());
assertSoftly(c -> {
c.assertThat(capturador.getValue().idAlquiler()).isEqualTo(99L);
c.assertThat(capturador.getValue().momento()).isEqualTo(AHORA);
});
}Comentario: el thenAnswer que asigna el identificador es imprescindible. Sin él, save devolvería null o un alquiler sin id, y el aserto sobre 99L no podría distinguir «el servicio no propaga el id» de «el mock no lo puso». Aquí se ve también por qué el captor es superior a argThat en este caso: si el momento no coincidiera, el mensaje muestra el instante recibido, y con argThat solo diría que no hubo coincidencias.
Por qué fallaría con Instant.now(): el aserto compara contra AHORA, el instante exacto del Clock.fixed del @BeforeEach. Una llamada estática devolvería la hora real de la ejecución, que nunca es esa. Y eso es justo lo que se busca: la prueba actúa como guardián del diseño. Si mañana alguien «simplifica» el código quitando el Clock, la suite se pone en rojo señalando la regresión de diseño antes de que llegue a producción. Es la cuarta razón para probar de 06-01 —la presión de diseño— convertida en un aserto concreto.
Solución 2
@ExtendWith(MockitoExtension.class)
@DisplayName("SeguridadAlquileres · @PreAuthorize de 05-05")
class SeguridadAlquileresTest {
@Mock private AlquilerRepositorio alquilerRepositorio;
private SeguridadAlquileres seguridad;
@BeforeEach
void prepararComponente() {
seguridad = new SeguridadAlquileres(alquilerRepositorio);
}
@Test
void permiteAlCiudadanoGestionarSuPropioAlquiler() {
when(alquilerRepositorio.existsByIdAndUsuarioId(9L, 7L)).thenReturn(true);
assertThat(seguridad.esPropietario(9L, UsuariosDePrueba.marta())).isTrue();
}
@Test
void deniegaAlCiudadanoElAlquilerDeOtro() {
when(alquilerRepositorio.existsByIdAndUsuarioId(9L, 7L)).thenReturn(false);
assertThat(seguridad.esPropietario(9L, UsuariosDePrueba.marta())).isFalse();
}
@Test
void deniegaSinConsultarLaBaseDeDatosCuandoElIdentificadorEsNulo() {
assertThat(seguridad.esPropietario(null, UsuariosDePrueba.marta())).isFalse();
verifyNoInteractions(alquilerRepositorio);
}
@Test
void deniegaSinConsultarLaBaseDeDatosCuandoNoHayUsuarioAutenticado() {
assertThat(seguridad.esPropietario(9L, null)).isFalse();
verifyNoInteractions(alquilerRepositorio);
}
}Por qué el verifyNoInteractions está justificado aquí, cuando el apartado 5 advertía contra la sobre-verificación: porque en estos dos casos no consultar es parte del comportamiento correcto, no un detalle de implementación. Si el código hiciera existsByIdAndUsuarioId(null, null), el resultado seguiría siendo false y un aserto sobre el valor devuelto no distinguiría ambas versiones; pero la segunda lanza una consulta a PostgreSQL en cada petición anónima, y en un endpoint público eso es una vía de saturación. La regla: verifica una ausencia de interacción cuando esa ausencia es la garantía que quieres.
Nótese además que estas cuatro pruebas se ejecutan en milisegundos y ya convierten en asertos la mitad de la regla de seguridad de 05-05. La otra mitad —que @PreAuthorize esté puesta y Spring la aplique— no se puede comprobar aquí: necesita el contexto, y es el trabajo de 06-04.
Solución 3
Los cinco defectos:
@InjectMocks: siAlquilerServicetiene más dependencias de las declaradas —y las tiene:EstacionRepositorio,Clock, el publicador—, Mockito inyectanullen silencio.test()como nombre: no describe ninguna regla; ni el informe ni el fallo dirán nada.anyLong()yanyString()donde se conocen los valores: si el código empieza a pedir otro identificador o la tarifa «estudiante», la prueba sigue pasando. Los matchers laxos ocultan regresiones.when(selectorTarifa.calcular(...))no se usa eniniciar: la tarifa solo se calcula al finalizar. ConSTRICT_STUBSesto es unaUnnecessaryStubbingException, y lo que está señalando es que la prueba mezcla dos flujos.- Sobre-verificación y ningún aserto: tres
verify(dos de ellos equivalentes, porqueverify(x)ya significatimes(1)) y ceroassertThatsobre el resultado. Es la prueba sin asertos de 06-01 con disfraz. Se suma un sexto problema de fondo:when(save(any())).thenReturn(new Alquiler())es un stub que miente, porque devuelve un alquiler vacío que no tiene nada que ver con lo que se le pasó.
Reescrita, con un solo comportamiento por prueba:
@Test
void guardaElAlquilerConLaBicicletaSolicitadaYPublicaElEvento() {
when(bicicletaRepositorio.findById(1L))
.thenReturn(Optional.of(BicicletasDePrueba.rb0142Disponible()));
when(alquilerRepositorio.save(any(Alquiler.class))).thenAnswer(i -> i.getArgument(0));
AlquilerResponse respuesta = servicio.iniciar(new IniciarAlquilerRequest(1L, 7L, 1L));
assertThat(respuesta.matriculaBicicleta()).isEqualTo("RB-0142");
assertThat(respuesta.estado()).isEqualTo(EstadoAlquiler.EN_CURSO);
verify(publicadorEventos).publishEvent(any(AlquilerIniciado.class));
}El cálculo de la tarifa se traslada a su propia prueba dentro del bloque AlFinalizar, que es donde ocurre.
Conclusión
AlquilerService ha dejado de ser la parte no probada de CicloUrbana. Sabes por qué hacen falta los dobles —aislar la unidad, evitar recursos lentos y, sobre todo, forzar escenarios que sería difícil provocar de verdad— y las tres formas de crearlos, con la política del curso bien fundada: @Mock con MockitoExtension y construcción explícita del objeto en el @BeforeEach, sin @InjectMocks, porque la inyección por constructor de 02-02 lo hace innecesario y porque el día que construir la clase resulte incómodo, esa incomodidad es información que no conviene esconder.
Dominas el stubbing en sus dos sintaxis y sabes cuándo doReturn deja de ser una alternativa estilística y pasa a ser obligatorio: métodos void y spies. Conoces los matchers, la regla de todo-o-nada y el motivo mecánico —la pila interna— por el que InvalidUseOfMatchersException aparece a veces en la línea equivocada. Sabes verificar con times, never, inOrder y verifyNoInteractions, y sabes lo más difícil: verificar poco, reservándolo para los efectos observables reales, porque una prueba llena de verify es una transcripción del método que se rompe en la primera refactorización. Capturas argumentos con ArgumentCaptor cuando el objeto interesante se le pasa a un colaborador —el caso del Alquiler que va a save y del evento AlquilerIniciado—, y sabes cuándo el captor gana a argThat: cuando el mensaje de fallo importa.
Has visto por qué los spies son casi siempre un olor de diseño y por qué mockStatic no debe competir con inyectar un Clock, y has aprendido a leer la UnnecessaryStubbingException como lo que es: tres diagnósticos posibles, el más interesante de los cuales es «tu prueba no está ejecutando el camino que crees». Tienes la comparación entre mock y fake con un criterio operativo —más de tres o cuatro líneas de stubbing sobre el mismo repositorio, o un escenario de guardar-y-leer, piden un fake— y la lista de lo que nunca se simula: los tipos que no posees, los objetos de valor y la clase bajo prueba.
Pero mira lo que sigue sin estar cubierto. Que @PreAuthorize esté puesta sobre AlquilerService.finalizar y que Spring la aplique de verdad; que EstacionController devuelva un 404 con formato ProblemDetail; que findByUsuarioIdAndFinIsNull genere el SQL correcto; que las reglas de authorizeHttpRequests cierren lo que creemos. Nada de eso se puede comprobar con mocks, porque lo que falla ahí no es la lógica: es el cableado, las anotaciones y el framework. La lección siguiente, Pruebas de Integración, levanta el contexto de Spring con criterio: @SpringBootTest y su caché de contextos, las rodajas @WebMvcTest y @DataJpaTest, MockMvc, @MockitoBean —el sustituto del obsoleto @MockBean— y las anotaciones de spring-security-test que convertirán en asertos automáticos cada regla del módulo 5.
Curso de Spring Boot
Módulo 1: Introducción a Spring Boot
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
