La lliçó anterior va tancar amb una escletxa anomenada però no tapada: tot el que hem provat contra la base de dades ha corregut sobre H2 en memòria, i la xarxa de Ribalta funciona sobre PostgreSQL 16. Mentre les proves són senzilles la diferència no es nota, i aquest és justament el parany: una prova que passa en verd amb H2 mentre el mateix codi falla en producció és pitjor que no tenir prova, perquè a més dona confiança.

Aquesta lliçó elimina aquesta zona de fe. Veurem amb exemples concrets què falla en canviar de motor, què és Testcontainers i com funciona, com aixecar un PostgreSQL real i efímer per a les proves, la diferència entre connectar-lo a mà amb @DynamicPropertySource i fer-ho automàticament amb @ServiceConnection, com compartir un contenidor entre totes les classes perquè la suite no trigui mitja hora, com comprovar de veritat que les migracions V1…V6 deixen l'esquema que les entitats esperen, com aïllar les dades entre proves i com fer servir els mateixos contenidors per arrencar l'aplicació en desenvolupament sense instal·lar res. En acabar, la suite de CicloUrbana estarà completa i el mòdul tancat.

Contingut

  1. El problema de provar contra H2
  2. Què és Testcontainers i com funciona
  3. Dependències
  4. La primera prova amb @Container
  5. @DynamicPropertySource davant d'@ServiceConnection
  6. Contenidor compartit: ProvaIntegracioBase
  7. Reutilització amb withReuse
  8. Verificar Flyway de veritat
  9. Les consultes que H2 no suporta
  10. Aïllament de dades entre proves
  11. Testcontainers en desenvolupament local
  12. Altres contenidors útils
  13. Cost, integració contínua i depuració
  14. Errors Comuns i Consells
  15. Exercicis

  1. El problema de provar contra H2

H2 té un mode de compatibilitat amb PostgreSQL, i funciona sorprenentment bé. Fins que no.

Diferència Què passa a CicloUrbana
SQL natiu cercarCars de 04-06 és nativeQuery = true; qualsevol funció específica de PostgreSQL (ILIKE, to_tsvector, jsonb_path_query) peta a H2
Índexs parcials La migració V3 crea CREATE INDEX ... WHERE estat <> 'RESOLTA'; H2 no els suporta i la migració falla o s'ignora
Seqüències L'allocationSize = 50 de 04-03 i l'INCREMENT BY real de la seqüència han de coincidir; a H2 els identificadors surten diferents i un desajust que a Ribalta corromp dades aquí no apareix
Tipus jsonb, uuid, text[], interval, timestamptz; H2 els aproxima o els rebutja
Restriccions diferides i ON CONFLICT Un upsert de PostgreSQL no té equivalent
Bloquejos El @Lock(PESSIMISTIC_WRITE) de 04-07 sobre Bicicleta es comporta diferent: la condició de cursa de dos usuaris llogant la mateixa bicicleta no es pot reproduir a H2
Sensibilitat a majúscules H2 plega els identificadors sense cometes a majúscules i PostgreSQL a minúscules: una taula Estacions funciona en un i no en l'altre
Missatges d'error El codi SQL d'una violació d'unicitat difereix, així que el catch que tradueix a ConflicteRecursException pot no disparar-se

I el cas més greu de tots, perquè és silenciós: ddl-auto: validate contra un esquema creat per H2 no valida res útil. A 06-04 vam desactivar Flyway al perfil de prova i vam deixar que Hibernate creés les taules amb create-drop. Això significa que l'esquema de les proves el genera la mateixa entitat, així que sempre coincideix amb si mateix. Una entitat desalineada de les migracions V1…V6 passaria totes les proves i fallaria a l'arrencada de producció.

La conclusió no és que H2 sigui dolent: és ràpid i serveix per a moltes llesques. La conclusió és que la suite necessita, a més, un tram que corri contra el motor real.

  1. Què és Testcontainers i com funciona

Testcontainers és una biblioteca que arrenca contenidors Docker des del codi de les proves i els destrueix en acabar. Dona bases de dades, cues, emmagatzematges i serveis falsos reals i efímers, sense instal·lar res a la màquina ni compartir un servidor entre desenvolupadors.

flowchart LR
    T["La prova JUnit"] --> API["API de Testcontainers"]
    API --> D["Dimoni de Docker"]
    D --> C["Contenidor postgres:16-alpine<br/>port aleatori"]
    D --> R["Ryuk (contenidor vigilant)"]
    C --> W["Espera que estigui llest<br/>(wait strategy)"]
    W --> T
    R -. "esborra tot si la JVM mor" .-> C

El cicle és sempre el mateix: descarregar la imatge si falta, arrencar el contenidor publicant el port intern en un de lliure i aleatori de l'amfitrió, esperar que el servei estigui llest —per a PostgreSQL, fins que accepti connexions— i lliurar a la prova la URL, l'usuari i la contrasenya reals.

Dues peces convé conèixer-les pel nom. El port aleatori és el que permet que diverses construccions corrin alhora al mateix agent sense col·lisionar, i és la raó per la qual la URL no es pot escriure a l'application.yml: no es coneix fins a l'arrencada. Ryuk és un contenidor auxiliar que Testcontainers aixeca i que esborra tot el que s'ha creat si la JVM mor de forma abrupta; sense ell, un kill -9 deixaria contenidors orfes consumint memòria.

Requisits: un entorn Docker en funcionament (Docker Desktop, Colima, Podman en mode compatible o Docker Engine a Linux) i permís per fer-lo servir. Si no n'hi ha, les proves fallen en arrencar el contenidor; a l'apartat 13 veurem com ometre-les amb elegància.

  1. Dependències

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-bom</artifactId>
            <version>1.20.4</version>
            <type>pom</type>
            <scope>import</scope>   <!-- alinea les versions de tots els mòduls -->
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>   <!-- @Testcontainers i @Container -->
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>      <!-- PostgreSQLContainer -->
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>  <!-- @ServiceConnection -->
        <scope>test</scope>
    </dependency>
</dependencies>

Tres notes. El BOM evita barrejar versions entre junit-jupiter, postgresql i la resta de mòduls, que és un origen habitual d'errors estranys; Spring Boot ja gestiona la versió de Testcontainers, així que declarar el BOM és opcional però recomanable si hi afegeixes mòduls que Boot no coneix. spring-boot-testcontainers és la peça d'Spring Boot 3.1+ que aporta @ServiceConnection i el suport de desenvolupament de l'apartat 11. I tot va amb <scope>test</scope>: res d'això no s'empaqueta.

  1. La primera prova amb @Container

package com.ciclourbana.lloguers;

@SpringBootTest
@Testcontainers            // gestiona el cicle de vida dels @Container
class LloguerRepositoriPostgresIT {

    @Container
    @ServiceConnection     // connecta el DataSource automàticament (apartat 5)
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired private LloguerRepositori lloguerRepositori;

    @Test
    void esConnectaAUnPostgresRealINoAUnaBaseEnMemoria() {
        assertThat(postgres.isRunning()).isTrue();
        assertThat(lloguerRepositori.count()).isNotNegative();
    }
}

Els detalls importen més del que sembla:

  • @Testcontainers és l'extensió de JUnit 5 que arrenca i atura els camps @Container.
  • static canvia el cicle de vida del tot: un camp @Container estàtic arrenca una vegada per a tota la classe; un d'instància, una vegada per cada mètode de prova. Amb PostgreSQL, la segona opció multiplica el temps pel nombre de proves i gairebé mai no compensa.
  • postgres:16-alpine: l'etiqueta es fixa sempre, i coincidint amb la versió de producció. Fer servir latest és garantir que un dia la construcció falla sola.
  • El sufix IT fa que l'executi Failsafe a ./mvnw verify i no Surefire a ./mvnw test (06-01). És el que manté el cicle curt en segons.

  1. @DynamicPropertySource davant d'@ServiceConnection

El contenidor arrenca amb una URL desconeguda per endavant. Hi ha dues maneres que Spring la faci servir.

La forma clàssica, amb el @DynamicPropertySource que 06-04 va deixar preparat:

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

Funciona perquè el mètode s'executa després d'arrencar el contenidor i abans de crear el context, i perquè registra proveïdors (Supplier), no valors: s'avaluen en el moment just.

La forma moderna (Spring Boot 3.1+) és una sola anotació:

@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource @ServiceConnection
Codi Tres o més línies per servei Una anotació
Noms de propietat Escrits a mà: un error tipogràfic es descobreix tard Els resol Spring Boot
Altres serveis (Redis, Kafka, RabbitMQ) Cal conèixer les claus de cadascun Mateix mecanisme per a tots
Ajustos fins (paràmetres extra del pool) Sí Combinable amb @DynamicPropertySource
Disponible des de Sempre Spring Boot 3.1

La recomanació del curs és @ServiceConnection, i @DynamicPropertySource queda per al que no és una connexió de servei: la URL d'una API falsa, un indicador de configuració calculat, un directori temporal. Per dins, @ServiceConnection funciona amb ConnectionDetails, el mateix mecanisme que l'autoconfiguració de 02-06 consulta abans de mirar les propietats.

  1. Contenidor compartit: ProvaIntegracioBase

Un contenidor per classe de prova sembla net i arruïna la suite: vint classes són vint arrencades de PostgreSQL, entre un i tres segons cadascuna, més vint contextos d'Spring diferents si cada classe declara la seva pròpia configuració.

La solució és el patró de contenidor estàtic compartit, i encaixa exactament amb la classe base que 06-04 recomanava per no fragmentar la memòria cau de contextos:

package com.ciclourbana;

@SpringBootTest
@ActiveProfiles("test")
@AutoConfigureMockMvc
public abstract class ProvaIntegracioBase {

    /*
     * Estàtic i SENSE @Container: no volem que l'extensió l'aturi en
     * acabar cada classe. S'arrenca una vegada al bloc static i viu
     * fins que mor la JVM; Ryuk s'encarrega d'esborrar-lo si alguna cosa falla.
     */
    static final PostgreSQLContainer<?> POSTGRES =
            new PostgreSQLContainer<>("postgres:16-alpine")
                    .withDatabaseName("ciclourbana")
                    .withUsername("ribalta")
                    .withPassword("ribalta");

    static {
        POSTGRES.start();
    }

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

I totes les proves d'integració hereten:

class LloguerFluxCompletIT extends ProvaIntegracioBase {

    @Autowired private MockMvc mockMvc;
    // ... sense ni una anotació de contenidors
}
Contenidor per classe Contenidor compartit
Arrencades de PostgreSQL Una per classe Una per execució
Suite de 20 classes +40 s només en contenidors +2 s
Aïllament de dades Total Requereix una estratègia (apartat 10)
Contextos d'Spring Risc d'un per classe Un, gràcies a la classe base

Per què el bloc static en lloc de @Container. L'extensió @Testcontainers atura un contenidor static en acabar la classe; heretat, això significaria aturar-lo i arrencar-lo amb cada subclasse. Arrencant-lo a mà al bloc estàtic, la JVM el manté durant tota l'execució de la suite i Ryuk garanteix la neteja. És el detall que separa una suite de dos minuts d'una de dotze.

Amb @ServiceConnection el patró és encara més curt, perquè desapareix el @DynamicPropertySource; a canvi, l'anotació exigeix el camp @Container, així que la variant habitual és declarar el contenidor en un @TestConfiguration importat per la classe base, que és just el que fa l'apartat 11.

  1. Reutilització amb withReuse

Es pot anar un pas més enllà i conservar el contenidor entre execucions de la suite: la primera vegada arrenca, i les següents es reaprofita el que ja està en marxa.

static final PostgreSQLContainer<?> POSTGRES =
        new PostgreSQLContainer<>("postgres:16-alpine")
                .withReuse(true);
# ~/.testcontainers.properties  (al HOME del desenvolupador, NO al repositori)
testcontainers.reuse.enable=true

Que l'interruptor sigui al fitxer personal i no al projecte és deliberat: la reutilització és una comoditat de desenvolupament, no una configuració compartida. Les seves condicions i els seus riscos:

  • Sense testcontainers.reuse.enable=true al fitxer de l'usuari, withReuse(true) s'ignora.
  • El contenidor no s'esborra en acabar: continua consumint memòria fins que s'aturi a mà.
  • Les dades persisteixen entre execucions. És l'avantatge —arrencada instantània— i el perill: una prova que depengui que la taula estigui buida començarà a fallar de forma intermitent.
  • En integració contínua es desactiva sempre. Cada construcció ha de partir de zero, i l'agent és efímer de totes maneres.

  1. Verificar Flyway de veritat

Aquest és l'apartat que justifica tota la lliçó. Al perfil de prova de 06-04 vam desactivar Flyway i vam deixar que Hibernate creés l'esquema; ara fem el contrari, que és el que fa producció:

# src/test/resources/application-integracio.yml
spring:
  flyway:
    enabled: true
    locations: classpath:db/migration
  jpa:
    hibernate:
      ddl-auto: validate      # EXACTAMENT com en producció (04-08)
@ActiveProfiles("integracio")
class MigracionsFlywayIT extends ProvaIntegracioBase {

    @Autowired private Flyway flyway;
    @Autowired private JdbcTemplate jdbc;

    @Test
    void aplicaLesSisMigracionsSobrePostgresNet() {
        MigrationInfo[] aplicades = flyway.info().applied();

        assertThat(aplicades).hasSize(6)
                .extracting(i -> i.getVersion().getVersion())
                .containsExactly("1", "2", "3", "4", "5", "6");
        assertThat(aplicades).allSatisfy(
                i -> assertThat(i.getState().isFailed()).isFalse());
    }

    @Test
    void deixaLesQuatreEstacionsDeRibaltaQueCarregaLaMigracioV2() {
        List<String> noms = jdbc.queryForList(
                "select nom from estacions order by nom", String.class);

        assertThat(noms).containsExactly(
                "Estació Nord", "Parc del Riu", "Plaça Major", "Universitat");
    }

    @Test
    void creaLIndexParcialDIncidenciesNoResoltesDeLaV3() {
        // Un índex parcial: PostgreSQL el suporta, H2 no. Aquí sí que es pot comprovar.
        assertThat(jdbc.queryForObject(
                "select indexdef from pg_indexes where indexname = 'idx_incidencies_obertes'",
                String.class)).contains("WHERE");
    }
}

I la prova més valuosa del mòdul, que cap en dues línies:

@Test
void lEsquemaDeLesMigracionsCoincideixAmbLesEntitats() {
    // Si aquesta prova passa, és que ddl-auto: validate no va protestar en crear
    // el context sobre l'esquema que deixen V1..V6. Una entitat amb un camp
    // que cap migració no va crear hauria fet fallar l'arrencada.
    assertThat(true).isTrue();
}

Sembla una broma i és exactament el contrari. Amb ddl-auto: validate, el context no arrenca si una entitat no encaixa amb l'esquema, així que el simple fet que la classe s'executi demostra l'alineació. És la prova que detecta l'escenari que obria aquesta lliçó: algú afegeix @Column private String observacions; a Incidencia i oblida la migració V7. Amb H2 i create-drop, tot verd; aquí, la construcció s'atura amb Schema-validation: missing column [observacions].

Si prefereixes una asserció explícita en lloc d'un isTrue() que sembla decoratiu, la forma honesta és comprovar l'esquema:

@Test
void laTaulaIncidenciesTeLesColumnesQueLEntitatDeclara() {
    assertThat(jdbc.queryForList(
            "select column_name from information_schema.columns where table_name='incidencies'",
            String.class))
        .contains("id", "bicicleta_id", "tipus", "descripcio", "estat",
                  "nivell_detectat", "denuncia_policial", "creat_el", "versio");
}

  1. Les consultes que H2 no suporta

Amb PostgreSQL real, les consultes natives de 04-06 passen a ser comprovables:

class ConsultesNativesIT extends ProvaIntegracioBase {

    @Autowired private LloguerRepositori lloguerRepositori;
    @Autowired private TestEntityManager em;

    @Test
    void cercarCarsFaServirSqlNatiuDePostgresIFiltraPerImport() {
        em.persist(unLloguer().finalitzatAmbImport("3.50").construir());
        em.persist(unLloguer().finalitzatAmbImport("12.80").construir());
        em.flush();

        List<Lloguer> cars = lloguerRepositori.cercarCars(new BigDecimal("10.00"));

        assertThat(cars).hasSize(1)
                .first().extracting(Lloguer::getImportTotal)
                .isEqualTo(new BigDecimal("12.80"));
    }

    @Test
    void laCercaPerNomIgnoraMajusculesIAccentsComARibalta() {
        // ILIKE és de PostgreSQL: a H2 aquesta consulta ni tan sols compila
        assertThat(estacioRepositori.cercarPerNomAproximat("placa"))
                .extracting(Estacio::getNom).containsExactly("Plaça Major");
    }

    @Test
    void laSequenciaAssignaIdentificadorsCoherentsAmbAllocationSize50() {
        Estacio a = em.persistFlushFind(EstacionsDeProva.placaMajor());
        Estacio b = em.persistFlushFind(EstacionsDeProva.universitat());

        // Amb allocationSize=50 i INCREMENT BY 50 els ids són consecutius en
        // memòria; si la migració declara INCREMENT BY 1, apareixen forats de 50.
        assertThat(b.getId()).isEqualTo(a.getId() + 1);
    }
}

La tercera és especialment interessant perquè comprova la coherència entre dos fitxers que ningú no relaciona: l'allocationSize = 50 de l'anotació a Estacio i l'INCREMENT BY de la seqüència a V1__crear_esquema_inicial.sql. Un desajust aquí no trenca res visiblement: simplement fa que els identificadors saltin de cinquanta en cinquanta o, pitjor, que dues instàncies de l'aplicació assignin el mateix. És una fallada que només es veu amb el motor real.

  1. Aïllament de dades entre proves

Amb un contenidor compartit, les dades que deixa una prova les veu la següent. Hi ha tres estratègies:

Estratègia Com Avantatges Inconvenients
Transacció amb rollback (@Transactional sobre la prova) Tot es desfà en acabar Rapidíssim, zero codi de neteja No serveix si el codi sota prova gestiona les seves pròpies transaccions; no prova el commit; amaga restriccions diferides
Neteja explícita (@Sql amb un TRUNCATE, o un @BeforeEach que esborra) Estat conegut abans de cada prova Funciona amb qualsevol codi; prova el commit de veritat Cal mantenir l'ordre d'esborrat per les claus foranes
Contenidor per classe Un @Container no compartit Aïllament perfecte Segons per classe: només per a casos excepcionals

La política de CicloUrbana, i el perquè:

  • @Transactional per defecte a les proves que només llegeixen o l'escriptura de les quals no involucra transaccions pròpies. És el que fa @DataJpaTest i funciona bé.
  • Neteja explícita a les proves de flux complet per HTTP, perquè allà el commit passa dins del servidor i el rollback de la prova no l'abasta. És un error clàssic: posar @Transactional sobre una prova amb MockMvc que escriu, veure que les dades persisteixen i no entendre per què.
@Sql(scripts = "/dades/netejar-ribalta.sql", executionPhase = BEFORE_TEST_METHOD)
class LloguerFluxCompletIT extends ProvaIntegracioBase { /* ... */ }
-- netejar-ribalta.sql: l'ordre importa per les claus foranes
TRUNCATE TABLE incidencies, lloguers, bicicletes, estacions, usuaris
    RESTART IDENTITY CASCADE;

RESTART IDENTITY reinicia les seqüències, de manera que els identificadors són predictibles entre proves; CASCADE evita haver d'encertar l'ordre exacte. I un advertiment de pes: aquest guió viu a src/test/resources i mai no ha de poder executar-se contra una altra base de dades. Que la URL la fabriqui un contenidor efímer és, a més de comoditat, una protecció.

  1. Testcontainers en desenvolupament local

Spring Boot 3.1 va donar a Testcontainers un segon ús que no té a veure amb provar: arrencar l'aplicació en desenvolupament amb les seves dependències reals, sense instal·lar PostgreSQL ni mantenir un docker-compose a mà.

// src/test/java/com/ciclourbana/ConfiguracioContenidors.java
@TestConfiguration(proxyBeanMethods = false)
public class ConfiguracioContenidors {

    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgresDeDesenvolupament() {
        return new PostgreSQLContainer<>("postgres:16-alpine").withReuse(true);
    }
}

// src/test/java/com/ciclourbana/TestCicloUrbanaApplication.java
public class TestCicloUrbanaApplication {

    public static void main(String[] args) {
        SpringApplication.from(CicloUrbanaApplication::main)
                .with(ConfiguracioContenidors.class)
                .run(args);
    }
}
./mvnw spring-boot:test-run

Amb aquesta ordre, l'aplicació arrenca amb el seu main real, però el context inclou a més el contenidor: Flyway aplica V1…V6 sobre un PostgreSQL 16 net i http://localhost:8080/api/v1/estacions respon amb les quatre estacions de Ribalta. Un desenvolupador nou clona el repositori i treballa sense instal·lar cap base de dades. I com que és un @Bean en un @TestConfiguration, la mateixa classe es pot importar des de ProvaIntegracioBase i compartir la definició del contenidor entre desenvolupament i proves.

L'alternativa, també d'Spring Boot 3.1, és el suport de Docker Compose:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-docker-compose</artifactId>
    <optional>true</optional>
</dependency>

Amb un compose.yaml a l'arrel, l'aplicació aixeca els serveis en arrencar i els atura en acabar, connectant-los igual que @ServiceConnection.

Testcontainers en desenvolupament spring-boot-docker-compose
On es defineix Codi Java (@TestConfiguration) compose.yaml
Es comparteix amb les proves Sí, la mateixa classe No directament
El fan servir altres eines No Sí: docker compose up sense Java
Control fi (esperes, inicialització) Total, en Java El de Compose

Cap no és superior: si l'equip ja viu amb docker compose, el segon encaixa millor; si el que vols és que les proves i el desenvolupament comparteixin exactament la mateixa definició, el primer.

  1. Altres contenidors útils

Testcontainers té mòduls per a gairebé tot, i CicloUrbana els anirà necessitant:

Mòdul Per a què On apareixerà
postgresql La base de dades real Aquesta lliçó
redis (o GenericContainer amb redis:7-alpine) Memòria cau distribuïda La memòria cau de 09-02
WireMock / MockServer Simular una API externa (el proveïdor de pagaments de Ribalta) Comunicació entre serveis, 07-06
kafka Missatgeria entre serveis 07-05 i 07-06
localstack AWS en local: S3, SQS, DynamoDB Desplegament a AWS, 08-03
selenium / Playwright Proves d'extrem a extrem amb navegador Fora de l'abast d'aquest curs
GenericContainer Qualsevol imatge sense mòdul propi Comodí

La mateixa idea s'aplica a tots: @Container més @ServiceConnection quan el mòdul ho suporti, o @DynamicPropertySource per a la resta —per exemple, la URL d'un WireMock que substitueixi el servei de pagaments.

  1. Cost, integració contínua i depuració

Què costa. La descàrrega de la imatge passa una vegada (uns 80 MB per a postgres:16-alpine); l'arrencada, entre un i tres segons; cada prova, el que trigui el seu SQL. Amb el contenidor compartit de l'apartat 6, vint classes d'integració afegeixen uns pocs segons al total.

Quan NO fer servir Testcontainers:

  • Per provar lògica de negoci pura. CalculadoraTarifa amb un contenidor seria absurd.
  • Per provar la capa web. @WebMvcTest amb @MockitoBean (06-04) és dos ordres de magnitud més ràpid.
  • Per a consultes trivials que H2 resol igual. @DataJpaTest continua sent vàlid per al gruix dels repositoris.
  • On no hi ha Docker. En aquest cas, o s'omet el tram o no es poden executar aquestes proves.

La proporció sana torna a ser la piràmide de 06-01: centenars d'unitàries, desenes de llesques i un grapat de proves amb contenidor que cobreixin el que només el motor real revela —migracions, SQL natiu, seqüències, bloquejos, tipus.

En integració contínua cal un executor amb Docker disponible. L'ordre és la de sempre:

./mvnw verify      # Surefire executa *Test; Failsafe, les *IT amb contenidor

I quan falta Docker, la forma elegant de no trencar la construcció és la suposició de 06-02:

@BeforeAll
static void requereixDocker() {
    assumeTrue(DockerClientFactory.instance().isDockerAvailable(),
               "Docker no disponible: s'ometen les proves amb contenidor");
}

La configuració concreta de l'executor —Docker dins de Docker, memòries cau d'imatges, serveis auxiliars— és matèria de 08-05, Integració i Lliurament Continu.

Depuració. Quan una prova amb contenidor falla de forma incomprensible, la causa sol ser als registres del mateix servei:

static final PostgreSQLContainer<?> POSTGRES =
        new PostgreSQLContainer<>("postgres:16-alpine")
                .withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("postgres")))
                .waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(60)));

// I en qualsevol punt de la prova:
System.out.println(POSTGRES.getLogs());

withLogConsumer redirigeix la sortida del contenidor al teu registre —on apareixerà el syntax error at or near que H2 mai no hauria donat—, i waitingFor ajusta l'estratègia d'espera quan un agent lent fa que l'arrencada excedeixi el temps per defecte.

Errors Comuns i Consells

Fer servir latest com a etiqueta d'imatge. La construcció funciona avui i falla d'aquí a tres mesos sense que ningú hagi tocat res. Fixa la versió, i que sigui la de producció.

Un @Container d'instància en lloc de static. Arrenca un PostgreSQL per cada mètode de prova. És la causa número u de suites de contenidors insuportablement lentes.

Posar @Container sobre el contenidor de la classe base. L'extensió l'atura en acabar cada subclasse i el torna a arrencar. Arrenca'l al bloc static i deixa que Ryuk netegi.

@Transactional sobre una prova que escriu per HTTP. El commit passa dins del servidor i el rollback de la prova no l'abasta: les dades persisteixen i la prova següent falla. Amb MockMvc que escriu, neteja explícita.

Suposar que el port és 5432. Testcontainers en publica un d'aleatori: fes servir getJdbcUrl() o @ServiceConnection, mai una URL escrita a mà.

Deixar Flyway desactivat a les proves amb contenidor. Es perd just el que es venia a comprovar. Al perfil d'integració, flyway.enabled: true i ddl-auto: validate.

withReuse(true) en integració contínua. Contamina construccions successives amb dades anteriors i produeix fallades intermitents impossibles de reproduir. És una comoditat local, i per això l'interruptor viu al HOME del desenvolupador.

Consell: una imatge, una classe base, un contenidor. Tota prova *IT de CicloUrbana hereta de ProvaIntegracioBase. Una sola arrencada de PostgreSQL, un sol context d'Spring i una suite que continua durant el que ha de durar.

Consell: quan una prova passi amb H2 i falli amb PostgreSQL, celebra-ho. Acabes de trobar una fallada de producció abans que arribés a Ribalta. Aquesta és exactament la feina d'aquesta lliçó.

Exercicis

Exercici 1

Crea ProvaIntegracioBase segons l'apartat 6 i migra-hi la SeguretatLloguersIT de 06-04, canviant a més el perfil perquè Flyway s'apliqui i ddl-auto sigui validate. Comprova amb el registre de la memòria cau de contextos que continua havent-hi un sol context i que el contenidor arrenca una sola vegada encara que hi hagi diverses classes *IT. Documenta el temps abans i després.

Exercici 2

Escriu MigracionsFlywayIT amb quatre comprovacions: que s'apliquen sis migracions sense fallades; que V2 deixa les quatre estacions de Ribalta amb les seves capacitats correctes (24, 30, 18, 36); que existeix l'índex sobre lloguers(inici) que crea V4; i que la restricció d'unicitat sobre estacions.nom és efectiva —inserint un duplicat i esperant l'excepció—. Després provoca deliberadament la fallada: afegeix un camp observacions a l'entitat Incidencia sense escriure la migració V7, executa i anota el missatge exacte que produeix.

Exercici 3

Un company proposa eliminar totes les proves amb H2 i executar-ho absolutament tot amb Testcontainers, «perquè així provem contra el que és real». Escriu una resposta raonada de tres paràgrafs: què té d'encertada la proposta, quins problemes concrets crearia a CicloUrbana, i quin és el repartiment que recomanes, amb la llista de quin tipus de prova fa servir cada motor i per què.

Solucions

Solució 1

La classe base és la de l'apartat 6, amb el perfil d'integració afegit; la prova migrada queda així:

@ActiveProfiles("integracio")
class SeguretatLloguersIT extends ProvaIntegracioBase {

    @Autowired private MockMvc mockMvc;

    @Test
    @WithUserDetails("[email protected]")
    void elCiutadaNoPotFinalitzarElLloguerDunAltre() throws Exception {
        mockMvc.perform(post("/api/v1/lloguers/9/finalitzar")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"estacioDestiId\": 2}"))
                .andExpect(status().isForbidden());
    }
}

Un canvi important que l'enunciat amaga: amb Flyway actiu, els usuaris i lloguers de prova ja no els crea Hibernate, sinó que han de venir d'una migració de dades de prova o d'un @Sql. És més feina i a canvi es prova el mateix esquema que Ribalta.

Xifres típiques en un portàtil modern, i el que ensenyen:

Escenari Contenidors Contextos Temps
Abans: cada *IT amb el seu @Container i les seves anotacions 4 4 ~38 s
Després: ProvaIntegracioBase 1 1 ~11 s

El registre ha de mostrar [size = 1, hitCount = 3, missCount = 1] amb quatre classes, i una sola línia Creating container for image: postgres:16-alpine a tota l'execució. Si n'apareix més d'una, el contenidor no s'està compartint, i la causa gairebé sempre és un @Container oblidat en una subclasse o una anotació que canvia la clau de memòria cau.

Solució 2

@ActiveProfiles("integracio")
class MigracionsFlywayIT extends ProvaIntegracioBase {

    @Autowired private Flyway flyway;
    @Autowired private JdbcTemplate jdbc;

    @Test
    void aplicaLesSisMigracionsSenseFallades() {
        assertThat(flyway.info().applied()).hasSize(6)
                .allSatisfy(i -> assertThat(i.getState().isFailed()).isFalse());
    }

    @Test
    void laV2DeixaLesQuatreEstacionsDeRibaltaAmbLaSevaCapacitat() {
        assertThat(jdbc.queryForList(
                "select nom, capacitat from estacions order by nom"))
            .extracting(f -> f.get("nom"), f -> f.get("capacitat"))
            .containsExactly(
                    tuple("Estació Nord", 30), tuple("Parc del Riu", 18),
                    tuple("Plaça Major", 24), tuple("Universitat", 36));
    }

    @Test
    void laV4CreaLIndexSobreLaDataDIniciDelsLloguers() {
        assertThat(jdbc.queryForList(
                "select indexname from pg_indexes where tablename = 'lloguers'",
                String.class)).contains("idx_lloguers_inici");
    }

    @Test
    void laRestriccioDUnicitatDelNomDEstacioEsEfectiva() {
        assertThatThrownBy(() -> jdbc.update(
                "insert into estacions (id, nom, adreca, capacitat) "
                        + "values (99, 'Plaça Major', 'Una altra adreça', 10)"))
                .isInstanceOf(DuplicateKeyException.class);
    }
}

Comentari sobre la quarta: comprova una garantia que cap prova anterior no podia donar. Que EstacioService llanci EstacioDuplicadaException és una comprovació d'aplicació, i no protegeix de dues peticions simultànies que consultin alhora i totes dues trobin lliure el nom. La restricció UNIQUE de la base de dades sí, i aquí queda verificada. Que l'excepció que arriba sigui DuplicateKeyException —traduïda pel @Repository de 02-01— i no una SQLException és un detall que també depèn del motor real.

La fallada provocada produeix, en crear el context, alguna cosa com:

org.hibernate.tool.schema.spi.SchemaManagementException:
Schema-validation: missing column [observacions] in table [incidencies]

I aquest missatge és el resultat que es buscava: l'error apareix a la construcció, no al desplegament. Amb H2 i create-drop, la columna s'hauria creat sola a partir de l'entitat, les proves haurien passat en verd i la fallada hauria esperat a l'arrencada a Ribalta, on validate sí que troba la taula real.

Solució 3

Què té d'encertada. El fons de l'argument és correcte i és la tesi d'aquesta lliçó: provar contra un motor diferent del de producció és provar una altra cosa. Tot el que depengui del dialecte, dels tipus, de les seqüències, dels índexs o del comportament transaccional només és verificable amb PostgreSQL, i una suite que no inclogui aquest tram té un punt cec perillós, precisament perquè està pintat de verd.

Quins problemes crearia. El primer, el temps: substituir cada @DataJpaTest per una prova amb contenidor converteix una suite de segons en una de minuts, i una suite lenta deixa d'executar-se durant el desenvolupament —el mecanisme exacte del con de gelat de 06-01—. El segon, la dependència de Docker: qui no en tingui, o l'executor que no ho permeti, es queda sense cap prova, no només sense les d'integració. El tercer, l'aïllament: amb un contenidor compartit cal gestionar la neteja a cada prova, i amb un per classe el cost es dispara. I el quart, més de fons: la majoria de les proves de repositori de CicloUrbana verifiquen consultes derivades trivials que es comporten igual en qualsevol motor; executar-les contra PostgreSQL no detecta ni una fallada més i costa cent vegades més.

El repartiment recomanat.

Tipus de prova Motor Motiu
Unitàries (CalculadoraTarifa, LloguerService amb mocks) Cap No toquen la base de dades
@WebMvcTest del contracte de l'API Cap El servei està simulat
@DataJpaTest de consultes derivades i JPQL senzill H2, ràpid Es comporten igual en tots dos motors
Migracions Flyway i ddl-auto: validate PostgreSQL És literalment el que s'està comprovant
SQL natiu, ILIKE, índexs parcials, jsonb PostgreSQL H2 no els suporta
Seqüències i allocationSize PostgreSQL La coherència entitat-esquema només es veu aquí
Bloquejos i concurrència (@Lock de 04-07) PostgreSQL H2 no reprodueix el comportament
Flux complet per HTTP dels camins crítics PostgreSQL És la prova més propera a producció

En una frase: H2 per al que és comú a tots els motors, PostgreSQL per al que és propi de PostgreSQL. I una salvaguarda que fa innecessari el debat: si una prova amb H2 passa i la seva equivalent amb contenidor falla, la resposta correcta no és discutir, és moure aquesta prova al tram amb contenidor, perquè acaba de demostrar que hi pertany.

Conclusió

El mòdul 6 es tanca amb CicloUrbana provada de dalt a baix. Saps per què H2 no n'hi ha prou —dialecte, tipus, funcions, índexs parcials, seqüències, bloquejos, missatges d'error— i, sobretot, per què el seu perill no és que falli, sinó que passi en verd mentre el codi fallaria a Ribalta. Coneixes Testcontainers per dins: el dimoni de Docker, el contenidor efímer amb el seu port aleatori, l'estratègia d'espera i el vigilant Ryuk que neteja si la JVM mor. Saps declarar les dependències amb el BOM, escriure la primera prova amb @Testcontainers i @Container, i connectar la font de dades de les dues formes possibles, amb la preferència clara per @ServiceConnection i @DynamicPropertySource reservat per al que no és una connexió de servei. Domines el patró que decideix el rendiment de tot el tram —contenidor static arrencat a la classe base ProvaIntegracioBase, una sola arrencada i un sol context d'Spring— i saps quan withReuse(true) ajuda en local i per què mai no s'ha d'activar en integració contínua.

Has convertit en assercions l'última zona de fe del projecte: les migracions V1…V6 aplicades sobre un PostgreSQL net, les quatre estacions que carrega V2, l'índex parcial de V3 que H2 no sap crear, la restricció UNIQUE que cap comprovació d'aplicació no pot substituir i, la més valuosa de totes, ddl-auto: validate fent fallar la construcció quan una entitat es desalinea de l'esquema —la fallada que abans esperava al desplegament—. Has provat les consultes natives de 04-06 i la coherència entre l'allocationSize de l'entitat i l'INCREMENT BY de la seqüència. Saps aïllar les dades triant entre rollback, neteja explícita i contenidor per classe, i per què @Transactional no serveix quan l'escriptura passa per HTTP. I has descobert que els mateixos contenidors arrenquen l'aplicació en desenvolupament amb ./mvnw spring-boot:test-run, de manera que qui cloni CicloUrbana treballi sense instal·lar una base de dades, amb spring-boot-docker-compose com a alternativa i amb Redis, WireMock, Kafka i LocalStack esperant el seu torn als mòduls següents.

Mira el que ha canviat des del principi del mòdul. CicloUrbana va passar de tenir una sola prova buida generada per Initializr a una suite amb forma de piràmide: centenars d'unitàries amb JUnit 5 i AssertJ que fixen les tarifes de Ribalta al cèntim i s'executen en segons; dobles de Mockito que forcen la bicicleta descarregada, l'estació plena i la caiguda del repositori; llesques que verifiquen cada codi d'estat, cada ProblemDetail i cada camp del JSON públic; la matriu d'accés completa del mòdul 5 escrita com una taula on la Marta rep el seu 403 en tocar el lloguer aliè i el seu 200 en finalitzar el seu; i un tram final contra PostgreSQL 16 real que garanteix que l'esquema, les consultes i les migracions es comporten com en producció. La pregunta amb què arrencava el mòdul —«com sabem que funciona?»— té per fi una resposta que no depèn de la memòria de ningú: ./mvnw verify.

I tanmateix CicloUrbana encara no està llesta per viure al món. És correcta, però no és operable. Ningú no li pot preguntar si està sana abans d'enviar-li trànsit, ni si la base de dades respon, ni quina versió està desplegada. No distingeix entorns: la mateixa configuració val per al portàtil d'un desenvolupador i per al servidor de l'ajuntament, amb el mateix nivell de registre i els mateixos secrets. No executa res pel seu compte: ningú no caduca els lloguers oblidats de matinada, ningú no recalcula l'ocupació de les estacions cada minut, i el correu de confirmació s'envia al mateix fil que atén la petició, fent esperar el ciutadà. I continua sent un JAR que cal arrencar a mà en una màquina amb Java 21 instal·lat. El mòdul 7, Funcions Avançades d'Spring Boot, resol tot això: Actuator i les seves sondes de salut, els perfils que separen desenvolupament de producció, les tasques programades i l'execució asíncrona, l'empaquetatge en Docker amb imatges en capes, i la porta d'entrada als microserveis i a la comunicació tolerant a fallades entre serveis. La xarxa de Ribalta funciona i està demostrat; ara cal posar-la en marxa de veritat.

Curs de Spring Boot

Mòdul 1: Introducció a Spring Boot

Mòdul 2: Conceptes bàsics de Spring Boot

Mòdul 3: Construint serveis web RESTful

Mòdul 4: Accés a dades amb Spring Boot

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

Mòdul 7: Funcions avançades de Spring Boot

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats