BiblioTech té arquitectura, patrons, una CLI professional i una API REST completa. I una pregunta sense resposta: funciona de debò?

Hi ha proves —les quaranta-una del mòdul 11, més les que va afegir la lliçó anterior—, però això no és una estratègia de qualitat. Ningú no sap quin percentatge del codi s'exercita. Ningú no ha comprovat si aquestes proves verifiquen res o simplement executen línies sense afirmar res. Les proves de repositori corren sobre H2, que no és la base de dades de producció i que menteix en detalls que importen. Ningú no ha passat una anàlisi estàtica. I no hi ha integració contínua: si en Diego Alonso trenca el càlcul de multes un divendres, ningú no se n'assabenta fins que la Marta Ruiz es queixa el dimarts.

Aquesta lliçó converteix «tinc proves» en «tinc una estratègia de qualitat»: saber què es prova a cada nivell i quant ha de trigar, provar contra la base de dades real, mesurar la cobertura i —el més important— interpretar-la amb honestedat, avaluar la qualitat de les mateixes assercions amb proves de mutació, passar eines d'anàlisi estàtica, refactoritzar amb xarxa de seguretat, escriure codi guiat per proves, revisar la feina dels altres, i automatitzar-ho tot perquè la màquina digui «no» abans que ho digui un usuari.

Un advertiment previ: res d'això no surt de franc. Cada eina afegeix temps de construcció i feina de manteniment. La lliçó inclou sempre el cost, no només el benefici, perquè una estratègia de qualitat que l'equip abandona al cap de tres setmanes és pitjor que no tenir-ne cap.

Contingut

  1. Què significa «qualitat» en un projecte de programari
  2. L'estratègia de proves de BiblioTech
  3. La piràmide de proves i per què s'inverteix sola
  4. Temps objectiu per nivell
  5. Testcontainers: per què H2 menteix
  6. PostgreSQL real des de la prova
  7. Separar proves unitàries i d'integració a Maven
  8. Cobertura amb JaCoCo
  9. La interpretació honesta de la cobertura
  10. Proves de mutació amb PIT
  11. Anàlisi estàtica: SpotBugs, PMD, Checkstyle, SonarQube
  12. Formatatge automàtic amb Spotless
  13. Complexitat, deute tècnic i code smells
  14. Refactorització segura
  15. TDD: el cicle vermell-verd-refactor
  16. TDD pas a pas: recàrrec per material danyat
  17. Quan aporta TDD i quan no
  18. Revisió de codi
  19. Integració contínua amb GitHub Actions
  20. Què NO provar
  21. Proves fràgils com a deute
  22. Rendiment i càrrega
  23. Errors Comuns i Consells
  24. Exercicis
  25. Conclusió

  1. Què significa «qualitat» en un projecte de programari

Hi ha dues qualitats, i confondre-les explica la meitat de les discussions sobre aquest tema:

Qualitat externa Qualitat interna
Qui la percep L'usuari Qui manté el codi
Què és Que funcioni, sigui ràpid, no perdi dades Que sigui fàcil d'entendre i canviar
Com es mesura Errors en producció, temps de resposta Complexitat, acoblament, temps d'un canvi
Si es descuida Els usuaris es queixen avui El projecte s'alenteix d'aquí a sis mesos

L'externa es defensa sola: els usuaris protesten. La interna no té qui la reclami, i per això es degrada. El seu símptoma és sempre el mateix i és mesurable: el temps que costa afegir una funcionalitat creix amb el temps.

Les proves són l'única eina que serveix a totes dues: verifiquen el comportament (externa) i permeten canviar el codi sense por (interna). Tota la resta d'aquesta lliçó —cobertura, mutació, anàlisi estàtica, revisions— existeix per respondre una pregunta: me'n puc fiar, d'aquestes proves?

  1. L'estratègia de proves de BiblioTech

Una estratègia no és «escriure proves». És decidir què es prova a cada nivell per no provar tres vegades el mateix ni deixar forats.

Nivell Què es prova Eines Aixeca Quantes
Domini Regles de negoci pures: multes, estats, invariants JUnit 5, AssertJ Res Moltes (~60 %)
Aplicació Casos d'ús: orquestració, camins d'error JUnit 5, Mockito Res Bastants (~20 %)
Repositoris Consultes, mapatge, relacions, transaccions @DataJpaTest, Testcontainers Base de dades Poques (~10 %)
Web Rutes, validació, codis, JSON @WebMvcTest, MockMvc MVC de Spring Poques (~8 %)
Extrem a extrem Fluxos complets de l'usuari @SpringBootTest, Testcontainers Tot Molt poques (~2 %)

Aplicat a una funcionalitat concreta, «prestar un material», el repartiment és aquest:

Es prova A quin nivell Per què allà
Un préstec no es pot retornar dues vegades Domini És un invariant de Prestec; no necessita res més
La multa són 0,50 €/dia amb un màxim de 20 € Domini Càlcul pur amb Clock.fixed
Un empleat no pot tenir 4 préstecs actius Aplicació Requereix el repositori (simulat)
Si el notificador falla, el préstec es crea igualment Aplicació Camí d'error amb Mockito
vencutsAbansDe retorna el que toca Repositori És JPQL: cal executar-lo contra una base real
POST /api/prestecs retorna 201 amb Location Web És contracte HTTP
Un ISBN invàlid retorna 400 amb el detall Web És validació d'entrada
Prestar i retornar funciona de cap a cap E2E Integració real de totes les peces

La regla que evita duplicar: cada comprovació es fa al nivell més baix possible. Verificar el càlcul de la multa en un @SpringBootTest costa cinc segons i prova el mateix que una prova de domini de cinc mil·lisegons.

  1. La piràmide de proves i per què s'inverteix sola

flowchart TD
    E["E2E — poques, lentes, fràgils<br/>~2%: 15 s cadascuna"]
    W["Web i integració<br/>~18%: 1-3 s cadascuna"]
    U["Unitàries — moltes, ràpides, estables<br/>~80%: 5 ms cadascuna"]

    E --- W
    W --- U

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

La forma correcta és una piràmide: base ampla de proves ràpides, punta estreta de proves lentes. La forma que apareix sola si ningú no vigila és la contrària, el con de gelat:

Motiu pel qual s'inverteix Com sona a l'equip
Una prova E2E sembla més «real» «Si passa l'E2E, funciona tot»
Escriure-la no requereix dissenyar res «Aixeco tot i ja està»
Provar una classe aïllada exigeix que sigui aïllable «És que aquesta classe necessita mig Spring»
Ningú no mesura el temps de la suite Fins que triga 25 minuts

I les conseqüències són concretes i totes dolentes: la suite triga tant que ningú no l'executa abans de pujar codi; quan alguna cosa falla, el diagnòstic és «alguna cosa al flux de préstec» en comptes de «la multa es calcula malament el dia 31»; les proves fallen de manera intermitent per temps d'espera i s'acaben ignorant; i el cost de mantenir-les supera el valor que aporten, moment en què algú proposa esborrar-les.

La causa arrel gairebé mai no és mandra: si provar una classe per separat és difícil, el problema és el disseny d'aquesta classe, no la prova. Una classe amb set dependències i estat estàtic no es pot provar aïllada. La solució no és escriure un E2E: és arreglar la classe (12-01 i 12-02).

  1. Temps objectiu per nivell

Els temps no són un caprici; determinen si la suite s'executa o s'ignora.

Nivell Per prova Suite completa Quan s'executa
Domini < 10 ms < 5 s A cada desat, des de l'IDE
Aplicació < 50 ms < 15 s Abans de cada commit
Repositori < 500 ms < 60 s Abans de cada push
Web < 200 ms < 30 s Abans de cada push
E2E < 20 s < 5 min A CI
Total a CI < 10 min A cada Pull Request

El límit dels deu minuts a CI no és arbitrari: per sobre d'aquest llindar, la gent deixa d'esperar el resultat, canvia de tasca i perd el context. I el dels quinze segons en local és encara més important: si la suite ràpida triga més, es deixa d'executar.

Mesurar el temps és tan important com mesurar el resultat:

# Les 10 proves més lentes del projecte
./mvnw test -Dsurefire.reportFormat=plain
grep -h "Time elapsed" target/surefire-reports/*.txt | sort -t: -k2 -rn | head -10

I una prova que la suite no es degrada, que resulta sorprenentment eficaç:

@Test
void laSuiteDeDominiEsRapida() {
    long inici = System.nanoTime();
    // executar el conjunt de proves de domini…
    long ms = (System.nanoTime() - inici) / 1_000_000;
    assertThat(ms)
        .as("Les proves de domini han de continuar sent rapides")
        .isLessThan(5_000);
}

  1. Testcontainers: per què H2 menteix

BiblioTech fa servir H2 en memòria per a les proves de repositori des del mòdul 11. És ràpid i còmode. I produeix falsos positius i falsos negatius, perquè H2 no és PostgreSQL.

Els casos concrets en què menteix:

Diferència H2 PostgreSQL Conseqüència
Tipus JSON Sense jsonb real jsonb amb operadors i índexs Consultes que a H2 funcionen i en producció no
Funcions natives Absents to_tsvector, similarity, generate_series La cerca a text complet no es pot provar
Seqüències Comportament propi Semàntica específica de SERIAL/IDENTITY Col·lisions d'identificador només en producció
Ordenació de text Binària Segons la collation del sistema «Álvarez» va abans o després d'«Alvarez» segons el motor
Bloquejos Simplificats FOR UPDATE, nivells reals d'aïllament Els interbloquejos no apareixen a les proves
Distinció de majúscules Depèn de la configuració Sensible per defecte Consultes que fallen només en producció
Restriccions Menys estrictes Estrictes Violacions d'integritat que només surten en real
Zones horàries Simplificades timestamptz real Errors de data en el canvi d'hora

El cas més dolorós, i molt real: una consulta JPQL amb una funció que Hibernate tradueix diferent segons el dialecte. Passa en verd amb H2 i explota en producció amb un error de sintaxi SQL. El cost d'aquesta lliçó es paga a les 3 de la matinada.

Testcontainers ho resol: aixeca un contenidor Docker amb la base de dades real des de la mateixa prova, i el destrueix en acabar.

<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 des de la prova

Spring Boot 3.1 va introduir @ServiceConnection, que elimina la part més tediosa: configurar l'URL, l'usuari i la contrasenya a partir del contenidor.

/**
 * Classe base de les proves d'integracio.
 *
 * El contenidor es static: es crea UNA VEGADA per a tota l'execucio
 * i el comparteixen totes les classes que n'hereten. Sense static,
 * s'aixecaria un PostgreSQL per classe de prova (30 s cadascun).
 */
@Testcontainers
public abstract class ProvaAmbPostgres {

    @Container
    @ServiceConnection            // Spring Boot 3.1+: configura el DataSource sol
    static final PostgreSQLContainer<?> POSTGRES =
            new PostgreSQLContainer<>("postgres:16-alpine")
                    .withDatabaseName("bibliotech_test")
                    .withUsername("test")
                    .withPassword("test")
                    .withReuse(true);      // reutilitza el contenidor entre execucions locals
}

Abans de @ServiceConnection calia escriure això, i encara es veu en molts projectes:

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

Una prova de repositori amb base de dades real:

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)   // no substitueixis per H2!
@Tag("integracio")
class PrestecRepositoryIT extends ProvaAmbPostgres {

    @Autowired PrestecRepository repositori;
    @Autowired TestEntityManager em;

    @Test
    void trobaElsPrestecsVencutsOrdenatsPerAntiguitat() {
        Empleat marta = em.persist(unEmpleat("Marta Ruiz"));
        Material java = em.persist(unLlibre("978-0000000001", "Java Eficac"));

        em.persist(unPrestec(java, marta).ambVenciment(LocalDate.of(2026, 3, 1)));   // vencut
        em.persist(unPrestec(java, marta).ambVenciment(LocalDate.of(2026, 3, 10)));  // vencut
        em.persist(unPrestec(java, marta).ambVenciment(LocalDate.of(2026, 9, 1)));   // vigent
        em.flush();

        List<Prestec> vencuts = repositori.vencutsAbansDe(LocalDate.of(2026, 8, 5));

        assertThat(vencuts)
                .hasSize(2)
                .extracting(Prestec::getDataVenciment)
                .containsExactly(LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 10));
    }

    @Test
    void laCercaIgnoraMajusculesIAccentsComAProduccio() {
        em.persist(unLlibre("978-0000000003", "Refactoritzacio"));
        em.flush();

        // Aixo fa servir unaccent() de PostgreSQL: a H2 seria IMPOSSIBLE de provar
        assertThat(repositori.cercarPerTitol("refactoritzacio")).hasSize(1);
        assertThat(repositori.cercarPerTitol("REFACTORITZACIÓ")).hasSize(1);
    }

    @Test
    void detectaElConflicteDeBloqueigOptimista() {
        Prestec p = em.persistFlushFind(unPrestec());
        em.detach(p);

        // Simular una modificacio concurrent per part d'una altra transaccio
        em.getEntityManager()
          .createNativeQuery("update prestecs set version = version + 1 where id = :id")
          .setParameter("id", p.getId())
          .executeUpdate();

        p.renovar(7);

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

El cost, sense adorns:

Aspecte H2 Testcontainers
Arrencada ~200 ms 3-15 s el primer contenidor
Per prova ~10 ms ~50 ms (contenidor compartit)
Requereix Docker No , també a CI
Fidelitat amb producció Baixa Total
Suite de 30 proves de repositori ~5 s ~25 s

Cinc vegades més lent, i val la pena, per una raó concreta: les proves de repositori són poques (10 % de la suite) i són exactament les que més es beneficien de la fidelitat. Les de domini, que són el 60 %, continuen sense tocar res i continuen trigant mil·lisegons.

Dos trucs que redueixen el cost real:

# ~/.testcontainers.properties — reutilitzar contenidors entre execucions locals
testcontainers.reuse.enable=true

I el patró de contenidor únic, que ja està aplicat més amunt amb el static a la classe base: sense ell, cada classe de prova aixeca el seu propi PostgreSQL.

  1. Separar proves unitàries i d'integració a Maven

Amb proves de dues velocitats, cal poder executar només les ràpides. Maven ja té el mecanisme des d'11-05: surefire per a les unitàries, failsafe per a les d'integració.

Convenció de noms:

Sufix Connector Fase Exemple
*Test.java surefire test CalculadoraMultesTest
*IT.java failsafe verify PrestecRepositoryIT
<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <configuration>
        <excludedGroups>integracio</excludedGroups>   <!-- per si alguna Test porta @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 qui FA FALLAR la construccio -->
          </goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>
./mvnw test                    # només unitàries: ~20 s
./mvnw verify                  # unitàries + integració: ~3 min
./mvnw verify -DskipITs        # saltar-se les d'integració
./mvnw test -Dgroups=rapida    # només les etiquetades

Complementàriament, @Tag de JUnit 5 permet talls transversals:

@Tag("integracio")
@Tag("lenta")
class ImportacioMassivaIT extends ProvaAmbPostgres { … }

Un detall sobre el paral·lelisme, que és la manera més barata de recuperar temps:

# 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

Amb l'advertiment obligatori: el paral·lelisme destapa proves que comparteixen estat. Si en activar-lo comencen a fallar de manera aleatòria, no desactivis el paral·lelisme; arregla les proves, perquè aquest estat compartit és un problema real.

  1. Cobertura amb JaCoCo

La cobertura mesura quin percentatge del codi s'executa durant les proves. JaCoCo és l'eina estàndard a Java.

<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <version>0.8.12</version>
  <executions>
    <!-- 1. Instrumentar abans de les proves unitaries -->
    <execution>
      <id>preparar-agent</id>
      <goals><goal>prepare-agent</goal></goals>
    </execution>

    <!-- 2. Generar l'informe despres de les proves -->
    <execution>
      <id>informe</id>
      <phase>verify</phase>
      <goals><goal>report</goal></goals>
    </execution>

    <!-- 3. Comprovar llindars: si no es compleixen, la construccio FALLA -->
    <execution>
      <id>comprovar-llindars</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 metrica que de debo importa -->
                <value>COVEREDRATIO</value>
                <minimum>0.70</minimum>
              </limit>
            </limits>
          </rule>
          <!-- El domini es logica pura: se n'exigeix mes -->
          <rule>
            <element>PACKAGE</element>
            <includes><include>com.nexussoftware.bibliotech.domini.*</include></includes>
            <limits>
              <limit>
                <counter>BRANCH</counter>
                <value>COVEREDRATIO</value>
                <minimum>0.90</minimum>
              </limit>
            </limits>
          </rule>
        </rules>
      </configuration>
    </execution>
  </executions>

  <configuration>
    <excludes>
      <!-- Excloure el que no te logica per provar -->
      <exclude>**/dto/**</exclude>
      <exclude>**/*Application.class</exclude>
      <exclude>**/config/**</exclude>
      <exclude>**/generated/**</exclude>
    </excludes>
  </configuration>
</plugin>
./mvnw verify
# L'informe navegable, amb el codi acolorit línia a línia:
open target/site/jacoco/index.html

Cobertura de línies enfront de cobertura de branques, que és la distinció que separa una mètrica útil d'una d'enganyosa:

public Diner calcularMulta(Prestec prestec, LocalDate avui) {
    long dies = ChronoUnit.DAYS.between(prestec.getDataVenciment(), avui);
    if (dies <= 0) {
        return Diner.ZERO;
    }
    Diner multa = prestec.multaPerDia().per(dies);
    return multa.esMesGranQue(MAXIM) ? MAXIM : multa;
}

Amb una sola prova:

@Test
void calculaLaMultaDeDeuDies() {
    assertThat(calculadora.calcularMulta(prestecVencutFa(10), AVUI))
            .isEqualTo(Diner.euros("5.00"));
}
Mètrica Resultat Què falta
Línies 80 % (4 de 5) El return Diner.ZERO
Branques 50 % (2 de 4) dies <= 0, i el sostre del màxim

La cobertura de línies diu 80 % i sona bé. La de branques diu 50 % i diu la veritat: la meitat dels camins de decisió no s'ha provat mai, inclòs el sostre de 20 € que és una regla de negoci explícita.

Mesura sempre branques. És més difícil de pujar i molt més informativa.

  1. La interpretació honesta de la cobertura

Aquí és on la majoria d'equips s'enganya, així que convé ser directe:

La cobertura alta NO garanteix qualitat. La cobertura baixa SÍ que assenyala risc.

És una implicació en un sol sentit, i aquesta prova ho demostra:

@Test
void calculaLaMulta() {
    // Executa TOT el metode: cobertura de linies del 100 %
    calculadora.calcularMulta(unPrestecVencutFa(10), AVUI);
    // I NO COMPROVA RES.
}

JaCoCo donarà 100 % de cobertura d'aquest mètode. Si demà algú canvia 0.50 per 50.00, la prova continua passant en verd. La cobertura mesura execució, no verificació.

Casos reals de cobertura que menteix:

Patró Cobertura Valor real
Prova sense assercions 100 % Zero
assertThat(resultat).isNotNull() 100 % Gairebé zero
Prova que només cobreix el camí feliç 60 % de branques Mig: els errors no es proven
Prova amb la mateixa lògica que el codi 100 % Negatiu: replica el bug

I a l'inrevés, la cobertura baixa sempre significa alguna cosa:

Cobertura d'un paquet Interpretació
0 % a domini.prestecs Alarma: la lògica de negoci no es prova
30 % en un servei Els camins d'error probablement no es proven
95 % a dto Irrellevant: no hi ha lògica; excloure del càlcul

Com fer servir la cobertura sense enganyar-se:

  1. Com a detector de forats, no com a objectiu. Obre l'informe i busca en vermell la lògica important. Això és una llista de tasques.
  2. Amb llindars per paquet, no globals. Exigir 90 % al domini i 60 % a infraestructura té sentit; exigir 80 % global premia provar getters.
  3. Vigilant la tendència, no el valor absolut. Que baixi del 78 % al 71 % en un PR és un senyal; que sigui 78 % i no 80 % no ho és.
  4. Mai com a objectiu individual. El dia que algú mesuri el rendiment d'un desenvolupador per cobertura, tindràs milers de proves sense assercions. La llei de Goodhart en estat pur: quan una mesura es converteix en objectiu, deixa de ser una bona mesura.

I per saber si les teves proves verifiquen alguna cosa, hi ha una eina específica.

  1. Proves de mutació amb PIT

La cobertura mesura si el codi s'executa. Les proves de mutació mesuren si les teves assercions detecten canvis.

Funcionament: l'eina introdueix petites modificacions al teu codi (els mutants) i executa les proves. Si alguna falla, el mutant ha estat eliminat (bé). Si totes passen, el mutant sobreviu: les teves proves no detectarien aquest canvi (malament).

Mutació Exemple
Condicional de frontera <<=
Negar condicional ==!=
Operador aritmètic +-
Valor de retorn return xreturn null
Eliminar crida a void S'esborra la línia
Increments ++--
<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.domini.*</param>   <!-- on hi ha la logica -->
    </targetClasses>
    <targetTests>
      <param>com.nexussoftware.bibliotech.domini.*Test</param>
    </targetTests>
    <mutationThreshold>70</mutationThreshold>
    <timestampedReports>false</timestampedReports>
  </configuration>
</plugin>
./mvnw org.pitest:pitest-maven:mutationCoverage
open target/pit-reports/index.html

El mutant supervivent al càlcul de multes. Aquest és el codi real:

public Diner calcularMulta(Prestec prestec, LocalDate avui) {
    long dies = ChronoUnit.DAYS.between(prestec.getDataVenciment(), avui);
    if (dies <= 0) {                                    // ← el punt critic
        return Diner.ZERO;
    }
    Diner multa = prestec.multaPerDia().per(dies);
    return multa.esMesGranQue(MAXIM) ? MAXIM : multa;
}

I aquestes són les proves que hi havia:

@Test void senseRetardNoHiHaMulta()    { assertThat(calcular(-3)).isEqualTo(Diner.ZERO); }
@Test void ambDeuDiesSonCincEuros()    { assertThat(calcular(10)).isEqualTo(Diner.euros("5.00")); }
@Test void maiNoSuperaElMaxim()        { assertThat(calcular(100)).isEqualTo(Diner.euros("20.00")); }

Cobertura de branques: 100 %. Informe de PIT:

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

El mutant supervivent canvia dies <= 0 per dies < 0. La diferència és exactament el dia 0: el dia en què venç el préstec. Amb el codi original, retornar aquell mateix dia no genera multa. Amb el mutant, dies == 0 entra al càlcul i genera... 0 dies × 0,50 € = 0 €. En aquest cas concret el resultat coincideix per casualitat, però la frontera no està provada, i n'hi ha prou que demà algú canviï la fórmula a (dies + 1) * tarifa perquè el dia del venciment comenci a cobrar-se sense que cap prova se n'assabenti.

La prova que falta, i que és la que un revisor experimentat demanaria:

@ParameterizedTest
@CsvSource({
    "-1, 0.00",     // un dia abans de vencer
    " 0, 0.00",     // EL DIA DEL VENCIMENT: la frontera
    " 1, 0.50",     // un dia de retard
    "39, 19.50",    // just per sota del maxim
    "40, 20.00",    // exactament el maxim
    "41, 20.00"     // per sobre: aplica el sostre
})
void calculaLaMultaEnLesFronteres(int diesDeRetard, String multaEsperada) {
    assertThat(calcular(diesDeRetard)).isEqualTo(Diner.euros(multaEsperada));
}

Amb ella, PIT elimina el mutant. I fixa't en el que ha passat: la cobertura era del 100 % abans i continua sent del 100 % després. La cobertura no podia veure aquest problema; la mutació sí.

El cost, que és real: PIT és lent (executa la suite una vegada per mutant) i produeix falsos positius (mutants equivalents, que no canvien el comportament i són impossibles de matar). Per això:

  • Aplica'l només al domini, que és on hi ha la lògica que importa.
  • Executa'l setmanalment o a la branca principal, no a cada PR.
  • Un llindar del 70-80 % al domini és exigent i assolible. El 100 % no és un objectiu raonable.

  1. Anàlisi estàtica: SpotBugs, PMD, Checkstyle, SonarQube

L'anàlisi estàtica examina el codi sense executar-lo. Cada eina busca coses diferents i són complementàries:

Eina Què busca Exemple de troballa Falsos positius
SpotBugs Bugs probables (analitza bytecode) NullPointerException possible, comparar String amb ==, recurs no tancat Pocs
PMD Males pràctiques i complexitat Mètode de 200 línies, complexitat 25, variable sense fer servir, catch buit Mitjans
Checkstyle Estil i convencions Noms, ordre d'imports, absència de Javadoc Molts si es configura malament
SonarQube Tot l'anterior + seguretat + duplicació + històric Injecció SQL, secrets, deute tècnic en hores Mitjans
ArchUnit Regles d'arquitectura (12-01) «El domini importa Spring» Cap
Error Prone Bugs en compilació Comparació de tipus incompatibles Molt pocs

SpotBugs és el que més valor aporta per línia de configuració, perquè troba errors reals:

<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-exclusions.xml</excludeFilterFile>
    <plugins>
      <plugin>
        <groupId>com.h3xstream.findsecbugs</groupId>     <!-- analisi de seguretat -->
        <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>

Troballes típiques en un projecte com BiblioTech:

// SpotBugs: DM_DEFAULT_ENCODING — depen de la codificacio de la plataforma
Files.readString(cami);                          // MALAMENT
Files.readString(cami, StandardCharsets.UTF_8);  // BE

// SpotBugs: ES_COMPARING_STRINGS_WITH_EQ
if (estat == "ACTIU")             // MALAMENT: compara referencies
if ("ACTIU".equals(estat))        // BE

// SpotBugs: EI_EXPOSE_REP — s'exposa la representacio interna
public List<Prestec> getPrestecs() { return prestecs; }             // MALAMENT: mutable
public List<Prestec> getPrestecs() { return List.copyOf(prestecs); } // BE

// FindSecBugs: SQL_INJECTION_JPA
em.createQuery("select m from Material m where m.titol like '%" + text + "%'");   // MALAMENT
em.createQuery("select m from Material m where m.titol like :t").setParameter("t", …); // BE

SonarQube / SonarCloud afegeix el que les altres no donen: històric i el concepte de codi nou.

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

La seva idea més útil es diu Clean as You Code: no exigeix arreglar el deute històric (impossible i desmoralitzador), sinó que el codi nou compleixi l'estàndard. Llindar típic:

Mètrica sobre el codi nou Llindar
Cobertura ≥ 80 %
Duplicació ≤ 3 %
Vulnerabilitats 0
Bugs amb severitat alta 0
Code smells bloquejants 0

I un consell d'adopció que val més que la configuració: no activis totes les regles de totes les eines el primer dia. Apareixeran tres mil avisos, l'equip els ignorarà en bloc i hauràs perdut l'eina. Comença amb SpotBugs en severitat alta i ArchUnit; afegeix la resta progressivament.

  1. Formatatge automàtic amb Spotless

Ja configurat a 12-01. Aquí només la part que correspon a CI:

./mvnw spotless:apply     # formata (en local, o com a enganxall de pre-commit)
./mvnw spotless:check     # falla si no està formatat (a CI)

El benefici real no és estètic: elimina de les revisions de codi tot el soroll sobre format, deixant espai per parlar del que importa. I fa que els diffs mostrin canvis de comportament, no resagnats.

  1. Complexitat, deute tècnic i code smells

La complexitat ciclomàtica és el nombre de camins independents per un mètode: 1 + el nombre de decisions (if, case, &&, ||, catch, bucles).

Complexitat Valoració Acció
1-5 Simple Cap
6-10 Moderada Acceptable
11-20 Complexa Refactoritzar quan s'hi toqui
21+ Molt complexa Refactoritzar ja

La seva utilitat pràctica més directa: la complexitat ciclomàtica és el nombre mínim de proves necessàries per cobrir totes les branques. Un mètode amb complexitat 15 necessita 15 proves. Si no les té, hi ha camins sense provar.

Deute tècnic. La metàfora de Ward Cunningham: prendre una drecera avui és demanar un préstec; els interessos són el temps extra que costarà cada canvi futur.

Tipus Exemple És acceptable?
Deliberat i prudent «Sortim sense memòria cau; l'afegim si cal» , si es documenta
Deliberat i imprudent «No hi ha temps per a proves» No
Involuntari i prudent «Ara sabem com s'hauria d'haver fet» Inevitable
Involuntari i imprudent «Què és una capa?» Es cura formant

El deute prudent s'anota. A BiblioTech:

// DEUTE: la cerca recorre tot el cataleg en memoria perque son ~3.000 materials.
// Amb mes de 50.000 caldra passar a cerca a text complet de PostgreSQL.
// Decidit conscientment el 2026-08-05 — veure docs/adr/0007-cerca-en-memoria.md

Code smells més freqüents i el seu remei:

Smell Símptoma Remei
Mètode llarg Més de 30 línies Extreure mètode
Classe gran Més de 300 línies, més de 10 dependències Extreure classe (12-02)
Llista llarga de paràmetres Més de 4 Objecte de paràmetres, Constructor
Enveja de funcionalitat Un mètode fa servir més dades d'una altra classe que de la seva Moure el mètode
Obsessió pels primitius String isbn en lloc d'Isbn Objecte de valor
Sentències switch repetides El mateix switch en cinc llocs Polimorfisme
Codi duplicat Copiar i enganxar Extreure mètode o classe
Comentaris explicatius «Aquí calculem la multa tenint en compte…» Extreure mètode amb bon nom

  1. Refactorització segura

Refactoritzar és canviar l'estructura interna sense canviar el comportament observable. La condició no és negociable: sense proves, no és refactorització, és reescriptura amb esperança.

El cicle:

flowchart LR
    A["Proves en verd"] --> B["Un canvi petit"]
    B --> C["Executar proves"]
    C -->|"verd"| D["Commit"]
    C -->|"vermell"| E["Desfer"]
    D --> B
    E --> A

Refactorització 1: extreure mètode.

// ABANS: 40 linies, tres responsabilitats entremesclades
public ResultatImportacio importar(Path fitxer) {
    List<String> linies = Files.readAllLines(fitxer, UTF_8);
    List<Material> materials = new ArrayList<>();
    List<String> errors = new ArrayList<>();

    for (int i = 1; i < linies.size(); i++) {
        String[] camps = linies.get(i).split(";");
        if (camps.length < 4) { errors.add("Linia " + i + ": camps insuficients"); continue; }
        if (!camps[0].matches("97[89]-\\d{10}")) { errors.add("Linia " + i + ": ISBN invalid"); continue; }
        // … 20 linies mes de conversio i validacio
    }
    // … desat i resum
}
// DESPRES: el metode principal explica la historia; els detalls son un nivell mes avall
public ResultatImportacio importar(Path fitxer) throws IOException {
    List<LiniaCsv> linies = llegirLinies(fitxer);
    ResultatParseig parseig = parsejar(linies);
    List<Material> desats = desar(parseig.valids());
    return new ResultatImportacio(desats.size(), parseig.errors());
}

private ResultatParseig parsejar(List<LiniaCsv> linies) {
    List<Material> valids = new ArrayList<>();
    List<ErrorImportacio> errors = new ArrayList<>();
    for (LiniaCsv linia : linies) {
        parsejarLinia(linia).ifPresentOrElse(valids::add, () -> errors.add(errorDe(linia)));
    }
    return new ResultatParseig(valids, errors);
}

Refactorització 2: extreure classe.

// ABANS: Prestec barreja la seva identitat amb el calcul de multes
public class Prestec {
    public Diner calcularMulta(LocalDate avui) {
        long dies = ChronoUnit.DAYS.between(dataVenciment, avui);
        if (dies <= 0) return Diner.ZERO;
        Diner base = material.multaPerDia().per(dies);
        if (empleat.antiguitatEnMesos() < 6) base = base.multiplicarPer(0.5);
        if (empleat.esDireccio()) return Diner.ZERO;
        return base.esMesGranQue(MAXIM) ? MAXIM : base;
    }
}
// DESPRES: la politica de multes es un concepte propi, amb les seves propies proves
public class PoliticaMultes {
    private final List<ReglaTarifa> regles;     // Estrategia (12-02)

    public Diner calcular(Prestec prestec, LocalDate avui) { … }
}

public class Prestec {
    public Diner multaAcumulada(LocalDate avui, PoliticaMultes politica) {
        return politica.calcular(this, avui);
    }
}

Refactorització 3: reemplaçar condicional per polimorfisme (reprèn 03-06).

// ABANS
public int diesDePrestec(Material m) {
    switch (m.getTipus()) {
        case LLIBRE: return 15;
        case REVISTA: return 7;
        case DVD: return 3;
        default: throw new IllegalStateException();
    }
}
// DESPRES: cada tipus respon per si mateix
public abstract class Material {
    public abstract int diesDePrestecPerDefecte();
}

I el procediment segur per fer-ho, que és el que evita trencar coses:

  1. Afegir el mètode abstracte i les seves implementacions, sense esborrar el switch.
  2. Fer que el switch delegui: return m.diesDePrestecPerDefecte();
  3. Executar les proves. En verd.
  4. Substituir les crides al mètode antic per crides directes.
  5. Executar les proves. En verd.
  6. Esborrar el mètode antic.
  7. Executar les proves. Commit.

Set passos, set oportunitats de detectar un error. Fer-ho de cop en són zero.

  1. TDD: el cicle vermell-verd-refactor

Test-Driven Development inverteix l'ordre habitual: primer la prova, després el codi.

flowchart LR
    R["🔴 VERMELL<br/>Escriu una prova que falla"]
    V["🟢 VERD<br/>El codi mínim per fer-la passar"]
    F["🔵 REFACTOR<br/>Millora sense trencar res"]
    R --> V --> F --> R

Les tres regles de Robert C. Martin:

  1. No escriguis codi de producció llevat que sigui per fer passar una prova que falla.
  2. No escriguis més prova de la necessària per fallar (no compilar és fallar).
  3. No escriguis més codi de producció del necessari per passar la prova.

I el que se sol malinterpretar: TDD no és una tècnica de proves, és una tècnica de disseny. Les proves són un efecte secundari. El que fa és forçar-te a fer servir la teva pròpia API abans d'implementar-la, i això produeix dissenys més usables, amb menys dependències, perquè una classe difícil de provar és dolorosa d'escriure en TDD i ho notes al principi, no al final.

  1. TDD pas a pas: recàrrec per material danyat

Requisit nou de Nexus Software: si un material es retorna danyat, s'aplica un recàrrec a més de la multa per retard.

  • Dany lleu: 20 % del valor del material.
  • Dany greu: 60 %.
  • Irrecuperable: 100 % del valor, i el material es retira del catàleg.
  • El recàrrec se suma a la multa per retard.
  • El total (multa + recàrrec) no pot superar el valor del material.

Anem pas a pas, sense saltar-nos-en cap.

Pas 1 — Vermell. La prova més simple possible:

class RecarrecPerDanyTest {

    @Test
    void unMaterialSenseDanyNoTeRecarrec() {
        var calculadora = new CalculadoraRecarrecs();
        var material = unLlibre("978-0000000001").ambValor(Diner.euros("45.00"));

        Diner recarrec = calculadora.calcular(material, EstatDevolucio.SENSE_DANY);

        assertThat(recarrec).isEqualTo(Diner.ZERO);
    }
}

No compila. CalculadoraRecarrecs i EstatDevolucio no existeixen. Això és vermell.

Pas 2 — Verd. El codi mínim. Literalment el mínim:

public enum EstatDevolucio { SENSE_DANY }

public class CalculadoraRecarrecs {
    public Diner calcular(Material material, EstatDevolucio estat) {
        return Diner.ZERO;      // si, aixo es fer trampa. I es correcte en TDD.
    }
}

Verd. Retornar sempre zero sembla absurd, però és exactament el que TDD demana: sense una prova que exigeixi una altra cosa, no hi ha justificació per escriure més.

Pas 3 — Vermell. Ara forcem el cas següent:

@Test
void unDanyLleuSuposaElVintPerCentDelValor() {
    var material = unLlibre("978-0000000001").ambValor(Diner.euros("45.00"));

    Diner recarrec = calculadora.calcular(material, EstatDevolucio.LLEU);

    assertThat(recarrec).isEqualTo(Diner.euros("9.00"));      // 45 x 0,20
}

Pas 4 — Verd:

public enum EstatDevolucio { SENSE_DANY, LLEU }

public class CalculadoraRecarrecs {
    public Diner calcular(Material material, EstatDevolucio estat) {
        if (estat == EstatDevolucio.SENSE_DANY) return Diner.ZERO;
        return material.getValor().multiplicarPer(new BigDecimal("0.20"));
    }
}

Pas 5 — Vermell, verd i aparició del duplicat. Afegim greu i irrecuperable:

@ParameterizedTest
@CsvSource({
    "SENSE_DANY,     0.00",
    "LLEU,           9.00",
    "GREU,          27.00",
    "IRRECUPERABLE, 45.00"
})
void elRecarrecDepenDelEstatDeDevolucio(EstatDevolucio estat, String esperat) {
    var material = unLlibre("978-0000000001").ambValor(Diner.euros("45.00"));
    assertThat(calculadora.calcular(material, estat)).isEqualTo(Diner.euros(esperat));
}

Implementació que ho passa:

public Diner calcular(Material material, EstatDevolucio estat) {
    BigDecimal percentatge = switch (estat) {
        case SENSE_DANY -> BigDecimal.ZERO;
        case LLEU -> new BigDecimal("0.20");
        case GREU -> new BigDecimal("0.60");
        case IRRECUPERABLE -> BigDecimal.ONE;
    };
    return material.getValor().multiplicarPer(percentatge);
}

Pas 6 — Refactor. Proves en verd: moment de millorar el disseny. Aquest switch és exactament el que 12-02 va ensenyar a substituir, i l'enum amb estat és la forma idiomàtica:

public enum EstatDevolucio {
    SENSE_DANY(BigDecimal.ZERO),
    LLEU(new BigDecimal("0.20")),
    GREU(new BigDecimal("0.60")),
    IRRECUPERABLE(BigDecimal.ONE);

    private final BigDecimal percentatgeRecarrec;

    EstatDevolucio(BigDecimal percentatgeRecarrec) {
        this.percentatgeRecarrec = percentatgeRecarrec;
    }

    public BigDecimal percentatgeRecarrec() { return percentatgeRecarrec; }
    public boolean exigeixRetirarDelCataleg() { return this == IRRECUPERABLE; }
}

public class CalculadoraRecarrecs {
    public Diner calcular(Material material, EstatDevolucio estat) {
        return material.getValor().multiplicarPer(estat.percentatgeRecarrec());
    }
}

Proves executades: continuen en verd. Aquest és el punt de TDD: el refactor no fa por perquè hi ha una xarxa a sota.

Pas 7 — Vermell. El límit del valor total:

@Test
void elTotalDeMultaIRecarrecNoSuperaElValorDelMaterial() {
    var material = unLlibre("978-0000000001").ambValor(Diner.euros("45.00"));
    var prestec = unPrestecDe(material).vencutFa(200);   // multa enorme

    Diner total = calculadora.totalACobrar(prestec, EstatDevolucio.GREU, AVUI);

    // multa (sostre 20 EUR) + recarrec (27 EUR) = 47 EUR, pero el material val 45 EUR
    assertThat(total).isEqualTo(Diner.euros("45.00"));
}

Pas 8 — Verd:

public Diner totalACobrar(Prestec prestec, EstatDevolucio estat, LocalDate avui) {
    Diner multa = politicaMultes.calcular(prestec, avui);
    Diner recarrec = calcular(prestec.getMaterial(), estat);
    Diner total = multa.mes(recarrec);
    Diner valorMaterial = prestec.getMaterial().getValor();
    return total.esMesGranQue(valorMaterial) ? valorMaterial : total;
}

Pas 9 — Vermell, l'efecte secundari. Falta la retirada del catàleg:

@Test
void unMaterialIrrecuperableEsRetiraDelCataleg() {
    var material = unLlibre("978-0000000001").ambValor(Diner.euros("45.00"));
    var prestec = unPrestecDe(material);

    servei.registrarDevolucio(prestec.getId(), EstatDevolucio.IRRECUPERABLE, AVUI);

    assertThat(material.estaRetirat()).isTrue();
    verify(cataleg).retirar(material.getIsbn(), MotiuRetirada.DANYAT);
}

@Test
void unMaterialAmbDanyGreuSegueixAlCataleg() {
    var material = unLlibre("978-0000000001");
    servei.registrarDevolucio(unPrestecDe(material).getId(), EstatDevolucio.GREU, AVUI);

    assertThat(material.estaRetirat()).isFalse();
    verifyNoInteractions(cataleg);
}

Pas 10 — Verd:

@Transactional
public ResultatDevolucio registrarDevolucio(Long idPrestec, EstatDevolucio estat, LocalDate data) {
    Prestec prestec = repositori.cercarPerId(idPrestec)
            .orElseThrow(() -> new PrestecNoTrobatException(idPrestec));

    prestec.registrarDevolucio(data);
    Diner total = calculadora.totalACobrar(prestec, estat, data);

    if (estat.exigeixRetirarDelCataleg()) {
        cataleg.retirar(prestec.getIsbn(), MotiuRetirada.DANYAT);
    }
    esdeveniments.publicar(new MaterialRetornat(prestec.getId(), estat, total));   // Observador
    return new ResultatDevolucio(prestec.getId(), data, total, estat);
}

Pas 11 — Refactor i verificació amb PIT:

./mvnw org.pitest:pitest-maven:mutationCoverage \
    -DtargetClasses=com.nexussoftware.bibliotech.domini.prestecs.*
CalculadoraRecarrecs   : 100% mutació (8/8 mutants eliminats)
EstatDevolucio         : 100% mutació (4/4)

El que ha produït aquest procés, i mereix assenyalar-se:

  1. Zero codi sense provar. Cada línia existeix perquè una prova la va exigir.
  2. Un disseny millor. L'enum amb estat no va sortir de la primera implementació: va sortir del pas de refactor, que TDD fa segur.
  3. Les fronteres cobertes des del principi. El cas del sostre per valor del material es va pensar en escriure la prova, no en rebre l'informe d'un error.
  4. Documentació executable. Els noms de les proves són l'especificació del requisit.

  1. Quan aporta TDD i quan no

Valoració honesta, sense dogma:

TDD aporta molt TDD aporta poc o destorba
Lògica de negoci amb regles i casos límit Codi exploratori: encara no saps què vols
Corregir un error (primer la prova que el reprodueix) Interfícies d'usuari, maquetació visual
Algorismes amb entrades i sortides clares Integracions amb API externes mal documentades
API que faran servir altres Configuració i cablejat
Refactoritzar codi heretat (primer caracteritzar) Prototips que es llençaran
Quan el disseny no és clar i vols que emergeixi Quan el disseny és evident i trivial

Dues observacions que solen faltar a les discussions sobre TDD:

  • No és tot o res. Es pot fer servir TDD per al domini i escriure les proves després per als controladors. És el que fa la majoria d'equips que l'usen de debò.
  • L'important és que les proves existeixin i verifiquin. Un equip que escriu proves exhaustives després del codi està infinitament millor que un que diu fer TDD i no ho fa.

I hi ha un cas en què TDD és senzillament la millor opció disponible: corregir un error. La seqüència és sempre la mateixa i sempre funciona:

  1. Escriu una prova que reprodueix l'error. Ha de fallar.
  2. Arregla el codi. La prova passa.
  3. Aquesta prova es queda per sempre, i l'error no pot tornar sense que algú se n'assabenti.

  1. Revisió de codi

La revisió (code review) és el control de qualitat més barat que existeix, i el que més malament es fa.

Què mirar, per ordre d'importància:

Prioritat Què Exemple de pregunta
1 Correcció Fa el que diu? Casos límit? Nuls?
2 Seguretat Entrada validada? Dades sensibles al registre?
3 Proves N'hi ha? Verifiquen de debò o només executen?
4 Disseny És a la capa correcta? Acobla el que no deu?
5 Llegibilitat S'entén sense explicació? Els noms diuen la veritat?
6 Consistència Segueix els patrons del projecte?
7 Estil (Hauria d'estar automatitzat amb Spotless)

Com donar retroalimentació útil. La diferència entre un comentari que millora el codi i un que genera resistència:

En comptes de Escriu
«Això està malament» «Si material és nul aquí, no llançaria NPE a la línia 42?»
«Fes servir un stream» «Un stream().filter().toList() faria això més directe, què et sembla?»
«No entenc res» «Podries explicar què representa flag2? Potser un nom més descriptiu ajudaria»
«Falta la prova» «Valdria la pena una prova del cas en què l'empleat ja té 3 préstecs?»

Tres convencions que funcionen:

  • Marca la severitat. [bloquejant], [suggeriment], [nit] (detall menor), [pregunta]. Sense això, l'autor no sap què ha de canviar i què és opcional.
  • Elogia el que està bé. «Bona idea extreure això a PoliticaMultes» costa cinc segons i canvia el to de la revisió.
  • Revisa aviat i en trossos petits. Un PR de 1.000 línies rep «LGTM»; un de 200 rep comentaris útils. La qualitat de la revisió cau en picat amb la mida.

Llista de comprovació de BiblioTech:

## Revisió de codi — BiblioTech

### Correcció
- [ ] Fa el que diu la descripció del PR?
- [ ] Es gestionen els casos límit (buit, nul, zero, negatiu, màxim)?
- [ ] Les excepcions es capturen al nivell correcte (06-07)?
- [ ] Hi ha condicions de cursa si això s'executa en paral·lel?

### Arquitectura
- [ ] És al mòdul correcte (domini / aplicació / infraestructura)?
- [ ] El domini continua sense importar Spring ni JPA?
- [ ] S'exposen entitats JPA a l'API? (no s'ha de fer)
- [ ] La transacció és al cas d'ús, no al controlador?

### Proves
- [ ] Hi ha proves del camí feliç I dels errors?
- [ ] Les proves tenen assercions significatives?
- [ ] Són al nivell més baix possible?
- [ ] Es fa servir `Clock` injectable en lloc de `LocalDate.now()`?

### Seguretat (12-07)
- [ ] Es valida tota entrada externa?
- [ ] Hi ha secrets, tokens o dades personals al codi o al registre?
- [ ] Les consultes fan servir paràmetres, mai concatenació?

### Llegibilitat
- [ ] Els noms descriuen la intenció?
- [ ] Algun mètode supera les 30 línies o la complexitat 10?
- [ ] Els comentaris expliquen el «per què», no el «què»?

  1. Integració contínua amb GitHub Actions

La integració contínua executa automàticament la construcció i les proves a cada canvi. El seu valor no és tècnic sinó social: treu la responsabilitat de recordar.

flowchart LR
    P["Push / PR"] --> C["Compilar"]
    C --> F["Format<br/>Spotless"]
    F --> U["Proves<br/>unitàries"]
    U --> I["Proves<br/>d'integració"]
    I --> CO["Cobertura<br/>JaCoCo"]
    CO --> A["Anàlisi<br/>SpotBugs"]
    A --> R["Resultat<br/>al PR"]

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

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

# Cancel·la execucions anteriors del mateix PR: no té sentit provar codi ja obsolet
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  JAVA_VERSION: '21'

jobs:

  # ---------------------------------------------------------------
  # Treball 1: ràpid. Dona resposta en menys de 3 minuts.
  # ---------------------------------------------------------------
  verificacio-rapida:
    name: Compilació, format i proves unitàries
    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              # posa ~/.m2 a la memòria cau: estalvia 1-2 minuts per execució

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

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

      - name: Proves unitàries
        run: ./mvnw -B test

      - name: Publicar resultats de les proves
        uses: mikepenz/action-junit-report@v4
        if: always()                # també quan fallen: és quan més es necessita
        with:
          report_paths: '**/target/surefire-reports/TEST-*.xml'
          check_name: 'Proves unitaries'

  # ---------------------------------------------------------------
  # Treball 2: lent. Testcontainers, cobertura i anàlisi estàtica.
  # ---------------------------------------------------------------
  verificacio-completa:
    name: Integració, cobertura i anàlisi
    runs-on: ubuntu-latest
    needs: verificacio-rapida       # no gastis 10 minuts si no compila
    timeout-minutes: 25

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0            # Sonar necessita l'històric per al "codi nou"

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

      # Docker ja està disponible a ubuntu-latest: Testcontainers funciona sense més

      - name: Proves d'integració i cobertura
        run: ./mvnw -B verify
        env:
          TESTCONTAINERS_RYUK_DISABLED: 'false'

      - name: Comprovar llindars de cobertura
        run: ./mvnw -B jacoco:check

      - name: Publicar cobertura al 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 codi NOU, més exigent
          title: 'Informe de cobertura'

      - name: Anàlisi estàtica
        run: ./mvnw -B spotbugs:check

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

  # ---------------------------------------------------------------
  # Treball 3: només a main. Proves de mutació, que són lentes.
  # ---------------------------------------------------------------
  mutacio:
    name: Proves de mutació
    runs-on: ubuntu-latest
    needs: verificacio-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 domini
        run: ./mvnw -B -pl bibliotech-domini org.pitest:pitest-maven:mutationCoverage

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

I la part que fa que tot això serveixi d'alguna cosa: protegir la branca main a la configuració del repositori.

Regla Efecte
Requerir PR abans de fusionar Ningú no empeny directament a main
Requerir que CI estigui en verd Un PR amb proves vermelles no es pot fusionar
Requerir 1 aprovació Tot ho revisa algú més
Descartar aprovacions quan hi ha canvis nous No s'aprova una cosa i se'n fusiona una altra
Requerir que la branca estigui actualitzada Es prova contra el main actual

Sense protecció de branca, CI és un semàfor que ningú no està obligat a mirar.

  1. Què NO provar

Escriure proves inútils costa temps, alenteix la suite i dona falsa sensació de seguretat.

No provis Per què
Getters i setters trivials No hi ha lògica. Si es trenquen, mil proves fallen igualment
El framework Spring, Hibernate i Jackson ja tenen les seves proves
La biblioteca estàndard ArrayList.add funciona
Codi generat (Lombok, MapStruct) El generador ja està provat
Configuració simple Que @Value injecti no és responsabilitat teva
Detalls d'implementació privats Prova el comportament públic; el privat canvia
Constants assertThat(MAXIM).isEqualTo(20) només duplica el codi

El cas dels detalls d'implementació mereix un exemple, perquè és l'error més car:

// MALAMENT: prova COM es fa. Refactoritzar la trenca encara que el comportament no canvii.
@Test
void faServirElRepositoriPerCercar() {
    servei.cercarPerIsbn(isbn);
    verify(repositori).findByIsbn(isbn);      // i si dema fa servir una memoria cau?
}

// BE: prova QUE fa. Sobreviu a qualsevol refactor intern.
@Test
void retornaElMaterialQuanExisteix() {
    when(repositori.findByIsbn(isbn)).thenReturn(Optional.of(javaEficac));

    Optional<Material> resultat = servei.cercarPerIsbn(isbn);

    assertThat(resultat).contains(javaEficac);
}

  1. Proves fràgils com a deute

Una prova fràgil falla per motius que no són una fallada real. I el seu cost és pitjor del que sembla: entrena l'equip a ignorar el vermell.

Tipus de fragilitat Causa Solució
Dependent del temps LocalDate.now() al codi Clock injectable (10-05)
Dependent de l'ordre Estat compartit entre proves Aïllar; @DirtiesContext com a últim recurs
Dependent de la xarxa Crida una API real WireMock, o un doble
Dependent de la màquina Camins absoluts, zona horària @TempDir, zona fixa
Dependent de l'atzar Math.random(), UUID Llavor fixa, generador injectat
Amb esperes fixes Thread.sleep(500) Awaitility amb condició
Sobreespecificada verify de cada crida Verificar només el rellevant

Els dos exemples que més apareixen a la pràctica:

// FRAGIL: falla l'1 de gener, o si la prova corre a mitjanit
@Test
void elPrestecVenceEnQuinzeDies() {
    Prestec p = gestor.prestar(isbn, 1L, 15);
    assertThat(p.getDataVenciment()).isEqualTo(LocalDate.now().plusDays(15));
}

// ROBUSTA: el temps es una dependencia com qualsevol altra
@Test
void elPrestecVenceEnQuinzeDies() {
    var rellotge = Clock.fixed(Instant.parse("2026-08-05T10:00:00Z"), ZoneId.of("Europe/Madrid"));
    var gestor = new GestorPrestecs(repositori, notificador, rellotge);

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

    assertThat(p.getDataVenciment()).isEqualTo(LocalDate.of(2026, 8, 20));
}
// FRAGIL: 500 ms pot no bastar en una maquina carregada, i sobra en una de rapida
@Test
void laImportacioAsincronaAcaba() throws Exception {
    servei.importarAsincron(fitxer);
    Thread.sleep(500);
    assertThat(repositori.count()).isEqualTo(100);
}

// ROBUSTA: espera a la CONDICIO, no a un temps
@Test
void laImportacioAsincronaAcaba() {
    servei.importarAsincron(fitxer);

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

La regla davant d'una prova intermitent: arregla-la o esborra-la. No la marquis amb @Disabled «temporalment», perquè aquest temporal dura anys i mentrestant no protegeix res.

  1. Rendiment i càrrega

Nota: proves de rendiment. Reprenent 10-07, JMH (Java Microbenchmark Harness) és l'única manera fiable de mesurar codi Java, perquè gestiona l'escalfament del JIT, evita que el compilador elimini codi sense efectes i calcula la variància. Un System.nanoTime() al voltant d'un bucle mesura, sobretot, l'estat del JIT en aquell instant.

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

    private List<Material> cataleg;

    @Setup public void preparar() { cataleg = generarCataleg(50_000); }

    @Benchmark
    public List<Material> cercaLineal() {
        return cataleg.stream()
                .filter(m -> m.getTitol().toLowerCase().contains("java"))
                .toList();
    }

    @Benchmark
    public List<Material> cercaAmbIndex() {
        return index.cercar("java");
    }
}

I les proves de càrrega (k6, Gatling, JMeter) mesuren una altra cosa diferent: com es comporta el sistema complet amb N usuaris concurrents. Interessen el percentil 95 i 99 de latència, no la mitjana —la mitjana amaga exactament els casos que molesten els usuaris— i el punt en què els errors comencen a aparèixer. S'executen contra un entorn semblant a producció, mai a la CI de cada PR.

// k6: 100 usuaris durant 5 minuts
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/materials?page=0&size=20'); }

Nota: proves de contracte. Quan dos serveis s'integren, les proves de contracte (Pact, Spring Cloud Contract) verifiquen que el consumidor i el proveïdor estan d'acord sobre el format, sense necessitat d'aixecar-los junts. El consumidor declara què espera, el proveïdor verifica que ho compleix. A BiblioTech encara no cal, però tan bon punt l'aplicació mòbil consumeixi l'API o BiblioTech depengui del servei de recursos humans, es converteix en la manera més barata d'evitar que un canvi trenqui un tercer sense que ningú no se n'assabenti fins a producció.

Errors Comuns i Consells

1. Perseguir el 100 % de cobertura. El cost de pujar del 80 % al 100 % és enorme i el valor, mínim: l'últim 20 % sol ser gestió d'errors impossibles i codi generat. Fes servir la cobertura per trobar forats, no com a objectiu.

2. Proves sense assercions. Cobreixen, no verifiquen. Si una prova passaria igualment amb el codi trencat, no és una prova.

3. Convertir la cobertura en objectiu individual. Llei de Goodhart: obtindràs milers de proves que executen codi sense comprovar res.

4. Confiar en H2 per provar consultes. H2 no és PostgreSQL. Les consultes es proven contra la base de dades real amb Testcontainers, i només aquestes.

5. Convertir-ho tot en @SpringBootTest. És el més fàcil i el pitjor: la suite se'n va a vint minuts, els diagnòstics es tornen vagues i ningú no l'executa.

6. Ignorar les proves intermitents. «Torna-la a llançar, de vegades falla» és el principi de la fi. S'arreglen o s'esborren.

7. Provar la implementació en comptes del comportament. verify(repositori).findByIsbn(...) es trenca amb qualsevol refactor legítim. Prova resultats.

8. Activar totes les regles d'anàlisi estàtica el primer dia. Tres mil avisos que ningú no mirarà. Comença amb el greu i creix.

9. Pull Requests de mil línies. Reben «LGTM» en dos minuts. Trosseja la feina: 200-400 línies és el punt en què una revisió és útil.

10. CI que no bloqueja. Si l'equip pot fusionar amb les proves en vermell, les proves deixen d'existir. Protegeix la branca.

11. Refactoritzar sense proves. No és refactoritzar. Si no hi ha proves, primer escriu proves de caracterització que fixin el comportament actual (encara que sigui incorrecte), i després canvia.

12. Creure que TDD va de proves. Va de disseny. Si una classe és difícil de provar, TDD t'ho diu abans d'escriure-la, no després.

Consell final: la millor mètrica de qualitat no és a cap eina. És la resposta a aquesta pregunta: l'equip desplega un divendres a la tarda sense por? Si la resposta és sí, l'estratègia funciona. Si és no, hi ha alguna cosa per arreglar, i cap xifra de cobertura no ho compensa.

Exercicis

Exercici 1: millorar unes proves que menteixen

Aquestes proves existeixen a BiblioTech i tenen 100 % de cobertura de línies sobre ProcessadorReserves. Identifica tots els seus problemes i reescriu-les.

class ProcessadorReservesTest {

    ProcessadorReserves processador = new ProcessadorReserves(
            new RepositoriReservesEnMemoria(), new NotificadorFals());

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

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

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

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

Exercici 2: TDD d'una regla nova

Nexus Software introdueix préstecs prioritaris: un empleat amb un projecte marcat com a crític pot saltar-se la cua de reserves d'un material.

Regles:

  • Només si l'empleat té un projecte crític actiu.
  • Màxim un préstec prioritari simultani per empleat.
  • El material ha d'estar prestat (si està lliure, és un préstec normal).
  • En fer servir la prioritat, el préstec en curs es marca per a devolució urgent en 48 h.
  • L'empleat que té el material rep un avís.
  • No es pot fer servir la prioritat si el material ja està marcat com a urgent.

Desenvolupa la funcionalitat amb TDD, mostrant cada cicle vermell-verd-refactor. En acabar, executa mentalment PIT sobre la teva implementació i identifica quins mutants podrien sobreviure.

Exercici 3: canonada de CI completa

Escriu el flux de treball de GitHub Actions per a BiblioTech que:

  • S'executi en PR i en push a main.
  • Tingui un treball ràpid (menys de 3 minuts) i un altre de complet.
  • Executi proves d'integració amb Testcontainers.
  • Faci fallar el PR si la cobertura del codi nou baixa del 80 %.
  • Publiqui al PR un comentari amb el resum de cobertura i les proves fallides.
  • Executi proves de mutació només els dilluns.
  • Posi les dependències de Maven a la memòria cau.
  • Executi la matriu de proves a Java 21 i Java 23 (per detectar problemes de la propera versió LTS).

Solucions

Solució 1

Problemes detectats (onze):

# Prova Problema Gravetat
1 testReservar Sense cap asserció: només executa Crítica
2 testCancellar assertNotNull(r) no comprova la cancel·lació Crítica
3 testCaducitat assertTrue(true) és una asserció falsa Crítica
4 testCaducitat Thread.sleep(1000) fràgil i lent Alta
5 testDataLimit LocalDate.now() a la prova: falla a mitjanit Alta
6 Totes Noms que no descriuen el comportament esperat Mitjana
7 Totes L'estat es comparteix entre proves (camp d'instància amb estat) Alta
8 Totes No es prova cap cas d'error Alta
9 testCancellar throws Exception innecessari, amaga què pot fallar Baixa
10 Totes Sense @DisplayName ni estructura Baixa
11 Totes assertNotNull/assertEquals de JUnit en lloc d'AssertJ Baixa

Reescriptura:

@DisplayName("Processador de reserves")
class ProcessadorReservesTest {

    // Rellotge FIX: elimina tota dependencia del moment d'execucio
    private static final Instant ARA = Instant.parse("2026-08-05T10:00:00Z");
    private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");

    private RepositoriReservesEnMemoria repositori;
    private NotificadorEspia notificador;
    private RellotgeMutable rellotge;            // rellotge avancable, sense Thread.sleep
    private ProcessadorReserves processador;

    @BeforeEach
    void preparar() {
        // Estat NOU a cada prova: sense contaminacio entre elles
        repositori = new RepositoriReservesEnMemoria();
        notificador = new NotificadorEspia();
        rellotge = RellotgeMutable.de(ARA, MADRID);
        processador = new ProcessadorReserves(repositori, notificador, rellotge);
    }

    @Nested
    @DisplayName("En crear una reserva")
    class EnReservar {

        @Test
        @DisplayName("queda pendent i a la cua del material")
        void quedaPendentIALaCua() {
            Reserva reserva = processador.reservar(ISBN_JAVA, MARTA);

            assertThat(reserva.getEstat()).isEqualTo(EstatReserva.PENDENT);
            assertThat(reserva.getMaterialIsbn()).isEqualTo(ISBN_JAVA);
            assertThat(reserva.getIdEmpleat()).isEqualTo(MARTA);
            assertThat(reserva.getDataSollicitud()).isEqualTo(ARA);
            assertThat(repositori.pendentsDe(ISBN_JAVA)).containsExactly(reserva);
        }

        @Test
        @DisplayName("respecta l'ordre d'arribada a la cua")
        void respectaLOrdreDArribada() {
            Reserva primera = processador.reservar(ISBN_JAVA, MARTA);
            rellotge.avancar(Duration.ofMinutes(5));
            Reserva segona = processador.reservar(ISBN_JAVA, DIEGO);

            assertThat(repositori.pendentsDe(ISBN_JAVA))
                    .containsExactly(primera, segona);       // l'ordre importa
        }

        @Test
        @DisplayName("rebutja una segona reserva del mateix empleat i material")
        void rebutjaReservaDuplicada() {
            processador.reservar(ISBN_JAVA, MARTA);

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

            assertThat(repositori.pendentsDe(ISBN_JAVA)).hasSize(1);
        }

        @Test
        @DisplayName("rebutja reservar un material inexistent")
        void rebutjaMaterialInexistent() {
            assertThatThrownBy(() -> processador.reservar(ISBN_INEXISTENT, MARTA))
                    .isInstanceOf(MaterialNoTrobatException.class);
        }
    }

    @Nested
    @DisplayName("En cancel·lar")
    class EnCancellar {

        @Test
        @DisplayName("la reserva passa a CANCELLADA i surt de la cua")
        void passaACancelladaISurtDeLaCua() {
            Reserva reserva = processador.reservar(ISBN_JAVA, MARTA);

            processador.cancellar(reserva.getId());

            assertThat(repositori.perId(reserva.getId()))
                    .get()
                    .extracting(Reserva::getEstat)
                    .isEqualTo(EstatReserva.CANCELLADA);
            assertThat(repositori.pendentsDe(ISBN_JAVA)).isEmpty();
        }

        @Test
        @DisplayName("cancel·lar dues vegades llança TransicioInvalidaException")
        void cancellarDuesVegadesFalla() {
            Reserva reserva = processador.reservar(ISBN_JAVA, MARTA);
            processador.cancellar(reserva.getId());

            assertThatThrownBy(() -> processador.cancellar(reserva.getId()))
                    .isInstanceOf(TransicioInvalidaException.class);
        }

        @Test
        @DisplayName("cancel·lar una reserva inexistent llança ReservaNoTrobadaException")
        void reservaInexistent() {
            assertThatThrownBy(() -> processador.cancellar(9999L))
                    .isInstanceOf(ReservaNoTrobadaException.class);
        }
    }

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

        @Test
        @DisplayName("una reserva disponible caduca a les 48 hores exactes")
        void caducaALes48Hores() {
            Reserva reserva = processador.reservar(ISBN_JAVA, MARTA);
            processador.assignarExemplar(reserva.getId());

            rellotge.avancar(Duration.ofHours(48));       // sense Thread.sleep!
            int caducades = processador.caducarVencudes();

            assertThat(caducades).isEqualTo(1);
            assertThat(repositori.perId(reserva.getId()))
                    .get().extracting(Reserva::getEstat)
                    .isEqualTo(EstatReserva.CADUCADA);
        }

        @Test
        @DisplayName("a les 47 hores i 59 minuts encara NO caduca")
        void noCaducaAbansDHora() {
            Reserva reserva = processador.reservar(ISBN_JAVA, MARTA);
            processador.assignarExemplar(reserva.getId());

            rellotge.avancar(Duration.ofHours(47).plusMinutes(59));
            int caducades = processador.caducarVencudes();

            assertThat(caducades).isZero();               // LA FRONTERA
            assertThat(repositori.perId(reserva.getId()))
                    .get().extracting(Reserva::getEstat)
                    .isEqualTo(EstatReserva.DISPONIBLE);
        }

        @Test
        @DisplayName("les reserves pendents no caduquen: no tenen data límit")
        void lesPendentsNoCaduquen() {
            processador.reservar(ISBN_JAVA, MARTA);       // PENDENT, sense exemplar assignat

            rellotge.avancar(Duration.ofDays(30));

            assertThat(processador.caducarVencudes()).isZero();
        }

        @Test
        @DisplayName("en caducar s'avisa l'empleat i s'activa la següent de la cua")
        void avisaIActivaLaSeguent() {
            Reserva deMarta = processador.reservar(ISBN_JAVA, MARTA);
            Reserva deDiego = processador.reservar(ISBN_JAVA, DIEGO);
            processador.assignarExemplar(deMarta.getId());

            rellotge.avancar(Duration.ofHours(48));
            processador.caducarVencudes();

            assertThat(notificador.avisosEnviats())
                    .extracting(Avis::tipus)
                    .containsExactly(TipusAvis.RESERVA_CADUCADA, TipusAvis.RESERVA_DISPONIBLE);
            assertThat(repositori.perId(deDiego.getId()))
                    .get().extracting(Reserva::getEstat)
                    .isEqualTo(EstatReserva.DISPONIBLE);
        }
    }

    @Nested
    @DisplayName("Data límit de recollida")
    class DataLimit {

        @Test
        @DisplayName("es fixa a 48 hores des de l'assignació de l'exemplar")
        void aLes48HoresDesDeLAssignacio() {
            Reserva reserva = processador.reservar(ISBN_JAVA, MARTA);
            rellotge.avancar(Duration.ofDays(3));         // l'assignacio arriba dies despres
            processador.assignarExemplar(reserva.getId());

            // Data ABSOLUTA calculada des del rellotge fix: mai no depen d'"avui"
            assertThat(repositori.perId(reserva.getId()))
                    .get().extracting(Reserva::getDataLimitRecollida)
                    .isEqualTo(ARA.plus(Duration.ofDays(3)).plus(Duration.ofHours(48)));
        }

        @Test
        @DisplayName("una reserva pendent no té data límit")
        void lesPendentsNoTenenDataLimit() {
            Reserva reserva = processador.reservar(ISBN_JAVA, MARTA);
            assertThat(reserva.getDataLimitRecollida()).isNull();
        }
    }
}

El rellotge avançable, que substitueix tots els Thread.sleep:

/** Clock mutable per a proves: permet avancar el temps sense esperar. */
public class RellotgeMutable extends Clock {

    private Instant instant;
    private final ZoneId zona;

    private RellotgeMutable(Instant instant, ZoneId zona) {
        this.instant = instant;
        this.zona = zona;
    }

    public static RellotgeMutable de(Instant instant, ZoneId zona) {
        return new RellotgeMutable(instant, zona);
    }

    public void avancar(Duration durada) { this.instant = instant.plus(durada); }

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

Resultat: de 4 proves que no verificaven res a 14 que cobreixen camí feliç, errors, fronteres i efectes secundaris. Sense ni un sol Thread.sleep, sense dependència de la data real, amb estat net a cadascuna i amb noms que documenten el requisit. La suite va passar de trigar més d'un segon a trigar mil·lisegons.

Solució 2

Cicle 1 — Vermell:

@Test
void unEmpleatSenseProjecteCriticNoPotUsarPrioritat() {
    Empleat diego = unEmpleat("Diego Alonso").senseProjectesCritics();

    assertThatThrownBy(() -> servei.prestarAmbPrioritat(ISBN_JAVA, diego.getId()))
            .isInstanceOf(SensePrioritatDisponibleException.class)
            .hasMessageContaining("no te cap projecte critic actiu");
}

Cicle 1 — Verd:

public Prestec prestarAmbPrioritat(Isbn isbn, Long idEmpleat) {
    Empleat empleat = empleats.cercarPerId(idEmpleat).orElseThrow(…);
    if (!empleat.teProjecteCriticActiu()) {
        throw new SensePrioritatDisponibleException(
                "L'empleat %s no te cap projecte critic actiu."
                        .formatted(empleat.getNom()));
    }
    return null;   // el minim perque la prova passi
}

Cicle 2 — Vermell:

@Test
void siElMaterialEstaLliureEsFaUnPrestecNormalSenseConsumirPrioritat() {
    Empleat marta = unEmpleat("Marta Ruiz").ambProjecteCritic();
    materialLliure(ISBN_JAVA);

    Prestec prestec = servei.prestarAmbPrioritat(ISBN_JAVA, marta.getId());

    assertThat(prestec.esPrioritari()).isFalse();
    assertThat(marta.prioritatsEnUs()).isZero();         // la prioritat NO es va gastar
}

Cicle 2 — Verd:

public Prestec prestarAmbPrioritat(Isbn isbn, Long idEmpleat) {
    Empleat empleat = …;
    verificarTeProjecteCritic(empleat);

    Material material = materials.cercarPerIsbn(isbn).orElseThrow(…);
    if (material.teUnitatsLliures()) {
        return gestorPrestecs.prestar(isbn, idEmpleat, null);   // prestec normal
    }
    return null;
}

Cicle 3 — Vermell: el cas central.

@Test
void marcaElPrestecEnCursPerADevolucioUrgentEnQuarantaVuitHores() {
    Empleat marta = unEmpleat("Marta Ruiz").ambProjecteCritic();
    Prestec enCurs = prestecActiuDe(ISBN_JAVA, DIEGO);
    materialSenseUnitatsLliures(ISBN_JAVA);

    servei.prestarAmbPrioritat(ISBN_JAVA, marta.getId());

    assertThat(enCurs.esUrgent()).isTrue();
    assertThat(enCurs.getDataVenciment())
            .isEqualTo(LocalDate.now(rellotge).plusDays(2));
}

@Test
void avisaLEmpleatQueTeElMaterial() {
    Empleat marta = unEmpleat("Marta Ruiz").ambProjecteCritic();
    prestecActiuDe(ISBN_JAVA, DIEGO);
    materialSenseUnitatsLliures(ISBN_JAVA);

    servei.prestarAmbPrioritat(ISBN_JAVA, marta.getId());

    assertThat(notificador.avisosEnviats())
            .singleElement()
            .satisfies(a -> {
                assertThat(a.tipus()).isEqualTo(TipusAvis.DEVOLUCIO_URGENT);
                assertThat(a.destinatari()).isEqualTo(DIEGO.getCorreu());
            });
}

Cicle 3 — Verd:

public Prestec prestarAmbPrioritat(Isbn isbn, Long idEmpleat) {
    Empleat empleat = …;
    verificarTeProjecteCritic(empleat);

    Material material = …;
    if (material.teUnitatsLliures()) {
        return gestorPrestecs.prestar(isbn, idEmpleat, null);
    }

    Prestec enCurs = prestecs.actiuDe(isbn).orElseThrow(…);
    enCurs.marcarUrgent(LocalDate.now(rellotge).plusDays(2));
    notificador.notificar(Avis.devolucioUrgent(enCurs));

    return Prestec.prioritariEnEspera(material, empleat, LocalDate.now(rellotge));
}

Cicle 4 — Vermell: les dues restriccions que falten.

@Test
void unEmpleatNoPotTenirDuesPrioritatsSimultanies() {
    Empleat marta = unEmpleat("Marta Ruiz").ambProjecteCritic().ambPrioritatEnUs();
    materialSenseUnitatsLliures(ISBN_JAVA);

    assertThatThrownBy(() -> servei.prestarAmbPrioritat(ISBN_JAVA, marta.getId()))
            .isInstanceOf(PrioritatJaEnUsException.class);
}

@Test
void noEsPotUsarPrioritatSobreUnMaterialJaMarcatComAUrgent() {
    Empleat marta = unEmpleat("Marta Ruiz").ambProjecteCritic();
    prestecActiuDe(ISBN_JAVA, DIEGO).jaMarcatUrgent();
    materialSenseUnitatsLliures(ISBN_JAVA);

    assertThatThrownBy(() -> servei.prestarAmbPrioritat(ISBN_JAVA, marta.getId()))
            .isInstanceOf(MaterialJaUrgentException.class)
            .hasMessageContaining("ja te una devolucio urgent en curs");
}

Cicle 4 — Verd i refactor. El mètode ha crescut; toca extreure i aplicar el patró Estratègia amb una cadena de verificacions (12-02):

@Service
public class ServeiPrestecPrioritari {

    private final List<VerificacioPrioritat> verificacions;     // Cadena de responsabilitat
    private final Clock rellotge;

    @Transactional
    public Prestec prestarAmbPrioritat(Isbn isbn, Long idEmpleat) {
        ContextPrioritat ctx = construirContext(isbn, idEmpleat);

        // Si el material esta lliure, no hi ha res a verificar: prestec normal
        if (ctx.material().teUnitatsLliures()) {
            return gestorPrestecs.prestar(isbn, idEmpleat, null);
        }

        // Cada verificacio llanca la seva propia excepcio especifica
        verificacions.forEach(v -> v.verificar(ctx));

        return activarPrioritat(ctx);
    }

    private Prestec activarPrioritat(ContextPrioritat ctx) {
        LocalDate limit = LocalDate.now(rellotge).plusDays(2);
        ctx.prestecEnCurs().marcarUrgent(limit);
        ctx.empleat().consumirPrioritat();
        notificador.notificar(Avis.devolucioUrgent(ctx.prestecEnCurs(), limit));
        esdeveniments.publicar(new PrioritatActivada(ctx.isbn(), ctx.empleat().getId(), limit));
        return Prestec.prioritariEnEspera(ctx.material(), ctx.empleat(), LocalDate.now(rellotge));
    }
}

@Component @Order(10)
class VerificarProjecteCritic implements VerificacioPrioritat {
    public void verificar(ContextPrioritat ctx) {
        if (!ctx.empleat().teProjecteCriticActiu()) {
            throw new SensePrioritatDisponibleException(ctx.empleat().getNom());
        }
    }
}

@Component @Order(20)
class VerificarPrioritatDisponible implements VerificacioPrioritat {
    public void verificar(ContextPrioritat ctx) {
        if (ctx.empleat().prioritatsEnUs() >= 1) {
            throw new PrioritatJaEnUsException(ctx.empleat().getNom());
        }
    }
}

@Component @Order(30)
class VerificarMaterialNoUrgent implements VerificacioPrioritat {
    public void verificar(ContextPrioritat ctx) {
        if (ctx.prestecEnCurs().esUrgent()) {
            throw new MaterialJaUrgentException(ctx.isbn());
        }
    }
}

Mutants que podrien sobreviure —l'anàlisi que demana l'exercici—:

Mutació Sobreviu? Prova que falta
prioritatsEnUs() >= 1> 1 Un empleat amb exactament 1 prioritat ha de ser rebutjat (ja hi és)
plusDays(2)plusDays(3) Sí, si només es comprova esUrgent() Afirmar la data exacta, no només la marca
Eliminar consumirPrioritat() Verificar que després de fer-la servir, la segona falla
Eliminar esdeveniments.publicar(...) Verificar que es publica l'esdeveniment
teUnitatsLliures() → negat No Ja cobert pel cicle 2

Les proves que tanquen aquests forats:

@Test
void despresDUsarLaPrioritatLEmpleatNoPotUsarNeUnaAltra() {
    Empleat marta = unEmpleat("Marta Ruiz").ambProjecteCritic();
    materialSenseUnitatsLliures(ISBN_JAVA);
    materialSenseUnitatsLliures(ISBN_PATRONS);

    servei.prestarAmbPrioritat(ISBN_JAVA, marta.getId());

    assertThatThrownBy(() -> servei.prestarAmbPrioritat(ISBN_PATRONS, marta.getId()))
            .isInstanceOf(PrioritatJaEnUsException.class);
}

@Test
void publicaLEsdevenimentDePrioritatActivada() {
    Empleat marta = unEmpleat("Marta Ruiz").ambProjecteCritic();
    prestecActiuDe(ISBN_JAVA, DIEGO);
    materialSenseUnitatsLliures(ISBN_JAVA);

    servei.prestarAmbPrioritat(ISBN_JAVA, marta.getId());

    assertThat(esdeveniments.publicats())
            .singleElement(as(InstanceOfAssertFactories.type(PrioritatActivada.class)))
            .satisfies(e -> {
                assertThat(e.isbn()).isEqualTo(ISBN_JAVA);
                assertThat(e.dataLimit()).isEqualTo(LocalDate.of(2026, 8, 7));   // data EXACTA
            });
}

Solució 3

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

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 4 * * 1'        # dilluns a les 04:00 UTC: proves de mutació

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

permissions:
  contents: read
  pull-requests: write         # necessari per comentar al PR
  checks: write

jobs:

  # =====================================================================
  # 1. RÀPID — resposta en menys de 3 minuts
  # =====================================================================
  rapid:
    name: Format i proves unitàries
    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 format
        run: ./mvnw -B --no-transfer-progress spotless:check

      - name: Compilar i proves unitàries
        run: ./mvnw -B --no-transfer-progress test

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

  # =====================================================================
  # 2. MATRIU — Java 21 (producció) i Java 23 (detecció primerenca)
  # =====================================================================
  compatibilitat:
    name: Java ${{ matrix.java }}
    runs-on: ubuntu-latest
    needs: rapid
    timeout-minutes: 15

    strategy:
      fail-fast: false            # que una fallada a 23 no cancel·li la 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: Proves unitàries
        run: ./mvnw -B --no-transfer-progress test
        # Java 23 és informatiu: no ha de bloquejar la fusió
        continue-on-error: ${{ matrix.java == '23' }}

  # =====================================================================
  # 3. COMPLET — integració amb Testcontainers, cobertura i anàlisi
  # =====================================================================
  complet:
    name: Integració, cobertura i anàlisi
    runs-on: ubuntu-latest
    needs: rapid
    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

      # Precarregar la imatge: evita que la primera prova carregui amb els 30 s de descàrrega
      - name: Precarregar imatge de PostgreSQL
        run: docker pull postgres:16-alpine

      - name: Proves d'integració i cobertura
        run: ./mvnw -B --no-transfer-progress verify
        env:
          TESTCONTAINERS_REUSE_ENABLE: 'false'      # a CI, contenidors nets

      - name: Comentar la cobertura al 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                      # actualitza en lloc d'acumular comentaris
          pass-emoji: '✅'
          fail-emoji: '❌'

      - name: Comprovar llindars de cobertura
        run: ./mvnw -B jacoco:check

      - name: Anàlisi estàtica (SpotBugs + FindSecBugs)
        run: ./mvnw -B spotbugs:check

      - name: Regles d'arquitectura (ArchUnit)
        run: ./mvnw -B test -Dtest='ReglesArquitecturaTest'

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

      - name: Resum a la pestanya de l'execució
        if: always()
        run: |
          echo "## Resum de qualitat" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "| Comprovacio | Resultat |" >> $GITHUB_STEP_SUMMARY
          echo "|---|---|" >> $GITHUB_STEP_SUMMARY
          echo "| Proves unitaries | ${{ needs.rapid.result }} |" >> $GITHUB_STEP_SUMMARY
          echo "| Integracio | ${{ job.status }} |" >> $GITHUB_STEP_SUMMARY

  # =====================================================================
  # 4. MUTACIÓ — només els dilluns i a main. És lenta.
  # =====================================================================
  mutacio:
    name: Proves de mutació (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 domini
        run: |
          ./mvnw -B -pl bibliotech-domini \
            org.pitest:pitest-maven:mutationCoverage \
            -DmutationThreshold=70

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

      - name: Obrir incidència si baixa del llindar
        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 mutacio del domini ha baixat del 70 %',
              body: 'Revisa l informe de PIT als artefactes de l execucio ' +
                    context.runId + '. Hi ha mutants supervivents: les assercions ' +
                    'd alguna prova no detecten canvis al codi.',
              labels: ['qualitat', 'proves']
            })

Decisions de disseny de la canonada, que és el que avalua l'exercici:

Decisió Motiu
Dos treballs, ràpid i complet El 90 % de les fallades es detecta en 3 minuts
needs: rapid al complet No gastar 25 minuts si el format està malament
concurrency amb cancel-in-progress Un push nou cancel·la l'anterior: estalvia minuts i cost
cache: maven Estalvia 1-2 minuts per execució
fail-fast: false a la matriu Una fallada a Java 23 no ha d'amagar el resultat a 21
continue-on-error a Java 23 Informatiu: detecta problemes futurs sense bloquejar avui
if: always() a les publicacions Els informes importen sobretot quan alguna cosa falla
min-coverage-changed-files: 80 Clean as You Code: exigir al codi nou, no al deute històric
Mutació a schedule És lenta; a cada PR seria insuportable
Incidència automàtica en baixar la mutació Ningú no mira els informes; una incidència sí que es veu
timeout-minutes a tots Un treball penjat no consumeix la quota indefinidament
permissions explícits Mínim privilegi (12-07) també a CI

Conclusió

BiblioTech ja no només funciona: es pot demostrar que funciona.

Tens una estratègia de proves de veritat, no un munt de proves: què es verifica a cada nivell —domini, aplicació, repositori, web, extrem a extrem—, amb la regla que evita duplicar (cada comprovació al nivell més baix possible) i amb temps objectiu que determinen si la suite s'executa o s'ignora: quinze segons en local, deu minuts a CI. I entens per què la piràmide s'inverteix sola si ningú no la vigila, i que la causa arrel gairebé mai no és mandra sinó disseny: si provar una classe aïllada és difícil, el problema és la classe.

Vas canviar H2 per Testcontainers amb PostgreSQL real, sabent exactament en què menteix H2 —tipus, funcions natives, seqüències, ordenació, bloquejos, restriccions, zones horàries— i acceptant-ne el cost amb coneixement de causa: cinc vegades més lent en el 10 % de la suite que més es beneficia de la fidelitat. Amb @ServiceConnection de Spring Boot 3.1, el contenidor compartit en una classe base i la separació surefire/failsafe que permet executar només el ràpid quan toca.

Mesures la cobertura amb JaCoCo, distingint línies de branques —i sabent que només la segona diu la veritat— amb llindars per paquet, més exigents al domini. I sobretot tens la interpretació honesta, que és el que separa qui fa servir la mètrica de qui s'hi enganya: la cobertura alta no garanteix qualitat, la baixa sí que assenyala risc; una prova sense assercions dona 100 % i val zero; i el dia que la cobertura es converteixi en objectiu individual, deixarà de mesurar res.

Per saber si les teves assercions serveixen, tens les proves de mutació amb PIT, amb l'exemple concret del mutant supervivent al càlcul de multes: dies <= 0 convertit en dies < 0, la frontera del dia exacte del venciment, invisible per a una cobertura del 100 %. Amb el cost assumit: només al domini, setmanalment, llindar del 70-80 %.

Coneixes l'anàlisi estàtica i què troba cada eina —SpotBugs els bugs reals, PMD la complexitat, Checkstyle l'estil, SonarQube l'històric i la seguretat, ArchUnit l'arquitectura— amb el consell d'adopció que evita que l'equip la ignori en bloc: començar pel greu i créixer. I saps llegir la complexitat ciclomàtica com el que és —el nombre mínim de proves necessàries—, classificar el deute tècnic entre prudent i imprudent, i identificar els code smells amb el seu remei.

Refactoritzes amb xarxa: extreure mètode, extreure classe i reemplaçar condicional per polimorfisme, amb el procediment per passos que dona set oportunitats de detectar un error on fer-ho de cop en dona zero. I saps que sense proves no és refactorització, és reescriptura amb esperança.

Vas desenvolupar una funcionalitat completa amb TDD —el recàrrec per material danyat— pas a pas, inclosos els passos que semblen absurds (retornar sempre zero) i que són exactament el que la disciplina demana. Amb el resultat a la vista: zero codi sense provar, les fronteres cobertes des del principi, un enum amb estat que va sortir del pas de refactor i no de la primera implementació, i documentació executable. I amb la valoració honesta de quan aporta i quan destorba, més el cas en què és senzillament la millor opció disponible: corregir un error.

Saps revisar codi —què mirar i en quin ordre, com donar retroalimentació que millori el codi en comptes de generar resistència, i per què un PR de mil línies rep «LGTM» i un de dos-centes rep comentaris útils—, i tens la llista de comprovació de BiblioTech. I tens integració contínua amb GitHub Actions: treball ràpid i treball complet, cobertura comentada al PR, anàlisi estàtica, mutació programada, matriu de versions de Java i la protecció de branca sense la qual tot l'anterior és un semàfor que ningú no està obligat a mirar.

I saps què no provar —getters, el framework, la biblioteca estàndard, els detalls d'implementació privats— i reconèixer les proves fràgils com el deute que són, amb les seves set causes i les seves solucions: Clock injectable en lloc de LocalDate.now(), Awaitility en lloc de Thread.sleep, aïllament en lloc d'estat compartit.

BiblioTech està provat, mesurat, analitzat i verificat a cada canvi. I continua sense existir per a ningú: corre al portàtil d'en Diego Alonso i al runner de GitHub Actions. Ni la Marta Ruiz ni la Núria Vidal poden obrir un navegador i fer-lo servir, perquè no hi ha cap servidor on estigui funcionant.

La lliçó següent el posa en producció: empaquetat en jar per capes, contenidors amb un Dockerfile multietapa comentat línia a línia, opcions de la JVM conscients dels cgroups, migracions de base de dades versionades amb Flyway —perquè ddl-auto no val en producció—, on desplegar i amb quin criteri, sondes de salut i aturada ordenada, estratègies de desplegament amb tornada enrere, i la canonada completa de lliurament continu que construeix la imatge, la publica i la desplega.

Curs de Programació en Java

Mòdul 1: Introducció a Java

Mòdul 2: Flux de control

Mòdul 3: Programació orientada a objectes

Mòdul 4: Programació orientada a objectes avançada

Mòdul 5: Estructures de dades i col·leccions

Mòdul 6: Gestió d'excepcions

Mòdul 7: Entrada/sortida de fitxers

Mòdul 8: Multifil i concurrència

Mòdul 9: Xarxes

Mòdul 10: Temes avançats

Mòdul 11: Frameworks i llibreries de Java

Mòdul 12: Construcció d'aplicacions del món real

© Copyright 2026. Tots els drets reservats