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

  1. Por qué hacen falta dobles
  2. Tres formas de crear un mock
  3. Programar respuestas: el stubbing
  4. Matchers de argumentos
  5. Verificación de interacciones
  6. ArgumentCaptor
  7. Spies: qué son y por qué casi nunca
  8. Strictness y UnnecessaryStubbingException
  9. Simulación de estáticos y constructores
  10. El caso central: AlquilerServiceTest
  11. Mock o fake: cuándo cada uno
  12. Qué no se debe simular
  13. Errores Comunes y Consejos
  14. Ejercicios

  1. 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:

when(alquilerRepositorio.save(any())).thenThrow(new DataAccessResourceFailureException("timeout"));

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).

  1. 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:

@InjectMocks private AlquilerService servicio;   // Mockito lo construye e inyecta

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.

  1. 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.

  1. 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.

  1. 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 absoluto

Y 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.

  1. ArgumentCaptor

Cuando 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.

  1. 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.

  1. Strictness y UnnecessaryStubbingException

MockitoExtension 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:

  1. El código no llega donde creías. Programaste estacionRepositorio.findById y 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.
  2. La prueba arrastra preparación de otra. Se copió un @BeforeEach con cinco stubs y este caso solo usa dos. Mueve al @BeforeEach únicamente lo común.
  3. 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.

  1. 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.

  1. El caso central: AlquilerServiceTest

Todo 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:

  1. El Clock congelado en el @BeforeEach hace que «treinta minutos» y «tres horas» sean afirmaciones exactas, no aproximaciones dependientes de cuándo se ejecute la suite.
  2. selectorTarifa está 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 que AlquilerService pide la tarifa correcta y usa el resultado, incluido el recargo que suma por su cuenta.
  3. Se verifica poco y se afirma mucho. Solo hay tres verify en 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.
  4. El caso «no encontrado» no necesita stubbing, porque el valor por defecto de un mock que devuelve Optional es Optional.empty(). Menos código y más claro.

  1. 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.

  1. 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:

  1. @InjectMocks: si AlquilerService tiene más dependencias de las declaradas —y las tiene: EstacionRepositorio, Clock, el publicador—, Mockito inyecta null en silencio.
  2. test() como nombre: no describe ninguna regla; ni el informe ni el fallo dirán nada.
  3. anyLong() y anyString() 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.
  4. when(selectorTarifa.calcular(...)) no se usa en iniciar: la tarifa solo se calcula al finalizar. Con STRICT_STUBS esto es una UnnecessaryStubbingException, y lo que está señalando es que la prueba mezcla dos flujos.
  5. Sobre-verificación y ningún aserto: tres verify (dos de ellos equivalentes, porque verify(x) ya significa times(1)) y cero assertThat sobre 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

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados