Once lecciones del módulo 10, tres del módulo 11, una migración completa de CSV a base de datos relacional, un dominio reescrito, genéricos, streams, java.time, clases selladas, hilos virtuales, Spring y Hibernate.

Y ni una sola prueba automática.

Cada refactorización de los dos últimos módulos se ha hecho sin red. Cada vez que has cambiado el cálculo de multas, la única forma de saber si seguía funcionando ha sido arrancar la aplicación, prestar un libro, esperar, y mirar la consola. Cada vez que has tocado el límite de préstamos por empleado, has confiado en que no rompiste nada.

Esta lección acaba con eso.

Una prueba automática es un programa que ejecuta tu código con entradas conocidas y comprueba que el resultado es el esperado, sin que nadie mire. JUnit 5 es el framework estándar para escribirlas en Java: descubre tus métodos, los ejecuta, informa de los fallos y se integra con Maven, con el IDE y con la integración continua.

Y aquí se cobra por fin una decisión de diseño que llevas dos módulos arrastrando: ese Clock que inyectaste en 10-05 "para poder probar el vencimiento sin esperar quince días", que pasó por Spring como un @Bean en 11-02 y que ha aparecido en cada LocalDate.now(reloj) de 11-03. Hoy lo usas.

Al terminar sabrás por qué se prueba y qué te da a cambio; conocerás la arquitectura de JUnit 5 y la anatomía de una prueba; dominarás las aserciones de JUnit y las de AssertJ; sabrás organizar pruebas con ciclo de vida, anidamiento y etiquetas; escribirás pruebas parametrizadas que cubren doce casos en seis líneas; y —lo más importante— entenderás qué hace que un diseño sea testeable y qué código sencillamente no se puede probar.

BiblioTech tendrá su primera batería de pruebas.

Contenido

  1. Por qué probar: el coste de no tener pruebas
  2. Qué te dan a cambio
  3. Qué es exactamente una prueba automática
  4. La pirámide de pruebas
  5. JUnit 5: arquitectura
  6. Las dependencias en el pom.xml
  7. La primera prueba de BiblioTech
  8. Anatomía: Preparar-Actuar-Comprobar
  9. Nombres de prueba y @DisplayName
  10. Las aserciones de JUnit
  11. assertThrows y las excepciones
  12. assertAll: agrupar comprobaciones
  13. AssertJ: por qué se recomienda
  14. El ciclo de vida: @BeforeEach y compañía
  15. Una instancia por prueba y @TestInstance
  16. @Disabled y las anotaciones condicionales
  17. Pruebas parametrizadas
  18. @MethodSource y los casos complejos
  19. @Nested: organizar por escenario
  20. @Tag y la ejecución selectiva
  21. @TempDir: probar el código de ficheros
  22. Qué hace testeable un diseño
  23. El Clock inyectable, por fin cobrado
  24. El código que NO se puede probar
  25. Qué es un buen conjunto de pruebas
  26. Antipatrones
  27. Probar código concurrente
  28. Ejecutar las pruebas con Maven
  29. Cobertura, mencionada
  30. @SpringBootTest y @DataJpaTest, presentados
  31. La batería de pruebas de BiblioTech
  32. Errores Comunes y Consejos
  33. Ejercicios

  1. Por qué probar: el coste de no tener pruebas

Empecemos por el lado incómodo, con BiblioTech como ejemplo real.

En 11-03 convertiste Material de un record sealed a una clase abstracta con @Entity. Cambiaste Optional<LocalDate> fechaDevolucion por un campo anulable con un getter que envuelve. Sustituiste LectorCsv por JpaRepository. Introdujiste @Version y un bucle de reintentos.

Pregunta: ¿el cálculo de multas sigue dando el mismo resultado que antes de la migración?

La respuesta honesta hoy es "creo que sí". No lo sabes. Nadie lo sabe.

Ese "creo que sí" tiene cinco costes concretos:

Coste En qué se traduce
Miedo a cambiar El código se pudre porque nadie se atreve a tocarlo. "Funciona, no lo toques"
Regresiones Un arreglo rompe algo que funcionaba, y se descubre en producción
Depuración lenta Sin pruebas, localizar un fallo es arrancar la aplicación y reproducirlo a mano
Verificación manual Cada cambio exige repetir a mano las mismas 20 comprobaciones
Actualizar dependencias Subir de Spring Boot 3.3 a 3.4 se convierte en un proyecto de tres semanas

Ese último merece atención, porque enlaza directamente con Log4Shell (11-01): la capacidad de responder rápido a una vulnerabilidad depende de tener pruebas. Sin ellas, cambiar una versión requiere una campaña de pruebas manuales, y tu ventana de exposición se mide en semanas.

Y hay un coste más sutil: el código sin pruebas tiende a ser peor código. No porque el autor sea peor, sino porque nada le obliga a que sus clases se puedan construir aisladamente. Un static que lee el reloj del sistema, un new dentro de un método, una clase con nueve dependencias: todo eso pasa desapercibido hasta que intentas probarlo. Las pruebas son un detector de problemas de diseño.

  1. Qué te dan a cambio

Beneficio Explicación
Refactorizar sin miedo Cambias la implementación, ejecutas las pruebas, sabes si rompiste algo. En segundos
Documentación ejecutable multaDeCincoDiasDeRetrasoEsDosEurosCincuenta documenta la regla y no puede desactualizarse, porque si miente, falla
Regresiones detectadas El fallo aparece al instante, no en producción
Depuración más rápida Una prueba que falla acota el problema a un método
Mejor diseño Probar obliga a inyectar dependencias, aislar efectos y separar responsabilidades
Confianza para desplegar La integración continua ejecuta 400 pruebas en 20 segundos antes de cada despliegue

Sobre la documentación ejecutable, un ejemplo de BiblioTech. Esto:

@Test
void laMultaSeLimitaAlMaximoConfigurado() {
    Prestamo prestamo = prestamoConRetraso(200);   // 200 días × 0,50 € = 100 €
    assertThat(calculadora.calcular(prestamo)).isEqualByComparingTo("20.00");
}

comunica una regla de negocio mejor que cualquier comentario, y con una propiedad que ningún comentario tiene: si alguien cambia el comportamiento sin cambiar la regla, la prueba se pone roja.

  1. Qué es exactamente una prueba automática

Una prueba automática es código que:

  1. Prepara un escenario conocido.
  2. Ejecuta el código que se quiere verificar.
  3. Comprueba que el resultado es el esperado.
  4. Falla ruidosamente si no lo es.

Comparado con lo que has hecho hasta ahora:

Verificación manual (módulos 1-11) Prueba automática
Quién comprueba Un humano mirando la consola El framework
Cuándo Cuando alguien se acuerda En cada compilación
Coste de repetirla Minutos de una persona Milisegundos
Casos límite Los que a alguien se le ocurren Todos los que escribiste, siempre
¿Se olvida? Sí No
Resultado "Parece que va bien" Verde o rojo

  1. La pirámide de pruebas

No todas las pruebas son iguales. La pirámide de pruebas describe la proporción sana entre los tipos.

graph TD
    E2E["EXTREMO A EXTREMO<br/>Pocas · Lentas (segundos-minutos) · Frágiles<br/>Toda la aplicación desplegada"]
    INT["INTEGRACIÓN<br/>Algunas · Medias (cientos de ms)<br/>Varias piezas juntas: BD, HTTP, contexto de Spring"]
    UNIT["UNITARIAS<br/>MUCHAS · Rápidas (milisegundos) · Estables<br/>Una clase, sin dependencias externas"]
    E2E --- INT
    INT --- UNIT
Tipo Qué prueba Velocidad Cuántas En BiblioTech
Unitaria Una clase aislada 1-10 ms Muchas (70-80 %) CalculadoraMultas, reglas de Prestamo
Integración Varias piezas reales juntas 100 ms - 2 s Algunas (15-25 %) PrestamoRepository contra H2, @SpringBootTest
Extremo a extremo El sistema completo Segundos o minutos Pocas (5 %) Prestar un libro por la API REST (12-05)

Por qué la forma importa: si inviertes la pirámide y casi todas tus pruebas son de integración, tu conjunto tarda veinte minutos, falla de forma intermitente y nadie lo ejecuta. Si es piramidal, tarda veinte segundos y se ejecuta en cada guardado.

Esta lección se centra en la base: pruebas unitarias del dominio de BiblioTech. La integración se presenta al final y se desarrolla en 11-06 y 12-05.

  1. JUnit 5: arquitectura

JUnit 5 no es un artefacto, sino tres subproyectos. Saberlo evita mucha confusión con las dependencias.

graph TD
    A["JUnit 5 (JUnit Platform + Jupiter + Vintage)"] --> B["JUnit Platform<br/>Motor de descubrimiento y ejecución.<br/>Es lo que usan Maven, Gradle y los IDE"]
    A --> C["JUnit Jupiter<br/>La API que tú escribes:<br/>@Test, @BeforeEach, Assertions...<br/>+ su motor de ejecución"]
    A --> D["JUnit Vintage<br/>Motor que ejecuta pruebas de JUnit 3 y 4<br/>(solo para código heredado)"]
    B --- C
    B --- D
Componente Qué es Lo usas
Platform Infraestructura de descubrimiento y ejecución Indirectamente
Jupiter La API y el motor de JUnit 5 Directamente: es lo que escribes
Vintage Compatibilidad con JUnit 4 Solo si migras código antiguo

Diferencias con JUnit 4, útiles si ves código antiguo:

JUnit 4 JUnit 5 (Jupiter)
org.junit.Test org.junit.jupiter.api.Test
@Before / @After @BeforeEach / @AfterEach
@BeforeClass / @AfterClass @BeforeAll / @AfterAll
@Ignore @Disabled
@RunWith / @Rule @ExtendWith (modelo unificado de extensiones)
expected = X.class assertThrows(X.class, ...)
Clases y métodos public obligatorios Puede ser visibilidad de paquete

  1. Las dependencias en el pom.xml

Con Spring Boot, una sola dependencia trae todo:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

mvn dependency:tree revela qué arrastra:

Artefacto Para qué
junit-jupiter JUnit 5: API y motor
assertj-core Aserciones fluidas (apartado 13)
mockito-core, mockito-junit-jupiter Dobles de prueba (11-06)
spring-test, spring-boot-test @SpringBootTest, MockMvc
hamcrest Matchers (menos usado hoy)
jsonassert, json-path Aserciones sobre JSON
xmlunit-core Aserciones sobre XML

Fíjate en <scope>test</scope>: esas librerías no se empaquetan en el jar final. Es el ámbito test de Maven, que se explica en 11-05.

Sin Spring, las dependencias mínimas serían:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.25.3</version>
    <scope>test</scope>
</dependency>

Y la estructura de directorios que Maven impone (11-05):

src/
├── main/java/com/nexussoftware/bibliotech/     <- código de producción
├── main/resources/                             <- application.yml
├── test/java/com/nexussoftware/bibliotech/     <- LAS PRUEBAS, mismo paquete
└── test/resources/                             <- application-test.yml, ficheros de prueba

La convención de poner la prueba en el mismo paquete que la clase probada tiene una consecuencia útil: la prueba puede acceder a los miembros con visibilidad de paquete sin necesidad de hacerlos públicos.

  1. La primera prueba de BiblioTech

La clase que vamos a probar, tal como quedó en 11-02:

package com.nexussoftware.bibliotech.servicio;

import java.math.BigDecimal;
import java.time.*;
import java.time.temporal.ChronoUnit;
import org.springframework.stereotype.Service;

@Service
public class CalculadoraMultas {

    private final PropiedadesBiblioTech propiedades;
    private final Clock reloj;

    public CalculadoraMultas(PropiedadesBiblioTech propiedades, Clock reloj) {
        this.propiedades = propiedades;
        this.reloj = reloj;
    }

    public BigDecimal calcular(Prestamo prestamo) {
        if (prestamo.getFechaDevolucion().isPresent()) {
            return BigDecimal.ZERO;   // ya devuelto: sin multa
        }
        LocalDate hoy = LocalDate.now(reloj);
        long diasRetraso = ChronoUnit.DAYS.between(prestamo.getFechaVencimiento(), hoy);
        if (diasRetraso <= 0) {
            return BigDecimal.ZERO;   // aún en plazo
        }
        BigDecimal multa = propiedades.multa().eurosPorDia()
                .multiply(BigDecimal.valueOf(diasRetraso));
        return multa.min(propiedades.multa().maxima());
    }
}

Y su primera prueba, en src/test/java/com/nexussoftware/bibliotech/servicio/CalculadoraMultasTest.java:

package com.nexussoftware.bibliotech.servicio;

import static org.assertj.core.api.Assertions.assertThat;

import java.math.BigDecimal;
import java.time.*;
import org.junit.jupiter.api.Test;

class CalculadoraMultasTest {

    @Test
    void unPrestamoConCincoDiasDeRetrasoGeneraDosEurosCincuenta() {

        // PREPARAR: un reloj FIJO. "Hoy" es siempre el 20 de marzo de 2026.
        Clock relojFijo = Clock.fixed(
                Instant.parse("2026-03-20T10:00:00Z"), ZoneId.of("Europe/Madrid"));

        PropiedadesBiblioTech propiedades = propiedadesDePrueba(
                new BigDecimal("0.50"), new BigDecimal("20.00"));

        CalculadoraMultas calculadora = new CalculadoraMultas(propiedades, relojFijo);

        // Vencía el 15 -> 5 días de retraso respecto al 20
        Prestamo prestamo = new Prestamo(unLibro(), unEmpleado(),
                LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 15));

        // ACTUAR
        BigDecimal multa = calculadora.calcular(prestamo);

        // COMPROBAR
        assertThat(multa).isEqualByComparingTo("2.50");
    }
}

Detente en algo importante: no hay Spring por ninguna parte. No hay @SpringBootTest, no hay contexto, no hay base de datos. Se construye la clase con new, se le pasan sus dependencias, se llama a un método y se comprueba el resultado. Tarda menos de un milisegundo.

Eso es posible exactamente porque en 11-02 se eligió inyección por constructor y en 10-05 se inyectó el Clock en vez de llamar a LocalDate.now(). Todas las decisiones de diseño de los últimos módulos se cobran en esta línea.

Ejecutarla:

mvn test
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

  1. Anatomía: Preparar-Actuar-Comprobar

Toda prueba bien escrita tiene tres fases, y conviene separarlas visualmente. El patrón se conoce como AAA (Arrange-Act-Assert) o Given-When-Then.

@Test
void unEmpleadoNoPuedeSuperarElLimiteDePrestamos() {

    // PREPARAR (Arrange): montar el escenario
    Empleado marta = new Empleado("[email protected]", "Marta Ruiz");
    GestorPrestamos gestor = new GestorPrestamos(repositorio, calculadora, avisos, propiedades, reloj);
    gestor.prestar("978-0000000001", marta.getCorreo());
    gestor.prestar("978-0000000002", marta.getCorreo());
    gestor.prestar("978-0000000003", marta.getCorreo());

    // ACTUAR (Act): UNA sola acción, la que se está probando
    ThrowingCallable cuartoPrestamo =
            () -> gestor.prestar("978-0000000004", marta.getCorreo());

    // COMPROBAR (Assert): verificar el resultado
    assertThatThrownBy(cuartoPrestamo)
            .isInstanceOf(LimitePrestamosException.class)
            .hasMessageContaining("[email protected]");
}

Reglas del patrón:

  1. Una sola acción por prueba. Si hay dos, son dos pruebas.
  2. Las fases se separan con una línea en blanco o un comentario.
  3. Sin lógica en la prueba: nada de if, for ni try/catch improvisados. Una prueba con lógica es código que también puede tener bugs, y no hay pruebas para las pruebas.
  4. Una prueba comprueba un comportamiento, no un método. Un método con tres reglas necesita tres pruebas.

  1. Nombres de prueba y @DisplayName

El nombre de una prueba es documentación. Cuando falla en la integración continua, lo primero —a veces lo único— que se lee es su nombre.

Mal nombre Buen nombre
test1 laMultaEsCeroSiElPrestamoEstaEnPlazo
testCalcular laMultaSeLimitaAlMaximoConfigurado
testMulta unPrestamoDevueltoNoGeneraMulta
testException prestarUnMaterialSinEjemplaresLanzaSinEjemplaresException

Una fórmula útil: condición + resultadoEsperado, o el estilo should: deberiaLimitarLaMultaAlMaximo.

Y para prosa completa, @DisplayName:

@DisplayName("Cálculo de multas por retraso")
class CalculadoraMultasTest {

    @Test
    @DisplayName("Un préstamo con 5 días de retraso genera una multa de 2,50 €")
    void multaDeCincoDias() { ... }

    @Test
    @DisplayName("La multa nunca supera el máximo configurado (20 €)")
    void multaLimitadaAlMaximo() { ... }
}

La salida del IDE y de los informes se vuelve legible para cualquiera:

Cálculo de multas por retraso
  ✔ Un préstamo con 5 días de retraso genera una multa de 2,50 €
  ✔ La multa nunca supera el máximo configurado (20 €)

  1. Las aserciones de JUnit

org.junit.jupiter.api.Assertions (habitualmente con import static):

Aserción Comprueba Ejemplo
assertEquals(esperado, real) Igualdad con equals assertEquals(3, gestor.contarActivos())
assertNotEquals(a, b) Desigualdad
assertTrue(cond) / assertFalse(cond) Condición booleana assertTrue(prestamo.estaVencido(hoy))
assertNull(o) / assertNotNull(o) Nulidad
assertSame(a, b) / assertNotSame(a, b) Identidad de referencia (==)
assertArrayEquals(a, b) Arrays elemento a elemento
assertIterableEquals(a, b) Iterables en orden
assertLinesMatch(a, b) Líneas, con expresiones regulares
assertThrows(C.class, ejec) Que se lanza una excepción
assertDoesNotThrow(ejec) Que no se lanza ninguna
assertTimeout(dur, ejec) Que termina a tiempo
assertAll(...) Agrupa varias aserciones
fail("motivo") Falla incondicionalmente Rama que no debería alcanzarse

Cuidado con el orden de los argumentos: el esperado va primero. Invertirlo no cambia el resultado, pero produce mensajes de error engañosos ("expected 5 but was 3" cuando era al revés).

Y añade siempre un mensaje cuando la aserción no sea obvia:

assertEquals(3, gestor.contarActivos(marta),
             "Marta debería tener 3 préstamos activos tras prestar tres materiales");

Ese mensaje puede ser un Supplier<String> para que solo se construya si la prueba falla:

assertTrue(prestamo.estaVencido(hoy),
           () -> "El préstamo vencía el " + prestamo.getFechaVencimiento() + " y hoy es " + hoy);

  1. assertThrows y las excepciones

Probar que algo falla como debe es tan importante como probar que funciona.

@Test
void prestarUnMaterialSinEjemplaresLanzaExcepcion() {

    Material agotado = new Libro("978-0000000003", "Refactorización", 0, "M. Fowler");

    // assertThrows DEVUELVE la excepción: se puede inspeccionar
    SinEjemplaresException excepcion = assertThrows(
            SinEjemplaresException.class,
            () -> agotado.prestarUnEjemplar());

    assertEquals("978-0000000003", excepcion.getIsbn());
    assertTrue(excepcion.getMessage().contains("978-0000000003"));
}

Tres detalles que importan:

  1. Devuelve la excepción, así que se pueden comprobar su mensaje, su causa y sus campos propios. Aprovéchalo: comprobar solo el tipo es una prueba débil.
  2. El segundo argumento es un Executable, una interfaz funcional. Por eso se pasa una lambda (módulo 4).
  3. Acepta subclases. assertThrows(BiblioTechException.class, ...) pasa si se lanza SinEjemplaresException. Si necesitas el tipo exacto, usa assertThrowsExactly.

Y el error a evitar:

// MAL: si NO lanza la excepción, la prueba pasa igualmente
@Test
void mal() {
    try {
        agotado.prestarUnEjemplar();
    } catch (SinEjemplaresException e) {
        assertEquals("978-0000000003", e.getIsbn());
    }
    // Si no se lanza nada, no se ejecuta ningún catch... y la prueba es verde.
}

  1. assertAll: agrupar comprobaciones

Problema: si una prueba tiene cinco aserciones y falla la primera, no llegas a ver las otras cuatro. Arreglas una cosa, ejecutas, falla la siguiente. Cinco iteraciones para ver toda la información.

assertAll ejecuta todas las aserciones y reporta todos los fallos:

@Test
void unPrestamoNuevoTieneLosDatosCorrectos() {

    LocalDate hoy = LocalDate.of(2026, 3, 1);
    Prestamo prestamo = new Prestamo(unLibro(), unEmpleado(), hoy, 15);

    assertAll("datos del préstamo recién creado",
            () -> assertEquals(hoy, prestamo.getFechaPrestamo()),
            () -> assertEquals(hoy.plusDays(15), prestamo.getFechaVencimiento()),
            () -> assertEquals(EstadoPrestamo.ACTIVO, prestamo.getEstado()),
            () -> assertTrue(prestamo.getFechaDevolucion().isEmpty()),
            () -> assertNotNull(prestamo.getMaterial()));
}

Si fallan dos, el informe muestra las dos. Úsalo cuando compruebes varias propiedades del mismo resultado; no lo uses para meter tres comportamientos distintos en una prueba.

  1. AssertJ: por qué se recomienda

AssertJ ofrece aserciones fluidas, y viene incluido en spring-boot-starter-test. La diferencia principal no es estética: son los mensajes de error.

// JUnit
assertEquals(3, prestamos.size());
org.opentest4j.AssertionFailedError: expected: <3> but was: <2>
// AssertJ
assertThat(prestamos).hasSize(3);
Expected size: 3 but was: 2 in:
  [Prestamo{isbn='978-0000000001', empleado='Marta Ruiz', vence=2026-03-15},
   Prestamo{isbn='978-0000000002', empleado='Marta Ruiz', vence=2026-03-16}]

El segundo te dice qué había, no solo cuántos. En una prueba que falla en la integración continua a las once de la noche, esa diferencia es enorme.

Comparativa:

Comprobación JUnit AssertJ
Igualdad assertEquals(a, b) assertThat(b).isEqualTo(a)
Tamaño de colección assertEquals(3, l.size()) assertThat(l).hasSize(3)
Contiene assertTrue(l.contains(x)) assertThat(l).contains(x)
Contiene exactamente Bucle a mano assertThat(l).containsExactly(a, b, c)
Vacío assertTrue(l.isEmpty()) assertThat(l).isEmpty()
Cadena contiene assertTrue(s.contains("x")) assertThat(s).contains("x")
BigDecimal sin escala assertEquals(0, a.compareTo(b)) assertThat(a).isEqualByComparingTo("2.50")
Optional con valor assertTrue(o.isPresent()) && ... assertThat(o).contains(x)
Excepción assertThrows(...) assertThatThrownBy(...).isInstanceOf(...)
Extraer un campo Bucle a mano assertThat(l).extracting("titulo").contains("...")

Ejemplos que muestran su expresividad:

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

// Encadenamiento sobre colecciones
assertThat(prestamosVencidos)
        .hasSize(2)
        .extracting(Prestamo::getEmpleado)
        .extracting(Empleado::getNombre)
        .containsExactlyInAnyOrder("Marta Ruiz", "Diego Alonso");

// BigDecimal: compara VALOR, no escala. 2.50 vs 2.5 no falla.
assertThat(multa).isEqualByComparingTo("2.50");

// Optional (10-04)
assertThat(repositorio.buscarPorIsbn("978-0000000001"))
        .isPresent()
        .get()
        .extracting(Material::getTitulo)
        .isEqualTo("Java Efectivo");

// Excepciones, con más detalle
assertThatThrownBy(() -> gestor.prestar("978-9999999999", "[email protected]"))
        .isInstanceOf(MaterialNoEncontradoException.class)
        .hasMessageContaining("978-9999999999")
        .hasNoCause();

// Comparar objetos campo a campo, ignorando el id generado
assertThat(prestamoGuardado)
        .usingRecursiveComparison()
        .ignoringFields("id", "version")
        .isEqualTo(prestamoEsperado);

// Predicados sobre todos los elementos
assertThat(prestamosActivos)
        .isNotEmpty()
        .allMatch(p -> p.getFechaDevolucion().isEmpty())
        .allSatisfy(p -> assertThat(p.getFechaVencimiento()).isAfter(p.getFechaPrestamo()));

Esa última línea, isEqualByComparingTo, resuelve un problema muy real: new BigDecimal("2.50").equals(new BigDecimal("2.5")) es false, porque equals de BigDecimal compara también la escala. Con assertEquals eso produce fallos desconcertantes. AssertJ tiene el método correcto.

Recomendación clara: usa AssertJ. Ya está en tu classpath, los mensajes son mejores y el autocompletado del IDE te va guiando (assertThat(lista). y mira qué te ofrece). Usa las de JUnit para assertThrows si prefieres inspeccionar la excepción devuelta, aunque assertThatThrownBy cubre casi todo.

  1. El ciclo de vida: @BeforeEach y compañía

Cuando varias pruebas comparten preparación, hay ganchos de ciclo de vida.

class GestorPrestamosTest {

    private Clock relojFijo;
    private CalculadoraMultas calculadora;
    private GestorPrestamos gestor;
    private Empleado marta;

    @BeforeAll
    static void prepararTodaLaClase() {
        // UNA vez, antes de todas las pruebas. static (salvo con @TestInstance PER_CLASS)
        // Para recursos caros y COMPARTIDOS: arrancar un contenedor, cargar un fichero grande.
    }

    @BeforeEach
    void prepararCadaPrueba() {
        // ANTES DE CADA prueba: estado limpio, sin contaminación entre pruebas
        relojFijo = Clock.fixed(Instant.parse("2026-03-20T10:00:00Z"), ZoneId.of("Europe/Madrid"));
        calculadora = new CalculadoraMultas(propiedadesDePrueba(), relojFijo);
        gestor = new GestorPrestamos(new RepositorioEnMemoria(), calculadora,
                                     new AvisosEnMemoria(), propiedadesDePrueba(), relojFijo);
        marta = new Empleado("[email protected]", "Marta Ruiz");
    }

    @AfterEach
    void limpiarCadaPrueba() {
        // Después de cada prueba. Con recursos gestionados, rara vez necesario.
    }

    @AfterAll
    static void limpiarTodaLaClase() {
        // Una vez, al final. Cerrar lo abierto en @BeforeAll.
    }
}
Anotación Cuándo Frecuencia static
@BeforeAll Antes de todo 1 vez Sí (por defecto)
@BeforeEach Antes de cada prueba N veces No
@AfterEach Después de cada prueba N veces No
@AfterAll Al final de todo 1 vez Sí (por defecto)

Con herencia, el orden es: @BeforeAll de la superclase → @BeforeAll de la subclase → @BeforeEach de la superclase → @BeforeEach de la subclase → prueba → @AfterEach subclase → @AfterEach superclase → ...

Consejo importante: prefiere @BeforeEach a @BeforeAll. El estado compartido entre pruebas es la causa número uno de pruebas que dependen del orden de ejecución. @BeforeAll solo para recursos caros e inmutables.

Y un aviso sobre el exceso: si @BeforeEach tiene treinta líneas y cada prueba usa una parte distinta, la preparación se vuelve ilegible ("mystery guest": no se sabe de dónde salen los datos). En ese caso, mejor métodos de fábrica en la propia clase de prueba:

private Prestamo prestamoConRetrasoDe(int dias) {
    LocalDate hoy = LocalDate.now(relojFijo);
    return new Prestamo(unLibro(), marta, hoy.minusDays(15 + dias), hoy.minusDays(dias));
}

private Material unLibro() {
    return new Libro("978-0000000001", "Java Efectivo", 3, "J. Bloch");
}

Así cada prueba dice exactamente qué escenario usa: prestamoConRetrasoDe(5).

  1. Una instancia por prueba y @TestInstance

Comportamiento por defecto que sorprende a mucha gente: JUnit crea una instancia nueva de la clase de prueba para cada método @Test.

class ContadorTest {

    private int contador = 0;   // se reinicia en CADA prueba

    @Test void primera()  { contador++; assertEquals(1, contador); }   // pasa
    @Test void segunda()  { contador++; assertEquals(1, contador); }   // TAMBIÉN pasa
}

Es deliberado: garantiza que las pruebas son independientes. Un campo modificado en una prueba no puede afectar a otra.

Consecuencia: @BeforeAll y @AfterAll deben ser static, porque no pertenecen a ninguna instancia. Si necesitas que no lo sean:

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class CatalogoTest {

    private List<Material> catalogo;   // compartido por TODAS las pruebas

    @BeforeAll
    void cargarCatalogo() {            // ya no necesita ser static
        catalogo = cargarCatalogoGrande();
    }
}

Úsalo con cuidado: al compartir instancia, vuelves a poder contaminar unas pruebas con otras. Su uso legítimo es evitar recargar un recurso caro e inmutable.

  1. @Disabled y las anotaciones condicionales

@Test
@Disabled("Pendiente de decidir la política de multas para revistas (BIB-142)")
void multaDeRevistaEsLaMitad() { ... }

Regla profesional: @Disabled siempre con motivo y con referencia. Una prueba desactivada sin explicación es basura que nadie se atreverá a borrar dentro de un año. Y una prueba desactivada durante meses es una prueba que hay que borrar: está dando una falsa sensación de cobertura.

Condicionales, para pruebas que solo tienen sentido en ciertos entornos:

@Test
@EnabledOnOs(OS.LINUX)
void rutasDePermisosPosix() { ... }

@Test
@DisabledOnOs(OS.WINDOWS)
void enlacesSimbolicos() { ... }

@Test
@EnabledOnJre(JRE.JAVA_21)
void hilosVirtuales() { ... }                       // retoma 10-06

@Test
@EnabledIfSystemProperty(named = "pruebas.integracion", matches = "true")
void contraLaBaseDeDatosReal() { ... }

@Test
@EnabledIfEnvironmentVariable(named = "CI", matches = "true")
void soloEnIntegracionContinua() { ... }

La diferencia con @Disabled es importante: una prueba condicional se ejecuta cuando se cumplen las condiciones; una @Disabled no se ejecuta nunca.

  1. Pruebas parametrizadas

Aquí está una de las mejores características de JUnit 5. Este código huele mal:

@Test void multaDeUnDia()    { assertThat(calcular(1)).isEqualByComparingTo("0.50"); }
@Test void multaDeDosDias()  { assertThat(calcular(2)).isEqualByComparingTo("1.00"); }
@Test void multaDeCincoDias(){ assertThat(calcular(5)).isEqualByComparingTo("2.50"); }
@Test void multaDeDiezDias() { assertThat(calcular(10)).isEqualByComparingTo("5.00"); }
// ... ocho más

Con @ParameterizedTest:

@ParameterizedTest(name = "{0} días de retraso -> {1} €")
@CsvSource({
        "  1,  0.50",
        "  2,  1.00",
        "  5,  2.50",
        " 10,  5.00",
        " 39, 19.50",
        " 40, 20.00",   // justo el máximo
        " 41, 20.00",   // pasado el máximo: se limita
        "200, 20.00",   // muy pasado: sigue limitado
        "  0,  0.00",   // vence hoy: sin retraso
        " -1,  0.00",   // vence mañana
        " -5,  0.00",
        "-30,  0.00"
})
void laMultaSeCalculaSegunLosDiasDeRetraso(int diasRetraso, BigDecimal multaEsperada) {
    Prestamo prestamo = prestamoConRetrasoDe(diasRetraso);
    assertThat(calculadora.calcular(prestamo)).isEqualByComparingTo(multaEsperada);
}

Doce casos en seis líneas, incluyendo los tres casos límite que de verdad importan (39, 40, 41 alrededor del máximo) y los negativos. Y la salida es legible:

laMultaSeCalculaSegunLosDiasDeRetraso
  ✔ 1 días de retraso -> 0.50 €
  ✔ 5 días de retraso -> 2.50 €
  ✔ 40 días de retraso -> 20.00 €
  ✔ 41 días de retraso -> 20.00 €
  ...

Las fuentes de datos disponibles:

Anotación Proporciona Ejemplo
@ValueSource Un array de literales @ValueSource(ints = {1, 5, 10})
@CsvSource Varias columnas en línea @CsvSource({"5, 2.50"})
@CsvFileSource Un CSV de src/test/resources @CsvFileSource(resources = "/multas.csv")
@EnumSource Constantes de un enum @EnumSource(EstadoPrestamo.class)
@MethodSource Un método que devuelve un Stream Objetos complejos
@NullSource, @EmptySource, @NullAndEmptySource null y vacío Validación de entradas
@ArgumentsSource Un proveedor propio Casos muy elaborados

Ejemplos de las más útiles:

// Todos los estados del enum (04-07)
@ParameterizedTest
@EnumSource(EstadoPrestamo.class)
void todoEstadoTieneDescripcionNoVacia(EstadoPrestamo estado) {
    assertThat(estado.getDescripcion()).isNotBlank();
}

// Solo algunos
@ParameterizedTest
@EnumSource(value = EstadoPrestamo.class, names = {"ACTIVO", "VENCIDO"})
void losEstadosNoFinalizadosPermitenDevolucion(EstadoPrestamo estado) {
    assertThat(estado.permiteDevolucion()).isTrue();
}

// Validación de entradas inválidas: null, vacío y basura
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = { " ", "abc", "978", "978-000000000X", "1234567890123" })
void unIsbnInvalidoEsRechazado(String isbnInvalido) {
    assertThatThrownBy(() -> new Isbn(isbnInvalido))
            .isInstanceOf(IsbnInvalidoException.class);
}

Ese último patrón —combinar @NullAndEmptySource con @ValueSource— es la forma canónica de probar validación de entrada, y cubre en cuatro líneas los casos que normalmente se olvidan.

  1. @MethodSource y los casos complejos

Cuando los parámetros son objetos y no literales:

class GestorPrestamosTest {

    // El método debe ser static (o la clase, @TestInstance(PER_CLASS))
    static Stream<Arguments> escenariosDePrestamo() {
        return Stream.of(
            Arguments.of("Empleado sin préstamos, material disponible",
                         0, 3, true,  null),
            Arguments.of("Empleado en el límite",
                         3, 3, false, LimitePrestamosException.class),
            Arguments.of("Material sin ejemplares",
                         1, 0, false, SinEjemplaresException.class),
            Arguments.of("Empleado a un préstamo del límite",
                         2, 5, true,  null)
        );
    }

    @ParameterizedTest(name = "{0}")
    @MethodSource("escenariosDePrestamo")
    void escenariosDePrestamoDeBiblioTech(String descripcion,
                                          int prestamosActivos,
                                          int ejemplaresDisponibles,
                                          boolean deberiaTenerExito,
                                          Class<? extends Exception> excepcionEsperada) {

        // PREPARAR
        Material material = new Libro("978-0000000001", "Java Efectivo",
                                      ejemplaresDisponibles, "J. Bloch");
        Empleado marta = new Empleado("[email protected]", "Marta Ruiz");
        repositorio.guardar(material);
        repositorio.guardar(marta);
        for (int i = 0; i < prestamosActivos; i++) {
            repositorio.guardarPrestamo(new Prestamo(otroLibro(i), marta, hoy(), 15));
        }

        // ACTUAR y COMPROBAR
        if (deberiaTenerExito) {
            assertThat(gestor.prestar("978-0000000001", marta.getCorreo())).isNotNull();
        } else {
            assertThatThrownBy(() -> gestor.prestar("978-0000000001", marta.getCorreo()))
                    .isInstanceOf(excepcionEsperada);
        }
    }
}

Aquí hay un compromiso que conviene reconocer: esta prueba tiene un if, y el apartado 8 decía que las pruebas no deben tener lógica. Es un caso admisible porque la lógica es trivial y la ganancia en cobertura de escenarios es grande, pero si crece hay que separarla en dos pruebas parametrizadas, una de éxito y otra de fallo.

@MethodSource también acepta un Stream simple cuando solo hay un parámetro:

static Stream<Material> materialesDePrueba() {
    return Stream.of(
        new Libro("978-0000000001", "Java Efectivo", 3, "J. Bloch"),
        new Revista("978-0000000010", "Java Magazine", 5, 42),
        new Dvd("978-0000000020", "Curso de Java", 1, 180));
}

@ParameterizedTest
@MethodSource("materialesDePrueba")
void todoMaterialSePuedePrestarSiHayEjemplares(Material material) {
    int antes = material.getEjemplaresDisponibles();
    material.prestarUnEjemplar();
    assertThat(material.getEjemplaresDisponibles()).isEqualTo(antes - 1);
}

  1. @Nested: organizar por escenario

Cuando una clase de prueba crece, @Nested permite agrupar por escenario con preparación propia:

@DisplayName("GestorPrestamos")
class GestorPrestamosTest {

    private GestorPrestamos gestor;
    private Empleado marta;

    @BeforeEach
    void prepararComun() {
        gestor = crearGestor();
        marta = new Empleado("[email protected]", "Marta Ruiz");
    }

    @Nested
    @DisplayName("cuando el empleado no tiene préstamos activos")
    class SinPrestamosActivos {

        @Test
        @DisplayName("puede prestar un material disponible")
        void puedePrestar() {
            Prestamo prestamo = gestor.prestar("978-0000000001", marta.getCorreo());
            assertThat(prestamo).isNotNull();
        }

        @Test
        @DisplayName("la fecha de vencimiento son 15 días después")
        void vencimientoCorrecto() {
            Prestamo prestamo = gestor.prestar("978-0000000001", marta.getCorreo());
            assertThat(prestamo.getFechaVencimiento())
                    .isEqualTo(LocalDate.now(relojFijo).plusDays(15));
        }
    }

    @Nested
    @DisplayName("cuando el empleado está en el límite de préstamos")
    class EnElLimite {

        @BeforeEach
        void prestarHastaElLimite() {          // preparación PROPIA de este escenario
            gestor.prestar("978-0000000001", marta.getCorreo());
            gestor.prestar("978-0000000002", marta.getCorreo());
            gestor.prestar("978-0000000003", marta.getCorreo());
        }

        @Test
        @DisplayName("no puede prestar más")
        void noPuedePrestarMas() {
            assertThatThrownBy(() -> gestor.prestar("978-0000000004", marta.getCorreo()))
                    .isInstanceOf(LimitePrestamosException.class);
        }

        @Test
        @DisplayName("puede prestar de nuevo tras devolver uno")
        void puedeTrasDevolver() {
            gestor.devolver("978-0000000001", marta.getCorreo());
            assertThat(gestor.prestar("978-0000000004", marta.getCorreo())).isNotNull();
        }
    }
}

Los @BeforeEach se acumulan: primero el de la clase externa, después el de la anidada. La salida es un informe que se lee como una especificación:

GestorPrestamos
  cuando el empleado no tiene préstamos activos
    ✔ puede prestar un material disponible
    ✔ la fecha de vencimiento son 15 días después
  cuando el empleado está en el límite de préstamos
    ✔ no puede prestar más
    ✔ puede prestar de nuevo tras devolver uno

Nota técnica: las clases @Nested deben ser internas no estáticas (clases internas, 04-03), precisamente para poder acceder al estado de la clase externa.

  1. @Tag y la ejecución selectiva

Etiquetar pruebas permite ejecutar subconjuntos:

@Tag("rapida")
class CalculadoraMultasTest { ... }

@Tag("lenta")
@Tag("integracion")
class PrestamoRepositoryIT { ... }
mvn test -Dgroups="rapida"                        # solo las rápidas
mvn test -DexcludedGroups="lenta,integracion"     # todo menos lo lento

O en el pom.xml (11-05):

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <excludedGroups>integracion</excludedGroups>
    </configuration>
</plugin>

Uso típico: en cada guardado se ejecutan las unitarias (segundos); en la integración continua, todo. Es la separación entre surefire y failsafe que verás en 11-05.

  1. @TempDir: probar el código de ficheros

BiblioTech tiene todo el código de E/S del módulo 7: LectorCsv, EscritorCsv, EscrituraAtomica, ImportadorCatalogo. Nunca se probó. @TempDir lo hace trivial:

class EscrituraAtomicaTest {

    @TempDir
    Path directorioTemporal;   // JUnit lo crea antes y lo BORRA después, solo

    @Test
    void escribeElFicheroCompletoONoLoEscribe() throws IOException {

        Path destino = directorioTemporal.resolve("catalogo.csv");
        EscrituraAtomica escritura = new EscrituraAtomica();

        escritura.escribir(destino, List.of(
                "isbn;titulo;ejemplares",
                "978-0000000001;Java Efectivo;3"));

        assertThat(destino).exists();
        assertThat(Files.readAllLines(destino, StandardCharsets.UTF_8))
                .containsExactly(
                        "isbn;titulo;ejemplares",
                        "978-0000000001;Java Efectivo;3");
    }

    @Test
    void noDejaFicherosTemporalesTrasUnaEscrituraCorrecta() throws IOException {
        Path destino = directorioTemporal.resolve("catalogo.csv");
        new EscrituraAtomica().escribir(destino, List.of("linea"));

        try (Stream<Path> ficheros = Files.list(directorioTemporal)) {
            assertThat(ficheros).containsExactly(destino);   // ni .tmp ni .bak
        }
    }

    @Test
    void elFicheroOriginalSobreviveSiLaEscrituraFalla() throws IOException {
        Path destino = directorioTemporal.resolve("catalogo.csv");
        Files.writeString(destino, "contenido original", StandardCharsets.UTF_8);

        assertThatThrownBy(() -> new EscrituraAtomica().escribir(destino, lineasQueFallan()))
                .isInstanceOf(PersistenciaException.class);

        // La garantía de la escritura atómica: el original intacto
        assertThat(Files.readString(destino, StandardCharsets.UTF_8))
                .isEqualTo("contenido original");
    }
}

Ventajas de @TempDir frente a escribir en /tmp a mano:

  1. Se limpia solo, incluso si la prueba falla.
  2. Es único por prueba: dos pruebas no se pisan.
  3. Funciona en cualquier sistema operativo, sin rutas escritas a fuego.
  4. Se puede compartir por clase con @TempDir static Path.

Esa tercera prueba es interesante: verifica la propiedad que motivó escribir EscrituraAtomica en 07-06 —que un fallo a mitad no corrompa el fichero existente—. Eso es probar comportamiento, no implementación.

  1. Qué hace testeable un diseño

Este es el apartado que más va a cambiar tu forma de escribir código.

La testeabilidad no se añade después: es una propiedad del diseño. Y llevas dos módulos tomando decisiones que apuntaban aquí sin que se dijera del todo.

  1. Inyección de dependencias

// NO TESTEABLE: crea sus dependencias
public class GestorPrestamos {
    private final Repositorio repo = new RepositorioJpa();   // necesita base de datos
    private final ServicioAvisos avisos = new AvisosSmtp();  // necesita servidor SMTP
}

// TESTEABLE: las recibe
public class GestorPrestamos {
    public GestorPrestamos(Repositorio repo, ServicioAvisos avisos) { ... }
}

Con la segunda, en la prueba pasas un repositorio en memoria y un servicio de avisos que solo registra llamadas. Sin base de datos, sin red, sin Spring, en un milisegundo.

  1. Interfaces en las fronteras

Toda dependencia sobre algo externo —base de datos, red, sistema de ficheros, reloj, correo— debe estar detrás de una interfaz. Así puedes sustituirla en la prueba. Esas interfaces son las fronteras de tu dominio.

  1. Funciones puras

Una función pura (mismas entradas → mismas salidas, sin efectos secundarios) es trivial de probar:

// Pura: se prueba con una línea
public static BigDecimal calcularMulta(long diasRetraso, BigDecimal porDia, BigDecimal maxima) {
    if (diasRetraso <= 0) return BigDecimal.ZERO;
    return porDia.multiply(BigDecimal.valueOf(diasRetraso)).min(maxima);
}

De ahí un principio arquitectónico muy potente: separa el cálculo de los efectos. Mete toda la lógica de negocio en funciones puras y deja los efectos (guardar, enviar, registrar) en una capa delgada alrededor. Lo puro se prueba exhaustivamente; lo impuro se prueba con dobles (11-06).

  1. El estado se recibe, no se busca

// MAL: busca su estado en un lugar global
public BigDecimal calcular(Prestamo p) {
    BigDecimal porDia = ReglasNegocio.multaPorDia();   // static: no se puede sustituir
    LocalDate hoy = LocalDate.now();                   // reloj del sistema
    ...
}

// BIEN: lo recibe
public BigDecimal calcular(Prestamo p) {
    BigDecimal porDia = propiedades.multa().eurosPorDia();   // inyectado
    LocalDate hoy = LocalDate.now(reloj);                    // inyectado
    ...
}

  1. Métodos pequeños con una responsabilidad

Un método de 200 líneas con ocho ramas necesita decenas de pruebas para cubrirse y ninguna es legible. Cinco métodos de 15 líneas se prueban de cinco en cinco.

Resumen:

Propiedad del diseño Efecto en las pruebas Dónde lo aplicaste
Inyección por constructor Sustituir colaboradores 11-02
Interfaces en las fronteras Simular BD, red, correo 10-03, 11-03
Clock inyectado Controlar el tiempo 10-05
Funciones puras Probar sin preparación 10-04 (streams)
Sin estado global mutable Pruebas independientes 11-02 (singletons sin estado)
Dominio sin anotaciones del framework Probar sin arrancar Spring 11-02
Métodos pequeños Pruebas legibles Módulo 3

  1. El Clock inyectable, por fin cobrado

En 10-05 se introdujo el Clock con esta justificación: "pensado precisamente para poder probarlo". Dos módulos después, aquí está la recompensa.

El problema sin Clock:

public BigDecimal calcular(Prestamo prestamo) {
    LocalDate hoy = LocalDate.now();   // el reloj del SISTEMA
    ...
}

Para probar que un préstamo vencido hace 5 días genera 2,50 € de multa, tendrías que:

  • Esperar cinco días reales (absurdo), o
  • Crear el préstamo con fecha pasada y confiar en que el cálculo va bien (frágil: la prueba da resultados distintos según el día en que se ejecute), o
  • Cambiar el reloj del sistema operativo (inaceptable).

Y hay un problema peor: la prueba pasaría hoy y fallaría el 1 de enero, o al cambiar la hora, o en un servidor en otra zona horaria. Eso es una prueba no determinista, el peor tipo.

La solución con Clock:

public BigDecimal calcular(Prestamo prestamo) {
    LocalDate hoy = LocalDate.now(reloj);   // el reloj INYECTADO
    ...
}
class CalculadoraMultasTest {

    // "Hoy" es SIEMPRE el 20 de marzo de 2026. En cualquier máquina, cualquier día.
    private static final Clock RELOJ_FIJO = Clock.fixed(
            Instant.parse("2026-03-20T10:00:00Z"), ZoneId.of("Europe/Madrid"));

    private CalculadoraMultas calculadora;

    @BeforeEach
    void preparar() {
        calculadora = new CalculadoraMultas(propiedadesDePrueba(), RELOJ_FIJO);
    }

    @Test
    void unPrestamoVencidoHaceCincoDiasGeneraDosEurosCincuenta() {
        Prestamo prestamo = prestamoQueVencioEl(LocalDate.of(2026, 3, 15));
        assertThat(calculadora.calcular(prestamo)).isEqualByComparingTo("2.50");
    }

    @Test
    void unPrestamoQueVenceHoyNoGeneraMulta() {
        Prestamo prestamo = prestamoQueVencioEl(LocalDate.of(2026, 3, 20));
        assertThat(calculadora.calcular(prestamo)).isEqualByComparingTo("0.00");
    }

    @Test
    void unPrestamoQueVenceMananaNoGeneraMulta() {
        Prestamo prestamo = prestamoQueVencioEl(LocalDate.of(2026, 3, 21));
        assertThat(calculadora.calcular(prestamo)).isEqualByComparingTo("0.00");
    }
}

Y se puede viajar en el tiempo dentro de una misma prueba:

@Test
void laMultaCreceUnEuroMedioPorCadaDiaQuePasa() {

    LocalDate vencimiento = LocalDate.of(2026, 3, 15);
    Prestamo prestamo = prestamoQueVencioEl(vencimiento);

    // Día 16: 1 día de retraso
    var dia16 = new CalculadoraMultas(propiedades, relojEn(LocalDate.of(2026, 3, 16)));
    assertThat(dia16.calcular(prestamo)).isEqualByComparingTo("0.50");

    // Día 20: 5 días
    var dia20 = new CalculadoraMultas(propiedades, relojEn(LocalDate.of(2026, 3, 20)));
    assertThat(dia20.calcular(prestamo)).isEqualByComparingTo("2.50");

    // Día 100: limitado al máximo
    var dia100 = new CalculadoraMultas(propiedades, relojEn(LocalDate.of(2026, 6, 23)));
    assertThat(dia100.calcular(prestamo)).isEqualByComparingTo("20.00");
}

private static Clock relojEn(LocalDate fecha) {
    return Clock.fixed(fecha.atStartOfDay(ZoneId.of("Europe/Madrid")).toInstant(),
                       ZoneId.of("Europe/Madrid"));
}

Tres días distintos, tres resultados verificados, en cinco milisegundos. Eso es lo que valía aquella decisión de 10-05.

Otros relojes útiles de java.time:

Fábrica Comportamiento
Clock.fixed(instante, zona) Congelado en un instante
Clock.systemDefaultZone() El del sistema (producción)
Clock.offset(base, duracion) Desplazado: "dentro de 3 días"
Clock.tick(base, duracion) Avanza a saltos (útil para reducir precisión)

Y la lección general, que va mucho más allá del reloj:

Todo lo que no controlas —el tiempo, el azar, la red, el sistema de ficheros, el entorno— debe entrar por una dependencia inyectada. Si no, tu código no es determinista y no se puede probar.

Lo mismo vale para Random (inyecta uno con semilla fija), para UUID.randomUUID() (inyecta un generador) y para las variables de entorno (inyecta la configuración).

  1. El código que NO se puede probar

Reconocer estos patrones te ahorrará horas.

static que lee estado global

// NO SE PUEDE PROBAR
public class CalculadoraMultas {
    public static BigDecimal calcular(Prestamo p) {
        BigDecimal porDia = ReglasNegocio.multaPorDia();   // static: no se sustituye
        LocalDate hoy = LocalDate.now();                   // reloj del sistema
        ...
    }
}

No hay ningún punto de inserción: no se puede sustituir ReglasNegocio ni controlar el reloj. Es exactamente el ReglasNegocio de 07-07, y por eso 11-02 lo convirtió en @ConfigurationProperties inyectable.

new dentro del método

// NO SE PUEDE PROBAR SIN RED
public class EnriquecedorCatalogo {
    public Material enriquecer(Material material) {
        ClienteMetadatos cliente = new ClienteMetadatos();   // llama a una API real
        return cliente.enriquecer(material);
    }
}

Cada ejecución de la prueba haría una petición HTTP real: lenta, no determinista y dependiente de que la API esté disponible. La solución es inyectar ClienteMetadatos detrás de una interfaz, y en 11-06 lo simularás.

Efectos secundarios ocultos

public void procesar(Prestamo p) {
    calcular(p);
    enviarCorreo(p);                    // envía un correo DE VERDAD
    System.out.println("Procesado");    // no se puede comprobar
    ficheroLog.append(...);             // escribe en disco
}

Cada prueba enviaría un correo real. Separa el cálculo de los efectos.

Métodos privados con lógica compleja

Si sientes la necesidad de probar un método privado, es señal de que esa lógica quiere ser una clase. Extráela a una clase propia con su interfaz y pruébala directamente. Usar reflexión para probar métodos privados es un antipatrón: acopla la prueba a la implementación.

Patrón Por qué no se prueba Solución
static con estado global No hay dónde insertar el doble Instancia con dependencias inyectadas
new en el método No se puede sustituir Inyectar por constructor
LocalDate.now(), new Random() No determinista Inyectar Clock, Random
System.out.println No se puede comprobar Logger inyectado o valor de retorno
Efectos mezclados con cálculo Cada prueba causa efectos reales Separar cálculo de efectos
Métodos privados complejos Solo accesibles por reflexión Extraer a otra clase
Constructor con trabajo pesado Construir ya causa efectos Mover a un método de inicialización

  1. Qué es un buen conjunto de pruebas

Las propiedades que hay que perseguir, con sus siglas habituales en inglés (FIRST):

Propiedad Qué significa Cómo se consigue
Rápido Milisegundos por prueba unitaria Sin BD, sin red, sin sleep
Independiente Cada prueba se puede ejecutar sola Sin estado compartido; @BeforeEach
Repetible Mismo resultado siempre, en cualquier máquina Clock fijo, sin dependencias del entorno
Autoverificable Verde o rojo, sin interpretación humana Aserciones, no System.out.println
Oportuno Se escriben con el código, no un año después Disciplina de equipo

Y dos criterios más que se usan mucho:

  • Independiente del orden: si al ejecutar las pruebas en orden aleatorio alguna falla, hay estado compartido. JUnit permite forzarlo con @TestMethodOrder(MethodOrderer.Random.class) para detectarlo.
  • Sin lógica: cero if, for y try/catch improvisados. La prueba debe ser tan obvia que no necesite pruebas.

Y la propiedad más importante de todas: una prueba que nunca falla no vale nada. Compruébalo rompiendo el código a propósito una vez —cambia un <= por un <— y verifica que la prueba se pone roja. Si no, no está probando lo que crees.

  1. Antipatrones

Pruebas frágiles. Se rompen al refactorizar aunque el comportamiento no cambie. Suelen probar implementación en vez de comportamiento:

// FRÁGIL: comprueba CÓMO se hace
verify(repositorio).findById(1L);
verify(repositorio).save(any());
verify(calculadora).calcular(any());
// Si mañana se usa findByIsbn, la prueba falla aunque el resultado sea idéntico.

// ROBUSTA: comprueba QUÉ resultado se obtiene
assertThat(gestor.prestar("978-0000000001", "[email protected]"))
        .extracting(Prestamo::getFechaVencimiento)
        .isEqualTo(LocalDate.of(2026, 3, 16));

Este tema se profundiza en 11-06, porque el exceso de verify es el pecado capital de Mockito.

Pruebas que prueban el framework. Comprobar que @Column(nullable = false) genera una columna NOT NULL, o que Spring inyecta un bean, es probar código de terceros que ya está probado. Prueba tu lógica.

Thread.sleep en las pruebas.

// MAL
servicio.procesarAsincrono();
Thread.sleep(1000);              // ¿y si tarda 1100 ms en la integración continua?
assertThat(resultado).isNotNull();

Es lento (multiplícalo por cincuenta pruebas) e intermitente: pasa en tu portátil y falla en el servidor de integración. Las alternativas: CountDownLatch (08-05), CompletableFuture.get() con timeout (08-07), o la librería Awaitility.

Pruebas intermitentes (flaky). Fallan a veces sin razón aparente. Causas típicas: dependencia del reloj, del orden de ejecución, de la concurrencia o de la red. Son peores que no tener pruebas, porque el equipo se acostumbra a ignorar los fallos. Una prueba intermitente se arregla o se borra el mismo día.

Pruebas gigantes. Cien líneas de preparación y treinta aserciones. Cuando falla, nadie sabe qué se rompió. Divide.

Aserciones vacías o inútiles.

@Test
void pruebaDePrestamo() {
    Prestamo p = gestor.prestar("978-0000000001", "[email protected]");
    assertNotNull(p);       // ¿y qué? No comprueba ninguna regla
}

Copiar la implementación en la prueba.

// Inútil: si la fórmula está mal, la prueba está mal igual
BigDecimal esperado = porDia.multiply(BigDecimal.valueOf(dias)).min(maxima);
assertThat(calculadora.calcular(prestamo)).isEqualByComparingTo(esperado);

Los valores esperados se escriben a mano, calculados por una persona: "2.50". Ese es el punto: la prueba es una segunda opinión independiente.

  1. Probar código concurrente

El módulo 8 dejó CatalogoConcurrente, ExecutorService, CompletableFuture y colecciones concurrentes. Probarlos tiene una dificultad de fondo:

Una prueba de concurrencia que pasa no demuestra que el código sea correcto. Solo demuestra que esa vez no se manifestó el fallo.

Aun así hay técnicas útiles:

@Test
void elCatalogoConcurrenteSoportaCienHilosSimultaneos() throws Exception {

    CatalogoConcurrente catalogo = new CatalogoConcurrente();
    int hilos = 100, operaciones = 1000;

    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {   // 10-06
        CountDownLatch listos = new CountDownLatch(hilos);
        CountDownLatch salida = new CountDownLatch(1);

        for (int i = 0; i < hilos; i++) {
            executor.submit(() -> {
                listos.countDown();
                salida.await();                    // todos empiezan A LA VEZ
                for (int j = 0; j < operaciones; j++) catalogo.incrementarConsultas();
                return null;
            });
        }
        listos.await(5, TimeUnit.SECONDS);
        salida.countDown();                        // disparo simultáneo
    }   // el cierre del executor espera a que terminen

    assertThat(catalogo.getConsultas()).isEqualTo((long) hilos * operaciones);
}

El truco de los dos CountDownLatch —uno para esperar a que todos estén listos, otro para dispararlos a la vez— maximiza la probabilidad de que la condición de carrera se manifieste. Es lo que hacías en 08-05.

Recomendaciones:

  1. Prefiere diseños sin estado compartido. El código concurrente que no comparte estado no necesita pruebas de concurrencia.
  2. Prueba la lógica por separado, sin concurrencia, y añade una prueba de estrés como complemento.
  3. Etiquétalas como lentas (@Tag("lenta")) y sácalas del ciclo rápido.
  4. Herramientas especializadas (jcstress) existen para verificación seria de concurrencia; están fuera del alcance de este curso.

  1. Ejecutar las pruebas con Maven

Comandos que usarás a diario (11-05 los explica a fondo):

mvn test                                     # compila y ejecuta las pruebas unitarias
mvn test -Dtest=CalculadoraMultasTest        # solo una clase
mvn test -Dtest=CalculadoraMultasTest#multa* # solo métodos que empiecen por "multa"
mvn test -Dgroups="rapida"                   # solo las etiquetadas
mvn verify                                   # unitarias + integración (failsafe)
mvn package -DskipTests                      # empaqueta SIN ejecutar pruebas

Un aviso sobre ese último: -DskipTests es útil para empaquetar rápido en local. Nunca en la integración continua. Si el proyecto necesita saltarse pruebas para compilar, hay un problema mayor.

Cuando algo falla, la salida es:

[ERROR] Failures:
[ERROR]   CalculadoraMultasTest.laMultaSeLimitaAlMaximo:47
           expected: 20.00 but was: 100.00
[INFO] Tests run: 24, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE

Con el informe completo en target/surefire-reports/. Y lo importante: la compilación falla. Eso es lo que impide que el código roto llegue a producción.

  1. Cobertura, mencionada

La cobertura de código mide qué porcentaje de tu código ejecutan las pruebas. En Java se mide con JaCoCo.

Dos advertencias, y son serias:

  1. Cobertura no es calidad. Una línea ejecutada no es una línea probada. Este código tiene 100 % de cobertura y no comprueba nada:
@Test
void cobertura100() {
    calculadora.calcular(prestamo);   // se ejecuta... y no se comprueba nada
}
  1. Un objetivo de cobertura alto genera pruebas malas. Si el equipo debe alcanzar el 90 %, aparecerán pruebas de getters y setters que suben el número sin aportar nada.

Lo que sí es útil: mirar qué está sin cubrir. Un método de negocio con 0 % de cobertura es información valiosa.

Se desarrolla en 12-05, con la configuración de JaCoCo y su interpretación honesta.

  1. @SpringBootTest y @DataJpaTest, presentados

Hasta aquí, todo han sido pruebas unitarias sin Spring, que es como debe ser: rápidas y numerosas. Pero también hay que verificar que las piezas encajan.

@SpringBootTest arranca el contexto completo:

@SpringBootTest
class BiblioTechApplicationTests {

    @Autowired
    private GestorPrestamos gestor;

    @Test
    void elContextoSeCargaCorrectamente() {
        assertThat(gestor).isNotNull();
    }
}

Esa prueba, aparentemente trivial, tiene valor real: detecta un bean que falta, una dependencia circular o una propiedad obligatoria sin definir. Es la prueba que evita descubrir en el despliegue que la aplicación no arranca.

@DataJpaTest arranca solo la capa de persistencia, con una base de datos embebida y transacción con reversión automática:

@DataJpaTest
class PrestamoRepositoryTest {

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

    @Test
    void encuentraLosPrestamosVencidosSinDevolver() {
        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.flush();

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

        assertThat(vencidos).hasSize(1)
                .first().extracting(p -> p.getMaterial().getTitulo())
                .isEqualTo("Java Efectivo");
    }
}

Cada prueba se ejecuta en una transacción que se revierte al terminar, así que no se contaminan entre sí.

Anotación Qué arranca Velocidad
(ninguna) Nada. Java puro 1-10 ms
@DataJpaTest JPA, repositorios, BD embebida ~1 s
@WebMvcTest Solo la capa web (12-04) ~1 s
@SpringBootTest Todo el contexto 2-10 s

La regla: usa la anotación más pequeña que sirva. Cada prueba con @SpringBootTest que podría ser unitaria es tiempo que pagas en cada compilación, para siempre.

Se desarrollan en 11-06 (con Mockito) y en 12-05.

  1. La batería de pruebas de BiblioTech

El resultado completo. Estructura:

src/test/java/com/nexussoftware/bibliotech/
├── dominio/
│   ├── PrestamoTest.java              <- reglas de vencimiento y devolución
│   ├── MaterialTest.java              <- ejemplares disponibles
│   └── IsbnTest.java                  <- validación
├── servicio/
│   ├── CalculadoraMultasTest.java     <- multas, con Clock fijo
│   └── GestorPrestamosTest.java       <- límite de préstamos
├── persistencia/
│   └── EscrituraAtomicaTest.java      <- @TempDir
└── BiblioTechApplicationTests.java    <- @SpringBootTest: carga del contexto

CalculadoraMultasTest completa:

package com.nexussoftware.bibliotech.servicio;

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

import java.math.BigDecimal;
import java.time.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

@DisplayName("Cálculo de multas de BiblioTech")
class CalculadoraMultasTest {

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

    private CalculadoraMultas calculadora;
    private Empleado marta;
    private Material libro;

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

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

    @Nested
    @DisplayName("cuando el préstamo sigue en plazo")
    class EnPlazo {

        @Test
        @DisplayName("no hay multa si vence dentro de una semana")
        void sinMultaSiVenceEnUnaSemana() {
            assertThat(calculadora.calcular(prestamoQueVence(HOY.plusDays(7))))
                    .isEqualByComparingTo("0.00");
        }

        @Test
        @DisplayName("no hay multa el mismo día del vencimiento")
        void sinMultaElDiaDelVencimiento() {
            assertThat(calculadora.calcular(prestamoQueVence(HOY)))
                    .isEqualByComparingTo("0.00");
        }
    }

    @Nested
    @DisplayName("cuando el préstamo está vencido")
    class Vencido {

        @ParameterizedTest(name = "{0} días de retraso -> {1} €")
        @CsvSource({
                "  1,  0.50", "  2,  1.00", "  5,  2.50", " 10,  5.00",
                " 39, 19.50", " 40, 20.00", " 41, 20.00", "200, 20.00"
        })
        @DisplayName("la multa es 0,50 € por día hasta el máximo de 20 €")
        void multaPorDiasDeRetraso(int dias, BigDecimal esperada) {
            assertThat(calculadora.calcular(prestamoQueVence(HOY.minusDays(dias))))
                    .isEqualByComparingTo(esperada);
        }

        @Test
        @DisplayName("la multa nunca supera el máximo configurado")
        void nuncaSuperaElMaximo() {
            assertThat(calculadora.calcular(prestamoQueVence(HOY.minusYears(2))))
                    .isEqualByComparingTo("20.00");
        }
    }

    @Nested
    @DisplayName("cuando el préstamo ya se devolvió")
    class Devuelto {

        @Test
        @DisplayName("no hay multa aunque se devolviera tarde")
        void sinMultaTrasLaDevolucion() {
            Prestamo prestamo = prestamoQueVence(HOY.minusDays(30));
            prestamo.devolver(HOY.minusDays(10));

            assertThat(calculadora.calcular(prestamo)).isEqualByComparingTo("0.00");
        }
    }

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

Ejecución:

mvn test
[INFO] Running com.nexussoftware.bibliotech.servicio.CalculadoraMultasTest
[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.089 s
[INFO] Running com.nexussoftware.bibliotech.dominio.PrestamoTest
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.021 s
...
[INFO] Tests run: 41, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
[INFO] Total time: 6.412 s

Cuarenta y una pruebas en menos de un segundo de ejecución efectiva. A partir de ahora, cada refactorización de BiblioTech tiene una red debajo.

  1. Errores Comunes y Consejos

Error: escribir pruebas que nunca fallan. Rompe el código a propósito y comprueba que la prueba se pone roja. Si no, no prueba nada.

Error: probar la implementación en vez del comportamiento. Si la prueba se rompe al refactorizar sin cambiar el resultado, está mal escrita.

Error: Thread.sleep en pruebas. Lento e intermitente. CountDownLatch, Future.get(timeout) o Awaitility.

Error: dependencia del orden. Si las pruebas fallan al ejecutarse en orden distinto, hay estado compartido. @BeforeEach, no campos estáticos mutables.

Error: LocalDate.now() en el código de producción. Hace la prueba no determinista. Clock inyectado.

Error: assertEquals con BigDecimal. new BigDecimal("2.50").equals(new BigDecimal("2.5")) es false. Usa isEqualByComparingTo de AssertJ o compareTo.

Error: invertir el orden en assertEquals. El esperado va primero; al revés, los mensajes engañan.

Error: try/catch en vez de assertThrows. Si no se lanza la excepción, la prueba pasa igualmente.

Error: @Disabled sin motivo. Y una prueba desactivada más de un mes hay que borrarla: está dando falsa sensación de cobertura.

Error: @SpringBootTest para todo. Multiplica el tiempo del conjunto por mil sin aportar nada cuando la clase se puede construir con new.

Error: perseguir un porcentaje de cobertura. Genera pruebas de getters que no verifican nada.

Consejo: usa AssertJ. Ya lo tienes, y sus mensajes de error ahorran tiempo real.

Consejo: nombres largos y descriptivos. laMultaSeLimitaAlMaximoConfigurado es mejor que testMulta. Nadie llama a estos métodos: su único propósito es comunicar.

Consejo: una prueba, un comportamiento. Si el nombre necesita un "y", son dos pruebas.

Consejo: pruebas parametrizadas para casos límite. El coste de añadir el caso 39, 40 y 41 es una línea, y justo ahí están los bugs.

Consejo: escribe primero la prueba que falla. Aunque no practiques TDD estricto, empezar por una prueba roja garantiza que la prueba prueba algo.

Consejo: cuando aparezca un bug, escribe primero la prueba que lo reproduce. Arréglalo después. Así ese bug concreto no vuelve nunca.

Consejo: si algo es difícil de probar, el problema está en el diseño. No pelees con la prueba: arregla el diseño.

  1. Ejercicios

Ejercicio 1: probar las reglas de Prestamo

Esta es la entidad Prestamo de BiblioTech tras 11-03:

@Entity
public class Prestamo {

    @Id @GeneratedValue private Long id;
    @ManyToOne(fetch = FetchType.LAZY) private Material material;
    @ManyToOne(fetch = FetchType.LAZY) private Empleado empleado;
    private LocalDate fechaPrestamo;
    private LocalDate fechaVencimiento;
    private LocalDate fechaDevolucion;
    @Enumerated(EnumType.STRING) private EstadoPrestamo estado;

    public Prestamo(Material material, Empleado empleado, LocalDate fechaPrestamo, int dias) {
        this.material = material;
        this.empleado = empleado;
        this.fechaPrestamo = fechaPrestamo;
        this.fechaVencimiento = fechaPrestamo.plusDays(dias);
        this.estado = EstadoPrestamo.ACTIVO;
    }

    public boolean estaVencido(LocalDate hoy) {
        return fechaDevolucion == null && hoy.isAfter(fechaVencimiento);
    }

    public void devolver(LocalDate fecha) {
        if (fechaDevolucion != null) {
            throw new PrestamoYaDevueltoException(id);
        }
        if (fecha.isBefore(fechaPrestamo)) {
            throw new FechaInvalidaException("No se puede devolver antes de prestar");
        }
        this.fechaDevolucion = fecha;
        this.estado = EstadoPrestamo.DEVUELTO;
    }

    public Optional<LocalDate> getFechaDevolucion() {
        return Optional.ofNullable(fechaDevolucion);
    }
}

Escribe una clase de prueba completa que cubra:

  1. Que un préstamo nuevo tiene los datos correctos (usa assertAll).
  2. estaVencido con al menos cinco casos, incluidos los límites (el día del vencimiento y el día siguiente), mediante una prueba parametrizada.
  3. Que devolver funciona y cambia el estado.
  4. Que devolver dos veces lanza PrestamoYaDevueltoException.
  5. Que devolver antes de prestar lanza FechaInvalidaException.
  6. Que un préstamo devuelto nunca está vencido, aunque se devolviera tarde.
  7. Organiza con @Nested y @DisplayName.

Ejercicio 2: hacer testeable lo intestable

Esta clase de BiblioTech no se puede probar. Identifica todos los problemas, reescríbela y escribe tres pruebas que antes eran imposibles.

public class ServicioAvisosVencimiento {

    public int enviarAvisosDelDia() {
        int enviados = 0;
        LocalDate hoy = LocalDate.now();

        RepositorioPrestamos repositorio = new RepositorioPrestamosJpa();
        List<Prestamo> prestamos = repositorio.buscarTodos();

        for (Prestamo p : prestamos) {
            long dias = ChronoUnit.DAYS.between(hoy, p.getFechaVencimiento());
            if (dias == Integer.parseInt(ReglasNegocio.get("aviso.dias.antelacion", "2"))) {
                ClienteCorreo correo = new ClienteCorreo("smtp.nexussoftware.com", 587);
                correo.enviar(p.getEmpleado().getCorreo(),
                              "Tu préstamo de " + p.getMaterial().getTitulo() + " vence pronto");
                System.out.println("Aviso enviado a " + p.getEmpleado().getCorreo());
                enviados++;
            }
        }
        return enviados;
    }
}

Se pide:

  1. Lista de todos los obstáculos para probarla.
  2. Versión reescrita, testeable, sin cambiar el comportamiento.
  3. Tres pruebas: se avisa cuando faltan exactamente los días configurados; no se avisa cuando faltan más o menos; se cuentan correctamente los avisos enviados.

(Nota: aquí usarás implementaciones falsas en memoria de las interfaces. Los simulados con Mockito son 11-06.)

Ejercicio 3: encontrar el bug con una prueba

Un compañero de Nexus Software ha implementado la renovación de préstamos. Marta Ruiz informa de que "a veces renovar no añade días". El código:

public class GestorRenovaciones {

    private static final int MAX_RENOVACIONES = 2;
    private final Clock reloj;

    public GestorRenovaciones(Clock reloj) { this.reloj = reloj; }

    public LocalDate renovar(Prestamo prestamo, int dias) {
        if (prestamo.getRenovaciones() >= MAX_RENOVACIONES) {
            throw new LimiteRenovacionesException(prestamo.getId());
        }
        if (prestamo.getFechaDevolucion().isPresent()) {
            throw new PrestamoYaDevueltoException(prestamo.getId());
        }

        LocalDate hoy = LocalDate.now(reloj);
        LocalDate nuevaFecha = prestamo.getFechaVencimiento().plusDays(dias);

        if (nuevaFecha.isBefore(hoy)) {
            nuevaFecha = hoy;
        }

        prestamo.actualizarVencimiento(nuevaFecha);
        prestamo.incrementarRenovaciones();
        return nuevaFecha;
    }
}

Se pide:

  1. Escribe pruebas que cubran el caso normal, el límite de renovaciones y el préstamo ya devuelto.
  2. Encuentra el bug escribiendo la prueba que lo revela. Pista: piensa en un préstamo que lleva mucho tiempo vencido.
  3. Explica el comportamiento incorrecto desde el punto de vista del negocio.
  4. Corrige el código y demuestra que la prueba pasa.

Soluciones

Solución 1

package com.nexussoftware.bibliotech.dominio;

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertAll;

import java.time.LocalDate;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

@DisplayName("Préstamo")
class PrestamoTest {

    private static final LocalDate FECHA_PRESTAMO = LocalDate.of(2026, 3, 1);
    private static final int DIAS = 15;
    private static final LocalDate VENCIMIENTO = LocalDate.of(2026, 3, 16);

    private Material libro;
    private Empleado marta;
    private Prestamo prestamo;

    @BeforeEach
    void preparar() {
        libro = new Libro("978-0000000001", "Java Efectivo", 3, "J. Bloch");
        marta = new Empleado("[email protected]", "Marta Ruiz");
        prestamo = new Prestamo(libro, marta, FECHA_PRESTAMO, DIAS);
    }

    @Nested
    @DisplayName("recién creado")
    class RecienCreado {

        @Test
        @DisplayName("(1) tiene todos los datos correctos")
        void datosCorrectos() {
            assertAll("datos del préstamo",
                    () -> assertThat(prestamo.getMaterial()).isEqualTo(libro),
                    () -> assertThat(prestamo.getEmpleado()).isEqualTo(marta),
                    () -> assertThat(prestamo.getFechaPrestamo()).isEqualTo(FECHA_PRESTAMO),
                    () -> assertThat(prestamo.getFechaVencimiento()).isEqualTo(VENCIMIENTO),
                    () -> assertThat(prestamo.getEstado()).isEqualTo(EstadoPrestamo.ACTIVO),
                    () -> assertThat(prestamo.getFechaDevolucion()).isEmpty());
        }
    }

    @Nested
    @DisplayName("comprobación de vencimiento")
    class Vencimiento {

        // (2) cinco casos, incluidos los límites
        @ParameterizedTest(name = "el {0} -> vencido = {1}")
        @CsvSource({
                "2026-03-01, false",   // el día del préstamo
                "2026-03-15, false",   // víspera del vencimiento
                "2026-03-16, false",   // LÍMITE: el mismo día NO está vencido
                "2026-03-17, true",    // LÍMITE: el día siguiente SÍ
                "2026-04-30, true",    // mucho después
                "2027-01-01, true"
        })
        void estaVencidoSegunLaFecha(LocalDate hoy, boolean esperado) {
            assertThat(prestamo.estaVencido(hoy)).isEqualTo(esperado);
        }
    }

    @Nested
    @DisplayName("al devolver")
    class Devolucion {

        @Test
        @DisplayName("(3) registra la fecha y cambia el estado")
        void devolucionCorrecta() {
            LocalDate fechaDevolucion = LocalDate.of(2026, 3, 10);

            prestamo.devolver(fechaDevolucion);

            assertAll(
                    () -> assertThat(prestamo.getFechaDevolucion()).contains(fechaDevolucion),
                    () -> assertThat(prestamo.getEstado()).isEqualTo(EstadoPrestamo.DEVUELTO));
        }

        @Test
        @DisplayName("(4) devolver dos veces lanza PrestamoYaDevueltoException")
        void noSePuedeDevolverDosVeces() {
            prestamo.devolver(LocalDate.of(2026, 3, 10));

            assertThatThrownBy(() -> prestamo.devolver(LocalDate.of(2026, 3, 11)))
                    .isInstanceOf(PrestamoYaDevueltoException.class);
        }

        @Test
        @DisplayName("(5) devolver antes de prestar lanza FechaInvalidaException")
        void noSePuedeDevolverAntesDePrestar() {
            assertThatThrownBy(() -> prestamo.devolver(FECHA_PRESTAMO.minusDays(1)))
                    .isInstanceOf(FechaInvalidaException.class)
                    .hasMessageContaining("antes de prestar");
        }

        @Test
        @DisplayName("(6) un préstamo devuelto nunca está vencido, aunque se devolviera tarde")
        void devueltoNuncaEstaVencido() {
            prestamo.devolver(LocalDate.of(2026, 4, 30));   // 45 días tarde

            assertThat(prestamo.estaVencido(LocalDate.of(2027, 1, 1))).isFalse();
        }
    }
}

Notas sobre las decisiones:

  • Los casos límite del punto 2 son los que importan: el día 16 (vence, pero no está vencido) y el 17 (sí lo está). Ahí es donde vive el bug de > frente a >=, y una prueba parametrizada los cubre por una línea cada uno.
  • El punto 6 prueba una regla de negocio real, no una línea de código: un préstamo cerrado no puede estar vencido, aunque su fecha de devolución sea posterior al vencimiento.
  • assertThat(optional).contains(x) de AssertJ es más limpio que isPresent() seguido de get().
  • El @BeforeEach deja el escenario en un estado conocido; cada prueba parte de cero.

Solución 2

1. Obstáculos para probar la versión original.

Línea Problema Consecuencia
LocalDate.now() Reloj del sistema La prueba da resultados distintos según el día
new RepositorioPrestamosJpa() Instancia una dependencia real Necesita base de datos
ReglasNegocio.get(...) Estático y global No se puede configurar en la prueba
Integer.parseInt en el bucle Conversión repetida Menor, pero es lógica repetida
new ClienteCorreo(...) en el bucle Crea un cliente SMTP por aviso Enviaría correos reales; además, ineficiente
System.out.println Efecto no comprobable No se puede verificar
Sin abstracción del correo Acoplado a SMTP No hay dónde poner un doble

2. Versión testeable.

package com.nexussoftware.bibliotech.servicio;

import java.time.Clock;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;
import org.slf4j.*;
import org.springframework.stereotype.Service;

@Service
public class ServicioAvisosVencimiento {

    private static final Logger log = LoggerFactory.getLogger(ServicioAvisosVencimiento.class);

    private final RepositorioPrestamos repositorio;    // interfaz
    private final ServicioAvisos avisos;               // interfaz
    private final PropiedadesBiblioTech propiedades;   // configuración inyectada
    private final Clock reloj;                         // tiempo controlable

    public ServicioAvisosVencimiento(RepositorioPrestamos repositorio,
                                     ServicioAvisos avisos,
                                     PropiedadesBiblioTech propiedades,
                                     Clock reloj) {
        this.repositorio = repositorio;
        this.avisos = avisos;
        this.propiedades = propiedades;
        this.reloj = reloj;
    }

    public int enviarAvisosDelDia() {
        LocalDate hoy = LocalDate.now(reloj);
        int diasAntelacion = propiedades.aviso().diasAntelacion();

        List<Prestamo> aAvisar = repositorio.buscarActivos().stream()
                .filter(p -> ChronoUnit.DAYS.between(hoy, p.getFechaVencimiento()) == diasAntelacion)
                .toList();

        aAvisar.forEach(p -> {
            avisos.avisar(p.getEmpleado(),
                    "Tu préstamo de " + p.getMaterial().getTitulo() + " vence pronto");
            log.info("Aviso enviado a {}", p.getEmpleado().getCorreo());
        });

        return aAvisar.size();
    }
}

Cambios y su motivo:

Antes Ahora Por qué
LocalDate.now() LocalDate.now(reloj) Determinista
new RepositorioPrestamosJpa() Interfaz inyectada Sustituible por un falso en memoria
ReglasNegocio.get(...) propiedades.aviso().diasAntelacion() Configurable y validado
new ClienteCorreo(...) en el bucle ServicioAvisos inyectado Sustituible, y una sola instancia
System.out.println Logger (11-07) Configurable por entorno
buscarTodos() buscarActivos() Menos datos: el filtrado en la consulta

3. Las tres pruebas.

package com.nexussoftware.bibliotech.servicio;

import static org.assertj.core.api.Assertions.assertThat;

import java.time.*;
import java.util.*;
import org.junit.jupiter.api.*;

@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);

    // Doble de prueba tipo FAKE: implementación real, pero en memoria
    static class RepositorioEnMemoria implements RepositorioPrestamos {
        private final List<Prestamo> prestamos = new ArrayList<>();
        void anadir(Prestamo p) { prestamos.add(p); }
        @Override public List<Prestamo> buscarActivos() { return List.copyOf(prestamos); }
    }

    // Doble tipo SPY casero: registra lo que se le pide
    static class AvisosRegistrados implements ServicioAvisos {
        final List<String> destinatarios = new ArrayList<>();
        final List<String> mensajes = new ArrayList<>();
        @Override public void avisar(Empleado destinatario, String mensaje) {
            destinatarios.add(destinatario.getCorreo());
            mensajes.add(mensaje);
        }
    }

    private RepositorioEnMemoria repositorio;
    private AvisosRegistrados avisos;
    private ServicioAvisosVencimiento servicio;

    @BeforeEach
    void preparar() {
        repositorio = new RepositorioEnMemoria();
        avisos = new AvisosRegistrados();

        var propiedades = propiedadesConAntelacionDe(2);   // avisar 2 días antes
        servicio = new ServicioAvisosVencimiento(repositorio, avisos, propiedades, RELOJ);
    }

    @Test
    @DisplayName("avisa cuando faltan exactamente los días configurados")
    void avisaConLaAntelacionConfigurada() {
        repositorio.anadir(prestamoQueVence(HOY.plusDays(2), "[email protected]"));

        int enviados = servicio.enviarAvisosDelDia();

        assertThat(enviados).isEqualTo(1);
        assertThat(avisos.destinatarios).containsExactly("[email protected]");
        assertThat(avisos.mensajes).first().asString().contains("Java Efectivo");
    }

    @Test
    @DisplayName("no avisa si faltan más o menos días de los configurados")
    void noAvisaFueraDeLaVentana() {
        repositorio.anadir(prestamoQueVence(HOY.plusDays(1), "[email protected]"));  // 1 día
        repositorio.anadir(prestamoQueVence(HOY.plusDays(3), "[email protected]"));  // 3 días
        repositorio.anadir(prestamoQueVence(HOY,             "[email protected]"));  // hoy
        repositorio.anadir(prestamoQueVence(HOY.minusDays(5),"[email protected]"));  // vencido

        int enviados = servicio.enviarAvisosDelDia();

        assertThat(enviados).isZero();
        assertThat(avisos.destinatarios).isEmpty();
    }

    @Test
    @DisplayName("cuenta correctamente los avisos enviados")
    void cuentaLosAvisos() {
        repositorio.anadir(prestamoQueVence(HOY.plusDays(2), "[email protected]"));
        repositorio.anadir(prestamoQueVence(HOY.plusDays(2), "[email protected]"));
        repositorio.anadir(prestamoQueVence(HOY.plusDays(2), "[email protected]"));
        repositorio.anadir(prestamoQueVence(HOY.plusDays(9), "[email protected]"));

        assertThat(servicio.enviarAvisosDelDia()).isEqualTo(3);
        assertThat(avisos.destinatarios).containsExactlyInAnyOrder(
                "[email protected]",
                "[email protected]",
                "[email protected]");
    }

    private Prestamo prestamoQueVence(LocalDate vencimiento, String correo) {
        return new Prestamo(
                new Libro("978-0000000001", "Java Efectivo", 3, "J. Bloch"),
                new Empleado(correo, "Empleado de prueba"),
                vencimiento.minusDays(15), vencimiento);
    }
}

Observa la segunda prueba: verifica los cuatro casos límite alrededor de la ventana de aviso (1 día, 3 días, hoy, vencido). Ese == diasAntelacion es exactamente donde un >= mal puesto avisaría a todo el mundo todos los días, y esta prueba lo detectaría.

Y observa los dos dobles caseros: un fake (repositorio en memoria con comportamiento real) y un spy (registra lo que recibe). En 11-06 verás sus nombres precisos y cómo Mockito los genera solo.

Solución 3

1 y 2. Las pruebas, incluida la que revela el bug.

package com.nexussoftware.bibliotech.servicio;

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

import java.time.*;
import org.junit.jupiter.api.*;

@DisplayName("Renovación de préstamos")
class GestorRenovacionesTest {

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

    private GestorRenovaciones gestor;

    @BeforeEach
    void preparar() { gestor = new GestorRenovaciones(RELOJ); }

    @Test
    @DisplayName("renovar añade los días a la fecha de vencimiento")
    void renovarAnadeDias() {
        Prestamo prestamo = prestamoQueVence(HOY.plusDays(3));   // vence el 23

        LocalDate nueva = gestor.renovar(prestamo, 15);

        assertThat(nueva).isEqualTo(LocalDate.of(2026, 4, 7));   // 23 + 15
        assertThat(prestamo.getRenovaciones()).isEqualTo(1);
    }

    @Test
    @DisplayName("no se puede renovar más de dos veces")
    void limiteDeRenovaciones() {
        Prestamo prestamo = prestamoQueVence(HOY.plusDays(3));
        gestor.renovar(prestamo, 15);
        gestor.renovar(prestamo, 15);

        assertThatThrownBy(() -> gestor.renovar(prestamo, 15))
                .isInstanceOf(LimiteRenovacionesException.class);
    }

    @Test
    @DisplayName("no se puede renovar un préstamo ya devuelto")
    void noSeRenuevaUnDevuelto() {
        Prestamo prestamo = prestamoQueVence(HOY.plusDays(3));
        prestamo.devolver(HOY.minusDays(1));

        assertThatThrownBy(() -> gestor.renovar(prestamo, 15))
                .isInstanceOf(PrestamoYaDevueltoException.class);
    }

    // ===== LA PRUEBA QUE REVELA EL BUG =====
    @Test
    @DisplayName("renovar un préstamo muy vencido debe añadir los días desde HOY, no desde el pasado")
    void renovarUnPrestamoMuyVencido() {
        // Marta tenía un préstamo que venció hace 60 días (el 19 de enero)
        Prestamo prestamo = prestamoQueVence(HOY.minusDays(60));

        LocalDate nueva = gestor.renovar(prestamo, 15);

        // Esperado por negocio: hoy (20 de marzo) + 15 días = 4 de abril
        assertThat(nueva).isEqualTo(LocalDate.of(2026, 4, 4));
    }
}

Esa última prueba falla:

Expected: 2026-04-04
Actual:   2026-03-20

3. El comportamiento incorrecto.

Trazando el código con un préstamo que venció el 19 de enero de 2026 y una renovación de 15 días:

LocalDate hoy = LocalDate.of(2026, 3, 20);
LocalDate nuevaFecha = LocalDate.of(2026, 1, 19).plusDays(15);   // = 2026-02-03

if (nuevaFecha.isBefore(hoy)) {    // 3 de febrero es anterior al 20 de marzo -> true
    nuevaFecha = hoy;              // ¡se queda en HOY!
}

El préstamo se renueva con vencimiento hoy mismo: Marta pulsa "Renovar 15 días" y su préstamo vence esa misma tarde. Y peor: ha gastado una de sus dos renovaciones a cambio de nada. Al día siguiente vuelve a estar vencido, con multa, y solo le queda una renovación.

Ese es el "a veces renovar no añade días" del informe. Es "a veces" porque solo ocurre cuando el retraso acumulado supera los días de renovación —justo el caso de quien más necesita renovar—.

La raíz del error es que la cláusula de seguridad if (nuevaFecha.isBefore(hoy)) tenía una buena intención —evitar que una renovación deje el vencimiento en el pasado— pero eligió mal la corrección: en vez de recalcular desde hoy, se limitó a hoy.

4. La corrección.

public LocalDate renovar(Prestamo prestamo, int dias) {
    if (prestamo.getRenovaciones() >= MAX_RENOVACIONES) {
        throw new LimiteRenovacionesException(prestamo.getId());
    }
    if (prestamo.getFechaDevolucion().isPresent()) {
        throw new PrestamoYaDevueltoException(prestamo.getId());
    }

    LocalDate hoy = LocalDate.now(reloj);

    // La renovación siempre cuenta desde la fecha MÁS TARDÍA entre hoy y el
    // vencimiento actual. Así se garantiza que SIEMPRE se añaden `dias` completos
    // de préstamo útil, tanto si el préstamo está en plazo como si está vencido.
    LocalDate base = prestamo.getFechaVencimiento().isAfter(hoy)
            ? prestamo.getFechaVencimiento()
            : hoy;

    LocalDate nuevaFecha = base.plusDays(dias);

    prestamo.actualizarVencimiento(nuevaFecha);
    prestamo.incrementarRenovaciones();
    return nuevaFecha;
}

Comprobación con las cuatro pruebas:

Escenario Vencimiento actual Base Resultado
En plazo (vence el 23) 2026-03-23 2026-03-23 2026-04-07 ✔
Vence hoy 2026-03-20 2026-03-20 2026-04-04 ✔
Vencido hace 60 días 2026-01-19 2026-03-20 (hoy) 2026-04-04 ✔

Las cuatro pruebas pasan, y la que revelaba el bug se queda en el conjunto para siempre: si alguien reintroduce la lógica antigua durante una refactorización, se pondrá roja al instante.

Esa es la práctica profesional del consejo del apartado 32: ante un bug, primero la prueba que lo reproduce, después el arreglo. Así ese bug concreto no vuelve nunca.

Conclusión

BiblioTech tiene por fin una red de seguridad.

Entiendes el coste de no tener pruebas, que llevabas once lecciones pagando sin saberlo: miedo a cambiar, regresiones descubiertas en producción, depuración lenta, verificación manual repetida y —el que conecta con Log4Shell de 11-01— la incapacidad de actualizar una dependencia con rapidez cuando aparece una vulnerabilidad. Y sabes qué te dan a cambio: refactorizar sin miedo, documentación ejecutable que no puede desactualizarse porque si miente se pone roja, y mejor diseño, porque las pruebas son un detector de problemas estructurales.

Conoces la pirámide de pruebas y por qué su forma importa: muchas unitarias que tardan milisegundos, algunas de integración, muy pocas de extremo a extremo. Un conjunto invertido tarda veinte minutos y nadie lo ejecuta.

Dominas JUnit 5: su arquitectura de Platform, Jupiter y Vintage; las dependencias que trae spring-boot-starter-test; la anatomía Preparar-Actuar-Comprobar con sus reglas —una acción por prueba, sin lógica, un comportamiento por prueba—; nombres largos y descriptivos con @DisplayName; las aserciones de JUnit con assertThrows que devuelve la excepción para inspeccionarla y assertAll que reporta todos los fallos de una vez; el ciclo de vida con @BeforeEach preferido sobre @BeforeAll; la instancia nueva por prueba que garantiza independencia; @Disabled siempre con motivo y las anotaciones condicionales; @Nested para organizar por escenario con preparación propia; @Tag para la ejecución selectiva; y @TempDir, que por fin permitió probar el código de ficheros del módulo 7 —incluida la propiedad que motivó escribir EscrituraAtomica: que un fallo a mitad no corrompa el fichero existente—.

Y dominas las pruebas parametrizadas, que convirtieron doce casos del cálculo de multas —con los tres límites que de verdad importan, 39, 40 y 41 alrededor del máximo— en seis líneas. Con @CsvSource, @EnumSource, @MethodSource para objetos complejos y el patrón canónico de @NullAndEmptySource más @ValueSource para probar validación de entrada.

Sabes por qué se recomienda AssertJ: no por estética, sino porque hasSize(3) te dice qué había cuando falla, porque isEqualByComparingTo resuelve el problema real de que new BigDecimal("2.50").equals(new BigDecimal("2.5")) sea false, y porque su encadenamiento sobre colecciones y Optional expresa comprobaciones que con JUnit serían bucles.

Y sobre todo entiendes qué hace testeable un diseño, que es la parte que cambia cómo escribes código de producción: inyección por constructor, interfaces en las fronteras, funciones puras separadas de los efectos, estado que se recibe en vez de buscarse, y métodos pequeños. Con el contrapunto: reconoces el código que no se puede probar —static que lee estado global, new dentro del método, LocalDate.now(), efectos ocultos, métodos privados con lógica compleja— y sabes que cuando algo es difícil de probar, el problema está en el diseño, no en la prueba.

Y el Clock inyectable de 10-05 se ha cobrado por fin. Con Clock.fixed, "hoy" es siempre el 20 de marzo de 2026, en cualquier máquina y cualquier día del año; se puede viajar en el tiempo dentro de una misma prueba y verificar tres fechas distintas en cinco milisegundos. Con la lección general que va mucho más allá del reloj: todo lo que no controlas —tiempo, azar, red, ficheros, entorno— debe entrar por una dependencia inyectada.

Conoces las propiedades de un buen conjunto —rápido, independiente, repetible, autoverificable, sin lógica, independiente del orden— y sus antipatrones: pruebas frágiles que prueban implementación, pruebas que prueban el framework, Thread.sleep, pruebas intermitentes que son peores que no tener pruebas, y copiar la fórmula de la implementación en el valor esperado.

Sabes ejecutarlas con Maven, interpretar el informe y —lo decisivo— que la compilación falla cuando una prueba se rompe. Conoces la cobertura y su interpretación honesta, remitida a 12-05. Y tienes presentados @SpringBootTest y @DataJpaTest, con la regla de oro: usa la anotación más pequeña que sirva.


Cuarenta y una pruebas en menos de un segundo. Y ahora, la pregunta incómoda de esta lección.

¿Qué es exactamente mvn test?

Lo has escrito cinco veces. Has visto que compila, que ejecuta las pruebas, que escribe informes en target/surefire-reports/ y que falla la compilación si algo se pone rojo. Has puesto <scope>test</scope> en una dependencia y has dado por bueno que eso hace que AssertJ y JUnit no acaben dentro del jar de producción. Has dado por hecho que src/test/java es donde van las pruebas y que el proyecto sabe encontrarlas. Has visto aparecer maven-surefire-plugin en un pom.xml sin saber qué es un plugin. Y en 11-01 se habló de mvn dependency:tree y de coordenadas groupId:artifactId:version como si ya supieras qué son.

Nada de eso se ha explicado. Y sin embargo lleva tres lecciones sosteniendo todo lo que has hecho: las dependencias de Spring, las de Hibernate, las de H2, las de JUnit, el jar ejecutable, el spring-boot:run.

Hay algo más grave. Hasta el módulo 10, BiblioTech se compilaba con javac a mano y un classpath escrito a mano. Con cuarenta jars en el árbol de dependencias, eso ya no es tedioso: es sencillamente imposible. Y una de las lecciones de Log4Shell (11-01) era que la capacidad de responder a una vulnerabilidad depende de poder cambiar una versión con una línea y verificar con un comando que nada se rompió. Acabas de conseguir la segunda mitad de esa frase. Falta la primera.

En la próxima lección se explica la herramienta que ha estado debajo todo el tiempo. Verás qué problema resuelve una herramienta de construcción frente al javac del módulo 1; la convención sobre configuración que explica por qué src/test/java funciona sin configurar nada; el POM completo y comentado de BiblioTech; las dependencias con sus ámbitos —incluido ese test que ya usaste—, sus dependencias transitivas y la regla que resuelve los conflictos de versiones; el ciclo de vida con sus fases, y por qué mvn test compila antes de probar sin que se lo pidas; los plugins, entre ellos el surefire que ejecuta tus pruebas y el failsafe que ejecutará las de integración; los perfiles, los proyectos multimódulo, el Maven Wrapper y la comparación honesta con Gradle.

Y al terminar, BiblioTech será un proyecto Maven completo y reproducible, con todas sus dependencias declaradas, ejecutable con un solo comando en cualquier máquina del mundo que tenga Java 17.

Después de eso volveremos a las pruebas — porque las que has escrito hoy solo han podido cubrir lo que no tiene colaboradores de verdad, y eso hay que resolverlo.

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