BiblioTech tiene arquitectura, patrones, una CLI profesional y una API REST completa. Y una pregunta sin responder: ¿funciona de verdad?

Hay pruebas —las cuarenta y una del módulo 11, más las que añadió la lección anterior—, pero eso no es una estrategia de calidad. Nadie sabe qué porcentaje del código se ejercita. Nadie ha comprobado si esas pruebas verifican algo o simplemente ejecutan líneas sin afirmar nada. Las pruebas de repositorio corren sobre H2, que no es la base de datos de producción y que miente en detalles que importan. Nadie ha pasado un análisis estático. Y no hay integración continua: si Diego Alonso rompe el cálculo de multas un viernes, nadie se entera hasta que Marta Ruiz se queja el martes.

Esta lección convierte «tengo pruebas» en «tengo una estrategia de calidad»: saber qué se prueba en cada nivel y cuánto debe tardar, probar contra la base de datos real, medir la cobertura y —lo más importante— interpretarla con honestidad, evaluar la calidad de las propias aserciones con pruebas de mutación, pasar herramientas de análisis estático, refactorizar con red de seguridad, escribir código guiado por pruebas, revisar el trabajo de otros, y automatizarlo todo para que la máquina diga «no» antes de que lo diga un usuario.

Una advertencia previa: nada de esto es gratis. Cada herramienta añade tiempo de construcción y trabajo de mantenimiento. La lección incluye siempre el coste, no solo el beneficio, porque una estrategia de calidad que el equipo abandona a las tres semanas es peor que no tener ninguna.

Contenido

  1. Qué significa «calidad» en un proyecto de software
  2. La estrategia de pruebas de BiblioTech
  3. La pirámide de pruebas y por qué se invierte sola
  4. Tiempos objetivo por nivel
  5. Testcontainers: por qué H2 miente
  6. PostgreSQL real desde la prueba
  7. Separar pruebas unitarias e integración en Maven
  8. Cobertura con JaCoCo
  9. La interpretación honesta de la cobertura
  10. Pruebas de mutación con PIT
  11. Análisis estático: SpotBugs, PMD, Checkstyle, SonarQube
  12. Formateo automático con Spotless
  13. Complejidad, deuda técnica y code smells
  14. Refactorización segura
  15. TDD: el ciclo rojo-verde-refactor
  16. TDD paso a paso: recargo por material dañado
  17. Cuándo aporta TDD y cuándo no
  18. Revisión de código
  19. Integración continua con GitHub Actions
  20. Qué NO probar
  21. Pruebas frágiles como deuda
  22. Rendimiento y carga
  23. Errores Comunes y Consejos
  24. Ejercicios
  25. Conclusión

  1. Qué significa «calidad» en un proyecto de software

Hay dos calidades, y confundirlas explica la mitad de las discusiones sobre este tema:

Calidad externa Calidad interna
Quién la percibe El usuario Quien mantiene el código
Qué es Que funcione, sea rápido, no pierda datos Que sea fácil de entender y cambiar
Cómo se mide Errores en producción, tiempo de respuesta Complejidad, acoplamiento, tiempo de un cambio
Si se descuida Los usuarios se quejan hoy El proyecto se ralentiza dentro de seis meses

La externa se defiende sola: los usuarios protestan. La interna no tiene quien la reclame, y por eso se degrada. Su síntoma es siempre el mismo y es medible: el tiempo que cuesta añadir una funcionalidad crece con el tiempo.

Las pruebas son la única herramienta que sirve a las dos: verifican el comportamiento (externa) y permiten cambiar el código sin miedo (interna). Todo lo demás en esta lección —cobertura, mutación, análisis estático, revisiones— existe para responder a una pregunta: ¿me puedo fiar de estas pruebas?

  1. La estrategia de pruebas de BiblioTech

Una estrategia no es «escribir pruebas». Es decidir qué se prueba en cada nivel para no probar tres veces lo mismo ni dejar huecos.

Nivel Qué se prueba Herramientas Levanta Cuántas
Dominio Reglas de negocio puras: multas, estados, invariantes JUnit 5, AssertJ Nada Muchas (~60 %)
Aplicación Casos de uso: orquestación, caminos de error JUnit 5, Mockito Nada Bastantes (~20 %)
Repositorios Consultas, mapeo, relaciones, transacciones @DataJpaTest, Testcontainers Base de datos Pocas (~10 %)
Web Rutas, validación, códigos, JSON @WebMvcTest, MockMvc MVC de Spring Pocas (~8 %)
Extremo a extremo Flujos completos del usuario @SpringBootTest, Testcontainers Todo Muy pocas (~2 %)

Aplicado a una funcionalidad concreta, «prestar un material», el reparto es este:

Se prueba En qué nivel Por qué ahí
Un préstamo no se puede devolver dos veces Dominio Es un invariante de Prestamo; no necesita nada más
La multa son 0,50 €/día con máximo de 20 € Dominio Cálculo puro con Clock.fixed
Un empleado no puede tener 4 préstamos activos Aplicación Requiere el repositorio (mockeado)
Si el notificador falla, el préstamo se crea igual Aplicación Camino de error con Mockito
findVencidosAntesDe devuelve lo correcto Repositorio Es JPQL: hay que ejecutarlo contra una base real
POST /api/prestamos devuelve 201 con Location Web Es contrato HTTP
Un ISBN inválido devuelve 400 con el detalle Web Es validación de entrada
Prestar y devolver funciona de principio a fin E2E Integración real de todas las piezas

La regla que evita duplicar: cada comprobación se hace en el nivel más bajo posible. Verificar el cálculo de la multa en un @SpringBootTest cuesta cinco segundos y prueba lo mismo que una prueba de dominio de cinco milisegundos.

  1. La pirámide de pruebas y por qué se invierte sola

flowchart TD
    E["E2E — pocas, lentas, frágiles<br/>~2%: 15 s cada una"]
    W["Web e integración<br/>~18%: 1-3 s cada una"]
    U["Unitarias — muchas, rápidas, estables<br/>~80%: 5 ms cada una"]

    E --- W
    W --- U

    style U fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style E fill:#ffebee,stroke:#c62828

La forma correcta es una pirámide: base ancha de pruebas rápidas, punta estrecha de pruebas lentas. La forma que aparece sola si nadie vigila es la contraria, el cono de helado:

Motivo por el que se invierte Cómo suena en el equipo
Una prueba E2E parece más «real» «Si pasa el E2E, funciona todo»
Escribirla no requiere diseñar nada «Levanto todo y ya está»
Probar una clase aislada exige que sea aislable «Es que esta clase necesita medio Spring»
Nadie mide el tiempo de la suite Hasta que tarda 25 minutos

Y las consecuencias son concretas y todas malas: la suite tarda tanto que nadie la ejecuta antes de subir código; cuando algo falla, el diagnóstico es «algo en el flujo de préstamo» en lugar de «la multa se calcula mal el día 31»; las pruebas fallan intermitentemente por tiempos de espera y se acaban ignorando; y el coste de mantenerlas supera el valor que aportan, momento en el que alguien propone borrarlas.

La causa raíz casi nunca es pereza: si probar una clase por separado es difícil, el problema es el diseño de esa clase, no la prueba. Una clase con siete dependencias y estado estático no se puede probar aislada. La solución no es escribir un E2E: es arreglar la clase (12-01 y 12-02).

  1. Tiempos objetivo por nivel

Los tiempos no son un capricho; determinan si la suite se ejecuta o se ignora.

Nivel Por prueba Suite completa Cuándo se ejecuta
Dominio < 10 ms < 5 s En cada guardado, desde el IDE
Aplicación < 50 ms < 15 s Antes de cada commit
Repositorio < 500 ms < 60 s Antes de cada push
Web < 200 ms < 30 s Antes de cada push
E2E < 20 s < 5 min En CI
Total en CI < 10 min En cada Pull Request

El límite de los diez minutos en CI no es arbitrario: por encima de ese umbral, la gente deja de esperar el resultado, cambia de tarea y pierde el contexto. Y el de quince segundos en local es aún más importante: si la suite rápida tarda más, se deja de ejecutar.

Medir el tiempo es tan importante como medir el resultado:

# Las 10 pruebas más lentas del proyecto
./mvnw test -Dsurefire.reportFormat=plain
grep -h "Time elapsed" target/surefire-reports/*.txt | sort -t: -k2 -rn | head -10

Y una prueba de que la suite no se degrada, que resulta sorprendentemente eficaz:

@Test
void laSuiteDeDominioEsRapida() {
    long inicio = System.nanoTime();
    // ejecutar el conjunto de pruebas de dominio…
    long ms = (System.nanoTime() - inicio) / 1_000_000;
    assertThat(ms)
        .as("Las pruebas de dominio deben seguir siendo rápidas")
        .isLessThan(5_000);
}

  1. Testcontainers: por qué H2 miente

BiblioTech usa H2 en memoria para las pruebas de repositorio desde el módulo 11. Es rápido y cómodo. Y produce falsos positivos y falsos negativos, porque H2 no es PostgreSQL.

Los casos concretos en los que miente:

Diferencia H2 PostgreSQL Consecuencia
Tipos JSON Sin jsonb real jsonb con operadores e índices Consultas que en H2 funcionan y en producción no
Funciones nativas Ausentes to_tsvector, similarity, generate_series La búsqueda a texto completo no se puede probar
Secuencias Comportamiento propio Semántica específica de SERIAL/IDENTITY Colisiones de identificador solo en producción
Ordenación de texto Binaria Según collation del sistema «Álvarez» va antes o después de «Alvarez» según el motor
Bloqueos Simplificados FOR UPDATE, niveles reales de aislamiento Los interbloqueos no aparecen en las pruebas
Distinción de mayúsculas Depende de la configuración Sensible por defecto Consultas que fallan solo en producción
Restricciones Menos estrictas Estrictas Violaciones de integridad que solo salen en real
Zonas horarias Simplificadas timestamptz real Errores de fecha en el cambio de hora

El caso más doloroso, y muy real: una consulta JPQL con una función que Hibernate traduce distinto según el dialecto. Pasa en verde con H2 y explota en producción con un error de sintaxis SQL. El coste de esa lección se paga a las 3 de la mañana.

Testcontainers resuelve esto: levanta un contenedor Docker con la base de datos real desde la propia prueba, y lo destruye al terminar.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-testcontainers</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>postgresql</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>junit-jupiter</artifactId>
  <scope>test</scope>
</dependency>

  1. PostgreSQL real desde la prueba

Spring Boot 3.1 introdujo @ServiceConnection, que elimina la parte más tediosa: configurar la URL, el usuario y la contraseña a partir del contenedor.

/**
 * Clase base de las pruebas de integración.
 *
 * El contenedor es static: se crea UNA VEZ para toda la ejecución
 * y lo comparten todas las clases que heredan. Sin static, se
 * levantaría un PostgreSQL por clase de prueba (30 s cada uno).
 */
@Testcontainers
public abstract class PruebaConPostgres {

    @Container
    @ServiceConnection            // Spring Boot 3.1+: configura el DataSource solo
    static final PostgreSQLContainer<?> POSTGRES =
            new PostgreSQLContainer<>("postgres:16-alpine")
                    .withDatabaseName("bibliotech_test")
                    .withUsername("test")
                    .withPassword("test")
                    .withReuse(true);      // reutiliza el contenedor entre ejecuciones locales
}

Antes de @ServiceConnection había que escribir esto, y todavía se ve en muchos proyectos:

@DynamicPropertySource
static void propiedades(DynamicPropertyRegistry registro) {
    registro.add("spring.datasource.url", POSTGRES::getJdbcUrl);
    registro.add("spring.datasource.username", POSTGRES::getUsername);
    registro.add("spring.datasource.password", POSTGRES::getPassword);
}

Una prueba de repositorio con base de datos real:

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)   // ¡no sustituyas por H2!
@Tag("integracion")
class PrestamoRepositoryIT extends PruebaConPostgres {

    @Autowired PrestamoRepository repositorio;
    @Autowired TestEntityManager em;

    @Test
    void encuentraLosPrestamosVencidosOrdenadosPorAntiguedad() {
        Empleado marta = em.persist(unEmpleado("Marta Ruiz"));
        Material java = em.persist(unLibro("978-0000000001", "Java Efectivo"));

        em.persist(unPrestamo(java, marta).conVencimiento(LocalDate.of(2026, 3, 1)));   // vencido
        em.persist(unPrestamo(java, marta).conVencimiento(LocalDate.of(2026, 3, 10)));  // vencido
        em.persist(unPrestamo(java, marta).conVencimiento(LocalDate.of(2026, 9, 1)));   // vigente
        em.flush();

        List<Prestamo> vencidos = repositorio.vencidosAntesDe(LocalDate.of(2026, 8, 5));

        assertThat(vencidos)
                .hasSize(2)
                .extracting(Prestamo::getFechaVencimiento)
                .containsExactly(LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 10));
    }

    @Test
    void laBusquedaIgnoraMayusculasYAcentosComoEnProduccion() {
        em.persist(unLibro("978-0000000003", "Refactorización"));
        em.flush();

        // Esto usa unaccent() de PostgreSQL: en H2 sería IMPOSIBLE de probar
        assertThat(repositorio.buscarPorTitulo("refactorizacion")).hasSize(1);
        assertThat(repositorio.buscarPorTitulo("REFACTORIZACIÓN")).hasSize(1);
    }

    @Test
    void detectaElConflictoDeBloqueoOptimista() {
        Prestamo p = em.persistFlushFind(unPrestamo());
        em.detach(p);

        // Simular una modificación concurrente por otra transacción
        em.getEntityManager()
          .createNativeQuery("update prestamo set version = version + 1 where id = :id")
          .setParameter("id", p.getId())
          .executeUpdate();

        p.renovar(7);

        assertThatThrownBy(() -> { repositorio.save(p); em.flush(); })
                .isInstanceOf(OptimisticLockingFailureException.class);
    }
}

El coste, sin adornos:

Aspecto H2 Testcontainers
Arranque ~200 ms 3-15 s el primer contenedor
Por prueba ~10 ms ~50 ms (contenedor compartido)
Requiere Docker No , también en CI
Fidelidad con producción Baja Total
Suite de 30 pruebas de repositorio ~5 s ~25 s

Cinco veces más lento, y merece la pena, por una razón concreta: las pruebas de repositorio son pocas (10 % de la suite) y son exactamente las que más se benefician de la fidelidad. Las de dominio, que son el 60 %, siguen sin tocar nada y siguen tardando milisegundos.

Dos trucos que reducen el coste real:

# ~/.testcontainers.properties — reutilizar contenedores entre ejecuciones locales
testcontainers.reuse.enable=true

Y el patrón de contenedor único, que ya está aplicado arriba con el static en la clase base: sin él, cada clase de prueba levanta su propio PostgreSQL.

  1. Separar pruebas unitarias e integración en Maven

Con pruebas de dos velocidades, hay que poder ejecutar solo las rápidas. Maven ya tiene el mecanismo desde 11-05: surefire para unitarias, failsafe para integración.

Convenio de nombres:

Sufijo Plugin Fase Ejemplo
*Test.java surefire test CalculadoraMultasTest
*IT.java failsafe verify PrestamoRepositoryIT
<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <configuration>
        <excludedGroups>integracion</excludedGroups>   <!-- por si alguna Test lleva @Tag -->
        <includes>
          <include>**/*Test.java</include>
        </includes>
      </configuration>
    </plugin>

    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-failsafe-plugin</artifactId>
      <configuration>
        <includes>
          <include>**/*IT.java</include>
        </includes>
      </configuration>
      <executions>
        <execution>
          <goals>
            <goal>integration-test</goal>
            <goal>verify</goal>            <!-- verify es quien FALLA la construcción -->
          </goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>
./mvnw test                    # solo unitarias: ~20 s
./mvnw verify                  # unitarias + integración: ~3 min
./mvnw verify -DskipITs        # saltarse las de integración
./mvnw test -Dgroups=rapidas    # solo las etiquetadas

Complementariamente, @Tag de JUnit 5 permite cortes transversales:

@Tag("integracion")
@Tag("lento")
class ImportacionMasivaIT extends PruebaConPostgres { … }

Un detalle sobre el paralelismo, que es la forma más barata de recuperar tiempo:

# src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.config.strategy=dynamic
junit.jupiter.execution.parallel.config.dynamic.factor=1.0

Con la advertencia obligatoria: el paralelismo destapa pruebas que comparten estado. Si al activarlo empiezan a fallar de forma aleatoria, no desactives el paralelismo; arregla las pruebas, porque ese estado compartido es un problema real.

  1. Cobertura con JaCoCo

La cobertura mide qué porcentaje del código se ejecuta durante las pruebas. JaCoCo es la herramienta estándar en Java.

<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <version>0.8.12</version>
  <executions>
    <!-- 1. Instrumentar antes de las pruebas unitarias -->
    <execution>
      <id>preparar-agente</id>
      <goals><goal>prepare-agent</goal></goals>
    </execution>

    <!-- 2. Generar el informe tras las pruebas -->
    <execution>
      <id>informe</id>
      <phase>verify</phase>
      <goals><goal>report</goal></goals>
    </execution>

    <!-- 3. Comprobar umbrales: si no se cumplen, la construcción FALLA -->
    <execution>
      <id>comprobar-umbrales</id>
      <phase>verify</phase>
      <goals><goal>check</goal></goals>
      <configuration>
        <rules>
          <rule>
            <element>BUNDLE</element>
            <limits>
              <limit>
                <counter>INSTRUCTION</counter>
                <value>COVEREDRATIO</value>
                <minimum>0.75</minimum>
              </limit>
              <limit>
                <counter>BRANCH</counter>          <!-- la métrica que de verdad importa -->
                <value>COVEREDRATIO</value>
                <minimum>0.70</minimum>
              </limit>
            </limits>
          </rule>
          <!-- El dominio es lógica pura: se exige más -->
          <rule>
            <element>PACKAGE</element>
            <includes><include>com.nexussoftware.bibliotech.dominio.*</include></includes>
            <limits>
              <limit>
                <counter>BRANCH</counter>
                <value>COVEREDRATIO</value>
                <minimum>0.90</minimum>
              </limit>
            </limits>
          </rule>
        </rules>
      </configuration>
    </execution>
  </executions>

  <configuration>
    <excludes>
      <!-- Excluir lo que no tiene lógica que probar -->
      <exclude>**/dto/**</exclude>
      <exclude>**/*Application.class</exclude>
      <exclude>**/config/**</exclude>
      <exclude>**/generated/**</exclude>
    </excludes>
  </configuration>
</plugin>
./mvnw verify
# El informe navegable, con el código coloreado línea a línea:
open target/site/jacoco/index.html

Cobertura de líneas frente a cobertura de ramas, que es la distinción que separa una métrica útil de una engañosa:

public Dinero calcularMulta(Prestamo prestamo, LocalDate hoy) {
    long dias = ChronoUnit.DAYS.between(prestamo.getFechaVencimiento(), hoy);
    if (dias <= 0) {
        return Dinero.CERO;
    }
    Dinero multa = prestamo.multaPorDia().por(dias);
    return multa.esMayorQue(MAXIMO) ? MAXIMO : multa;
}

Con una sola prueba:

@Test
void calculaLaMultaDeDiezDias() {
    assertThat(calculadora.calcularMulta(prestamoVencidoHace(10), HOY))
            .isEqualTo(Dinero.euros("5.00"));
}
Métrica Resultado Qué falta
Líneas 80 % (4 de 5) El return Dinero.CERO
Ramas 50 % (2 de 4) dias <= 0, y el tope del máximo

La cobertura de líneas dice 80 % y suena bien. La de ramas dice 50 % y dice la verdad: la mitad de los caminos de decisión no se ha probado nunca, incluido el tope de 20 € que es una regla de negocio explícita.

Mide siempre ramas. Es más difícil de subir y mucho más informativa.

  1. La interpretación honesta de la cobertura

Aquí es donde la mayoría de los equipos se engaña, así que conviene ser directo:

La cobertura alta NO garantiza calidad. La cobertura baja SÍ señala riesgo.

Es una implicación en un solo sentido, y esta prueba lo demuestra:

@Test
void calculaLaMulta() {
    // Ejecuta TODO el método: cobertura de líneas del 100 %
    calculadora.calcularMulta(unPrestamoVencidoHace(10), HOY);
    // Y NO COMPRUEBA NADA.
}

JaCoCo dará 100 % de cobertura de ese método. Si mañana alguien cambia 0.50 por 50.00, la prueba sigue pasando en verde. La cobertura mide ejecución, no verificación.

Casos reales de cobertura que miente:

Patrón Cobertura Valor real
Prueba sin aserciones 100 % Cero
assertThat(resultado).isNotNull() 100 % Casi cero
Prueba que solo cubre el camino feliz 60 % de ramas Medio: los errores no se prueban
Prueba con la misma lógica que el código 100 % Negativo: replica el bug

Y al revés, la cobertura baja siempre significa algo:

Cobertura de un paquete Interpretación
0 % en dominio.prestamos Alarma: la lógica de negocio no se prueba
30 % en un servicio Los caminos de error probablemente no se prueban
95 % en dto Irrelevante: no hay lógica; excluir del cálculo

Cómo usar la cobertura sin engañarse:

  1. Como detector de huecos, no como objetivo. Abre el informe y busca en rojo la lógica importante. Eso es una lista de tareas.
  2. Con umbrales por paquete, no globales. Exigir 90 % en el dominio y 60 % en infraestructura tiene sentido; exigir 80 % global premia probar getters.
  3. Vigilando la tendencia, no el valor absoluto. Que baje de 78 % a 71 % en un PR es una señal; que sea 78 % y no 80 % no lo es.
  4. Nunca como objetivo individual. El día que alguien mida el rendimiento de un desarrollador por cobertura, tendrás miles de pruebas sin aserciones. La ley de Goodhart en estado puro: cuando una medida se convierte en objetivo, deja de ser una buena medida.

Y para saber si tus pruebas verifican algo, hay una herramienta específica.

  1. Pruebas de mutación con PIT

La cobertura mide si el código se ejecuta. Las pruebas de mutación miden si tus aserciones detectan cambios.

Funcionamiento: la herramienta introduce pequeñas modificaciones en tu código (los mutantes) y ejecuta las pruebas. Si alguna falla, el mutante ha sido eliminado (bien). Si todas pasan, el mutante sobrevive: tus pruebas no detectarían ese cambio (mal).

Mutación Ejemplo
Condicional de frontera <<=
Negar condicional ==!=
Operador aritmético +-
Valor de retorno return xreturn null
Eliminar llamada a void Se borra la línea
Incrementos ++--
<plugin>
  <groupId>org.pitest</groupId>
  <artifactId>pitest-maven</artifactId>
  <version>1.16.1</version>
  <dependencies>
    <dependency>
      <groupId>org.pitest</groupId>
      <artifactId>pitest-junit5-plugin</artifactId>
      <version>1.2.1</version>
    </dependency>
  </dependencies>
  <configuration>
    <targetClasses>
      <param>com.nexussoftware.bibliotech.dominio.*</param>   <!-- donde está la lógica -->
    </targetClasses>
    <targetTests>
      <param>com.nexussoftware.bibliotech.dominio.*Test</param>
    </targetTests>
    <mutationThreshold>70</mutationThreshold>
    <timestampedReports>false</timestampedReports>
  </configuration>
</plugin>
./mvnw org.pitest:pitest-maven:mutationCoverage
open target/pit-reports/index.html

El mutante superviviente en el cálculo de multas. Este es el código real:

public Dinero calcularMulta(Prestamo prestamo, LocalDate hoy) {
    long dias = ChronoUnit.DAYS.between(prestamo.getFechaVencimiento(), hoy);
    if (dias <= 0) {                                    // ← el punto crítico
        return Dinero.CERO;
    }
    Dinero multa = prestamo.multaPorDia().por(dias);
    return multa.esMayorQue(MAXIMO) ? MAXIMO : multa;
}

Y estas son las pruebas que había:

@Test void sinRetrasoNoHayMulta()      { assertThat(calcular(-3)).isEqualTo(Dinero.CERO); }
@Test void conDiezDiasSonCincoEuros()  { assertThat(calcular(10)).isEqualTo(Dinero.euros("5.00")); }
@Test void nuncaSuperaElMaximo()       { assertThat(calcular(100)).isEqualTo(Dinero.euros("20.00")); }

Cobertura de ramas: 100 %. Informe de PIT:

CalculadoraMultas.java
  L.4   changed conditional boundary → SURVIVED    (dias <= 0  →  dias < 0)
  L.8   changed conditional boundary → KILLED
  L.7   Replaced long multiplication with division → KILLED

El mutante superviviente cambia dias <= 0 por dias < 0. La diferencia está exactamente en el día 0: el día en que vence el préstamo. Con el código original, devolver ese mismo día no genera multa. Con el mutante, dias == 0 entra en el cálculo y genera... 0 días × 0,50 € = 0 €. En este caso concreto el resultado coincide por casualidad, pero la frontera no está probada, y basta con que mañana alguien cambie la fórmula a (dias + 1) * tarifa para que el día del vencimiento empiece a cobrarse sin que ninguna prueba se entere.

La prueba que falta, y que es la que un revisor experimentado pediría:

@ParameterizedTest
@CsvSource({
    "-1, 0.00",     // un día antes de vencer
    " 0, 0.00",     // EL DÍA DEL VENCIMIENTO: la frontera
    " 1, 0.50",     // un día de retraso
    "39, 19.50",    // justo por debajo del máximo
    "40, 20.00",    // exactamente el máximo
    "41, 20.00"     // por encima: se aplica el tope
})
void calculaLaMultaEnLasFronteras(int diasDeRetraso, String multaEsperada) {
    assertThat(calcular(diasDeRetraso)).isEqualTo(Dinero.euros(multaEsperada));
}

Con ella, PIT elimina el mutante. Y fíjate en lo que ha pasado: la cobertura era del 100 % antes y sigue siendo del 100 % después. La cobertura no podía ver este problema; la mutación sí.

El coste, que es real: PIT es lento (ejecuta la suite una vez por mutante) y produce falsos positivos (mutantes equivalentes, que no cambian el comportamiento y son imposibles de matar). Por eso:

  • Aplícalo solo al dominio, que es donde está la lógica que importa.
  • Ejecútalo semanalmente o en la rama principal, no en cada PR.
  • Un umbral del 70-80 % en el dominio es exigente y alcanzable. El 100 % no es un objetivo razonable.

  1. Análisis estático: SpotBugs, PMD, Checkstyle, SonarQube

El análisis estático examina el código sin ejecutarlo. Cada herramienta busca cosas distintas y son complementarias:

Herramienta Qué busca Ejemplo de hallazgo Falsos positivos
SpotBugs Bugs probables (analiza bytecode) NullPointerException posible, comparar String con ==, recurso no cerrado Pocos
PMD Malas prácticas y complejidad Método de 200 líneas, complejidad 25, variable sin usar, catch vacío Medios
Checkstyle Estilo y convenciones Nombres, orden de imports, ausencia de Javadoc Muchos si se configura mal
SonarQube Todo lo anterior + seguridad + duplicación + histórico Inyección SQL, secretos, deuda técnica en horas Medios
ArchUnit Reglas de arquitectura (12-01) «El dominio importa Spring» Ninguno
Error Prone Bugs en compilación Comparación de tipos incompatibles Muy pocos

SpotBugs es el que más valor aporta por línea de configuración, porque encuentra errores reales:

<plugin>
  <groupId>com.github.spotbugs</groupId>
  <artifactId>spotbugs-maven-plugin</artifactId>
  <version>4.8.6.4</version>
  <configuration>
    <effort>Max</effort>
    <threshold>Medium</threshold>
    <failOnError>true</failOnError>
    <excludeFilterFile>config/spotbugs-exclusiones.xml</excludeFilterFile>
    <plugins>
      <plugin>
        <groupId>com.h3xstream.findsecbugs</groupId>     <!-- análisis de seguridad -->
        <artifactId>findsecbugs-plugin</artifactId>
        <version>1.13.0</version>
      </plugin>
    </plugins>
  </configuration>
  <executions>
    <execution><phase>verify</phase><goals><goal>check</goal></goals></execution>
  </executions>
</plugin>

Hallazgos típicos en un proyecto como BiblioTech:

// SpotBugs: DM_DEFAULT_ENCODING — depende de la codificación de la plataforma
Files.readString(ruta);                          // MAL
Files.readString(ruta, StandardCharsets.UTF_8);  // BIEN

// SpotBugs: ES_COMPARING_STRINGS_WITH_EQ
if (estado == "ACTIVO")           // MAL: compara referencias
if ("ACTIVO".equals(estado))      // BIEN

// SpotBugs: EI_EXPOSE_REP — se expone la representación interna
public List<Prestamo> getPrestamos() { return prestamos; }             // MAL: mutable
public List<Prestamo> getPrestamos() { return List.copyOf(prestamos); } // BIEN

// FindSecBugs: SQL_INJECTION_JPA
em.createQuery("select m from Material m where m.titulo like '%" + texto + "%'");   // MAL
em.createQuery("select m from Material m where m.titulo like :t").setParameter("t", …); // BIEN

SonarQube / SonarCloud añade lo que las demás no dan: histórico y el concepto de código nuevo.

./mvnw verify sonar:sonar \
  -Dsonar.projectKey=nexussoftware_bibliotech \
  -Dsonar.host.url=https://sonarcloud.io \
  -Dsonar.token=$SONAR_TOKEN

Su idea más útil se llama Clean as You Code: no exige arreglar la deuda histórica (imposible y desmoralizante), sino que el código nuevo cumpla el estándar. Umbral típico:

Métrica sobre el código nuevo Umbral
Cobertura ≥ 80 %
Duplicación ≤ 3 %
Vulnerabilidades 0
Bugs con severidad alta 0
Code smells bloqueantes 0

Y un consejo de adopción que vale más que la configuración: no actives todas las reglas de todas las herramientas el primer día. Aparecerán tres mil avisos, el equipo los ignorará en bloque y habrás perdido la herramienta. Empieza con SpotBugs en severidad alta y ArchUnit; añade el resto progresivamente.

  1. Formateo automático con Spotless

Ya configurado en 12-01. Aquí solo la parte que corresponde a CI:

./mvnw spotless:apply     # formatea (en local, o como enganche de pre-commit)
./mvnw spotless:check     # falla si no está formateado (en CI)

El beneficio real no es estético: elimina de las revisiones de código todo el ruido sobre formato, dejando espacio para hablar de lo que importa. Y hace que los diffs muestren cambios de comportamiento, no reindentaciones.

  1. Complejidad, deuda técnica y code smells

Complejidad ciclomática es el número de caminos independientes por un método: 1 + el número de decisiones (if, case, &&, ||, catch, bucles).

Complejidad Valoración Acción
1-5 Simple Ninguna
6-10 Moderada Aceptable
11-20 Compleja Refactorizar cuando se toque
21+ Muy compleja Refactorizar ya

Su utilidad práctica más directa: la complejidad ciclomática es el número mínimo de pruebas necesarias para cubrir todas las ramas. Un método con complejidad 15 necesita 15 pruebas. Si no las tiene, hay caminos sin probar.

Deuda técnica. La metáfora de Ward Cunningham: tomar un atajo hoy es pedir un préstamo; los intereses son el tiempo extra que costará cada cambio futuro.

Tipo Ejemplo ¿Aceptable?
Deliberada y prudente «Salimos sin caché; lo añadimos si hace falta» , si se documenta
Deliberada e imprudente «No hay tiempo para pruebas» No
Involuntaria y prudente «Ahora sabemos cómo debería haberse hecho» Inevitable
Involuntaria e imprudente «¿Qué es una capa?» Se cura formando

La deuda prudente se anota. En BiblioTech:

// DEUDA: la búsqueda recorre todo el catálogo en memoria porque son ~3.000 materiales.
// Con más de 50.000 habrá que pasar a búsqueda a texto completo de PostgreSQL.
// Decidido conscientemente el 2026-08-05 — ver docs/adr/0007-busqueda-en-memoria.md

Code smells más frecuentes y su remedio:

Smell Síntoma Remedio
Método largo Más de 30 líneas Extraer método
Clase grande Más de 300 líneas, más de 10 dependencias Extraer clase (12-02)
Lista larga de parámetros Más de 4 Objeto de parámetros, Builder
Envidia de funcionalidad Un método usa más datos de otra clase que de la suya Mover el método
Obsesión por los primitivos String isbn en vez de Isbn Objeto de valor
Sentencias switch repetidas El mismo switch en cinco sitios Polimorfismo
Código duplicado Copiar y pegar Extraer método o clase
Comentarios explicativos «Aquí calculamos la multa considerando…» Extraer método con buen nombre

  1. Refactorización segura

Refactorizar es cambiar la estructura interna sin cambiar el comportamiento observable. La condición no es negociable: sin pruebas, no es refactorización, es reescritura con esperanza.

El ciclo:

flowchart LR
    A["Pruebas en verde"] --> B["Un cambio pequeño"]
    B --> C["Ejecutar pruebas"]
    C -->|"verde"| D["Commit"]
    C -->|"rojo"| E["Deshacer"]
    D --> B
    E --> A

Refactorización 1: extraer método.

// ANTES: 40 líneas, tres responsabilidades entremezcladas
public ResultadoImportacion importar(Path fichero) {
    List<String> lineas = Files.readAllLines(fichero, UTF_8);
    List<Material> materiales = new ArrayList<>();
    List<String> errores = new ArrayList<>();

    for (int i = 1; i < lineas.size(); i++) {
        String[] campos = lineas.get(i).split(";");
        if (campos.length < 4) { errores.add("Línea " + i + ": campos insuficientes"); continue; }
        if (!campos[0].matches("97[89]-\\d{10}")) { errores.add("Línea " + i + ": ISBN inválido"); continue; }
        // … 20 líneas más de conversión y validación
    }
    // … guardado y resumen
}
// DESPUÉS: el método principal cuenta la historia; los detalles están un nivel más abajo
public ResultadoImportacion importar(Path fichero) throws IOException {
    List<LineaCsv> lineas = leerLineas(fichero);
    ResultadoParseo parseo = parsear(lineas);
    List<Material> guardados = guardar(parseo.validos());
    return new ResultadoImportacion(guardados.size(), parseo.errores());
}

private ResultadoParseo parsear(List<LineaCsv> lineas) {
    List<Material> validos = new ArrayList<>();
    List<ErrorImportacion> errores = new ArrayList<>();
    for (LineaCsv linea : lineas) {
        parsearLinea(linea).ifPresentOrElse(validos::add, () -> errores.add(errorDe(linea)));
    }
    return new ResultadoParseo(validos, errores);
}

Refactorización 2: extraer clase.

// ANTES: Prestamo mezcla su identidad con el cálculo de multas
public class Prestamo {
    public Dinero calcularMulta(LocalDate hoy) {
        long dias = ChronoUnit.DAYS.between(fechaVencimiento, hoy);
        if (dias <= 0) return Dinero.CERO;
        Dinero base = material.multaPorDia().por(dias);
        if (empleado.antiguedadEnMeses() < 6) base = base.multiplicarPor(0.5);
        if (empleado.esDireccion()) return Dinero.CERO;
        return base.esMayorQue(MAXIMO) ? MAXIMO : base;
    }
}
// DESPUÉS: la política de multas es un concepto propio, con sus propias pruebas
public class PoliticaMultas {
    private final List<ReglaTarifa> reglas;     // Estrategia (12-02)

    public Dinero calcular(Prestamo prestamo, LocalDate hoy) { … }
}

public class Prestamo {
    public Dinero multaAcumulada(LocalDate hoy, PoliticaMultas politica) {
        return politica.calcular(this, hoy);
    }
}

Refactorización 3: reemplazar condicional por polimorfismo (retoma 03-06).

// ANTES
public int diasDePrestamo(Material m) {
    switch (m.getTipo()) {
        case LIBRO: return 15;
        case REVISTA: return 7;
        case DVD: return 3;
        default: throw new IllegalStateException();
    }
}
// DESPUÉS: cada tipo responde por sí mismo
public abstract class Material {
    public abstract int diasDePrestamoPorDefecto();
}

Y el procedimiento seguro para hacerlo, que es lo que evita romper cosas:

  1. Añadir el método abstracto y sus implementaciones, sin borrar el switch.
  2. Hacer que el switch delegue: return m.diasDePrestamoPorDefecto();
  3. Ejecutar las pruebas. En verde.
  4. Sustituir las llamadas al método antiguo por llamadas directas.
  5. Ejecutar las pruebas. En verde.
  6. Borrar el método antiguo.
  7. Ejecutar las pruebas. Commit.

Siete pasos, siete oportunidades de detectar un error. Hacerlo de una vez son cero.

  1. TDD: el ciclo rojo-verde-refactor

Test-Driven Development invierte el orden habitual: primero la prueba, después el código.

flowchart LR
    R["🔴 ROJO<br/>Escribe una prueba que falla"]
    V["🟢 VERDE<br/>El código mínimo para pasarla"]
    F["🔵 REFACTOR<br/>Mejora sin romper nada"]
    R --> V --> F --> R

Las tres reglas de Robert C. Martin:

  1. No escribas código de producción salvo para hacer pasar una prueba que falla.
  2. No escribas más prueba de la necesaria para fallar (no compilar es fallar).
  3. No escribas más código de producción del necesario para pasar la prueba.

Y lo que se suele malinterpretar: TDD no es una técnica de pruebas, es una técnica de diseño. Las pruebas son un efecto secundario. Lo que hace es forzarte a usar tu propia API antes de implementarla, y eso produce diseños más usables, con menos dependencias, porque una clase difícil de probar es dolorosa de escribir en TDD y lo notas al principio, no al final.

  1. TDD paso a paso: recargo por material dañado

Requisito nuevo de Nexus Software: si un material se devuelve dañado, se aplica un recargo además de la multa por retraso.

  • Daño leve: 20 % del valor del material.
  • Daño grave: 60 %.
  • Irrecuperable: 100 % del valor, y el material se retira del catálogo.
  • El recargo se suma a la multa por retraso.
  • El recargo total (multa + recargo) no puede superar el valor del material.

Vamos paso a paso, sin saltarnos ninguno.

Paso 1 — Rojo. La prueba más simple posible:

class RecargoPorDanoTest {

    @Test
    void unMaterialSinDanoNoTieneRecargo() {
        var calculadora = new CalculadoraRecargos();
        var material = unLibro("978-0000000001").conValor(Dinero.euros("45.00"));

        Dinero recargo = calculadora.calcular(material, EstadoDevolucion.SIN_DANO);

        assertThat(recargo).isEqualTo(Dinero.CERO);
    }
}

No compila. CalculadoraRecargos y EstadoDevolucion no existen. Eso es rojo.

Paso 2 — Verde. El código mínimo. Literalmente el mínimo:

public enum EstadoDevolucion { SIN_DANO }

public class CalculadoraRecargos {
    public Dinero calcular(Material material, EstadoDevolucion estado) {
        return Dinero.CERO;      // sí, esto es hacer trampa. Y es correcto en TDD.
    }
}

Verde. Devolver siempre cero parece absurdo, pero es exactamente lo que TDD pide: sin una prueba que exija otra cosa, no hay justificación para escribir más.

Paso 3 — Rojo. Ahora forzamos el siguiente caso:

@Test
void unDanoLeveSuponeElVeintePorCientoDelValor() {
    var material = unLibro("978-0000000001").conValor(Dinero.euros("45.00"));

    Dinero recargo = calculadora.calcular(material, EstadoDevolucion.LEVE);

    assertThat(recargo).isEqualTo(Dinero.euros("9.00"));      // 45 × 0,20
}

Paso 4 — Verde:

public enum EstadoDevolucion { SIN_DANO, LEVE }

public class CalculadoraRecargos {
    public Dinero calcular(Material material, EstadoDevolucion estado) {
        if (estado == EstadoDevolucion.SIN_DANO) return Dinero.CERO;
        return material.getValor().multiplicarPor(new BigDecimal("0.20"));
    }
}

Paso 5 — Rojo, verde y aparición del duplicado. Añadimos grave e irrecuperable:

@ParameterizedTest
@CsvSource({
    "SIN_DANO,       0.00",
    "LEVE,           9.00",
    "GRAVE,         27.00",
    "IRRECUPERABLE, 45.00"
})
void elRecargoDependeDelEstadoDeDevolucion(EstadoDevolucion estado, String esperado) {
    var material = unLibro("978-0000000001").conValor(Dinero.euros("45.00"));
    assertThat(calculadora.calcular(material, estado)).isEqualTo(Dinero.euros(esperado));
}

Implementación que lo pasa:

public Dinero calcular(Material material, EstadoDevolucion estado) {
    BigDecimal porcentaje = switch (estado) {
        case SIN_DANO -> BigDecimal.ZERO;
        case LEVE -> new BigDecimal("0.20");
        case GRAVE -> new BigDecimal("0.60");
        case IRRECUPERABLE -> BigDecimal.ONE;
    };
    return material.getValor().multiplicarPor(porcentaje);
}

Paso 6 — Refactor. Pruebas en verde: momento de mejorar el diseño. Ese switch es exactamente lo que 12-02 enseñó a sustituir, y el enum con estado es la forma idiomática:

public enum EstadoDevolucion {
    SIN_DANO(BigDecimal.ZERO),
    LEVE(new BigDecimal("0.20")),
    GRAVE(new BigDecimal("0.60")),
    IRRECUPERABLE(BigDecimal.ONE);

    private final BigDecimal porcentajeRecargo;

    EstadoDevolucion(BigDecimal porcentajeRecargo) {
        this.porcentajeRecargo = porcentajeRecargo;
    }

    public BigDecimal porcentajeRecargo() { return porcentajeRecargo; }
    public boolean exigeRetirarDelCatalogo() { return this == IRRECUPERABLE; }
}

public class CalculadoraRecargos {
    public Dinero calcular(Material material, EstadoDevolucion estado) {
        return material.getValor().multiplicarPor(estado.porcentajeRecargo());
    }
}

Pruebas ejecutadas: siguen en verde. Ese es el punto de TDD: el refactor no da miedo porque hay una red debajo.

Paso 7 — Rojo. El límite del valor total:

@Test
void elTotalDeMultaYRecargoNoSuperaElValorDelMaterial() {
    var material = unLibro("978-0000000001").conValor(Dinero.euros("45.00"));
    var prestamo = unPrestamoDe(material).vencidoHace(200);   // multa enorme

    Dinero total = calculadora.totalACobrar(prestamo, EstadoDevolucion.GRAVE, HOY);

    // multa (tope 20 €) + recargo (27 €) = 47 €, pero el material vale 45 €
    assertThat(total).isEqualTo(Dinero.euros("45.00"));
}

Paso 8 — Verde:

public Dinero totalACobrar(Prestamo prestamo, EstadoDevolucion estado, LocalDate hoy) {
    Dinero multa = politicaMultas.calcular(prestamo, hoy);
    Dinero recargo = calcular(prestamo.getMaterial(), estado);
    Dinero total = multa.mas(recargo);
    Dinero valorMaterial = prestamo.getMaterial().getValor();
    return total.esMayorQue(valorMaterial) ? valorMaterial : total;
}

Paso 9 — Rojo, el efecto secundario. Falta la retirada del catálogo:

@Test
void unMaterialIrrecuperableSeRetiraDelCatalogo() {
    var material = unLibro("978-0000000001").conValor(Dinero.euros("45.00"));
    var prestamo = unPrestamoDe(material);

    servicio.registrarDevolucion(prestamo.getId(), EstadoDevolucion.IRRECUPERABLE, HOY);

    assertThat(material.estaRetirado()).isTrue();
    verify(catalogo).retirar(material.getIsbn(), MotivoRetirada.DANADO);
}

@Test
void unMaterialConDanoGraveSigueEnElCatalogo() {
    var material = unLibro("978-0000000001");
    servicio.registrarDevolucion(unPrestamoDe(material).getId(), EstadoDevolucion.GRAVE, HOY);

    assertThat(material.estaRetirado()).isFalse();
    verifyNoInteractions(catalogo);
}

Paso 10 — Verde:

@Transactional
public ResultadoDevolucion registrarDevolucion(Long idPrestamo, EstadoDevolucion estado, LocalDate fecha) {
    Prestamo prestamo = repositorio.buscarPorId(idPrestamo)
            .orElseThrow(() -> new PrestamoNoEncontradoException(idPrestamo));

    prestamo.registrarDevolucion(fecha);
    Dinero total = calculadora.totalACobrar(prestamo, estado, fecha);

    if (estado.exigeRetirarDelCatalogo()) {
        catalogo.retirar(prestamo.getIsbn(), MotivoRetirada.DANADO);
    }
    eventos.publicar(new MaterialDevuelto(prestamo.getId(), estado, total));   // Observador
    return new ResultadoDevolucion(prestamo.getId(), fecha, total, estado);
}

Paso 11 — Refactor y verificación con PIT:

./mvnw org.pitest:pitest-maven:mutationCoverage \
    -DtargetClasses=com.nexussoftware.bibliotech.dominio.prestamos.*
CalculadoraRecargos    : 100% mutación (8/8 mutantes eliminados)
EstadoDevolucion       : 100% mutación (4/4)

Lo que ha producido este proceso, y merece señalarse:

  1. Cero código sin probar. Cada línea existe porque una prueba la exigió.
  2. Un diseño mejor. El enum con estado no salió de la primera implementación: salió del paso de refactor, que TDD hace seguro.
  3. Las fronteras cubiertas desde el principio. El caso del tope por valor del material se pensó al escribir la prueba, no al recibir el informe de un error.
  4. Documentación ejecutable. Los nombres de las pruebas son la especificación del requisito.

  1. Cuándo aporta TDD y cuándo no

Valoración honesta, sin dogma:

TDD aporta mucho TDD aporta poco o estorba
Lógica de negocio con reglas y casos límite Código exploratorio: aún no sabes qué quieres
Corregir un error (primero la prueba que lo reproduce) Interfaces de usuario, maquetación visual
Algoritmos con entradas y salidas claras Integraciones con APIs externas mal documentadas
API que van a usar otros Configuración y cableado
Refactorizar código heredado (primero caracterizar) Prototipos que se van a tirar
Cuando el diseño no está claro y quieres que emerja Cuando el diseño es evidente y trivial

Dos observaciones que suelen faltar en las discusiones sobre TDD:

  • No es todo o nada. Se puede usar TDD para el dominio y escribir las pruebas después para los controladores. Es lo que hace la mayoría de equipos que lo usan de verdad.
  • Lo importante es que las pruebas existan y verifiquen. Un equipo que escribe pruebas exhaustivas después del código está infinitamente mejor que uno que dice hacer TDD y no lo hace.

Y hay un caso en el que TDD es sencillamente la mejor opción disponible: corregir un error. La secuencia es siempre la misma y siempre funciona:

  1. Escribe una prueba que reproduce el error. Debe fallar.
  2. Arregla el código. La prueba pasa.
  3. Esa prueba se queda para siempre, y el error no puede volver sin que alguien se entere.

  1. Revisión de código

La revisión (code review) es el control de calidad más barato que existe, y el que más se hace mal.

Qué mirar, en orden de importancia:

Prioridad Qué Ejemplo de pregunta
1 Corrección ¿Hace lo que dice? ¿Casos límite? ¿Nulos?
2 Seguridad ¿Entrada validada? ¿Datos sensibles en el log?
3 Pruebas ¿Las hay? ¿Verifican de verdad o solo ejecutan?
4 Diseño ¿Está en la capa correcta? ¿Acopla lo que no debe?
5 Legibilidad ¿Se entiende sin explicación? ¿Los nombres dicen la verdad?
6 Consistencia ¿Sigue los patrones del proyecto?
7 Estilo (Debería estar automatizado con Spotless)

Cómo dar retroalimentación útil. La diferencia entre un comentario que mejora el código y uno que genera resistencia:

En vez de Escribe
«Esto está mal» «Si material es nulo aquí, ¿no lanzaría NPE en la línea 42?»
«Usa un stream» «Un stream().filter().toList() haría esto más directo, ¿qué te parece?»
«No entiendo nada» «¿Podrías explicar qué representa flag2? Quizá un nombre más descriptivo ayude»
«Falta la prueba» «¿Merecería la pena una prueba del caso en que el empleado ya tiene 3 préstamos?»

Tres convenciones que funcionan:

  • Marca la severidad. [bloqueante], [sugerencia], [nit] (detalle menor), [pregunta]. Sin eso, el autor no sabe qué debe cambiar y qué es opcional.
  • Elogia lo bueno. «Buena idea extraer esto a PoliticaMultas» cuesta cinco segundos y cambia el tono de la revisión.
  • Revisa pronto y en trozos pequeños. Un PR de 1.000 líneas recibe «LGTM»; uno de 200 recibe comentarios útiles. La calidad de la revisión cae en picado con el tamaño.

Lista de comprobación de BiblioTech:

## Revisión de código — BiblioTech

### Corrección
- [ ] ¿Hace lo que dice la descripción del PR?
- [ ] ¿Se manejan los casos límite (vacío, nulo, cero, negativo, máximo)?
- [ ] ¿Las excepciones se capturan en el nivel correcto (06-07)?
- [ ] ¿Hay condiciones de carrera si esto se ejecuta en paralelo?

### Arquitectura
- [ ] ¿Está en el módulo correcto (dominio / aplicación / infraestructura)?
- [ ] ¿El dominio sigue sin importar Spring ni JPA?
- [ ] ¿Se exponen entidades JPA en la API? (no se debe)
- [ ] ¿La transacción está en el caso de uso, no en el controlador?

### Pruebas
- [ ] ¿Hay pruebas del camino feliz Y de los errores?
- [ ] ¿Las pruebas tienen aserciones significativas?
- [ ] ¿Están en el nivel más bajo posible?
- [ ] ¿Se usa `Clock` inyectable en lugar de `LocalDate.now()`?

### Seguridad (12-07)
- [ ] ¿Se valida toda entrada externa?
- [ ] ¿Hay secretos, tokens o datos personales en el código o en el log?
- [ ] ¿Las consultas usan parámetros, nunca concatenación?

### Legibilidad
- [ ] ¿Los nombres describen la intención?
- [ ] ¿Algún método supera las 30 líneas o la complejidad 10?
- [ ] ¿Los comentarios explican el «por qué», no el «qué»?

  1. Integración continua con GitHub Actions

La integración continua ejecuta automáticamente la construcción y las pruebas en cada cambio. Su valor no es técnico sino social: quita la responsabilidad de recordar.

flowchart LR
    P["Push / PR"] --> C["Compilar"]
    C --> F["Formato<br/>Spotless"]
    F --> U["Pruebas<br/>unitarias"]
    U --> I["Pruebas de<br/>integración"]
    I --> CO["Cobertura<br/>JaCoCo"]
    CO --> A["Análisis<br/>SpotBugs"]
    A --> R["Resultado<br/>en el PR"]

    style R fill:#e8f5e9,stroke:#2e7d32
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# Cancela ejecuciones anteriores del mismo PR: no tiene sentido probar código ya obsoleto
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  JAVA_VERSION: '21'

jobs:

  # ---------------------------------------------------------------
  # Trabajo 1: rápido. Da respuesta en menos de 3 minutos.
  # ---------------------------------------------------------------
  verificacion-rapida:
    name: Compilación, formato y pruebas unitarias
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
      - uses: actions/checkout@v4

      - name: Configurar JDK ${{ env.JAVA_VERSION }}
        uses: actions/setup-java@v4
        with:
          java-version: ${{ env.JAVA_VERSION }}
          distribution: temurin
          cache: maven              # cachea ~/.m2: ahorra 1-2 minutos por ejecución

      - name: Verificar formato
        run: ./mvnw -B spotless:check

      - name: Compilar
        run: ./mvnw -B clean compile

      - name: Pruebas unitarias
        run: ./mvnw -B test

      - name: Publicar resultados de las pruebas
        uses: mikepenz/action-junit-report@v4
        if: always()                # también cuando fallan: es cuando más se necesita
        with:
          report_paths: '**/target/surefire-reports/TEST-*.xml'
          check_name: 'Pruebas unitarias'

  # ---------------------------------------------------------------
  # Trabajo 2: lento. Testcontainers, cobertura y análisis estático.
  # ---------------------------------------------------------------
  verificacion-completa:
    name: Integración, cobertura y análisis
    runs-on: ubuntu-latest
    needs: verificacion-rapida      # no gastes 10 minutos si no compila
    timeout-minutes: 25

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0            # Sonar necesita el historial para el "código nuevo"

      - uses: actions/setup-java@v4
        with:
          java-version: ${{ env.JAVA_VERSION }}
          distribution: temurin
          cache: maven

      # Docker ya está disponible en ubuntu-latest: Testcontainers funciona sin más

      - name: Pruebas de integración y cobertura
        run: ./mvnw -B verify
        env:
          TESTCONTAINERS_RYUK_DISABLED: 'false'

      - name: Comprobar umbrales de cobertura
        run: ./mvnw -B jacoco:check

      - name: Publicar cobertura en el PR
        uses: madrapps/[email protected]
        if: github.event_name == 'pull_request'
        with:
          paths: '**/target/site/jacoco/jacoco.xml'
          token: ${{ secrets.GITHUB_TOKEN }}
          min-coverage-overall: 75
          min-coverage-changed-files: 80    # el código NUEVO, más exigente
          title: 'Informe de cobertura'

      - name: Análisis estático
        run: ./mvnw -B spotbugs:check

      - name: Guardar informes
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: informes
          path: |
            **/target/site/jacoco/
            **/target/spotbugsXml.xml
          retention-days: 7

  # ---------------------------------------------------------------
  # Trabajo 3: solo en main. Pruebas de mutación, que son lentas.
  # ---------------------------------------------------------------
  mutacion:
    name: Pruebas de mutación
    runs-on: ubuntu-latest
    needs: verificacion-completa
    if: github.ref == 'refs/heads/main'
    timeout-minutes: 30

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: ${{ env.JAVA_VERSION }}
          distribution: temurin
          cache: maven

      - name: PIT sobre el dominio
        run: ./mvnw -B -pl bibliotech-dominio org.pitest:pitest-maven:mutationCoverage

      - uses: actions/upload-artifact@v4
        with:
          name: informe-mutacion
          path: '**/target/pit-reports/'

Y la parte que hace que todo esto sirva de algo: proteger la rama main en la configuración del repositorio.

Regla Efecto
Requerir PR antes de fusionar Nadie empuja directamente a main
Requerir que CI esté en verde Un PR con pruebas rojas no se puede fusionar
Requerir 1 aprobación Todo lo revisa alguien más
Descartar aprobaciones al haber cambios nuevos No se aprueba una cosa y se fusiona otra
Requerir que la rama esté actualizada Se prueba contra el main actual

Sin protección de rama, CI es un semáforo que nadie está obligado a mirar.

  1. Qué NO probar

Escribir pruebas inútiles cuesta tiempo, ralentiza la suite y da falsa sensación de seguridad.

No pruebes Por qué
Getters y setters triviales No hay lógica. Si se rompen, mil pruebas fallan igual
El framework Spring, Hibernate y Jackson ya tienen sus pruebas
La biblioteca estándar ArrayList.add funciona
Código generado (Lombok, MapStruct) El generador ya está probado
Configuración simple Que @Value inyecte no es tu responsabilidad
Detalles de implementación privados Prueba el comportamiento público; lo privado cambia
Constantes assertThat(MAXIMO).isEqualTo(20) solo duplica el código

El caso de los detalles de implementación merece un ejemplo, porque es el error más caro:

// MAL: prueba CÓMO se hace. Refactorizar la rompe aunque el comportamiento no cambie.
@Test
void usaElRepositorioParaBuscar() {
    servicio.buscarPorIsbn(isbn);
    verify(repositorio).findByIsbn(isbn);      // ¿y si mañana usa una caché?
}

// BIEN: prueba QUÉ hace. Sobrevive a cualquier refactor interno.
@Test
void devuelveElMaterialCuandoExiste() {
    when(repositorio.findByIsbn(isbn)).thenReturn(Optional.of(javaEfectivo));

    Optional<Material> resultado = servicio.buscarPorIsbn(isbn);

    assertThat(resultado).contains(javaEfectivo);
}

  1. Pruebas frágiles como deuda

Una prueba frágil falla por motivos que no son un fallo real. Y su coste es peor de lo que parece: entrena al equipo a ignorar el rojo.

Tipo de fragilidad Causa Solución
Dependiente del tiempo LocalDate.now() en el código Clock inyectable (10-05)
Dependiente del orden Estado compartido entre pruebas Aislar; @DirtiesContext como último recurso
Dependiente de la red Llama a una API real WireMock, o un doble
Dependiente de la máquina Rutas absolutas, zona horaria @TempDir, zona fija
Dependiente del azar Math.random(), UUID Semilla fija, generador inyectado
Con esperas fijas Thread.sleep(500) Awaitility con condición
Sobreespecificada verify de cada llamada Verificar solo lo relevante

Los dos ejemplos que más aparecen en la práctica:

// FRÁGIL: falla el 1 de enero, o si la prueba corre a medianoche
@Test
void elPrestamoVenceEnQuinceDias() {
    Prestamo p = gestor.prestar(isbn, 1L, 15);
    assertThat(p.getFechaVencimiento()).isEqualTo(LocalDate.now().plusDays(15));
}

// ROBUSTA: el tiempo es una dependencia como cualquier otra
@Test
void elPrestamoVenceEnQuinceDias() {
    var reloj = Clock.fixed(Instant.parse("2026-08-05T10:00:00Z"), ZoneId.of("Europe/Madrid"));
    var gestor = new GestorPrestamos(repositorio, notificador, reloj);

    Prestamo p = gestor.prestar(isbn, 1L, 15);

    assertThat(p.getFechaVencimiento()).isEqualTo(LocalDate.of(2026, 8, 20));
}
// FRÁGIL: 500 ms puede no bastar en una máquina cargada, y sobra en una rápida
@Test
void laImportacionAsincronaTermina() throws Exception {
    servicio.importarAsincrono(fichero);
    Thread.sleep(500);
    assertThat(repositorio.count()).isEqualTo(100);
}

// ROBUSTA: espera a la CONDICIÓN, no a un tiempo
@Test
void laImportacionAsincronaTermina() {
    servicio.importarAsincrono(fichero);

    await().atMost(Duration.ofSeconds(5))
           .pollInterval(Duration.ofMillis(50))
           .untilAsserted(() -> assertThat(repositorio.count()).isEqualTo(100));
}

La regla ante una prueba intermitente: arréglala o bórrala. No la marques con @Disabled «temporalmente», porque ese temporal dura años y mientras tanto no protege nada.

  1. Rendimiento y carga

Nota: pruebas de rendimiento. Retomando 10-07, JMH (Java Microbenchmark Harness) es la única forma fiable de medir código Java, porque gestiona el calentamiento de la JIT, evita que el compilador elimine código sin efectos y calcula la varianza. Un System.nanoTime() alrededor de un bucle mide, sobre todo, el estado de la JIT en ese instante.

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
public class BenchmarkBusqueda {

    private List<Material> catalogo;

    @Setup public void preparar() { catalogo = generarCatalogo(50_000); }

    @Benchmark
    public List<Material> busquedaLineal() {
        return catalogo.stream()
                .filter(m -> m.getTitulo().toLowerCase().contains("java"))
                .toList();
    }

    @Benchmark
    public List<Material> busquedaConIndice() {
        return indice.buscar("java");
    }
}

Y las pruebas de carga (k6, Gatling, JMeter) miden otra cosa distinta: cómo se comporta el sistema completo con N usuarios concurrentes. Interesan el percentil 95 y 99 de latencia, no la media —la media esconde exactamente los casos que molestan a los usuarios— y el punto en el que los errores empiezan a aparecer. Se ejecutan contra un entorno parecido a producción, nunca en CI de cada PR.

// k6: 100 usuarios durante 5 minutos
export const options = {
  stages: [ { duration: '1m', target: 100 }, { duration: '3m', target: 100 },
            { duration: '1m', target: 0 } ],
  thresholds: { http_req_duration: ['p(95)<300'], http_req_failed: ['rate<0.01'] },
};
export default function () { http.get('http://localhost:8080/api/materiales?page=0&size=20'); }

Nota: pruebas de contrato. Cuando dos servicios se integran, las pruebas de contrato (Pact, Spring Cloud Contract) verifican que el consumidor y el proveedor están de acuerdo sobre el formato, sin necesidad de levantarlos juntos. El consumidor declara qué espera, el proveedor verifica que lo cumple. En BiblioTech no hace falta aún, pero en cuanto la app móvil consuma la API o BiblioTech dependa del servicio de recursos humanos, se convierte en la forma más barata de evitar que un cambio rompa a un tercero sin que nadie se entere hasta producción.

Errores Comunes y Consejos

1. Perseguir el 100 % de cobertura. El coste de subir del 80 % al 100 % es enorme y el valor, mínimo: el último 20 % suele ser manejo de errores imposibles y código generado. Usa la cobertura para encontrar huecos, no como objetivo.

2. Pruebas sin aserciones. Cubren, no verifican. Si una prueba pasaría igual con el código roto, no es una prueba.

3. Convertir la cobertura en objetivo individual. Ley de Goodhart: obtendrás miles de pruebas que ejecutan código sin comprobar nada.

4. Confiar en H2 para probar consultas. H2 no es PostgreSQL. Las consultas se prueban contra la base de datos real con Testcontainers, y solo esas.

5. Convertir todo en @SpringBootTest. Es lo más fácil y lo peor: la suite se va a veinte minutos, los diagnósticos se vuelven vagos y nadie la ejecuta.

6. Ignorar las pruebas intermitentes. «Vuelve a lanzarla, a veces falla» es el principio del fin. Se arreglan o se borran.

7. Probar la implementación en vez del comportamiento. verify(repositorio).findByIsbn(...) rompe con cualquier refactor legítimo. Prueba resultados.

8. Activar todas las reglas de análisis estático el primer día. Tres mil avisos que nadie mirará. Empieza con lo grave y crece.

9. Pull Requests de mil líneas. Reciben «LGTM» en dos minutos. Trocea el trabajo: 200-400 líneas es el punto en el que una revisión es útil.

10. CI que no bloquea. Si el equipo puede fusionar con las pruebas en rojo, las pruebas dejan de existir. Protege la rama.

11. Refactorizar sin pruebas. No es refactorizar. Si no hay pruebas, primero escribe pruebas de caracterización que fijen el comportamiento actual (aunque sea incorrecto), y luego cambia.

12. Creer que TDD es sobre pruebas. Es sobre diseño. Si una clase es difícil de probar, TDD te lo dice antes de escribirla, no después.

Consejo final: la mejor métrica de calidad no está en ninguna herramienta. Es la respuesta a esta pregunta: ¿el equipo despliega un viernes por la tarde sin miedo? Si la respuesta es sí, la estrategia funciona. Si es no, hay algo que arreglar, y ninguna cifra de cobertura lo compensa.

Ejercicios

Ejercicio 1: mejorar unas pruebas que mienten

Estas pruebas existen en BiblioTech y tienen 100 % de cobertura de líneas sobre ProcesadorReservas. Identifica todos sus problemas y reescríbelas.

class ProcesadorReservasTest {

    ProcesadorReservas procesador = new ProcesadorReservas(
            new RepositorioReservasEnMemoria(), new NotificadorFalso());

    @Test
    void testReservar() {
        procesador.reservar("978-0000000001", 1L);
    }

    @Test
    void testCancelar() throws Exception {
        Reserva r = procesador.reservar("978-0000000001", 1L);
        procesador.cancelar(r.getId());
        assertNotNull(r);
    }

    @Test
    void testCaducidad() throws Exception {
        Reserva r = procesador.reservar("978-0000000001", 1L);
        Thread.sleep(1000);
        procesador.caducarVencidas();
        assertTrue(true);
    }

    @Test
    void testFechaLimite() {
        Reserva r = procesador.reservar("978-0000000001", 1L);
        assertEquals(LocalDate.now().plusDays(2), r.getFechaLimite());
    }
}

Ejercicio 2: TDD de una regla nueva

Nexus Software introduce préstamos prioritarios: un empleado con un proyecto marcado como crítico puede saltarse la cola de reservas de un material.

Reglas:

  • Solo si el empleado tiene un proyecto crítico activo.
  • Máximo un préstamo prioritario simultáneo por empleado.
  • El material debe estar prestado (si está libre, es un préstamo normal).
  • Al usar la prioridad, el préstamo en curso se marca para devolución urgente en 48 h.
  • El empleado que tiene el material recibe un aviso.
  • No se puede usar la prioridad si el material ya está marcado como urgente.

Desarrolla la funcionalidad con TDD, mostrando cada ciclo rojo-verde-refactor. Al terminar, ejecuta mentalmente PIT sobre tu implementación e identifica qué mutantes podrían sobrevivir.

Ejercicio 3: canalización de CI completa

Escribe el flujo de trabajo de GitHub Actions para BiblioTech que:

  • Se ejecute en PR y en push a main.
  • Tenga un trabajo rápido (menos de 3 minutos) y otro completo.
  • Ejecute pruebas de integración con Testcontainers.
  • Falle el PR si la cobertura del código nuevo baja del 80 %.
  • Publique en el PR un comentario con el resumen de cobertura y las pruebas fallidas.
  • Ejecute pruebas de mutación solo los lunes.
  • Cachee las dependencias de Maven.
  • Ejecute la matriz de pruebas en Java 21 y Java 23 (para detectar problemas de la próxima versión LTS).

Soluciones

Solución 1

Problemas detectados (once):

# Prueba Problema Gravedad
1 testReservar Sin aserción alguna: solo ejecuta Crítica
2 testCancelar assertNotNull(r) no comprueba la cancelación Crítica
3 testCaducidad assertTrue(true) es una aserción falsa Crítica
4 testCaducidad Thread.sleep(1000) frágil y lento Alta
5 testFechaLimite LocalDate.now() en la prueba: falla a medianoche Alta
6 Todas Nombres que no describen el comportamiento esperado Media
7 Todas El estado se comparte entre pruebas (campo de instancia con estado) Alta
8 Todas No se prueba ningún caso de error Alta
9 testCancelar throws Exception innecesario, oculta qué puede fallar Baja
10 Todas Sin @DisplayName ni estructura Baja
11 Todas assertNotNull/assertEquals de JUnit en vez de AssertJ Baja

Reescritura:

@DisplayName("Procesador de reservas")
class ProcesadorReservasTest {

    // Reloj FIJO: elimina toda dependencia del momento de ejecución
    private static final Instant AHORA = Instant.parse("2026-08-05T10:00:00Z");
    private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");

    private RepositorioReservasEnMemoria repositorio;
    private NotificadorEspia notificador;
    private MutableClock reloj;                  // reloj avanzable, sin Thread.sleep
    private ProcesadorReservas procesador;

    @BeforeEach
    void preparar() {
        // Estado NUEVO en cada prueba: sin contaminación entre ellas
        repositorio = new RepositorioReservasEnMemoria();
        notificador = new NotificadorEspia();
        reloj = MutableClock.de(AHORA, MADRID);
        procesador = new ProcesadorReservas(repositorio, notificador, reloj);
    }

    @Nested
    @DisplayName("Al crear una reserva")
    class AlReservar {

        @Test
        @DisplayName("queda pendiente y en la cola del material")
        void quedaPendienteYEnLaCola() {
            Reserva reserva = procesador.reservar(ISBN_JAVA, MARTA);

            assertThat(reserva.getEstado()).isEqualTo(EstadoReserva.PENDIENTE);
            assertThat(reserva.getMaterialIsbn()).isEqualTo(ISBN_JAVA);
            assertThat(reserva.getIdEmpleado()).isEqualTo(MARTA);
            assertThat(reserva.getFechaSolicitud()).isEqualTo(AHORA);
            assertThat(repositorio.pendientesDe(ISBN_JAVA)).containsExactly(reserva);
        }

        @Test
        @DisplayName("respeta el orden de llegada en la cola")
        void respetaElOrdenDeLlegada() {
            Reserva primera = procesador.reservar(ISBN_JAVA, MARTA);
            reloj.avanzar(Duration.ofMinutes(5));
            Reserva segunda = procesador.reservar(ISBN_JAVA, DIEGO);

            assertThat(repositorio.pendientesDe(ISBN_JAVA))
                    .containsExactly(primera, segunda);      // el orden importa
        }

        @Test
        @DisplayName("rechaza una segunda reserva del mismo empleado y material")
        void rechazaReservaDuplicada() {
            procesador.reservar(ISBN_JAVA, MARTA);

            assertThatThrownBy(() -> procesador.reservar(ISBN_JAVA, MARTA))
                    .isInstanceOf(ReservaDuplicadaException.class)
                    .hasMessageContaining(ISBN_JAVA.valor());

            assertThat(repositorio.pendientesDe(ISBN_JAVA)).hasSize(1);
        }

        @Test
        @DisplayName("rechaza reservar un material inexistente")
        void rechazaMaterialInexistente() {
            assertThatThrownBy(() -> procesador.reservar(ISBN_INEXISTENTE, MARTA))
                    .isInstanceOf(MaterialNoEncontradoException.class);
        }
    }

    @Nested
    @DisplayName("Al cancelar")
    class AlCancelar {

        @Test
        @DisplayName("la reserva pasa a CANCELADA y sale de la cola")
        void pasaACanceladaYSaleDeLaCola() {
            Reserva reserva = procesador.reservar(ISBN_JAVA, MARTA);

            procesador.cancelar(reserva.getId());

            assertThat(repositorio.porId(reserva.getId()))
                    .get()
                    .extracting(Reserva::getEstado)
                    .isEqualTo(EstadoReserva.CANCELADA);
            assertThat(repositorio.pendientesDe(ISBN_JAVA)).isEmpty();
        }

        @Test
        @DisplayName("cancelar dos veces lanza TransicionInvalidaException")
        void cancelarDosVecesFalla() {
            Reserva reserva = procesador.reservar(ISBN_JAVA, MARTA);
            procesador.cancelar(reserva.getId());

            assertThatThrownBy(() -> procesador.cancelar(reserva.getId()))
                    .isInstanceOf(TransicionInvalidaException.class);
        }

        @Test
        @DisplayName("cancelar una reserva inexistente lanza ReservaNoEncontradaException")
        void reservaInexistente() {
            assertThatThrownBy(() -> procesador.cancelar(9999L))
                    .isInstanceOf(ReservaNoEncontradaException.class);
        }
    }

    @Nested
    @DisplayName("Caducidad")
    class Caducidad {

        @Test
        @DisplayName("una reserva disponible caduca a las 48 horas exactas")
        void caducaALas48Horas() {
            Reserva reserva = procesador.reservar(ISBN_JAVA, MARTA);
            procesador.asignarEjemplar(reserva.getId());

            reloj.avanzar(Duration.ofHours(48));          // ¡sin Thread.sleep!
            int caducadas = procesador.caducarVencidas();

            assertThat(caducadas).isEqualTo(1);
            assertThat(repositorio.porId(reserva.getId()))
                    .get().extracting(Reserva::getEstado)
                    .isEqualTo(EstadoReserva.CADUCADA);
        }

        @Test
        @DisplayName("a las 47 horas y 59 minutos todavía NO caduca")
        void noCaducaAntesDeTiempo() {
            Reserva reserva = procesador.reservar(ISBN_JAVA, MARTA);
            procesador.asignarEjemplar(reserva.getId());

            reloj.avanzar(Duration.ofHours(47).plusMinutes(59));
            int caducadas = procesador.caducarVencidas();

            assertThat(caducadas).isZero();               // LA FRONTERA
            assertThat(repositorio.porId(reserva.getId()))
                    .get().extracting(Reserva::getEstado)
                    .isEqualTo(EstadoReserva.DISPONIBLE);
        }

        @Test
        @DisplayName("las reservas pendientes no caducan: no tienen fecha límite")
        void lasPendientesNoCaducan() {
            procesador.reservar(ISBN_JAVA, MARTA);        // PENDIENTE, sin ejemplar asignado

            reloj.avanzar(Duration.ofDays(30));

            assertThat(procesador.caducarVencidas()).isZero();
        }

        @Test
        @DisplayName("al caducar se notifica al empleado y se activa la siguiente de la cola")
        void notificaYActivaLaSiguiente() {
            Reserva deMarta = procesador.reservar(ISBN_JAVA, MARTA);
            Reserva deDiego = procesador.reservar(ISBN_JAVA, DIEGO);
            procesador.asignarEjemplar(deMarta.getId());

            reloj.avanzar(Duration.ofHours(48));
            procesador.caducarVencidas();

            assertThat(notificador.avisosEnviados())
                    .extracting(Aviso::tipo)
                    .containsExactly(TipoAviso.RESERVA_CADUCADA, TipoAviso.RESERVA_DISPONIBLE);
            assertThat(repositorio.porId(deDiego.getId()))
                    .get().extracting(Reserva::getEstado)
                    .isEqualTo(EstadoReserva.DISPONIBLE);
        }
    }

    @Nested
    @DisplayName("Fecha límite de recogida")
    class FechaLimite {

        @Test
        @DisplayName("se fija a 48 horas desde la asignación del ejemplar")
        void aLas48HorasDesdeLaAsignacion() {
            Reserva reserva = procesador.reservar(ISBN_JAVA, MARTA);
            reloj.avanzar(Duration.ofDays(3));            // la asignación llega días después
            procesador.asignarEjemplar(reserva.getId());

            // Fecha ABSOLUTA calculada desde el reloj fijo: nunca depende de "hoy"
            assertThat(repositorio.porId(reserva.getId()))
                    .get().extracting(Reserva::getFechaLimiteRecogida)
                    .isEqualTo(AHORA.plus(Duration.ofDays(3)).plus(Duration.ofHours(48)));
        }

        @Test
        @DisplayName("una reserva pendiente no tiene fecha límite")
        void lasPendientesNoTienenFechaLimite() {
            Reserva reserva = procesador.reservar(ISBN_JAVA, MARTA);
            assertThat(reserva.getFechaLimiteRecogida()).isNull();
        }
    }
}

El reloj avanzable, que sustituye a todos los Thread.sleep:

/** Clock mutable para pruebas: permite avanzar el tiempo sin esperar. */
public class MutableClock extends Clock {

    private Instant instante;
    private final ZoneId zona;

    private MutableClock(Instant instante, ZoneId zona) {
        this.instante = instante;
        this.zona = zona;
    }

    public static MutableClock de(Instant instante, ZoneId zona) {
        return new MutableClock(instante, zona);
    }

    public void avanzar(Duration duracion) { this.instante = instante.plus(duracion); }

    @Override public Instant instant() { return instante; }
    @Override public ZoneId getZone() { return zona; }
    @Override public Clock withZone(ZoneId z) { return new MutableClock(instante, z); }
}

Resultado: de 4 pruebas que no verificaban nada a 14 que cubren camino feliz, errores, fronteras y efectos secundarios. Sin un solo Thread.sleep, sin dependencia de la fecha real, con estado limpio en cada una y con nombres que documentan el requisito. La suite pasó de tardar más de un segundo a tardar milisegundos.

Solución 2

Ciclo 1 — Rojo:

@Test
void unEmpleadoSinProyectoCriticoNoPuedeUsarPrioridad() {
    Empleado diego = unEmpleado("Diego Alonso").sinProyectosCriticos();

    assertThatThrownBy(() -> servicio.prestarConPrioridad(ISBN_JAVA, diego.getId()))
            .isInstanceOf(SinPrioridadDisponibleException.class)
            .hasMessageContaining("no tiene ningún proyecto crítico activo");
}

Ciclo 1 — Verde:

public Prestamo prestarConPrioridad(Isbn isbn, Long idEmpleado) {
    Empleado empleado = empleados.buscarPorId(idEmpleado).orElseThrow(…);
    if (!empleado.tieneProyectoCriticoActivo()) {
        throw new SinPrioridadDisponibleException(
                "El empleado %s no tiene ningún proyecto crítico activo."
                        .formatted(empleado.getNombre()));
    }
    return null;   // lo mínimo para que la prueba pase
}

Ciclo 2 — Rojo:

@Test
void siElMaterialEstaLibreSeHaceUnPrestamoNormalSinConsumirPrioridad() {
    Empleado marta = unEmpleado("Marta Ruiz").conProyectoCritico();
    materialLibre(ISBN_JAVA);

    Prestamo prestamo = servicio.prestarConPrioridad(ISBN_JAVA, marta.getId());

    assertThat(prestamo.esPrioritario()).isFalse();
    assertThat(marta.prioridadesEnUso()).isZero();       // la prioridad NO se gastó
}

Ciclo 2 — Verde:

public Prestamo prestarConPrioridad(Isbn isbn, Long idEmpleado) {
    Empleado empleado = …;
    verificarTieneProyectoCritico(empleado);

    Material material = materiales.buscarPorIsbn(isbn).orElseThrow(…);
    if (material.tieneUnidadesLibres()) {
        return gestorPrestamos.prestar(isbn, idEmpleado, null);   // préstamo normal
    }
    return null;
}

Ciclo 3 — Rojo: el caso central.

@Test
void marcaElPrestamoEnCursoParaDevolucionUrgenteEnCuarentaYOchoHoras() {
    Empleado marta = unEmpleado("Marta Ruiz").conProyectoCritico();
    Prestamo enCurso = prestamoActivoDe(ISBN_JAVA, DIEGO);
    materialSinUnidadesLibres(ISBN_JAVA);

    servicio.prestarConPrioridad(ISBN_JAVA, marta.getId());

    assertThat(enCurso.esUrgente()).isTrue();
    assertThat(enCurso.getFechaVencimiento())
            .isEqualTo(LocalDate.now(reloj).plusDays(2));
}

@Test
void avisaAlEmpleadoQueTieneElMaterial() {
    Empleado marta = unEmpleado("Marta Ruiz").conProyectoCritico();
    prestamoActivoDe(ISBN_JAVA, DIEGO);
    materialSinUnidadesLibres(ISBN_JAVA);

    servicio.prestarConPrioridad(ISBN_JAVA, marta.getId());

    assertThat(notificador.avisosEnviados())
            .singleElement()
            .satisfies(a -> {
                assertThat(a.tipo()).isEqualTo(TipoAviso.DEVOLUCION_URGENTE);
                assertThat(a.destinatario()).isEqualTo(DIEGO.getCorreo());
            });
}

Ciclo 3 — Verde:

public Prestamo prestarConPrioridad(Isbn isbn, Long idEmpleado) {
    Empleado empleado = …;
    verificarTieneProyectoCritico(empleado);

    Material material = …;
    if (material.tieneUnidadesLibres()) {
        return gestorPrestamos.prestar(isbn, idEmpleado, null);
    }

    Prestamo enCurso = prestamos.activoDe(isbn).orElseThrow(…);
    enCurso.marcarUrgente(LocalDate.now(reloj).plusDays(2));
    notificador.notificar(Aviso.devolucionUrgente(enCurso));

    return Prestamo.prioritarioEnEspera(material, empleado, LocalDate.now(reloj));
}

Ciclo 4 — Rojo: las dos restricciones que faltan.

@Test
void unEmpleadoNoPuedeTenerDosPrioridadesSimultaneas() {
    Empleado marta = unEmpleado("Marta Ruiz").conProyectoCritico().conPrioridadEnUso();
    materialSinUnidadesLibres(ISBN_JAVA);

    assertThatThrownBy(() -> servicio.prestarConPrioridad(ISBN_JAVA, marta.getId()))
            .isInstanceOf(PrioridadYaEnUsoException.class);
}

@Test
void noSePuedeUsarPrioridadSobreUnMaterialYaMarcadoComoUrgente() {
    Empleado marta = unEmpleado("Marta Ruiz").conProyectoCritico();
    prestamoActivoDe(ISBN_JAVA, DIEGO).yaMarcadoUrgente();
    materialSinUnidadesLibres(ISBN_JAVA);

    assertThatThrownBy(() -> servicio.prestarConPrioridad(ISBN_JAVA, marta.getId()))
            .isInstanceOf(MaterialYaUrgenteException.class)
            .hasMessageContaining("ya tiene una devolución urgente en curso");
}

Ciclo 4 — Verde y refactor. El método ha crecido; toca extraer y aplicar el patrón Estrategia con una cadena de verificaciones (12-02):

@Service
public class ServicioPrestamoPrioritario {

    private final List<VerificacionPrioridad> verificaciones;   // Cadena de responsabilidad
    private final Clock reloj;

    @Transactional
    public Prestamo prestarConPrioridad(Isbn isbn, Long idEmpleado) {
        ContextoPrioridad ctx = construirContexto(isbn, idEmpleado);

        // Si el material está libre, no hay nada que verificar: préstamo normal
        if (ctx.material().tieneUnidadesLibres()) {
            return gestorPrestamos.prestar(isbn, idEmpleado, null);
        }

        // Cada verificación lanza su propia excepción específica
        verificaciones.forEach(v -> v.verificar(ctx));

        return activarPrioridad(ctx);
    }

    private Prestamo activarPrioridad(ContextoPrioridad ctx) {
        LocalDate limite = LocalDate.now(reloj).plusDays(2);
        ctx.prestamoEnCurso().marcarUrgente(limite);
        ctx.empleado().consumirPrioridad();
        notificador.notificar(Aviso.devolucionUrgente(ctx.prestamoEnCurso(), limite));
        eventos.publicar(new PrioridadActivada(ctx.isbn(), ctx.empleado().getId(), limite));
        return Prestamo.prioritarioEnEspera(ctx.material(), ctx.empleado(), LocalDate.now(reloj));
    }
}

@Component @Order(10)
class VerificarProyectoCritico implements VerificacionPrioridad {
    public void verificar(ContextoPrioridad ctx) {
        if (!ctx.empleado().tieneProyectoCriticoActivo()) {
            throw new SinPrioridadDisponibleException(ctx.empleado().getNombre());
        }
    }
}

@Component @Order(20)
class VerificarPrioridadDisponible implements VerificacionPrioridad {
    public void verificar(ContextoPrioridad ctx) {
        if (ctx.empleado().prioridadesEnUso() >= 1) {
            throw new PrioridadYaEnUsoException(ctx.empleado().getNombre());
        }
    }
}

@Component @Order(30)
class VerificarMaterialNoUrgente implements VerificacionPrioridad {
    public void verificar(ContextoPrioridad ctx) {
        if (ctx.prestamoEnCurso().esUrgente()) {
            throw new MaterialYaUrgenteException(ctx.isbn());
        }
    }
}

Mutantes que podrían sobrevivir —el análisis que pide el ejercicio—:

Mutación ¿Sobrevive? Prueba que falta
prioridadesEnUso() >= 1> 1 Un empleado con exactamente 1 prioridad debe ser rechazado (ya está)
plusDays(2)plusDays(3) Sí, si solo se comprueba esUrgente() Afirmar la fecha exacta, no solo la marca
Eliminar consumirPrioridad() Verificar que tras usarla, la segunda falla
Eliminar eventos.publicar(...) Verificar que se publica el evento
tieneUnidadesLibres() → negado No Ya cubierto por el ciclo 2

Las pruebas que cierran esos huecos:

@Test
void trasUsarLaPrioridadElEmpleadoNoPuedeUsarOtra() {
    Empleado marta = unEmpleado("Marta Ruiz").conProyectoCritico();
    materialSinUnidadesLibres(ISBN_JAVA);
    materialSinUnidadesLibres(ISBN_PATRONES);

    servicio.prestarConPrioridad(ISBN_JAVA, marta.getId());

    assertThatThrownBy(() -> servicio.prestarConPrioridad(ISBN_PATRONES, marta.getId()))
            .isInstanceOf(PrioridadYaEnUsoException.class);
}

@Test
void publicaElEventoDePrioridadActivada() {
    Empleado marta = unEmpleado("Marta Ruiz").conProyectoCritico();
    prestamoActivoDe(ISBN_JAVA, DIEGO);
    materialSinUnidadesLibres(ISBN_JAVA);

    servicio.prestarConPrioridad(ISBN_JAVA, marta.getId());

    assertThat(eventos.publicados())
            .singleElement(as(InstanceOfAssertFactories.type(PrioridadActivada.class)))
            .satisfies(e -> {
                assertThat(e.isbn()).isEqualTo(ISBN_JAVA);
                assertThat(e.fechaLimite()).isEqualTo(LocalDate.of(2026, 8, 7));   // fecha EXACTA
            });
}

Solución 3

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 4 * * 1'        # lunes a las 04:00 UTC: pruebas de mutación

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read
  pull-requests: write         # necesario para comentar en el PR
  checks: write

jobs:

  # =====================================================================
  # 1. RÁPIDO — respuesta en menos de 3 minutos
  # =====================================================================
  rapido:
    name: Formato y pruebas unitarias
    runs-on: ubuntu-latest
    timeout-minutes: 8

    steps:
      - uses: actions/checkout@v4

      - name: Configurar JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: temurin
          cache: maven

      - name: Verificar formato
        run: ./mvnw -B --no-transfer-progress spotless:check

      - name: Compilar y pruebas unitarias
        run: ./mvnw -B --no-transfer-progress test

      - name: Publicar resultados
        uses: mikepenz/action-junit-report@v4
        if: always()
        with:
          report_paths: '**/target/surefire-reports/TEST-*.xml'
          check_name: 'Pruebas unitarias'
          detailed_summary: true

  # =====================================================================
  # 2. MATRIZ — Java 21 (producción) y Java 23 (detección temprana)
  # =====================================================================
  compatibilidad:
    name: Java ${{ matrix.java }}
    runs-on: ubuntu-latest
    needs: rapido
    timeout-minutes: 15

    strategy:
      fail-fast: false            # que un fallo en 23 no cancele el de 21
      matrix:
        java: ['21', '23']

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: ${{ matrix.java }}
          distribution: temurin
          cache: maven

      - name: Pruebas unitarias
        run: ./mvnw -B --no-transfer-progress test
        # Java 23 es informativo: no debe bloquear la fusión
        continue-on-error: ${{ matrix.java == '23' }}

  # =====================================================================
  # 3. COMPLETO — integración con Testcontainers, cobertura y análisis
  # =====================================================================
  completo:
    name: Integración, cobertura y análisis
    runs-on: ubuntu-latest
    needs: rapido
    timeout-minutes: 25

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: temurin
          cache: maven

      # Precargar la imagen: evita que el primer test cargue con los 30 s de descarga
      - name: Precargar imagen de PostgreSQL
        run: docker pull postgres:16-alpine

      - name: Pruebas de integración y cobertura
        run: ./mvnw -B --no-transfer-progress verify
        env:
          TESTCONTAINERS_REUSE_ENABLE: 'false'      # en CI, contenedores limpios

      - name: Comentar la cobertura en el PR
        uses: madrapps/[email protected]
        if: github.event_name == 'pull_request'
        with:
          paths: '**/target/site/jacoco/jacoco.xml'
          token: ${{ secrets.GITHUB_TOKEN }}
          min-coverage-overall: 75
          min-coverage-changed-files: 80
          title: '📊 Cobertura'
          update-comment: true                      # actualiza en vez de acumular comentarios
          pass-emoji: '✅'
          fail-emoji: '❌'

      - name: Comprobar umbrales de cobertura
        run: ./mvnw -B jacoco:check

      - name: Análisis estático (SpotBugs + FindSecBugs)
        run: ./mvnw -B spotbugs:check

      - name: Reglas de arquitectura (ArchUnit)
        run: ./mvnw -B test -Dtest='ReglasDeArquitecturaTest'

      - name: Guardar informes
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: informes-calidad
          path: |
            **/target/site/jacoco/
            **/target/spotbugsXml.xml
            **/target/failsafe-reports/
          retention-days: 14

      - name: Resumen en la pestaña de la ejecución
        if: always()
        run: |
          echo "## Resumen de calidad" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "| Comprobación | Resultado |" >> $GITHUB_STEP_SUMMARY
          echo "|---|---|" >> $GITHUB_STEP_SUMMARY
          echo "| Pruebas unitarias | ${{ needs.rapido.result }} |" >> $GITHUB_STEP_SUMMARY
          echo "| Integración | ${{ job.status }} |" >> $GITHUB_STEP_SUMMARY

  # =====================================================================
  # 4. MUTACIÓN — solo los lunes y en main. Es lenta.
  # =====================================================================
  mutacion:
    name: Pruebas de mutación (PIT)
    runs-on: ubuntu-latest
    if: github.event_name == 'schedule' || github.ref == 'refs/heads/main'
    timeout-minutes: 40

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: temurin
          cache: maven

      - name: PIT sobre el dominio
        run: |
          ./mvnw -B -pl bibliotech-dominio \
            org.pitest:pitest-maven:mutationCoverage \
            -DmutationThreshold=70

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: informe-mutacion
          path: '**/target/pit-reports/'
          retention-days: 30

      - name: Abrir incidencia si baja del umbral
        if: failure()
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              title: '⚠️ La cobertura de mutación del dominio bajó del 70 %',
              body: 'Revisa el informe de PIT en los artefactos de la ejecución ' +
                    context.runId + '. Hay mutantes supervivientes: las aserciones ' +
                    'de alguna prueba no detectan cambios en el código.',
              labels: ['calidad', 'pruebas']
            })

Decisiones de diseño de la canalización, que es lo que evalúa el ejercicio:

Decisión Motivo
Dos trabajos, rápido y completo El 90 % de los fallos se detecta en 3 minutos
needs: rapido en el completo No gastar 25 minutos si el formato está mal
concurrency con cancel-in-progress Un push nuevo cancela el anterior: ahorra minutos y coste
cache: maven Ahorra 1-2 minutos por ejecución
fail-fast: false en la matriz Un fallo en Java 23 no debe ocultar el resultado en 21
continue-on-error en Java 23 Informativo: detecta problemas futuros sin bloquear hoy
if: always() en publicaciones Los informes importan sobre todo cuando algo falla
min-coverage-changed-files: 80 Clean as You Code: exigir al código nuevo, no a la deuda histórica
Mutación en schedule Es lenta; en cada PR sería insoportable
Incidencia automática al bajar la mutación Nadie mira los informes; una incidencia sí se ve
timeout-minutes en todos Un trabajo colgado no consume la cuota indefinidamente
permissions explícitos Mínimo privilegio (12-07) también en CI

Conclusión

BiblioTech ya no solo funciona: se puede demostrar que funciona.

Tienes una estrategia de pruebas de verdad, no un montón de pruebas: qué se verifica en cada nivel —dominio, aplicación, repositorio, web, extremo a extremo—, con la regla que evita duplicar (cada comprobación en el nivel más bajo posible) y con tiempos objetivo que determinan si la suite se ejecuta o se ignora: quince segundos en local, diez minutos en CI. Y entiendes por qué la pirámide se invierte sola si nadie la vigila, y que la causa raíz casi nunca es pereza sino diseño: si probar una clase aislada es difícil, el problema es la clase.

Cambiaste H2 por Testcontainers con PostgreSQL real, sabiendo exactamente en qué miente H2 —tipos, funciones nativas, secuencias, ordenación, bloqueos, restricciones, zonas horarias— y aceptando el coste con conocimiento de causa: cinco veces más lento en el 10 % de la suite que más se beneficia de la fidelidad. Con @ServiceConnection de Spring Boot 3.1, el contenedor compartido en una clase base y la separación surefire/failsafe que permite ejecutar solo lo rápido cuando toca.

Mides la cobertura con JaCoCo, distinguiendo líneas de ramas —y sabiendo que solo la segunda dice la verdad— con umbrales por paquete, más exigentes en el dominio. Y sobre todo tienes la interpretación honesta, que es lo que separa a quien usa la métrica de quien se engaña con ella: la cobertura alta no garantiza calidad, la baja sí señala riesgo; una prueba sin aserciones da 100 % y vale cero; y el día que la cobertura se convierta en objetivo individual, dejará de medir nada.

Para saber si tus aserciones sirven, tienes las pruebas de mutación con PIT, con el ejemplo concreto del mutante superviviente en el cálculo de multas: dias <= 0 convertido en dias < 0, la frontera del día exacto del vencimiento, invisible para una cobertura del 100 %. Con el coste asumido: solo al dominio, semanalmente, umbral del 70-80 %.

Conoces el análisis estático y qué encuentra cada herramienta —SpotBugs los bugs reales, PMD la complejidad, Checkstyle el estilo, SonarQube el histórico y la seguridad, ArchUnit la arquitectura— con el consejo de adopción que evita que el equipo lo ignore en bloque: empezar por lo grave y crecer. Y sabes leer la complejidad ciclomática como lo que es —el número mínimo de pruebas necesarias—, clasificar la deuda técnica entre prudente e imprudente, e identificar los code smells con su remedio.

Refactorizas con red: extraer método, extraer clase y reemplazar condicional por polimorfismo, con el procedimiento por pasos que da siete oportunidades de detectar un error donde hacerlo de golpe da cero. Y sabes que sin pruebas no es refactorización, es reescritura con esperanza.

Desarrollaste una funcionalidad completa con TDD —el recargo por material dañado— paso a paso, incluidos los pasos que parecen absurdos (devolver siempre cero) y que son exactamente lo que la disciplina pide. Con el resultado a la vista: cero código sin probar, las fronteras cubiertas desde el principio, un enum con estado que salió del paso de refactor y no de la primera implementación, y documentación ejecutable. Y con la valoración honesta de cuándo aporta y cuándo estorba, más el caso en el que es sencillamente la mejor opción disponible: corregir un error.

Sabes revisar código —qué mirar y en qué orden, cómo dar retroalimentación que mejore el código en vez de generar resistencia, y por qué un PR de mil líneas recibe «LGTM» y uno de doscientas recibe comentarios útiles—, y tienes la lista de comprobación de BiblioTech. Y tienes integración continua con GitHub Actions: trabajo rápido y trabajo completo, cobertura comentada en el PR, análisis estático, mutación programada, matriz de versiones de Java y la protección de rama sin la cual todo lo anterior es un semáforo que nadie está obligado a mirar.

Y sabes qué no probar —getters, el framework, la biblioteca estándar, los detalles de implementación privados— y reconocer las pruebas frágiles como la deuda que son, con sus siete causas y sus soluciones: Clock inyectable en lugar de LocalDate.now(), Awaitility en lugar de Thread.sleep, aislamiento en lugar de estado compartido.

BiblioTech está probado, medido, analizado y verificado en cada cambio. Y sigue sin existir para nadie: corre en el portátil de Diego Alonso y en el runner de GitHub Actions. Ni Marta Ruiz ni Nuria Vidal pueden abrir un navegador y usarlo, porque no hay ningún servidor donde esté funcionando.

La siguiente lección lo pone en producción: empaquetado en jar por capas, contenedores con un Dockerfile multietapa comentado línea a línea, opciones de la JVM conscientes de los cgroups, migraciones de base de datos versionadas con Flyway —porque ddl-auto no vale en producción—, dónde desplegar y con qué criterio, sondas de salud y apagado ordenado, estrategias de despliegue con vuelta atrás, y la canalización completa de entrega continua que construye la imagen, la publica y la despliega.

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