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
- El problema de provar contra H2
- Què és Testcontainers i com funciona
- Dependències
- La primera prova amb
@Container @DynamicPropertySourcedavant d'@ServiceConnection- Contenidor compartit:
ProvaIntegracioBase - Reutilització amb
withReuse - Verificar Flyway de veritat
- Les consultes que H2 no suporta
- Aïllament de dades entre proves
- Testcontainers en desenvolupament local
- Altres contenidors útils
- Cost, integració contínua i depuració
- Errors Comuns i Consells
- Exercicis
- 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.
- 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.
- 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.
- La primera prova amb
@Container
@Containerpackage 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.staticcanvia el cicle de vida del tot: un camp@Containerestà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 servirlatestés garantir que un dia la construcció falla sola.- El sufix
ITfa que l'executi Failsafe a./mvnw verifyi no Surefire a./mvnw test(06-01). És el que manté el cicle curt en segons.
@DynamicPropertySource davant d'@ServiceConnection
@DynamicPropertySource davant d'@ServiceConnectionEl 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.
- Contenidor compartit:
ProvaIntegracioBase
ProvaIntegracioBaseUn 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.
- Reutilització amb
withReuse
withReuseEs 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=trueQue 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=trueal 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.
- 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");
}
- 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.
- 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è:
@Transactionalper defecte a les proves que només llegeixen o l'escriptura de les quals no involucra transaccions pròpies. És el que fa@DataJpaTesti funciona bé.- Neteja explícita a les proves de flux complet per HTTP, perquè allà el
commitpassa dins del servidor i el rollback de la prova no l'abasta. És un error clàssic: posar@Transactionalsobre una prova ambMockMvcque 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ó.
- 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);
}
}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.
- 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.
- 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.
CalculadoraTarifaamb un contenidor seria absurd. - Per provar la capa web.
@WebMvcTestamb@MockitoBean(06-04) és dos ordres de magnitud més ràpid. - Per a consultes trivials que H2 resol igual.
@DataJpaTestcontinua 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:
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
- Què és Spring Boot?
- Configuració del teu entorn de desenvolupament
- Creant la teva primera aplicació Spring Boot
- Entenent l'estructura del projecte
- L'arrencada i el cicle de vida de l'aplicació
Mòdul 2: Conceptes bàsics de Spring Boot
- Anotacions de Spring Boot
- Injecció de dependències a Spring Boot
- Àmbit i cicle de vida dels beans
- Configuració de Spring Boot
- Propietats de Spring Boot
- Autoconfiguració i starters per dins
Mòdul 3: Construint serveis web RESTful
- Introducció als serveis web RESTful
- Creant controladors REST
- Gestió dels mètodes HTTP
- Validació de dades d'entrada
- DTOs i mapatge entre capes
- Gestió d'excepcions a REST
- Documentar l'API amb OpenAPI
Mòdul 4: Accés a dades amb Spring Boot
- Introducció a Spring Data JPA
- Configuració de fonts de dades
- Creació d'entitats JPA
- Relacions entre entitats
- Ús de repositoris de Spring Data
- Mètodes de consulta a Spring Data JPA
- Transaccions i gestió de la persistència
- Migracions d'esquema amb Flyway
Mòdul 5: Seguretat a Spring Boot
- Introducció a Spring Security
- Configuració de Spring Security
- Autenticació i autorització d'usuaris
- Implementació d'autenticació JWT
- Seguretat a nivell de mètode i enduriment de l'API
Mòdul 6: Proves a Spring Boot
- Introducció a les proves
- Proves unitàries amb JUnit
- Simulació amb Mockito
- Proves d'integració
- Proves amb Testcontainers
Mòdul 7: Funcions avançades de Spring Boot
- Spring Boot Actuator
- Perfils de Spring Boot
- Tasques programades i execució asíncrona
- Spring Boot amb Docker
- Spring Boot i microserveis
- Comunicació entre serveis i tolerància a fallades
Mòdul 8: Desplegament d'aplicacions Spring Boot
- Introducció al desplegament
- Desplegant a Heroku
- Desplegant a AWS
- Desplegant a Kubernetes
- Integració i lliurament continus
Mòdul 9: Rendiment i monitoratge
- Ajust de rendiment
- Memòria cau amb Spring Cache
- Monitoratge amb Spring Boot Actuator
- Ús de Prometheus i Grafana
- Gestió de registres i logs
- Traçabilitat distribuïda
