Las dos últimas lecciones dejaron a BiblioTech con cuarenta y una pruebas y un proyecto Maven reproducible. Y también dejaron un límite muy claro.

Todo lo que se ha probado hasta ahora era fácil de probar: CalculadoraMultas, que solo necesita un Clock fijo; las reglas de Prestamo, que son Java puro; EscrituraAtomica, con un directorio temporal. En cuanto apareció un colaborador de verdad, hubo que escribir a mano una implementación en memoria del repositorio y otra que registrara las llamadas al servicio de avisos. Veinte líneas de clases internas por prueba, con dos colaboradores sencillos.

GestorPrestamos tiene cinco. PrestamoRepository es una interfaz de Spring Data con veinte métodos heredados: implementarla a mano es sencillamente inviable. Y el ClienteMetadatos de 09-06 necesita una API HTTP real al otro lado.

Además hay preguntas que las aserciones sobre el resultado no responden. ¿Se envió el aviso al empleado correcto, con el texto correcto? ¿Se llamó al repositorio una vez o cuarenta? ¿Qué pasa cuando la base de datos lanza una excepción, un escenario dificilísimo de provocar con una implementación real y que en producción ocurrirá un martes cualquiera?

Mockito responde a todo eso. Es la librería de dobles de prueba estándar en Java: genera implementaciones falsas de interfaces y clases en tiempo de ejecución —con la generación de bytecode que estudiaste en 10-03—, permite programar su comportamiento y verificar cómo se han usado.

Pero esta lección no va solo de una API. Va del criterio para usarla: qué mockear y qué no, cuándo verificar interacciones y cuándo comprobar solo el resultado, y por qué el exceso de simulación produce exactamente esas pruebas frágiles que 11-04 puso entre los antipatrones. Porque una prueba mal hecha con Mockito puede ser peor que ninguna prueba.

Al terminar sabrás distinguir los cinco tipos de doble; dominarás la API de Mockito 5; verificarás interacciones con criterio; capturarás argumentos; sabrás qué no se debe mockear nunca; y conocerás las pruebas de integración con @SpringBootTest y @DataJpaTest.

Contenido

  1. Por qué no basta con JUnit
  2. Los cinco tipos de doble de prueba
  3. El fake en memoria: la alternativa infravalorada
  4. Mockito 5: dependencia y puesta en marcha
  5. Crear mocks: anotaciones y métodos de fábrica
  6. @InjectMocks y sus reservas
  7. Definir comportamiento: when(...).thenReturn(...)
  8. thenThrow: probar los caminos de error
  9. thenAnswer: respuestas dinámicas
  10. doReturn, doThrow, doNothing y cuándo son obligatorios
  11. Valores por defecto de un mock
  12. Verificar interacciones: verify
  13. times, never, atLeast, only
  14. inOrder y verifyNoMoreInteractions
  15. El criterio: verificar comandos, no consultas
  16. Matchers: any, eq, argThat
  17. La regla de todos o ninguno
  18. ArgumentCaptor
  19. Espías con @Spy
  20. mockStatic y por qué es una señal de alarma
  21. Qué NO se debe mockear
  22. El riesgo del mock excesivo
  23. Pruebas basadas en estado frente a basadas en interacción
  24. El mismo caso, resuelto de las dos formas
  25. Cuando el diseño elimina la necesidad del mock
  26. Combinar Mockito con AssertJ
  27. BiblioTech: GestorPrestamos con repositorios simulados
  28. BiblioTech: ServicioAvisos verificado con ArgumentCaptor
  29. BiblioTech: ClienteMetadatos probado sin red
  30. Pruebas de integración: @SpringBootTest y @MockitoBean
  31. @DataJpaTest con H2
  32. Testcontainers, presentado
  33. Cobertura: interpretación honesta
  34. Errores Comunes y Consejos
  35. Ejercicios

  1. Por qué no basta con JUnit

JUnit ejecuta pruebas y comprueba resultados. Lo que no hace es resolver el problema de los colaboradores.

GestorPrestamos depende de cinco cosas:

public GestorPrestamos(MaterialRepository materiales,
                       EmpleadoRepository empleados,
                       PrestamoRepository prestamos,
                       ServicioAvisos avisos,
                       PropiedadesBiblioTech propiedades,
                       Clock reloj) { ... }

Para probarlo necesitas los seis. Y tres de ellos son problemáticos:

Colaborador Problema
MaterialRepository Base de datos: lenta de arrancar, hay que preparar datos y limpiarlos
PrestamoRepository Interfaz de Spring Data con veinte métodos heredados
ServicioAvisos En producción envía correos de verdad
ClienteMetadatos (09-06) Llama a una API externa: lenta, no determinista, puede estar caída

Las cuatro razones por las que un colaborador estorba en una prueba:

  1. Lento: base de datos, red, disco.
  2. No determinista: reloj, azar, respuestas de una API que cambian.
  3. Difícil de poner en un estado concreto: ¿cómo haces que la base de datos falle exactamente en el tercer INSERT?
  4. Con efectos reales: enviar correos, cobrar, borrar.

Un doble de prueba (test double) es un sustituto que ocupa el lugar del colaborador real y hace lo que la prueba necesita. El término viene del cine: el doble de acción.

  1. Los cinco tipos de doble de prueba

La taxonomía clásica, con definiciones precisas porque en la práctica se confunden constantemente:

Tipo Definición Para qué Ejemplo en BiblioTech
Dummy Se pasa para rellenar un parámetro; nunca se usa Satisfacer una firma Un ServicioAvisos en una prueba que no avisa a nadie
Stub Devuelve respuestas predefinidas; no verifica nada Controlar lo que entra al sistema bajo prueba Repositorio que siempre devuelve "Java Efectivo"
Spy Es real o casi, y además registra cómo se le llamó Verificar interacciones sin sustituir todo El AvisosRegistrados que escribiste en 11-04
Mock Doble con expectativas sobre cómo debe usarse Verificar interacciones Verificar que se avisó una sola vez
Fake Implementación real pero simplificada Sustituir infraestructura completa Repositorio sobre un HashMap

Visto de otra forma, según lo que aporta cada uno:

graph TD
    A["¿Qué necesito del colaborador?"] --> B{"¿Se usa siquiera?"}
    B -->|No| C["DUMMY<br/>cualquier objeto vale"]
    B -->|Sí| D{"¿Necesito que devuelva<br/>datos concretos?"}
    D -->|Sí, y no compruebo cómo se usó| E["STUB<br/>when().thenReturn()"]
    D -->|Sí, y además compruebo cómo se usó| F["MOCK<br/>when() + verify()"]
    D -->|No, solo compruebo que se llamó| G["MOCK o SPY<br/>verify()"]
    A --> H{"¿Necesito comportamiento<br/>real y completo?"}
    H -->|Sí| I["FAKE<br/>implementación en memoria"]

Una aclaración importante sobre el vocabulario: en Mockito, todo se llama mock. Mockito.mock(X.class) crea un objeto que puedes usar como dummy, como stub o como mock según lo que hagas con él. La distinción es conceptual, no de API, pero saberla te ayuda a razonar sobre qué estás haciendo — y sobre si deberías estar verificando o no.

  1. El fake en memoria: la alternativa infravalorada

Antes de entrar en Mockito, conviene defender la opción que mucha gente olvida.

Un fake es una implementación real pero simplificada. Lo escribiste en 11-04 sin darle nombre:

class RepositorioEnMemoria implements RepositorioPrestamos {
    private final Map<Long, Prestamo> datos = new HashMap<>();
    private long siguienteId = 1;

    @Override public Prestamo save(Prestamo p) {
        if (p.getId() == null) p.asignarId(siguienteId++);
        datos.put(p.getId(), p);
        return p;
    }
    @Override public Optional<Prestamo> findById(Long id) {
        return Optional.ofNullable(datos.get(id));
    }
    @Override public List<Prestamo> findByEstado(EstadoPrestamo estado) {
        return datos.values().stream().filter(p -> p.getEstado() == estado).toList();
    }
}

Comparado con un mock:

Aspecto Mock (Mockito) Fake en memoria
Coste de creación Una línea 30-80 líneas, una vez
Comportamiento Solo lo que programes Real y coherente
Estado entre llamadas No hay, salvo que lo simules : guardas y luego lees
Acoplamiento a la implementación Alto: sabe qué métodos se llaman Bajo
Fragilidad al refactorizar Alta Baja
Verificar interacciones No, salvo que lo añadas
Reutilizable entre pruebas Se reconfigura cada vez Se escribe una vez

El caso donde el fake gana claramente:

// Con MOCK: hay que programar cada respuesta, y no hay coherencia
when(repositorio.save(any())).thenReturn(prestamo);
when(repositorio.findById(1L)).thenReturn(Optional.of(prestamo));
when(repositorio.countByEmpleado(marta)).thenReturn(1L);
// Si el código guarda y luego cuenta, el mock NO refleja lo que se guardó.
// Hay que mantener a mano una coherencia que el fake da gratis.

// Con FAKE: guardas y cuentas, y sale lo correcto
repositorio.save(prestamo);
assertThat(repositorio.countByEmpleado(marta)).isEqualTo(1);

Recomendación profesional: para el repositorio principal de una aplicación, un fake en memoria bien hecho suele ser mejor inversión que veinte mocks configurados en veinte pruebas distintas. Se escribe una vez, se reutiliza siempre y no se rompe al refactorizar. Para colaboradores puntuales, o cuando necesitas provocar fallos, Mockito.

En la práctica se usan los dos, y saber elegir es parte del oficio.

  1. Mockito 5: dependencia y puesta en marcha

Ya lo tienes. spring-boot-starter-test (11-04, 11-05) trae mockito-core y mockito-junit-jupiter.

Sin Spring:

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>5.12.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <version>5.12.0</version>
    <scope>test</scope>
</dependency>

La estructura básica de una prueba con Mockito:

package com.nexussoftware.bibliotech.servicio;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

import org.junit.jupiter.api.*;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)   // activa @Mock, @Spy, @Captor, @InjectMocks
class GestorPrestamosTest {

    @Mock private MaterialRepository materiales;
    @Mock private EmpleadoRepository empleados;
    @Mock private PrestamoRepository prestamos;
    @Mock private ServicioAvisos avisos;

    private GestorPrestamos gestor;

    @BeforeEach
    void preparar() {
        // Construcción explícita: mejor que @InjectMocks (apartado 6)
        gestor = new GestorPrestamos(materiales, empleados, prestamos, avisos,
                                     propiedadesDePrueba(), RELOJ_FIJO);
    }
}

@ExtendWith(MockitoExtension.class) es el mecanismo de extensiones de JUnit 5 (11-04). Hace tres cosas: crea los mocks antes de cada prueba, los reinicia entre pruebas, y valida el uso al terminar —avisando de comportamientos programados que nunca se usaron, lo que suele indicar una prueba mal escrita o código muerto—.

Cómo funciona por dentro, y aquí vuelve el módulo 10: Mockito usa ByteBuddy para generar en tiempo de ejecución una subclase de la clase o una implementación de la interfaz, con todos los métodos sobrescritos para registrar la llamada y devolver lo programado. Es exactamente el mecanismo de generación de bytecode del apartado 6 de 11-01, y es la razón de dos limitaciones que verás: una clase final o un método final no se pueden mockear por el mecanismo clásico (Mockito 5 lo permite con un motor alternativo, pero por defecto no).

Y ese es el mismo ByteBuddy que apareció en dependency:tree en 11-05, traído por Hibernate para sus proxies de carga perezosa. Tres herramientas distintas, el mismo mecanismo.

  1. Crear mocks: anotaciones y métodos de fábrica

Dos formas equivalentes:

// Forma 1: anotación (recomendada, más legible)
@ExtendWith(MockitoExtension.class)
class PruebaConAnotaciones {
    @Mock private MaterialRepository materiales;
}

// Forma 2: método de fábrica (útil para mocks locales de una prueba)
class PruebaConFabrica {
    @Test
    void ejemplo() {
        MaterialRepository materiales = mock(MaterialRepository.class);
        // ...
    }
}

Opciones útiles al crear un mock:

// Con nombre: los mensajes de error lo identifican
MaterialRepository materiales = mock(MaterialRepository.class, "repositorioDeMateriales");

// Respuestas por defecto distintas
ServicioAvisos avisos = mock(ServicioAvisos.class, Answers.RETURNS_DEEP_STUBS);

// Lanza excepción si se llama a un método no programado: fuerza a ser explícito
MaterialRepository estricto = mock(MaterialRepository.class, Answers.RETURNS_SMART_NULLS);

Sobre RETURNS_DEEP_STUBS: permite when(a.getB().getC()).thenReturn(x) sin programar los intermedios. Es casi siempre una señal de alarma: significa que tu código encadena llamadas a través de varios objetos, lo que viola la ley de Demeter. Evítalo y arregla el diseño.

  1. @InjectMocks y sus reservas

Mockito puede construir el objeto bajo prueba e inyectarle los mocks:

@ExtendWith(MockitoExtension.class)
class GestorPrestamosTest {

    @Mock private MaterialRepository materiales;
    @Mock private ServicioAvisos avisos;

    @InjectMocks private GestorPrestamos gestor;   // construido automáticamente
}

Cómoda, pero tiene inconvenientes reales que conviene conocer:

  1. Falla en silencio. Si Mockito no encuentra un mock para un parámetro, inyecta null sin avisar. La prueba falla luego con un NullPointerException cuya causa no es evidente.
  2. No admite valores no simulables. El Clock.fixed y el PropiedadesBiblioTech de BiblioTech no son mocks: son objetos reales. @InjectMocks no los pone.
  3. Oculta el constructor. Uno de los beneficios de la inyección por constructor (11-02) es que un constructor con nueve parámetros grita "esta clase hace demasiado". @InjectMocks esconde exactamente esa señal.

Recomendación: construye el objeto explícitamente en @BeforeEach.

@BeforeEach
void preparar() {
    gestor = new GestorPrestamos(materiales, empleados, prestamos, avisos,
                                 propiedadesDePrueba(), RELOJ_FIJO);
}

Es una línea más, es explícito, permite mezclar mocks con objetos reales y falla en compilación si cambia el constructor. Muchos equipos con experiencia han abandonado @InjectMocks por estos motivos.

  1. Definir comportamiento: when(...).thenReturn(...)

La operación central. Se conoce como stubbing.

@Test
void prestarDevuelveUnPrestamoConVencimientoAQuinceDias() {

    // PREPARAR: programar lo que devuelven los colaboradores
    Material libro = new Libro("978-0000000001", "Java Efectivo", 3, "J. Bloch");
    Empleado marta = new Empleado("[email protected]", "Marta Ruiz");

    when(materiales.findByIsbn("978-0000000001")).thenReturn(Optional.of(libro));
    when(empleados.findByCorreo("[email protected]")).thenReturn(Optional.of(marta));
    when(prestamos.countByEmpleadoAndEstado(marta, EstadoPrestamo.ACTIVO)).thenReturn(0L);
    when(prestamos.save(any(Prestamo.class))).thenAnswer(inv -> inv.getArgument(0));

    // ACTUAR
    Prestamo prestamo = gestor.prestar("978-0000000001", "[email protected]");

    // COMPROBAR
    assertThat(prestamo.getFechaVencimiento())
            .isEqualTo(LocalDate.of(2026, 3, 20).plusDays(15));
}

Variantes de thenReturn:

// Valores distintos en llamadas sucesivas
when(prestamos.count())
        .thenReturn(0L)      // 1ª llamada
        .thenReturn(1L)      // 2ª
        .thenReturn(2L);     // 3ª y siguientes

// Equivalente, más corto
when(prestamos.count()).thenReturn(0L, 1L, 2L);

Un detalle sobre when: no es magia, es un truco. when(materiales.findByIsbn("...")) llama de verdad al método del mock, que registra internamente la invocación y devuelve null. when recupera esa última invocación registrada y le asocia el comportamiento. Saber esto explica por qué when no funciona con métodos void (no hay nada que pasar a when) y por qué hay que usar la forma do... con espías (apartado 10).

  1. thenThrow: probar los caminos de error

Aquí está una de las razones más fuertes para usar Mockito. Probar qué pasa cuando la base de datos falla es casi imposible con una implementación real; con un mock es una línea.

@Test
void siElRepositorioFallaAlGuardarNoSeEnviaNingunAviso() {

    when(materiales.findByIsbn(anyString())).thenReturn(Optional.of(unLibro()));
    when(empleados.findByCorreo(anyString())).thenReturn(Optional.of(marta()));
    when(prestamos.countByEmpleadoAndEstado(any(), any())).thenReturn(0L);

    // El repositorio falla al guardar
    when(prestamos.save(any()))
            .thenThrow(new DataAccessResourceFailureException("Conexión perdida"));

    assertThatThrownBy(() -> gestor.prestar("978-0000000001", "[email protected]"))
            .isInstanceOf(DataAccessResourceFailureException.class);

    // Lo importante: NO se avisó a nadie de un préstamo que no se guardó
    verifyNoInteractions(avisos);
}

Esa prueba verifica una propiedad de negocio real: no se notifica lo que no se ha persistido. Sin Mockito, provocarla exigiría desconectar la base de datos a mitad de una prueba.

Otros escenarios de fallo que ahora puedes probar:

// Excepción de concurrencia (el bloqueo optimista de 11-03)
when(materiales.findByIsbn(anyString()))
        .thenThrow(new OptimisticLockingFailureException("Versión obsoleta"));

// Tiempo de espera agotado en la red (el ClienteMetadatos de 09-06)
when(clienteHttp.send(any(), any()))
        .thenThrow(new HttpTimeoutException("La API no responde"));

// Fallo la primera vez, éxito la segunda: probar el reintento
when(prestamos.save(any()))
        .thenThrow(new OptimisticLockingFailureException("conflicto"))
        .thenReturn(prestamoGuardado);

Ese último patrón es exactamente lo que necesitas para probar el bucle de reintentos que escribiste en 11-03. Sin Mockito, no hay forma razonable de hacerlo.

  1. thenAnswer: respuestas dinámicas

Cuando la respuesta depende de los argumentos:

// El caso más común: save devuelve lo que se le pasó
when(prestamos.save(any(Prestamo.class))).thenAnswer(inv -> inv.getArgument(0));

// Simular la asignación de id que haría la base de datos
when(prestamos.save(any(Prestamo.class))).thenAnswer(invocacion -> {
    Prestamo p = invocacion.getArgument(0);
    if (p.getId() == null) {
        p.asignarId(siguienteId.getAndIncrement());
    }
    return p;
});

// Respuesta según el argumento
when(materiales.findByIsbn(anyString())).thenAnswer(invocacion -> {
    String isbn = invocacion.getArgument(0);
    return catalogoDePrueba.containsKey(isbn)
            ? Optional.of(catalogoDePrueba.get(isbn))
            : Optional.empty();
});

Aviso: cuando tu Answer empieza a tener lógica, estás escribiendo un fake dentro de un mock. En ese punto, escribe un fake de verdad (apartado 3): será más legible y reutilizable.

  1. doReturn, doThrow, doNothing y cuándo son obligatorios

Existe una segunda sintaxis que invierte el orden:

// Sintaxis when: la habitual
when(materiales.findByIsbn("x")).thenReturn(Optional.empty());

// Sintaxis do: equivalente, pero el método se nombra al final
doReturn(Optional.empty()).when(materiales).findByIsbn("x");

No son intercambiables siempre. Hay tres casos donde do... es obligatorio:

Caso 1: métodos void

// NO COMPILA: avisar() devuelve void, no hay nada que pasar a when()
when(avisos.avisar(marta, "mensaje")).thenThrow(new RuntimeException());

// CORRECTO
doThrow(new ServicioAvisosException("SMTP caído"))
        .when(avisos).avisar(any(Empleado.class), anyString());

// Y para que un void no haga nada (es el comportamiento por defecto,
// pero explicitarlo a veces aclara la intención)
doNothing().when(avisos).avisar(any(), anyString());

Caso 2: espías

List<String> lista = new ArrayList<>();
List<String> espia = spy(lista);

// MAL: when() LLAMA DE VERDAD a get(0) sobre una lista vacía
// -> IndexOutOfBoundsException antes de programar nada
when(espia.get(0)).thenReturn("Java Efectivo");

// BIEN: do... NO invoca el método real
doReturn("Java Efectivo").when(espia).get(0);

Esta es la trampa número uno de los espías, y se entiende con lo del apartado 7: when(x.metodo()) ejecuta x.metodo(). En un mock puro eso es inofensivo porque no hay implementación real; en un espía, ejecuta el código real con todas sus consecuencias.

Caso 3: reprogramar un comportamiento ya definido

when(materiales.findByIsbn(anyString())).thenReturn(Optional.of(libro));
// Más adelante, cambiar el comportamiento:
doReturn(Optional.empty()).when(materiales).findByIsbn("978-9999999999");

Resumen:

Situación Sintaxis
Método normal en un mock when(...).thenReturn(...) — más legible
Método void doThrow / doNothing
Espía (@Spy) doReturn(...).when(espia).metodo()
Reprogramar doReturn

Regla mnemotécnica: usa when por defecto; cambia a do... cuando when no compile o cuando trabajes con un espía.

  1. Valores por defecto de un mock

Un mock recién creado responde a todo, sin necesidad de programarlo:

Tipo de retorno Devuelve por defecto
int, long, double 0
boolean false
Object y cualquier clase null
String null
List, Set, Map Colección vacía (no null)
Optional Optional.empty() (desde Mockito 2)
Stream Stream vacío
void No hace nada

Estos valores por defecto son muy convenientes. Que Optional devuelva Optional.empty() y que las colecciones devuelvan vacío evita la mayoría de los NullPointerException en pruebas donde no te importa ese colaborador.

Práctica útil: solo programa lo que la prueba necesita de verdad. Si GestorPrestamos llama a cinco métodos del repositorio pero tu prueba solo depende de dos, programa dos. Menos configuración, prueba más legible y menos frágil.

Y ahí entra la validación estricta de MockitoExtension:

org.mockito.exceptions.misusing.UnnecessaryStubbingException:
Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.
  1. -> at GestorPrestamosTest.preparar(GestorPrestamosTest.java:44)

Esa excepción no es una molestia: te está diciendo que programaste un comportamiento que nunca se usó. O la prueba no hace lo que crees, o hay código muerto. Vale la pena investigarlo en vez de silenciarlo con @MockitoSettings(strictness = Strictness.LENIENT).

  1. Verificar interacciones: verify

Hasta aquí, los dobles servían para controlar lo que entra. verify sirve para comprobar lo que sale: qué llamadas hizo el objeto bajo prueba a sus colaboradores.

@Test
void devolverConRetrasoRegistraLaMultaYAvisaAlEmpleado() {

    Prestamo vencido = prestamoVencidoHace(10);
    when(prestamos.findById(1L)).thenReturn(Optional.of(vencido));

    gestor.devolver(1L);

    // Se guardó el préstamo actualizado
    verify(prestamos).save(vencido);

    // Se avisó al empleado
    verify(avisos).avisar(eq(vencido.getEmpleado()), contains("multa"));
}

verify(mock).metodo(args) significa: "comprueba que se llamó a metodo con esos argumentos exactamente una vez".

Es imprescindible cuando el efecto que quieres comprobar no está en el valor de retorno. Enviar un aviso, publicar un evento, borrar un fichero: son efectos, y solo se pueden verificar así.

  1. times, never, atLeast, only

verify(avisos, times(1)).avisar(any(), anyString());        // exactamente 1 (por defecto)
verify(avisos, times(3)).avisar(any(), anyString());        // exactamente 3
verify(avisos, never()).avisar(any(), anyString());         // NUNCA
verify(avisos, atLeastOnce()).avisar(any(), anyString());   // 1 o más
verify(avisos, atLeast(2)).avisar(any(), anyString());      // 2 o más
verify(avisos, atMost(5)).avisar(any(), anyString());       // 5 o menos
verify(avisos, only()).avisar(any(), anyString());          // esta llamada Y NINGUNA OTRA

verifyNoInteractions(avisos);                    // el mock no se usó en absoluto
verifyNoMoreInteractions(prestamos);             // no hubo más llamadas de las verificadas

never() es probablemente el más valioso de todos, porque comprueba que algo no ocurrió:

@Test
void unPrestamoDevueltoEnPlazoNoGeneraAviso() {
    when(prestamos.findById(1L)).thenReturn(Optional.of(prestamoEnPlazo()));

    gestor.devolver(1L);

    verify(avisos, never()).avisar(any(), anyString());
}

Comprobar ausencias es imposible con aserciones sobre el resultado, y es donde verify aporta valor único.

  1. inOrder y verifyNoMoreInteractions

Cuando el orden importa:

@Test
void seDecrementaElStockAntesDeGuardarElPrestamo() {

    InOrder orden = inOrder(materiales, prestamos, avisos);

    gestor.prestar("978-0000000001", "[email protected]");

    orden.verify(materiales).findByIsbn("978-0000000001");
    orden.verify(prestamos).save(any(Prestamo.class));
    orden.verify(avisos).avisar(any(), anyString());
}

Aviso: verificar el orden es una de las formas más rápidas de crear una prueba frágil. Solo hazlo cuando el orden sea un requisito de negocio real —por ejemplo, "el aviso debe enviarse después de confirmar el guardado, nunca antes"—, no porque el código actual lo haga así.

verifyNoMoreInteractions comprueba que no hubo llamadas adicionales:

gestor.consultar("978-0000000001");

verify(materiales).findByIsbn("978-0000000001");
verifyNoMoreInteractions(materiales);   // no se llamó a nada más

Úsalo con moderación: cualquier llamada nueva y legítima —añadir una métrica, una traza— rompe la prueba sin que el comportamiento haya cambiado.

  1. El criterio: verificar comandos, no consultas

Este es el apartado más importante de la lección, y el que separa las pruebas buenas de las frágiles.

La distinción viene de la separación entre comandos y consultas (command-query separation):

Consulta (query) Comando (command)
Qué hace Devuelve datos, sin efectos Produce un efecto, normalmente sin devolver nada
Ejemplos findByIsbn, countByEmpleado, calcular save, avisar, delete, publicar
Cómo probarlo Con un stub: programa lo que devuelve Con verify: comprueba que se llamó
¿Verificar? NO

Regla: programa las consultas, verifica los comandos.

Por qué no se verifican las consultas:

// MAL: verificar una consulta
verify(materiales).findByIsbn("978-0000000001");
verify(prestamos).countByEmpleadoAndEstado(marta, EstadoPrestamo.ACTIVO);

Esas dos líneas dicen "el código llama a estos métodos". Pero eso es implementación, no comportamiento. Si mañana optimizas GestorPrestamos para hacer una sola consulta combinada en vez de dos, el resultado será idéntico y la prueba se romperá. Eso es exactamente el antipatrón de prueba frágil de 11-04.

Además es redundante: si el código no hubiera llamado a findByIsbn, no tendría el material y el resultado sería distinto. La aserción sobre el resultado ya lo comprueba, indirectamente y sin acoplarse.

Por qué sí se verifican los comandos:

// BIEN: verificar un comando
verify(avisos).avisar(marta, "Tu préstamo de Java Efectivo vence en 2 días");

Enviar un aviso es un efecto observable que el sistema debe producir. No aparece en ningún valor de retorno. La única forma de comprobarlo es verificar la interacción, y es un requisito de negocio genuino: "cuando falten dos días, se avisa al empleado".

Aplicado a BiblioTech:

Colaborador Método Tipo Qué hacer
MaterialRepository findByIsbn Consulta Programar
PrestamoRepository countByEmpleadoAndEstado Consulta Programar
PrestamoRepository save Comando Verificar
ServicioAvisos avisar Comando Verificar
CalculadoraMultas calcular Consulta Programar (o usar la real)
RegistroAuditoria anotar Comando Verificar

Si aplicas solo esta regla, tus pruebas con Mockito serán mucho mejores que la media.

  1. Matchers: any, eq, argThat

Cuando no quieres —o no puedes— fijar el argumento exacto:

Matcher Coincide con
any() Cualquier cosa, incluido null
any(Prestamo.class) Cualquier Prestamo no nulo
anyString(), anyInt(), anyLong() Cualquier valor de ese tipo, no nulo
anyList(), anyMap(), anyCollection() Cualquier colección
eq(valor) Ese valor exacto (con equals)
isNull(), isNotNull() Nulidad
contains("texto") String que contiene ese texto
startsWith, endsWith, matches("regex") Coincidencias de cadena
argThat(predicado) Lo que tú definas
when(materiales.findByIsbn(anyString())).thenReturn(Optional.of(libro));

verify(avisos).avisar(any(Empleado.class), contains("vence"));

// argThat: condiciones arbitrarias
verify(prestamos).save(argThat(p ->
        p.getEstado() == EstadoPrestamo.ACTIVO
        && p.getFechaVencimiento().equals(LocalDate.of(2026, 4, 4))));

Consejo importante sobre any(): es cómodo y debilita la prueba. verify(avisos).avisar(any(), any()) comprueba que se avisó a alguien de algo. Si el bug es que se avisa al empleado equivocado, la prueba no lo detecta. Sé todo lo específico que puedas; usa any() solo para lo que de verdad es irrelevante.

  1. La regla de todos o ninguno

Un error que comete todo el mundo la primera vez:

// NO FUNCIONA
verify(avisos).avisar(marta, anyString());
org.mockito.exceptions.misusing.InvalidUseOfMatchersException:
Invalid use of argument matchers!
2 matchers expected, 1 recorded.
This exception may occur if matchers are combined with raw values:
    //incorrect:
    someMethod(anyObject(), "raw String");
When using matchers, all arguments have to be provided by matchers.

Regla: si usas un matcher en un argumento, TODOS los argumentos deben ser matchers.

La corrección es envolver los valores literales en eq():

// CORRECTO
verify(avisos).avisar(eq(marta), anyString());

// También correcto: ningún matcher
verify(avisos).avisar(marta, "Tu préstamo vence en 2 días");

// También correcto: todos matchers
verify(avisos).avisar(any(Empleado.class), anyString());

Por qué ocurre: los matchers no son valores, son efectos secundarios sobre una pila interna. Cuando escribes anyString(), Mockito apila un matcher y devuelve null. Al procesar la invocación, compara el número de matchers apilados con el número de argumentos del método. Si no coinciden, no puede saber cuál corresponde a cuál, y falla.

Consecuencia práctica y desconcertante: el error puede aparecer en la línea siguiente, o incluso en la prueba siguiente, porque la pila queda contaminada. Si ves un InvalidUseOfMatchersException en un sitio donde no usas matchers, mira la prueba anterior.

  1. ArgumentCaptor

verify con matchers responde a "¿se llamó con algo así?". ArgumentCaptor responde a "¿con qué exactamente se llamó?".

@ExtendWith(MockitoExtension.class)
class ServicioAvisosVencimientoTest {

    @Mock private PrestamoRepository prestamos;
    @Mock private ServicioAvisos avisos;

    @Captor private ArgumentCaptor<String> capturadorMensaje;
    @Captor private ArgumentCaptor<Empleado> capturadorEmpleado;

    @Test
    void elMensajeDeAvisoIncluyeElTituloYLosDiasRestantes() {

        Prestamo prestamo = prestamoQueVence(HOY.plusDays(2), marta(), "Java Efectivo");
        when(prestamos.buscarActivos()).thenReturn(List.of(prestamo));

        servicio.enviarAvisosDelDia();

        // Capturar los argumentos REALES de la llamada
        verify(avisos).avisar(capturadorEmpleado.capture(), capturadorMensaje.capture());

        assertThat(capturadorEmpleado.getValue().getCorreo())
                .isEqualTo("[email protected]");
        assertThat(capturadorMensaje.getValue())
                .contains("Java Efectivo")
                .contains("2 días")
                .doesNotContain("null");        // el clásico bug de concatenación
    }
}

Ese doesNotContain("null") merece un comentario: es una comprobación pequeña que detecta uno de los bugs más frecuentes y más visibles para el usuario final — un correo que dice "Tu préstamo de null vence pronto".

Capturar varias llamadas:

@Test
void seAvisaAcadaEmpleadoConPrestamoProximoAVencer() {

    when(prestamos.buscarActivos()).thenReturn(List.of(
            prestamoQueVence(HOY.plusDays(2), marta(), "Java Efectivo"),
            prestamoQueVence(HOY.plusDays(2), diego(), "Patrones de Diseño"),
            prestamoQueVence(HOY.plusDays(9), nuria(), "Refactorización")));   // aún no toca

    servicio.enviarAvisosDelDia();

    verify(avisos, times(2)).avisar(capturadorEmpleado.capture(), capturadorMensaje.capture());

    assertThat(capturadorEmpleado.getAllValues())
            .extracting(Empleado::getCorreo)
            .containsExactly("[email protected]",
                             "[email protected]");

    assertThat(capturadorMensaje.getAllValues())
            .allSatisfy(mensaje -> assertThat(mensaje).contains("2 días"));
}

Nota: getValue() devuelve la última captura; getAllValues() devuelve todas en orden.

ArgumentCaptor frente a argThat:

ArgumentCaptor argThat
Cuándo se comprueba Después de verify Durante verify
Mensaje de error Claro: dice qué valor había Pobre: "no hubo llamada coincidente"
Aserciones complejas Sí, con AssertJ completo Limitado a un predicado
Verbosidad Mayor Menor

Para comprobaciones no triviales, prefiere ArgumentCaptor, precisamente por los mensajes de error. Con argThat, cuando falla solo sabes que ninguna llamada cumplió el predicado; con el captor, ves exactamente qué llegó.

  1. Espías con @Spy

Un espía envuelve un objeto real: por defecto ejecuta el código real, y puedes sustituir métodos concretos.

@ExtendWith(MockitoExtension.class)
class CalculadoraMultasTest {

    @Spy private CalculadoraMultas calculadora =
            new CalculadoraMultas(propiedadesDePrueba(), RELOJ_FIJO);

    @Test
    void ejemplo() {
        // Comportamiento REAL por defecto
        assertThat(calculadora.calcular(prestamoConRetraso(5))).isEqualByComparingTo("2.50");

        // Sustituir SOLO un método (nota: do... obligatorio, apartado 10)
        doReturn(new BigDecimal("999.99")).when(calculadora).calcular(any());
        assertThat(calculadora.calcular(prestamoConRetraso(5))).isEqualByComparingTo("999.99");
    }
}

Casos de uso legítimos:

  1. Código heredado que no se puede refactorizar y hay que aislar un método.
  2. Verificar llamadas sobre un objeto real sin sustituir su comportamiento.
  3. Sustituir un único método caro de una clase por lo demás barata.

Y la advertencia, que es seria:

Necesitar un espía sobre tu propia clase casi siempre indica un problema de diseño.

Si tienes que sustituir un método de la clase que estás probando, esa clase hace dos cosas: la que pruebas y la que sustituyes. La solución correcta es extraer la segunda a un colaborador e inyectarla. Entonces es un mock normal, y el diseño ha mejorado.

Dos trampas técnicas de los espías, además de la de when (apartado 10):

// TRAMPA: el espía es una COPIA, no el objeto original
List<String> original = new ArrayList<>();
List<String> espia = spy(original);

espia.add("Java Efectivo");
assertThat(espia).hasSize(1);       // ok
assertThat(original).isEmpty();     // ¡el original NO cambió!
// TRAMPA: las llamadas internas NO pasan por el espía
// Es exactamente el problema del proxy de 11-02 §26.
// Si metodoA() llama a this.metodoB(), sustituir metodoB no afecta a metodoA.

Esa segunda es la misma trampa de la llamada interna de @Transactional. El mecanismo es idéntico: un proxy solo intercepta lo que pasa por él.

  1. mockStatic y por qué es una señal de alarma

Desde Mockito 3.4 se pueden simular métodos estáticos:

@Test
void ejemploConEstatico() {
    try (MockedStatic<LocalDate> fecha = mockStatic(LocalDate.class)) {
        fecha.when(LocalDate::now).thenReturn(LocalDate.of(2026, 3, 20));

        // dentro de este bloque, LocalDate.now() devuelve la fecha fija
        assertThat(LocalDate.now()).isEqualTo(LocalDate.of(2026, 3, 20));
    }
    // fuera, comportamiento normal
}

El try-with-resources (06-06) es obligatorio: la simulación es por hilo y hay que deshacerla, o contaminará las pruebas siguientes.

Y ahora la parte importante:

Necesitar mockStatic es casi siempre la señal de que hay un problema de diseño.

Si tienes que simular LocalDate.now(), es que tu código lo llama directamente en vez de usar un Clock inyectado. La solución no es el mock estático: es inyectar el Clock, exactamente lo que hiciste en 10-05 y cobraste en 11-04.

Comparación honesta del mismo problema:

// Opción A: mockStatic. Funciona, y arrastra problemas.
try (MockedStatic<LocalDate> f = mockStatic(LocalDate.class)) {
    f.when(LocalDate::now).thenReturn(LocalDate.of(2026, 3, 20));
    assertThat(calculadora.calcular(prestamo)).isEqualByComparingTo("2.50");
}

// Opción B: Clock inyectado. El diseño elimina la necesidad del mock.
var calculadora = new CalculadoraMultas(propiedades, Clock.fixed(...));
assertThat(calculadora.calcular(prestamo)).isEqualByComparingTo("2.50");
mockStatic Clock inyectado
Cambio en producción Ninguno Un parámetro más
Legibilidad de la prueba Peor Mejor
Rendimiento Lento (instrumenta la clase) Nulo
Riesgo de contaminar otras pruebas Sí, si olvidas cerrar No
Funciona en paralelo Con cuidado
Mejora el diseño No

Cuándo mockStatic es aceptable: código heredado que no puedes cambiar, o una utilidad estática de terceros que no tiene alternativa inyectable. En código nuevo, arregla el diseño.

  1. Qué NO se debe mockear

Una lista corta que evita mucho dolor:

No mockees Por qué Qué hacer
Tipos que no controlas Simulas tu creencia sobre cómo se comportan, no cómo se comportan. Si te equivocas, la prueba pasa y el código falla Envolverlos en una interfaz propia y mockear esa
Clases de valor (String, LocalDate, BigDecimal, tus record) Son baratas de crear y su comportamiento es el que quieres Usar instancias reales
El framework Mockear el EntityManager prueba tu idea de JPA, no JPA Prueba de integración con H2
Colecciones del JDK Un List mockeado es más frágil y más lento que un ArrayList Usar el real
La clase bajo prueba Si tienes que mockear parte de ella, hace demasiado Extraer un colaborador
Objetos de datos sin lógica No hay nada que simular Construirlos

El primero merece desarrollo, porque es el más sutil. Imagina que mockeas HttpClient de Java 11 directamente:

@Mock private HttpClient http;

when(http.send(any(), any())).thenReturn(respuestaSimulada);

Esa prueba pasa. Pero se apoya en tu suposición de que send lanza IOException en determinado caso, que la respuesta tiene ese formato exacto, que los códigos de estado llegan de cierta manera. Si te equivocas en cualquiera de esas suposiciones, la prueba estará verde mientras el código falla en producción. Es el peor resultado posible: confianza injustificada.

La alternativa correcta: envolver la dependencia externa en una interfaz propia que expresa lo que tu dominio necesita, y mockear esa. Es lo que se hace en el apartado 29 con ClienteMetadatos.

  1. El riesgo del mock excesivo

Mira esta prueba, que parece minuciosa:

@Test
void prestar() {
    when(materiales.findByIsbn("978-0000000001")).thenReturn(Optional.of(libro));
    when(empleados.findByCorreo("[email protected]")).thenReturn(Optional.of(marta));
    when(prestamos.countByEmpleadoAndEstado(marta, ACTIVO)).thenReturn(0L);
    when(propiedades.prestamo()).thenReturn(new Prestamo(15, 3));
    when(calculadora.calcular(any())).thenReturn(BigDecimal.ZERO);
    when(prestamos.save(any())).thenReturn(prestamoGuardado);

    gestor.prestar("978-0000000001", "[email protected]");

    verify(materiales).findByIsbn("978-0000000001");
    verify(empleados).findByCorreo("[email protected]");
    verify(prestamos).countByEmpleadoAndEstado(marta, ACTIVO);
    verify(prestamos).save(any());
    verify(avisos).avisar(any(), anyString());
    verifyNoMoreInteractions(materiales, empleados, prestamos, avisos);
}

Es un ejemplo de manual de prueba frágil. Sus problemas:

  1. Describe la implementación, no el comportamiento. Es una transcripción del cuerpo del método.
  2. Se rompe al refactorizar. Combinar dos consultas en una, cachear el empleado, cambiar el orden: todo la rompe, sin que el comportamiento cambie.
  3. No comprueba el resultado. Ni una aserción sobre el préstamo devuelto. Podría devolver null y la prueba pasaría.
  4. Mockea cosas que no debería. propiedades es un record inmutable y calculadora es una función pura: los dos son baratos de usar de verdad.
  5. Es ilegible. Doce líneas de configuración para probar un caso.
  6. Da falsa confianza. Está verde, y no garantiza casi nada.

Y el problema más profundo: si el método está mal, la prueba sigue en verde. Puede calcular mal el vencimiento, poner el estado equivocado o asignar el material incorrecto. Nada de eso se comprueba.

La misma prueba, bien:

@Test
void prestarCreaUnPrestamoActivoConVencimientoAQuinceDias() {

    when(materiales.findByIsbn("978-0000000001")).thenReturn(Optional.of(libro));
    when(empleados.findByCorreo("[email protected]")).thenReturn(Optional.of(marta));
    when(prestamos.save(any(Prestamo.class))).thenAnswer(inv -> inv.getArgument(0));

    Prestamo prestamo = gestor.prestar("978-0000000001", "[email protected]");

    // COMPORTAMIENTO: qué se produjo
    assertThat(prestamo)
            .satisfies(p -> {
                assertThat(p.getMaterial()).isEqualTo(libro);
                assertThat(p.getEmpleado()).isEqualTo(marta);
                assertThat(p.getEstado()).isEqualTo(EstadoPrestamo.ACTIVO);
                assertThat(p.getFechaVencimiento()).isEqualTo(LocalDate.of(2026, 4, 4));
            });

    // EFECTOS observables (comandos)
    verify(prestamos).save(prestamo);
    verify(avisos).avisar(eq(marta), contains("Java Efectivo"));
}

Más corta, más legible, comprueba lo que importa y sobrevive a cualquier refactorización que no cambie el comportamiento.

  1. Pruebas basadas en estado frente a basadas en interacción

Dos filosofías, y conviene tenerlas claras:

Basada en estado Basada en interacción
Qué comprueba El resultado y el estado final Las llamadas a los colaboradores
Herramienta Aserciones (AssertJ) verify de Mockito
Acoplamiento a la implementación Bajo Alto
Resistencia al refactorizar Alta Baja
Mensajes de error Claros: "esperaba X, había Y" Peores: "no hubo esta llamada"
Cuándo es la única opción Efectos sin retorno

Recomendación: prefiere las pruebas basadas en estado. Usa las basadas en interacción solo cuando el efecto no sea observable de otra forma.

Casos donde la interacción es la única opción:

  • Enviar un correo o una notificación.
  • Publicar un evento o un mensaje en una cola.
  • Registrar en un sistema de auditoría externo.
  • Comprobar que algo no ocurrió (never()).
  • Comprobar que se llamó exactamente una vez (idempotencia, evitar duplicados).

  1. El mismo caso, resuelto de las dos formas

Requisito de BiblioTech: "Al devolver un préstamo con retraso, se registra la multa correspondiente."

Versión basada en interacción

@Test
void devolverConRetrasoRegistraLaMulta_interaccion() {

    Prestamo vencido = prestamoVencidoHace(10);
    when(prestamos.findById(1L)).thenReturn(Optional.of(vencido));
    when(calculadora.calcular(vencido)).thenReturn(new BigDecimal("5.00"));

    gestor.devolver(1L);

    verify(calculadora).calcular(vencido);
    verify(multas).registrar(eq(vencido), eq(new BigDecimal("5.00")));
}

Problemas: depende de que se use CalculadoraMultas y de que el registro sea con esos dos argumentos. Si mañana el cálculo se mueve a la entidad Prestamo —una refactorización muy razonable—, el comportamiento es idéntico y la prueba se rompe. Y no comprueba si la multa registrada es correcta: comprueba que se pasó el valor que el propio mock devolvió, que es circular.

Versión basada en estado

@Test
void devolverConRetrasoRegistraLaMulta_estado() {

    // Colaboradores REALES para lo que es barato y determinista
    var calculadora = new CalculadoraMultas(propiedadesDePrueba(), RELOJ_FIJO);
    var multasEnMemoria = new RegistroMultasEnMemoria();          // fake
    var gestor = new GestorPrestamos(prestamos, calculadora, multasEnMemoria, avisos, RELOJ_FIJO);

    Prestamo vencido = prestamoVencidoHace(10);
    when(prestamos.findById(1L)).thenReturn(Optional.of(vencido));

    gestor.devolver(1L);

    // Se comprueba el ESTADO resultante
    assertThat(multasEnMemoria.deEmpleado(vencido.getEmpleado()))
            .singleElement()
            .satisfies(multa -> {
                assertThat(multa.importe()).isEqualByComparingTo("5.00");
                assertThat(multa.prestamo()).isEqualTo(vencido);
            });

    assertThat(vencido.getEstado()).isEqualTo(EstadoPrestamo.DEVUELTO);
}

Ventajas: comprueba que la multa vale realmente 5,00 €, calculada por el código real. Sobrevive a que el cálculo se mueva de sitio. Y comprueba también el estado del préstamo, que la otra ignoraba.

La recomendación

Situación Enfoque
Hay un valor de retorno Estado: aserciones sobre él
Hay un estado final observable Estado: con un fake si hace falta
El colaborador es barato y determinista (calculadora, record, Clock) Usarlo de verdad, no mockearlo
El efecto es externo (correo, evento, cola) Interacción: verify
Hay que comprobar que algo no pasó Interacción: never()
El colaborador es lento, no determinista o falla Mock para controlarlo

En la práctica, una buena prueba mezcla: mockea lo que estorba, usa lo real para lo barato, comprueba el estado y verifica solo los efectos externos.

  1. Cuando el diseño elimina la necesidad del mock

La lección más valiosa de todo el módulo de pruebas, y ya la conoces.

El caso del reloj:

// Sin Clock: hace falta mockear un método estático
try (MockedStatic<LocalDate> f = mockStatic(LocalDate.class)) {
    f.when(LocalDate::now).thenReturn(LocalDate.of(2026, 3, 20));
    // ... prueba lenta, frágil y con riesgo de contaminación
}

// Con Clock inyectado: NO hace falta ningún mock
var calculadora = new CalculadoraMultas(propiedades, Clock.fixed(...));

El diseño eliminó la necesidad del doble. No es un truco de pruebas: es que un Clock inyectado es mejor diseño, y la facilidad de prueba es una consecuencia.

Este patrón se repite:

Problema Solución con mock Solución de diseño
El tiempo mockStatic(LocalDate.class) Clock inyectado
El azar mockStatic(Math.class) Random inyectado con semilla
Los identificadores mockStatic(UUID.class) Un GeneradorIds inyectado
La configuración Mockear ReglasNegocio (imposible: es estático) @ConfigurationProperties inyectado (11-02)
Un cálculo dentro de un servicio Mockear el propio servicio con @Spy Extraerlo a una función pura
La base de datos Mockear el repositorio Un fake en memoria reutilizable

La heurística general:

Antes de escribir un mock, pregúntate si el problema es que el diseño necesita cambiar. Si un colaborador es difícil de simular, muchas veces es porque no debería ser un colaborador.

Mockito es una herramienta excelente. Y como toda herramienta potente, puede usarse para trabajar alrededor de un problema en lugar de resolverlo.

  1. Combinar Mockito con AssertJ

Se complementan perfectamente: Mockito controla y captura, AssertJ comprueba.

@Test
void elAvisoContieneTodosLosDatosDelPrestamo() {

    when(prestamos.buscarActivos()).thenReturn(List.of(prestamoDeMarta(), prestamoDeDiego()));

    servicio.enviarAvisosDelDia();

    ArgumentCaptor<Aviso> captor = ArgumentCaptor.forClass(Aviso.class);
    verify(avisos, times(2)).enviar(captor.capture());

    // AssertJ sobre lo capturado: expresivo y con buenos mensajes de error
    assertThat(captor.getAllValues())
            .hasSize(2)
            .extracting(Aviso::destinatario, Aviso::asunto)
            .containsExactly(
                    tuple("[email protected]",  "Tu préstamo vence en 2 días"),
                    tuple("[email protected]","Tu préstamo vence en 2 días"));

    assertThat(captor.getAllValues())
            .allSatisfy(aviso -> {
                assertThat(aviso.cuerpo()).doesNotContain("null");
                assertThat(aviso.destinatario()).endsWith("@nexussoftware.com");
            });
}

AssertJ ofrece además assertThatThrownBy combinado con thenThrow:

when(prestamos.save(any())).thenThrow(new DataAccessResourceFailureException("Sin conexión"));

assertThatThrownBy(() -> gestor.prestar("978-0000000001", "[email protected]"))
        .isInstanceOf(BiblioTechException.class)                  // se traduce la excepción
        .hasCauseInstanceOf(DataAccessResourceFailureException.class)
        .hasMessageContaining("978-0000000001");

Esa prueba verifica la estrategia de manejo de errores por capas del módulo 6: la excepción técnica se envuelve en una de dominio, conservando la causa.

  1. BiblioTech: GestorPrestamos con repositorios simulados

La prueba completa, aplicando todo el criterio de la lección:

package com.nexussoftware.bibliotech.servicio;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;

import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import org.junit.jupiter.api.*;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
@DisplayName("GestorPrestamos")
class GestorPrestamosTest {

    private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");
    private static final LocalDate HOY = LocalDate.of(2026, 3, 20);
    private static final Clock RELOJ = Clock.fixed(HOY.atStartOfDay(MADRID).toInstant(), MADRID);

    @Mock private MaterialRepository materiales;
    @Mock private EmpleadoRepository empleados;
    @Mock private PrestamoRepository prestamos;
    @Mock private ServicioAvisos avisos;

    private GestorPrestamos gestor;
    private Material libro;
    private Empleado marta;

    @BeforeEach
    void preparar() {
        // Propiedades y calculadora REALES: son baratas, deterministas y sin efectos
        var propiedades = new PropiedadesBiblioTech(
                "BiblioTech",
                new PropiedadesBiblioTech.Prestamo(15, 3),
                new PropiedadesBiblioTech.Multa(new BigDecimal("0.50"), new BigDecimal("20.00")),
                new PropiedadesBiblioTech.Reserva(3));
        var calculadora = new CalculadoraMultas(propiedades, RELOJ);

        gestor = new GestorPrestamos(materiales, empleados, prestamos, calculadora,
                                     avisos, propiedades, RELOJ);

        libro = new Libro("978-0000000001", "Java Efectivo", 3, "J. Bloch");
        marta = new Empleado("[email protected]", "Marta Ruiz");
    }

    @Nested
    @DisplayName("al prestar")
    class Prestar {

        @Test
        @DisplayName("crea un préstamo activo con vencimiento a 15 días")
        void creaPrestamoCorrecto() {
            when(materiales.findByIsbn("978-0000000001")).thenReturn(Optional.of(libro));
            when(empleados.findByCorreo(marta.getCorreo())).thenReturn(Optional.of(marta));
            when(prestamos.save(any(Prestamo.class))).thenAnswer(inv -> inv.getArgument(0));

            Prestamo prestamo = gestor.prestar("978-0000000001", marta.getCorreo());

            assertThat(prestamo.getMaterial()).isEqualTo(libro);
            assertThat(prestamo.getEmpleado()).isEqualTo(marta);
            assertThat(prestamo.getEstado()).isEqualTo(EstadoPrestamo.ACTIVO);
            assertThat(prestamo.getFechaPrestamo()).isEqualTo(HOY);
            assertThat(prestamo.getFechaVencimiento()).isEqualTo(HOY.plusDays(15));
        }

        @Test
        @DisplayName("decrementa los ejemplares disponibles del material")
        void decrementaEjemplares() {
            when(materiales.findByIsbn("978-0000000001")).thenReturn(Optional.of(libro));
            when(empleados.findByCorreo(marta.getCorreo())).thenReturn(Optional.of(marta));
            when(prestamos.save(any(Prestamo.class))).thenAnswer(inv -> inv.getArgument(0));

            gestor.prestar("978-0000000001", marta.getCorreo());

            assertThat(libro.getEjemplaresDisponibles()).isEqualTo(2);   // estado real
        }

        @Test
        @DisplayName("falla si el material no existe, y no guarda nada")
        void materialInexistente() {
            when(materiales.findByIsbn("978-9999999999")).thenReturn(Optional.empty());

            assertThatThrownBy(() -> gestor.prestar("978-9999999999", marta.getCorreo()))
                    .isInstanceOf(MaterialNoEncontradoException.class)
                    .hasMessageContaining("978-9999999999");

            verify(prestamos, never()).save(any());
            verifyNoInteractions(avisos);
        }

        @Test
        @DisplayName("falla si el material no tiene ejemplares disponibles")
        void sinEjemplares() {
            Material agotado = new Libro("978-0000000003", "Refactorización", 0, "M. Fowler");
            when(materiales.findByIsbn("978-0000000003")).thenReturn(Optional.of(agotado));
            when(empleados.findByCorreo(marta.getCorreo())).thenReturn(Optional.of(marta));

            assertThatThrownBy(() -> gestor.prestar("978-0000000003", marta.getCorreo()))
                    .isInstanceOf(SinEjemplaresException.class);

            verify(prestamos, never()).save(any());
        }

        @Test
        @DisplayName("falla si el empleado ha alcanzado el límite de 3 préstamos")
        void limiteAlcanzado() {
            when(materiales.findByIsbn("978-0000000001")).thenReturn(Optional.of(libro));
            when(empleados.findByCorreo(marta.getCorreo())).thenReturn(Optional.of(marta));
            when(prestamos.countByEmpleadoAndEstado(marta, EstadoPrestamo.ACTIVO))
                    .thenReturn(3L);

            assertThatThrownBy(() -> gestor.prestar("978-0000000001", marta.getCorreo()))
                    .isInstanceOf(LimitePrestamosException.class)
                    .hasMessageContaining(marta.getCorreo());

            verify(prestamos, never()).save(any());
            assertThat(libro.getEjemplaresDisponibles()).isEqualTo(3);   // sin efectos
        }
    }

    @Nested
    @DisplayName("ante fallos de infraestructura")
    class Fallos {

        @Test
        @DisplayName("si el guardado falla, no se avisa a nadie")
        void fallaElGuardado() {
            when(materiales.findByIsbn("978-0000000001")).thenReturn(Optional.of(libro));
            when(empleados.findByCorreo(marta.getCorreo())).thenReturn(Optional.of(marta));
            when(prestamos.save(any()))
                    .thenThrow(new DataAccessResourceFailureException("Conexión perdida"));

            assertThatThrownBy(() -> gestor.prestar("978-0000000001", marta.getCorreo()))
                    .isInstanceOf(DataAccessResourceFailureException.class);

            verifyNoInteractions(avisos);   // requisito: no se notifica lo no persistido
        }

        @Test
        @DisplayName("reintenta ante un conflicto de concurrencia y acaba con éxito")
        void reintentaAnteConflicto() {
            when(materiales.findByIsbn("978-0000000001")).thenReturn(Optional.of(libro));
            when(empleados.findByCorreo(marta.getCorreo())).thenReturn(Optional.of(marta));
            when(prestamos.save(any()))
                    .thenThrow(new OptimisticLockingFailureException("versión obsoleta"))
                    .thenAnswer(inv -> inv.getArgument(0));   // segunda vez, éxito

            Resultado<Prestamo> resultado =
                    gestor.prestarConReintento("978-0000000001", marta.getCorreo());

            assertThat(resultado.esExito()).isTrue();
            verify(prestamos, times(2)).save(any());   // se intentó dos veces
        }
    }
}

Decisiones que conviene subrayar:

  • CalculadoraMultas y PropiedadesBiblioTech son reales. Son baratas, deterministas y sin efectos: mockearlas solo añadiría ruido y debilitaría la prueba.
  • Nunca se verifica findByIsbn ni findByCorreo. Son consultas. Que se llamaron ya está implícito en que el resultado es correcto.
  • Sí se verifica save y avisar. Son comandos con efecto.
  • never() y verifyNoInteractions en los casos de fallo. Comprobar ausencias es donde verify aporta valor único.
  • La prueba del reintento solo es posible con thenThrow().thenAnswer(). Es el escenario del bloqueo optimista de 11-03, imposible de provocar de otra forma.

  1. BiblioTech: ServicioAvisos verificado con ArgumentCaptor

@ExtendWith(MockitoExtension.class)
@DisplayName("Avisos de vencimiento")
class ServicioAvisosVencimientoTest {

    private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");
    private static final LocalDate HOY = LocalDate.of(2026, 3, 20);
    private static final Clock RELOJ = Clock.fixed(HOY.atStartOfDay(MADRID).toInstant(), MADRID);

    @Mock private PrestamoRepository prestamos;
    @Mock private ServicioAvisos avisos;

    @Captor private ArgumentCaptor<Empleado> capturadorEmpleado;
    @Captor private ArgumentCaptor<String> capturadorMensaje;

    private ServicioAvisosVencimiento servicio;

    @BeforeEach
    void preparar() {
        servicio = new ServicioAvisosVencimiento(prestamos, avisos, propiedadesConAntelacion(2), RELOJ);
    }

    @Test
    @DisplayName("el mensaje incluye el título del material y los días restantes")
    void contenidoDelMensaje() {
        when(prestamos.buscarActivos()).thenReturn(List.of(
                prestamo("Java Efectivo", marta(), HOY.plusDays(2))));

        servicio.enviarAvisosDelDia();

        verify(avisos).avisar(capturadorEmpleado.capture(), capturadorMensaje.capture());

        assertThat(capturadorEmpleado.getValue().getCorreo())
                .isEqualTo("[email protected]");

        assertThat(capturadorMensaje.getValue())
                .contains("Java Efectivo")
                .contains("2 días")
                .doesNotContain("null")          // bug clásico de concatenación
                .doesNotContain("@{");           // plantilla sin sustituir
    }

    @Test
    @DisplayName("avisa solo a los empleados cuyo préstamo vence en exactamente 2 días")
    void ventanaDeAviso() {
        when(prestamos.buscarActivos()).thenReturn(List.of(
                prestamo("Java Efectivo",      marta(), HOY.plusDays(2)),   // SÍ
                prestamo("Patrones de Diseño", diego(), HOY.plusDays(2)),   // SÍ
                prestamo("Refactorización",    nuria(), HOY.plusDays(1)),   // no
                prestamo("Java Efectivo",      nuria(), HOY.plusDays(3)),   // no
                prestamo("Refactorización",    diego(), HOY.minusDays(5))));// no

        servicio.enviarAvisosDelDia();

        verify(avisos, times(2)).avisar(capturadorEmpleado.capture(), anyString());

        assertThat(capturadorEmpleado.getAllValues())
                .extracting(Empleado::getCorreo)
                .containsExactly("[email protected]",
                                 "[email protected]");
    }

    @Test
    @DisplayName("un canal caído no impide los avisos restantes")
    void unFalloNoDetieneElResto() {
        when(prestamos.buscarActivos()).thenReturn(List.of(
                prestamo("Java Efectivo",      marta(), HOY.plusDays(2)),
                prestamo("Patrones de Diseño", diego(), HOY.plusDays(2)),
                prestamo("Refactorización",    nuria(), HOY.plusDays(2))));

        // El segundo aviso falla
        doNothing()
                .doThrow(new ServicioAvisosException("SMTP no responde"))
                .doNothing()
                .when(avisos).avisar(any(), anyString());

        int enviados = servicio.enviarAvisosDelDia();

        assertThat(enviados).isEqualTo(2);                       // 3 intentos, 2 con éxito
        verify(avisos, times(3)).avisar(any(), anyString());     // se intentaron los 3
    }
}

Esa última prueba es un ejemplo excelente de lo que solo Mockito permite: encadenar doNothing().doThrow().doNothing() para simular que la segunda de tres llamadas falla. Verifica la estrategia de resiliencia del módulo 6 —un fallo parcial no debe abortar el proceso completo— y provocarla de otra forma sería inviable.

  1. BiblioTech: ClienteMetadatos probado sin red

El ClienteMetadatos de 09-06 llama a una API externa con HttpClient. Probarlo tal cual significa depender de la red.

Primero, el diseño correcto (recuerda el apartado 21: no mockees HttpClient directamente):

package com.nexussoftware.bibliotech.red;

// Interfaz PROPIA que expresa lo que el dominio necesita
public interface PasarelaMetadatos {
    Optional<MetadatosLibro> buscarPorIsbn(String isbn);
}

// Implementación real, que sí usa HttpClient
@Component
public class PasarelaMetadatosHttp implements PasarelaMetadatos {

    private final HttpClient http;
    private final String urlBase;

    public PasarelaMetadatosHttp(HttpClient http,
                                 @Value("${bibliotech.metadatos.url}") String urlBase) {
        this.http = http;
        this.urlBase = urlBase;
    }

    @Override
    public Optional<MetadatosLibro> buscarPorIsbn(String isbn) { /* HTTP real */ }
}

Y el servicio que la usa:

@Service
public class EnriquecedorCatalogo {

    private final PasarelaMetadatos pasarela;     // la INTERFAZ, no HttpClient
    private final MaterialRepository materiales;

    public EnriquecedorCatalogo(PasarelaMetadatos pasarela, MaterialRepository materiales) {
        this.pasarela = pasarela;
        this.materiales = materiales;
    }

    public int enriquecerCatalogo() {
        int enriquecidos = 0;
        for (Material material : materiales.findByAutorIsNull()) {
            try {
                Optional<MetadatosLibro> metadatos = pasarela.buscarPorIsbn(material.getIsbn());
                if (metadatos.isPresent()) {
                    material.completarCon(metadatos.get());
                    materiales.save(material);
                    enriquecidos++;
                }
            } catch (PasarelaNoDisponibleException e) {
                log.warn("No se pudo enriquecer {}: {}", material.getIsbn(), e.getMessage());
                // continúa con el siguiente
            }
        }
        return enriquecidos;
    }
}

Ahora la prueba, sin una sola conexión de red:

@ExtendWith(MockitoExtension.class)
@DisplayName("Enriquecimiento del catálogo")
class EnriquecedorCatalogoTest {

    @Mock private PasarelaMetadatos pasarela;
    @Mock private MaterialRepository materiales;

    private EnriquecedorCatalogo enriquecedor;

    @BeforeEach
    void preparar() { enriquecedor = new EnriquecedorCatalogo(pasarela, materiales); }

    @Test
    @DisplayName("completa el autor de los materiales que lo tienen vacío")
    void completaLosDatos() {
        Material sinAutor = new Libro("978-0000000001", "Java Efectivo", 3, null);
        when(materiales.findByAutorIsNull()).thenReturn(List.of(sinAutor));
        when(pasarela.buscarPorIsbn("978-0000000001"))
                .thenReturn(Optional.of(new MetadatosLibro("Joshua Bloch", 2018, "Addison-Wesley")));

        int enriquecidos = enriquecedor.enriquecerCatalogo();

        assertThat(enriquecidos).isEqualTo(1);
        assertThat(sinAutor.getAutor()).isEqualTo("Joshua Bloch");
        verify(materiales).save(sinAutor);
    }

    @Test
    @DisplayName("no guarda nada si la API no conoce el ISBN")
    void isbnDesconocido() {
        Material sinAutor = new Libro("978-0000000099", "Título raro", 1, null);
        when(materiales.findByAutorIsNull()).thenReturn(List.of(sinAutor));
        when(pasarela.buscarPorIsbn(anyString())).thenReturn(Optional.empty());

        assertThat(enriquecedor.enriquecerCatalogo()).isZero();
        verify(materiales, never()).save(any());
    }

    @Test
    @DisplayName("si la API está caída, continúa con el resto del catálogo")
    void apiCaidaParcialmente() {
        Material uno = new Libro("978-0000000001", "Java Efectivo", 3, null);
        Material dos = new Libro("978-0000000002", "Patrones de Diseño", 2, null);
        when(materiales.findByAutorIsNull()).thenReturn(List.of(uno, dos));

        when(pasarela.buscarPorIsbn("978-0000000001"))
                .thenThrow(new PasarelaNoDisponibleException("504 Gateway Timeout"));
        when(pasarela.buscarPorIsbn("978-0000000002"))
                .thenReturn(Optional.of(new MetadatosLibro("GoF", 1994, "Addison-Wesley")));

        assertThat(enriquecedor.enriquecerCatalogo()).isEqualTo(1);
        assertThat(dos.getAutor()).isEqualTo("GoF");
        verify(materiales, times(1)).save(dos);
    }

    @Test
    @DisplayName("no llama a la API si no hay materiales incompletos")
    void catalogoCompleto() {
        when(materiales.findByAutorIsNull()).thenReturn(List.of());

        assertThat(enriquecedor.enriquecerCatalogo()).isZero();
        verifyNoInteractions(pasarela);      // ni una petición innecesaria
    }
}

Cuatro pruebas, milisegundos, sin red, y cubriendo escenarios —la API caída, un ISBN desconocido, un fallo parcial— que con la API real serían impredecibles o imposibles.

Y para probar PasarelaMetadatosHttp en sí misma, que es la clase que habla HTTP, la herramienta correcta no es Mockito sino WireMock: un servidor HTTP falso que responde lo que le programes. Eso es una prueba de integración y pertenece a 12-05.

  1. Pruebas de integración: @SpringBootTest y @MockitoBean

Las pruebas unitarias no lo cubren todo. Falta comprobar que las piezas encajan: que Spring inyecta lo correcto, que la configuración se lee, que las transacciones funcionan.

@SpringBootTest
@ActiveProfiles("test")
class GestorPrestamosIT {          // sufijo IT: la ejecuta failsafe (11-05)

    @Autowired private GestorPrestamos gestor;
    @Autowired private MaterialRepository materiales;

    // Sustituye el bean real en el contexto de Spring por un mock
    @MockitoBean private ServicioAvisos avisos;

    @Test
    void prestarPersisteYAvisa() {
        materiales.save(new Libro("978-0000000001", "Java Efectivo", 3, "J. Bloch"));

        Prestamo prestamo = gestor.prestar("978-0000000001", "[email protected]");

        assertThat(prestamo.getId()).isNotNull();      // se persistió de verdad
        verify(avisos).avisar(any(), contains("Java Efectivo"));   // no se envió correo real
    }
}

Sobre el nombre de la anotación: @MockitoBean es la de Spring Boot 3.4 en adelante; en versiones anteriores era @MockBean, hoy obsoleta. Hacen lo mismo: reemplazar un bean del contexto por un mock.

Diferencia clave con @Mock:

@Mock @MockitoBean
Ámbito Un objeto en la prueba Un bean del contexto de Spring
Necesita contexto No
Velocidad Milisegundos Segundos
Uso Pruebas unitarias Pruebas de integración

Aviso de rendimiento poco conocido: cada combinación distinta de @MockitoBean crea un contexto de Spring nuevo. Spring cachea los contextos entre clases de prueba, pero si cada clase mockea beans distintos, se arrancan muchos contextos y el conjunto se vuelve lentísimo. Es una de las causas más frecuentes de "nuestras pruebas tardan quince minutos".

  1. @DataJpaTest con H2

Para probar la capa de persistencia sin arrancar toda la aplicación:

@DataJpaTest                       // solo JPA: repositorios, EntityManager, BD embebida
@Tag("integracion")
class PrestamoRepositoryIT {

    @Autowired private PrestamoRepository repositorio;
    @Autowired private TestEntityManager em;

    @Test
    @DisplayName("encuentra los préstamos vencidos sin devolver, con sus relaciones cargadas")
    void encuentraVencidos() {
        Material libro = em.persist(new Libro("978-0000000001", "Java Efectivo", 3, "J. Bloch"));
        Empleado marta = em.persist(new Empleado("[email protected]", "Marta Ruiz"));

        em.persist(new Prestamo(libro, marta, LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 10)));
        em.persist(new Prestamo(libro, marta, LocalDate.of(2026, 3, 1), LocalDate.of(2026, 4, 10)));
        em.flush();
        em.clear();                // vacía el contexto: fuerza a leer de la BD de verdad

        List<Prestamo> vencidos = repositorio.vencidos(LocalDate.of(2026, 3, 20));

        assertThat(vencidos).hasSize(1);
        // El JOIN FETCH de 11-03 funciona: no hay LazyInitializationException
        assertThat(vencidos.get(0).getMaterial().getTitulo()).isEqualTo("Java Efectivo");
    }
}

Ese em.clear() es un detalle que mucha gente olvida y que hace la diferencia: sin él, las entidades siguen en el contexto de persistencia de primer nivel (11-03) y la consulta las devuelve de la caché sin tocar la base de datos. La prueba pasaría aunque el mapeo estuviera mal.

Y esta prueba verifica algo que una prueba unitaria no puede: que el JPQL es sintácticamente válido, que los nombres de atributo existen, que el JOIN FETCH evita el N+1 y que el mapeo genera el esquema correcto. Todo eso solo se comprueba contra una base de datos real.

Características de @DataJpaTest:

Característica Comportamiento
Contexto Solo la capa JPA (repositorios, entidades, DataSource)
Base de datos Embebida (H2) por defecto
Transacción Una por prueba, con reversión automática al terminar
TestEntityManager Versión simplificada del EntityManager para preparar datos
Servicios y controladores No se cargan

La reversión automática es lo que hace que las pruebas sean independientes: cada una parte de una base de datos limpia sin que tengas que limpiarla.

  1. Testcontainers, presentado

H2 es cómodo para aprender y para el ciclo rápido, pero no es la base de datos de producción. Sus diferencias con PostgreSQL o MySQL son reales: dialectos SQL distintos, comportamiento de las secuencias, tipos, funciones, niveles de aislamiento por defecto. Una consulta nativa que funciona en H2 puede fallar en PostgreSQL.

Testcontainers es la respuesta estándar hoy: arranca la base de datos real en un contenedor Docker, durante la prueba.

@SpringBootTest
@Testcontainers
class PrestamoRepositoryPostgresIT {

    @Container
    @ServiceConnection    // Spring Boot 3.1+: configura el DataSource solo
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired private PrestamoRepository repositorio;

    @Test
    void funcionaContraPostgresDeVerdad() {
        // Se ejecuta contra PostgreSQL 16 real, arrancado y destruido por la prueba
    }
}
H2 en memoria Testcontainers
Velocidad Milisegundos Segundos (el arranque se comparte)
Fidelidad Aproximada Idéntica a producción
Requisitos Ninguno Docker
Cuándo Ciclo rápido, aprendizaje Integración continua, consultas nativas

Sirve también para colas de mensajes, Redis, Elasticsearch o cualquier servicio con imagen Docker. Se desarrolla en 12-05.

  1. Cobertura: interpretación honesta

La cobertura mide qué porcentaje del código ejecutan las pruebas. Con Mockito es fácil inflarla sin probar nada:

@Test
void coberturaAlta() {
    when(materiales.findByIsbn(anyString())).thenReturn(Optional.of(libro));
    when(empleados.findByCorreo(anyString())).thenReturn(Optional.of(marta));
    when(prestamos.save(any())).thenReturn(prestamo);

    gestor.prestar("978-0000000001", "[email protected]");
    // Ni una aserción. Cobertura del método: 100 %. Valor: cero.
}

Una línea ejecutada no es una línea probada.

Lo que la cobertura sí dice:

  • 0 % en un método de negocio es información valiosa: nadie lo ha probado.
  • Una rama sin cubrir señala un camino que nunca se ejercita, a menudo el de error.

Lo que no dice: nada sobre si las aserciones comprueban lo correcto, si los casos límite están cubiertos o si el comportamiento es el esperado.

Y hay un efecto perverso conocido: un objetivo de cobertura alto genera pruebas malas. Si el equipo debe llegar al 90 %, aparecerán pruebas de getters y setters que suben el número sin aportar nada, y nadie escribirá la prueba difícil del caso límite porque cuesta más.

Se desarrolla en 12-05, con JaCoCo configurado y criterios razonables.

  1. Errores Comunes y Consejos

Error: mezclar matchers y valores literales. verify(avisos).avisar(marta, anyString()) lanza InvalidUseOfMatchersException. O todos matchers, o ninguno: eq(marta).

Error: usar when con un método void o con un espía. No compila con void, y con un espía ejecuta el método real. Usa doThrow / doReturn.

Error: verificar consultas. verify(materiales).findByIsbn(...) prueba implementación y produce pruebas frágiles. Verifica comandos.

Error: mockear tipos que no controlas. Simulas tu creencia sobre cómo se comportan. Envuélvelos en una interfaz propia.

Error: mockear clases de valor. String, LocalDate, BigDecimal y tus record se construyen: son baratos y su comportamiento es el que quieres.

Error: verifyNoMoreInteractions por sistema. Cualquier llamada nueva y legítima rompe la prueba.

Error: @InjectMocks con dependencias no simulables. Inyecta null sin avisar y el fallo aparece más tarde como NullPointerException. Construye explícitamente.

Error: no cerrar mockStatic. Contamina las pruebas siguientes. try-with-resources siempre — y antes, pregúntate si el diseño debería cambiar.

Error: pruebas sin ninguna aserción. Con Mockito es fácil escribir una prueba que solo configura y ejecuta. Cobertura alta, valor cero.

Error: silenciar UnnecessaryStubbingException. Está señalando un comportamiento programado que nadie usa: o la prueba no hace lo que crees, o hay código muerto.

Error: usar @SpringBootTest con muchos @MockitoBean distintos. Cada combinación crea un contexto nuevo y el conjunto se vuelve lentísimo.

Consejo: aplica la regla de los comandos y las consultas. Es lo único que necesitas recordar para escribir pruebas con Mockito mucho mejores que la media.

Consejo: usa objetos reales para lo barato. Un record de configuración, una calculadora pura o un Clock.fixed no se mockean: se usan. La prueba es más fuerte y más legible.

Consejo: considera un fake en memoria para el repositorio principal. Se escribe una vez, da coherencia de estado gratis y no se rompe al refactorizar.

Consejo: ArgumentCaptor para comprobaciones no triviales. Sus mensajes de error son mucho mejores que los de argThat.

Consejo: aprovecha thenThrow para los caminos de error. Es la capacidad más valiosa de Mockito y la que cubre el código que en producción se ejecuta el peor día.

Consejo: antes de escribir un mock complicado, pregúntate si el diseño debería cambiar. El Clock de 10-05 eliminó la necesidad de un mock estático. Esa es la mejor solución posible.

Consejo: usa la anotación de prueba más pequeña que sirva. Sin Spring si puede ser; @DataJpaTest si necesitas la base de datos; @SpringBootTest solo cuando de verdad haga falta el contexto completo.

  1. Ejercicios

Ejercicio 1: elegir el doble adecuado

Para cada escenario de BiblioTech, indica qué tipo de doble usarías (dummy, stub, spy, mock, fake, o ninguno) y por qué. Escribe además el fragmento de código de la parte relevante.

  1. Probar que CalculadoraMultas limita la multa a 20 € cuando el retraso es de 200 días.
  2. Probar que al prestar el último ejemplar se envía un aviso al bibliotecario responsable.
  3. Probar que GestorPrestamos no guarda nada si el empleado ha alcanzado el límite.
  4. Probar EstadisticasBiblioTech, que hace ocho consultas distintas al repositorio y agrega los resultados con streams.
  5. Probar que EnriquecedorCatalogo continúa procesando cuando la API externa devuelve un error 503.
  6. Probar que RegistroOperaciones escribe una entrada de auditoría por cada préstamo.
  7. Probar que ProcesadorReservas caduca las reservas de más de 3 días.

Ejercicio 2: arreglar una prueba frágil

Esta prueba está en el proyecto de Nexus Software. Pasa, pero es mala. Identifica al menos seis problemas, explica por qué cada uno lo es, y reescríbela.

@ExtendWith(MockitoExtension.class)
class ProcesadorReservasTest {

    @Mock private ReservaRepository reservas;
    @Mock private GestorPrestamos gestor;
    @Mock private PropiedadesBiblioTech propiedades;
    @Mock private PropiedadesBiblioTech.Reserva propiedadesReserva;
    @Mock private Clock reloj;
    @Mock private Reserva reserva;
    @Mock private Material material;

    @InjectMocks private ProcesadorReservas procesador;

    @Test
    void test() {
        when(propiedades.reserva()).thenReturn(propiedadesReserva);
        when(propiedadesReserva.diasCaducidad()).thenReturn(3);
        when(reloj.instant()).thenReturn(Instant.parse("2026-03-20T10:00:00Z"));
        when(reloj.getZone()).thenReturn(ZoneId.of("Europe/Madrid"));
        when(reservas.pendientes()).thenReturn(List.of(reserva));
        when(reserva.getFechaSolicitud()).thenReturn(LocalDate.of(2026, 3, 10));
        when(reserva.getMaterial()).thenReturn(material);
        when(material.getIsbn()).thenReturn("978-0000000001");
        when(gestor.hayDisponible(anyString())).thenReturn(false);

        procesador.procesarPendientes();

        verify(propiedades).reserva();
        verify(propiedadesReserva).diasCaducidad();
        verify(reservas).pendientes();
        verify(reserva).getFechaSolicitud();
        verify(reservas).caducar(reserva);
        verifyNoMoreInteractions(reservas, gestor, propiedades);
    }
}

Ejercicio 3: probar el flujo completo de devolución

Implementa las pruebas de este método, que es el más complejo de BiblioTech:

@Service
public class GestorDevoluciones {

    private final PrestamoRepository prestamos;
    private final CalculadoraMultas calculadora;
    private final RegistroMultas multas;
    private final ProcesadorReservas reservas;
    private final ServicioAvisos avisos;
    private final Clock reloj;

    @Transactional
    public ResultadoDevolucion devolver(Long prestamoId) {

        Prestamo prestamo = prestamos.findById(prestamoId)
                .orElseThrow(() -> new PrestamoNoEncontradoException(prestamoId));

        if (prestamo.getFechaDevolucion().isPresent()) {
            throw new PrestamoYaDevueltoException(prestamoId);
        }

        LocalDate hoy = LocalDate.now(reloj);
        BigDecimal multa = calculadora.calcular(prestamo);

        prestamo.devolver(hoy);
        prestamo.getMaterial().devolverUnEjemplar();
        prestamos.save(prestamo);

        if (multa.compareTo(BigDecimal.ZERO) > 0) {
            multas.registrar(prestamo, multa);
            avisos.avisar(prestamo.getEmpleado(),
                    "Se ha registrado una multa de " + multa + " € por la devolución tardía de "
                    + prestamo.getMaterial().getTitulo());
        }

        Optional<Reserva> siguiente = reservas.siguienteDe(prestamo.getMaterial());
        siguiente.ifPresent(r -> {
            r.avisar(reloj);
            avisos.avisar(r.getEmpleado(),
                    "Ya está disponible el material que reservaste: "
                    + prestamo.getMaterial().getTitulo());
        });

        return new ResultadoDevolucion(prestamo, multa, siguiente.isPresent());
    }
}

Escribe una clase de prueba que cubra, aplicando el criterio de la lección:

  1. Devolución en plazo: sin multa, sin aviso de multa.
  2. Devolución con 10 días de retraso: multa de 5,00 € registrada y avisada, con el contenido del mensaje verificado con ArgumentCaptor.
  3. Devolución de un material con una reserva pendiente: se avisa también al siguiente empleado (dos avisos en total, comprobados en orden y contenido).
  4. Préstamo inexistente: excepción y ningún efecto.
  5. Préstamo ya devuelto: excepción y ningún efecto.
  6. El material recupera un ejemplar disponible.

Justifica en cada caso qué mockeas, qué usas real y qué verificas.

Soluciones

Solución 1

# Escenario Doble Por qué
1 Límite de la multa Ninguno CalculadoraMultas solo necesita PropiedadesBiblioTech (un record) y un Clock.fixed. Ambos reales. Es el caso ideal: el diseño hizo innecesario el doble
2 Aviso al bibliotecario Mock de ServicioAvisos Enviar un aviso es un comando con efecto externo. La única forma de comprobarlo es verify
3 Límite de préstamos Stub de repositorio + mock para verificar la ausencia countByEmpleadoAndEstado es consulta (stub); verify(prestamos, never()).save(any()) verifica que no hubo comando
4 EstadisticasBiblioTech Fake en memoria Ocho consultas distintas serían ocho when por prueba, y los resultados deben ser coherentes entre sí (un préstamo que aparece en una consulta debe aparecer en otra). Un fake da esa coherencia gratis
5 API que devuelve 503 Mock con thenThrow Es la capacidad exclusiva de Mockito: provocar un fallo imposible de reproducir de otra forma
6 Auditoría por préstamo Mock + ArgumentCaptor anotar es comando; y hay que comprobar el contenido de la entrada, no solo que se llamó
7 Caducar reservas Fake o stub + Clock.fixed La clave es el reloj fijo (no un mock de Clock, sino Clock.fixed); el repositorio puede ser un fake que además permita comprobar el estado final

Fragmentos relevantes:

// 1. Sin dobles: todo real
var calculadora = new CalculadoraMultas(propiedadesDePrueba(), Clock.fixed(...));
assertThat(calculadora.calcular(prestamoConRetraso(200))).isEqualByComparingTo("20.00");
// 2. Mock para verificar el comando
verify(avisos).avisar(eq(bibliotecarioResponsable), contains("último ejemplar"));
// 3. Stub para la consulta, verify(never) para la ausencia de comando
when(prestamos.countByEmpleadoAndEstado(marta, ACTIVO)).thenReturn(3L);
assertThatThrownBy(() -> gestor.prestar(...)).isInstanceOf(LimitePrestamosException.class);
verify(prestamos, never()).save(any());
// 4. Fake: coherencia entre consultas, gratis
var repositorio = new RepositorioPrestamosEnMemoria();
repositorio.save(prestamoDe(marta, "978-0000000001"));
repositorio.save(prestamoDe(marta, "978-0000000002"));
repositorio.save(prestamoDe(diego, "978-0000000001"));

var estadisticas = new EstadisticasBiblioTech(repositorio);
assertThat(estadisticas.materialMasPrestado()).contains("978-0000000001");
assertThat(estadisticas.prestamosPorEmpleado()).containsEntry("Marta Ruiz", 2L);
// 5. thenThrow: el escenario imposible de reproducir
when(pasarela.buscarPorIsbn("978-0000000001"))
        .thenThrow(new PasarelaNoDisponibleException("503 Service Unavailable"));
// 6. Captor para el contenido de la auditoría
verify(registro).anotar(captorEntrada.capture());
assertThat(captorEntrada.getValue())
        .extracting(EntradaAuditoria::operacion, EntradaAuditoria::isbn)
        .containsExactly("PRESTAMO", "978-0000000001");
// 7. Reloj fijo REAL, no mock de Clock
var procesador = new ProcesadorReservas(repositorio, gestor, propiedades,
                                        Clock.fixed(Instant.parse("2026-03-20T10:00:00Z"), MADRID));

Solución 2

Los problemas:

# Problema Por qué es malo
1 @Mock sobre Clock Clock es una clase de valor con Clock.fixed. Mockearla obliga a programar instant() y getZone(), es más frágil y menos legible. Y si el código llama a otro método del reloj, devuelve null
2 @Mock sobre PropiedadesBiblioTech y su record anidado Son record inmutables: se construyen con new en una línea. Mockearlos genera cuatro líneas de configuración inútiles
3 @Mock sobre Reserva y Material Son entidades de dominio. Mockearlas hace que la prueba no ejercite su lógica real (validaciones, transiciones de estado) y produce el "mock de mock de mock"
4 Verificar consultas (propiedades.reserva(), diasCaducidad(), pendientes(), getFechaSolicitud()) Es implementación pura. Cualquier refactorización —cachear el valor de configuración, por ejemplo— rompe la prueba sin que cambie el comportamiento
5 verifyNoMoreInteractions sobre tres mocks Añadir una traza, una métrica o una comprobación adicional rompe la prueba
6 Nombre test No dice nada. Cuando falla en la integración continua, no se sabe qué se rompió
7 Ninguna aserción sobre el resultado o el estado Solo verifica llamadas. Si caducar recibiera la reserva equivocada... bueno, eso sí se vería; pero si el estado de la reserva no cambiara, la prueba seguiría verde
8 @InjectMocks Con tantos mocks funciona, pero si mañana se añade un parámetro no simulable al constructor, inyecta null sin avisar

Versión reescrita:

@ExtendWith(MockitoExtension.class)
@DisplayName("ProcesadorReservas")
class ProcesadorReservasTest {

    private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");
    private static final LocalDate HOY = LocalDate.of(2026, 3, 20);
    private static final Clock RELOJ = Clock.fixed(HOY.atStartOfDay(MADRID).toInstant(), MADRID);

    @Mock private ReservaRepository reservas;      // colaborador externo: mock
    @Mock private GestorPrestamos gestor;          // colaborador con efectos: mock

    private ProcesadorReservas procesador;
    private Material libro;
    private Empleado marta;

    @BeforeEach
    void preparar() {
        // Objetos REALES: records de configuración, reloj fijo y entidades de dominio
        var propiedades = new PropiedadesBiblioTech(
                "BiblioTech",
                new PropiedadesBiblioTech.Prestamo(15, 3),
                new PropiedadesBiblioTech.Multa(new BigDecimal("0.50"), new BigDecimal("20.00")),
                new PropiedadesBiblioTech.Reserva(3));       // 3 días de caducidad

        // Construcción explícita, no @InjectMocks
        procesador = new ProcesadorReservas(reservas, gestor, propiedades, RELOJ);

        libro = new Libro("978-0000000001", "Java Efectivo", 0, "J. Bloch");
        marta = new Empleado("[email protected]", "Marta Ruiz");
    }

    @Test
    @DisplayName("caduca las reservas solicitadas hace más de 3 días")
    void caducaLasReservasAntiguas() {
        // Solicitada el 10 -> caducaba el 13 -> hoy es 20: caducada
        Reserva antigua = new Reserva(libro, marta, LocalDate.of(2026, 3, 10), 3);
        when(reservas.pendientes()).thenReturn(List.of(antigua));

        procesador.procesarPendientes();

        verify(reservas).caducar(antigua);                       // COMANDO: se verifica
        assertThat(antigua.getEstado()).isEqualTo(EstadoReserva.CADUCADA);   // ESTADO real
        verify(gestor, never()).prestar(anyString(), anyString());
    }

    @Test
    @DisplayName("no caduca una reserva dentro del plazo de 3 días")
    void noCaducaLasRecientes() {
        // Solicitada el 18 -> caduca el 21 -> hoy es 20: todavía vigente
        Reserva reciente = new Reserva(libro, marta, LocalDate.of(2026, 3, 18), 3);
        when(reservas.pendientes()).thenReturn(List.of(reciente));
        when(gestor.hayDisponible("978-0000000001")).thenReturn(false);

        procesador.procesarPendientes();

        verify(reservas, never()).caducar(any());
        assertThat(reciente.getEstado()).isEqualTo(EstadoReserva.PENDIENTE);
    }

    @Test
    @DisplayName("presta y completa la reserva cuando hay ejemplar disponible")
    void completaLaReservaSiHayEjemplar() {
        Reserva vigente = new Reserva(libro, marta, LocalDate.of(2026, 3, 18), 3);
        when(reservas.pendientes()).thenReturn(List.of(vigente));
        when(gestor.hayDisponible("978-0000000001")).thenReturn(true);

        procesador.procesarPendientes();

        verify(gestor).prestar("978-0000000001", marta.getCorreo());   // COMANDO
        verify(reservas).completar(vigente);                            // COMANDO
        verify(reservas, never()).caducar(any());
    }

    @Test
    @DisplayName("el día exacto de caducidad la reserva todavía es válida")
    void limiteDelPlazo() {
        // Solicitada el 17 -> caduca el 20 -> hoy es 20: ES el último día, aún vale
        Reserva enElLimite = new Reserva(libro, marta, LocalDate.of(2026, 3, 17), 3);
        when(reservas.pendientes()).thenReturn(List.of(enElLimite));
        when(gestor.hayDisponible(anyString())).thenReturn(false);

        procesador.procesarPendientes();

        verify(reservas, never()).caducar(any());
    }

    @Test
    @DisplayName("no hace nada si no hay reservas pendientes")
    void sinReservas() {
        when(reservas.pendientes()).thenReturn(List.of());

        procesador.procesarPendientes();

        verifyNoInteractions(gestor);
    }
}

Qué ha cambiado y por qué:

Antes Ahora Beneficio
@Mock Clock con dos when Clock.fixed real Dos líneas menos, más legible, sin riesgo de null
@Mock sobre dos record de propiedades Construidos con new Cuatro líneas menos, y el valor es visible en la prueba
@Mock Reserva, @Mock Material Entidades reales Se ejercita la lógica de dominio (getEstado, transiciones)
Cinco verify de consultas Ninguno Sobrevive a las refactorizaciones
verifyNoMoreInteractions never() y verifyNoInteractions puntuales Comprueba lo que importa sin bloquear cambios legítimos
Un método test Cinco pruebas con nombres descriptivos Cuando falla, se sabe qué
Sin aserciones de estado assertThat(reserva.getEstado()) Comprueba el efecto real, no solo la llamada
Sin casos límite El día exacto de caducidad Ahí es donde vive el bug de < frente a <=

Solución 3

package com.nexussoftware.bibliotech.servicio;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;

import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import org.junit.jupiter.api.*;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
@DisplayName("GestorDevoluciones")
class GestorDevolucionesTest {

    private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");
    private static final LocalDate HOY = LocalDate.of(2026, 3, 20);
    private static final Clock RELOJ = Clock.fixed(HOY.atStartOfDay(MADRID).toInstant(), MADRID);

    // MOCKS: colaboradores con efectos externos o infraestructura
    @Mock private PrestamoRepository prestamos;
    @Mock private RegistroMultas multas;
    @Mock private ProcesadorReservas reservas;
    @Mock private ServicioAvisos avisos;

    @Captor private ArgumentCaptor<String> capturadorMensaje;
    @Captor private ArgumentCaptor<Empleado> capturadorEmpleado;
    @Captor private ArgumentCaptor<BigDecimal> capturadorImporte;

    private GestorDevoluciones gestor;
    private Material libro;
    private Empleado marta;
    private Empleado diego;

    @BeforeEach
    void preparar() {
        var propiedades = new PropiedadesBiblioTech(
                "BiblioTech",
                new PropiedadesBiblioTech.Prestamo(15, 3),
                new PropiedadesBiblioTech.Multa(new BigDecimal("0.50"), new BigDecimal("20.00")),
                new PropiedadesBiblioTech.Reserva(3));

        // REAL: pura, barata, determinista. Mockearla debilitaría la prueba
        var calculadora = new CalculadoraMultas(propiedades, RELOJ);

        gestor = new GestorDevoluciones(prestamos, calculadora, multas, reservas, avisos, RELOJ);

        libro  = new Libro("978-0000000001", "Java Efectivo", 2, "J. Bloch");
        marta  = new Empleado("[email protected]", "Marta Ruiz");
        diego  = new Empleado("[email protected]", "Diego Alonso");
    }

    // ============ (1) EN PLAZO ============
    @Nested
    @DisplayName("devolución en plazo")
    class EnPlazo {

        @Test
        @DisplayName("no genera multa ni aviso")
        void sinMultaNiAviso() {
            Prestamo prestamo = prestamoQueVence(HOY.plusDays(5));   // aún en plazo
            when(prestamos.findById(1L)).thenReturn(Optional.of(prestamo));
            when(reservas.siguienteDe(libro)).thenReturn(Optional.empty());

            ResultadoDevolucion resultado = gestor.devolver(1L);

            assertThat(resultado.multa()).isEqualByComparingTo("0.00");
            assertThat(resultado.hayReservaPendiente()).isFalse();
            assertThat(prestamo.getEstado()).isEqualTo(EstadoPrestamo.DEVUELTO);
            assertThat(prestamo.getFechaDevolucion()).contains(HOY);

            verify(prestamos).save(prestamo);          // COMANDO
            verifyNoInteractions(multas);              // no se registró multa
            verify(avisos, never()).avisar(any(), anyString());
        }

        // ============ (6) EJEMPLARES ============
        @Test
        @DisplayName("el material recupera un ejemplar disponible")
        void recuperaEjemplar() {
            Prestamo prestamo = prestamoQueVence(HOY.plusDays(5));
            when(prestamos.findById(1L)).thenReturn(Optional.of(prestamo));
            when(reservas.siguienteDe(libro)).thenReturn(Optional.empty());

            int antes = libro.getEjemplaresDisponibles();
            gestor.devolver(1L);

            assertThat(libro.getEjemplaresDisponibles()).isEqualTo(antes + 1);
        }
    }

    // ============ (2) CON RETRASO ============
    @Nested
    @DisplayName("devolución con retraso")
    class ConRetraso {

        @Test
        @DisplayName("registra una multa de 5,00 € por 10 días y avisa con el detalle")
        void multaDeDiezDias() {
            Prestamo prestamo = prestamoQueVence(HOY.minusDays(10));
            when(prestamos.findById(1L)).thenReturn(Optional.of(prestamo));
            when(reservas.siguienteDe(libro)).thenReturn(Optional.empty());

            ResultadoDevolucion resultado = gestor.devolver(1L);

            // La multa la calcula el código REAL: 10 días x 0,50 € = 5,00 €
            assertThat(resultado.multa()).isEqualByComparingTo("5.00");

            // COMANDO 1: registro de la multa, con captura de los argumentos reales
            verify(multas).registrar(eq(prestamo), capturadorImporte.capture());
            assertThat(capturadorImporte.getValue()).isEqualByComparingTo("5.00");

            // COMANDO 2: aviso, con el CONTENIDO verificado
            verify(avisos).avisar(capturadorEmpleado.capture(), capturadorMensaje.capture());
            assertThat(capturadorEmpleado.getValue()).isEqualTo(marta);
            assertThat(capturadorMensaje.getValue())
                    .contains("multa")
                    .contains("5.00")
                    .contains("Java Efectivo")
                    .doesNotContain("null");
        }

        @Test
        @DisplayName("la multa se limita al máximo de 20 € con retrasos muy largos")
        void multaLimitada() {
            Prestamo prestamo = prestamoQueVence(HOY.minusDays(300));
            when(prestamos.findById(1L)).thenReturn(Optional.of(prestamo));
            when(reservas.siguienteDe(libro)).thenReturn(Optional.empty());

            assertThat(gestor.devolver(1L).multa()).isEqualByComparingTo("20.00");
            verify(multas).registrar(eq(prestamo), argThat(m -> m.compareTo(new BigDecimal("20.00")) == 0));
        }
    }

    // ============ (3) CON RESERVA PENDIENTE ============
    @Nested
    @DisplayName("cuando hay una reserva pendiente del material")
    class ConReserva {

        @Test
        @DisplayName("avisa también al siguiente empleado de la cola")
        void avisaAlSiguiente() {
            Prestamo prestamo = prestamoQueVence(HOY.plusDays(5));   // en plazo: solo 1 aviso
            Reserva reservaDeDiego = new Reserva(libro, diego, HOY.minusDays(1), 3);

            when(prestamos.findById(1L)).thenReturn(Optional.of(prestamo));
            when(reservas.siguienteDe(libro)).thenReturn(Optional.of(reservaDeDiego));

            ResultadoDevolucion resultado = gestor.devolver(1L);

            assertThat(resultado.hayReservaPendiente()).isTrue();
            assertThat(reservaDeDiego.getEstado()).isEqualTo(EstadoReserva.AVISADA);
            assertThat(reservaDeDiego.getInstanteAviso()).isPresent();

            verify(avisos).avisar(capturadorEmpleado.capture(), capturadorMensaje.capture());
            assertThat(capturadorEmpleado.getValue()).isEqualTo(diego);
            assertThat(capturadorMensaje.getValue())
                    .contains("disponible")
                    .contains("Java Efectivo");
        }

        @Test
        @DisplayName("con retraso Y reserva se envían dos avisos, primero la multa")
        void dosAvisosEnOrden() {
            Prestamo prestamo = prestamoQueVence(HOY.minusDays(10));
            Reserva reservaDeDiego = new Reserva(libro, diego, HOY.minusDays(1), 3);

            when(prestamos.findById(1L)).thenReturn(Optional.of(prestamo));
            when(reservas.siguienteDe(libro)).thenReturn(Optional.of(reservaDeDiego));

            gestor.devolver(1L);

            verify(avisos, times(2))
                    .avisar(capturadorEmpleado.capture(), capturadorMensaje.capture());

            // El orden aquí SÍ es un requisito: el afectado por la multa se entera primero
            assertThat(capturadorEmpleado.getAllValues()).containsExactly(marta, diego);
            assertThat(capturadorMensaje.getAllValues().get(0)).contains("multa");
            assertThat(capturadorMensaje.getAllValues().get(1)).contains("disponible");
        }
    }

    // ============ (4) y (5) CASOS DE ERROR ============
    @Nested
    @DisplayName("casos de error")
    class Errores {

        @Test
        @DisplayName("préstamo inexistente: excepción y ningún efecto")
        void prestamoInexistente() {
            when(prestamos.findById(99L)).thenReturn(Optional.empty());

            assertThatThrownBy(() -> gestor.devolver(99L))
                    .isInstanceOf(PrestamoNoEncontradoException.class)
                    .hasMessageContaining("99");

            verify(prestamos, never()).save(any());
            verifyNoInteractions(multas, avisos, reservas);
        }

        @Test
        @DisplayName("préstamo ya devuelto: excepción y ningún efecto")
        void prestamoYaDevuelto() {
            Prestamo devuelto = prestamoQueVence(HOY.minusDays(10));
            devuelto.devolver(HOY.minusDays(5));                  // ya devuelto
            when(prestamos.findById(1L)).thenReturn(Optional.of(devuelto));

            int ejemplaresAntes = libro.getEjemplaresDisponibles();

            assertThatThrownBy(() -> gestor.devolver(1L))
                    .isInstanceOf(PrestamoYaDevueltoException.class);

            verify(prestamos, never()).save(any());
            verifyNoInteractions(multas, avisos, reservas);
            // Ningún efecto sobre el estado del material
            assertThat(libro.getEjemplaresDisponibles()).isEqualTo(ejemplaresAntes);
        }
    }

    // --- fábrica de escenarios ---
    private Prestamo prestamoQueVence(LocalDate vencimiento) {
        return new Prestamo(libro, marta, vencimiento.minusDays(15), vencimiento);
    }
}

Justificación de las decisiones:

Colaborador Decisión Motivo
PrestamoRepository Mock Infraestructura. findById es consulta (stub), save es comando (verify)
CalculadoraMultas REAL Pura, barata, determinista. Usarla de verdad hace que la prueba compruebe que la multa vale realmente 5,00 €, no que se pasó el valor que el mock devolvió
PropiedadesBiblioTech REAL Es un record. Los valores están a la vista en la prueba
Clock Clock.fixed, no mock Es una clase de valor con fábrica adecuada. Mockearla sería peor en todos los sentidos
RegistroMultas Mock Comando con efecto externo. Se verifica
ServicioAvisos Mock + Captor Comando con efecto externo, y hay que comprobar el contenido del mensaje
ProcesadorReservas Mock siguienteDe es consulta (stub)
Prestamo, Material, Reserva, Empleado REALES Entidades de dominio con lógica propia. Mockearlas impediría comprobar transiciones de estado como getEstado() o getInstanteAviso()

Y el criterio aplicado en cada prueba:

  • Nunca se verifica findById ni siguienteDe: son consultas, y su efecto ya está implícito en el resultado.
  • Siempre se verifica save, registrar y avisar: son comandos con efecto observable.
  • Se comprueba el estado real de las entidades (getEstado, getEjemplaresDisponibles, getInstanteAviso), no solo las llamadas.
  • En los casos de error se usa verifyNoInteractions para garantizar la propiedad más importante del método: si falla, no debe dejar efectos parciales.
  • El orden se verifica solo en un caso, y porque es un requisito de negocio explícito, no porque el código lo haga así.
  • El caso del retraso de 300 días comprueba el límite de la multa, con el cálculo hecho por el código real.

Conclusión

Las pruebas de BiblioTech ya no se detienen donde acaban las clases fáciles.

Sabes por qué JUnit no basta: los colaboradores reales son lentos, no deterministas, difíciles de poner en un estado concreto o tienen efectos reales. Y conoces los cinco tipos de doble con definiciones precisas —dummy, stub, spy, mock y fake— con la aclaración de vocabulario que evita confusiones: en Mockito todo se llama mock, y la distinción es conceptual. Y conoces la opción que casi todo el mundo olvida: el fake en memoria, que se escribe una vez, da coherencia de estado gratis y no se rompe al refactorizar — muchas veces la mejor inversión para el repositorio principal de una aplicación.

Dominas Mockito 5: @ExtendWith(MockitoExtension.class) con su validación estricta que te avisa de comportamientos programados y nunca usados; la creación de mocks; y por qué construir el objeto bajo prueba explícitamente es mejor que @InjectMocks, que inyecta null en silencio, no admite objetos no simulables y oculta la señal de un constructor con demasiados parámetros. Programas comportamiento con when(...).thenReturn(...) —sabiendo que no es magia, sino una invocación registrada—, provocas fallos imposibles de reproducir de otra forma con thenThrow, y das respuestas dinámicas con thenAnswer, con el aviso de que cuando el Answer tiene lógica, en realidad querías un fake.

Sabes cuándo la sintaxis do... es obligatoria —métodos void, espías y reprogramación—, y por qué: porque when(x.metodo()) ejecuta el método, lo que en un espía dispara el código real con todas sus consecuencias. Conoces los valores por defecto de un mock, incluidos los dos que ahorran más problemas: Optional.empty() y colecciones vacías.

Verificas interacciones con verify y toda su familia —times, never, atLeast, only, inOrder, verifyNoMoreInteractions— sabiendo que never() es probablemente el más valioso, porque comprobar que algo no ocurrió es imposible con aserciones sobre el resultado. Y sobre todo tienes el criterio, que es lo que separa las pruebas buenas de las frágiles:

Programa las consultas, verifica los comandos.

Verificar una consulta prueba implementación, se rompe al refactorizar y además es redundante, porque la aserción sobre el resultado ya la comprueba indirectamente. Verificar un comando prueba un efecto observable que no aparece en ningún valor de retorno. Si recuerdas solo una frase de esta lección, que sea esa.

Manejas los matchers con la regla de todos o ninguno —y sabes por qué el InvalidUseOfMatchersException puede aparecer en la línea o la prueba siguiente, porque la pila queda contaminada—, y usas ArgumentCaptor cuando la pregunta no es "¿se llamó con algo así?" sino "¿con qué exactamente?", prefiriéndolo a argThat por sus mensajes de error.

Conoces los espías y su advertencia: necesitar uno sobre tu propia clase casi siempre significa que esa clase hace dos cosas. Y conoces mockStatic con una advertencia aún más fuerte: necesitarlo es casi siempre la señal de que el diseño debería cambiar — si tienes que simular LocalDate.now(), lo que falta es un Clock inyectado.

Sabes qué no se debe mockear: tipos que no controlas —porque simulas tu creencia sobre su comportamiento y la prueba puede estar verde mientras el código falla—, clases de valor, el framework, colecciones del JDK y la propia clase bajo prueba. Y reconoces el mock excesivo, con su diagnóstico completo: transcribe la implementación, se rompe al refactorizar, no comprueba el resultado, es ilegible y da falsa confianza, porque el método puede estar mal y la prueba seguir verde.

Distingues las pruebas basadas en estado de las basadas en interacción, has visto el mismo requisito resuelto de las dos formas y sabes por qué la segunda versión es mejor: comprueba que la multa vale realmente 5,00 €, calculada por el código real, en vez de comprobar que se pasó el valor que el propio mock devolvió. Con la recomendación clara: prefiere el estado; usa la interacción solo cuando el efecto no sea observable de otra forma.

Y tienes la lección más importante de todo el módulo de pruebas: a veces el diseño elimina la necesidad del doble. El Clock inyectado de 10-05 en lugar de mockStatic; @ConfigurationProperties en lugar de un ReglasNegocio estático imposible de simular; una función pura extraída en lugar de un espía sobre uno mismo; un fake reutilizable en lugar de veinte mocks. Antes de escribir un mock complicado, pregúntate si el problema es que el diseño necesita cambiar.

BiblioTech tiene ahora GestorPrestamos probado con repositorios simulados —incluidos los caminos de error y el reintento ante conflicto optimista de 11-03—, ServicioAvisos verificado con ArgumentCaptor hasta el contenido del mensaje, y el ClienteMetadatos del módulo 9 probado sin tocar la red, tras envolverlo en una interfaz propia como manda el criterio de no mockear lo que no controlas.

Y conoces el complemento necesario: @SpringBootTest con @MockitoBean —con su aviso de rendimiento sobre los contextos múltiples—, @DataJpaTest con H2 y su em.clear() sin el cual la prueba pasaría aunque el mapeo estuviera mal, Testcontainers como el estándar actual para probar contra la base de datos real, y la cobertura con su interpretación honesta: una línea ejecutada no es una línea probada, y un objetivo alto genera pruebas malas.


BiblioTech tiene ahora un proyecto Maven reproducible con Spring, JPA sobre H2, y una batería de pruebas unitarias y de integración que cubre el dominio, los servicios y la persistencia. Se compila con ./mvnw clean verify y se despliega con java -jar.

Quedan tres deudas del módulo 10 sin saldar, y las tres son de la misma naturaleza: son librerías, no frameworks.

El JSON de BiblioTech se sigue parseando con indexOf. Ese "apaño didáctico" de 09-06 lleva dos módulos esperando su sustituto, y se rompe con el primer carácter de escape o el primer objeto anidado. Las entidades siguen teniendo cincuenta líneas de getters, equals, hashCode y toString escritos a mano — el código repetitivo que un procesador de anotaciones, como el que estudiaste en 10-02, puede generar solo. Y el logging sigue siendo java.util.logging, elegido en 06-07 por no añadir dependencias, con la limitación que ya se señaló allí: el ecosistema entero usa SLF4J, y mvn dependency:tree te ha mostrado tres veces que Spring Boot ya te lo trajo sin que lo pidieras.

En la próxima lección, que cierra el módulo, se saldan las tres. Verás Jackson y el ObjectMapper de verdad —con sus anotaciones, TypeReference para los genéricos que sobreviven al borrado de tipos de 10-01, el JavaTimeModule para el java.time de 10-05, y la reescritura del ClienteMetadatos que se prometió hace dos módulos—; verás Lombok, cómo genera código en compilación y una valoración honesta de sus problemas, incluido @Data en entidades JPA como fuente real de bugs; y verás SLF4J y Logback, la fachada frente a la implementación, el logging parametrizado con {} y por qué es medible mejor que concatenar, y la migración completa de BiblioTech.

Y cerrarás el módulo con el panorama de las librerías que todo desarrollador Java debería conocer — incluida la que salda la última deuda pendiente: el CSV escrito a mano de 07-07.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados