Un test che passa su H2 e fallisce su PostgreSQL ti avvisa troppo tardi: l'hai scoperto in produzione, o la sera prima del rilascio. Succede perché i due motori differiscono nel dialetto SQL, nei tipi, nelle estensioni e nel comportamento delle transazioni. Testcontainers è la libreria Java che risolve il problema nel modo più diretto: avvia durante il test un container Docker con il database vero, della stessa versione di produzione, e lo spegne alla fine. Non elimina ogni differenza (configurazione, volume di dati e rete restano diverse), ma elimina quella dovuta a un motore diverso. In questo articolo vediamo come si scrive il test, i tre errori che lo rendono inutile in silenzio, e come il backend di questo sito li evita con un container condiviso da tutta la suite.
Un avvertimento sul codice: tutti i listati di questo articolo richiedono Docker e un progetto Spring Boot, e in questa sessione di lavoro non sono stati eseguiti. Sono «schema da adattare», scritti contro le API di Testcontainers 1.21.4 (la versione del pom.xml del backend) e letti contro la documentazione ufficiale. Le affermazioni sul comportamento sono tutte ancorate a una fonte indicata in fondo.
Che cosa fa Testcontainers e di che cosa hai bisogno
Testcontainers si appoggia a un runtime di container già presente sulla macchina: se il demone Docker non è attivo, il test non parte. Il vantaggio è che ogni sviluppatore e ogni pipeline ottiene un database vuoto, isolato e riproducibile, senza installare PostgreSQL né fissare la porta 5432: il container espone una porta casuale e il test chiede quale sia. Nel progetto servono tre dipendenze di test, tutte della stessa versione. La versione è dichiarata una volta sola in una proprietà, perché moduli di versioni diverse sono una fonte di errori difficili da leggere.
<properties>
<testcontainers.version>1.21.4</testcontainers.version>
</properties>
<!-- GenericContainer (usato per Redis) -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<!-- PostgreSQLContainer -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<!-- @Testcontainers e @Container per JUnit 5 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
Il modulo testcontainers contiene le classi generiche (GenericContainer, che nel backend serve per Redis), postgresql aggiunge PostgreSQLContainer e junit-jupiter porta le annotazioni per JUnit 5. Non porta il driver JDBC: quello deve essere già tra le dipendenze del progetto. Un'avvertenza sulle versioni: la documentazione attuale di Testcontainers descrive la serie 2.x, dove il modulo si chiama testcontainers-postgresql; gli esempi di questo articolo usano il nome della serie 1.x, quella del backend. Se parti da zero, leggi la documentazione della versione che scegli.
Il test d'integrazione base
Il listato avvia un PostgreSQL 16 e lo collega all'applicazione. Le righe che contano sono quattro. @Testcontainers attiva l'estensione JUnit che gestisce i campi annotati. @Container su un campo static dichiara il container e ne fa partire il ciclo di vita. @DynamicPropertySource registra le proprietà di connessione come fornitori (postgres::getJdbcUrl): Spring li interroga quando costruisce il contesto, cioè dopo l'avvio del container, quando la porta casuale è nota. Infine il metodo che le registra deve essere static, come prescrive la documentazione di Spring Framework.
@Testcontainers // attiva l'estensione che avvia i campi @Container
@SpringBootTest
class RegisterUserIntegrationTest {
@Container // senza questa annotazione il container non parte mai
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@DynamicPropertySource // il metodo DEVE essere static
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl); // letti al momento dell'uso
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired RegisterUserUseCase registerUser;
@Autowired UserRepository users;
@Test
void registersAndPersists() {
registerUser.execute(new RegisterUserRequest("new@example.com", "Secret123!"));
assertTrue(users.findByEmail(new Email("new@example.com")).isPresent());
}
}
Prima di guardare gli errori, nota che cosa manca: nessuna porta scritta, nessun URL fisso, nessuna configurazione di H2. Flyway applica le migration al database vuoto all'avvio del contesto, quindi il test esercita anche lo schema vero. Qui si vede anche perché usare @Transactional per ripulire i dati non basta quando il test passa dal server HTTP: la documentazione di Spring Boot precisa che con RANDOM_PORT client e server girano in thread separati, quindi in transazioni separate, e ciò che il server scrive non viene annullato dal rollback del test. Nel backend di questo sito, i test che passano da HTTP ripuliscono con script @Sql eseguiti prima e dopo ogni metodo.
Tre errori che rendono il test inutile o lento
- Il container senza
@Container: il campo esiste, ma nessuno lo avvia. Alla prima chiamata agetJdbcUrl()ci si aspetta un errore, perché la porta mappata si conosce solo dopo l'avvio (non l'ho riprodotto: richiederebbe Docker). È l'errore più frequente di chi copia un esempio senza l'annotazione, o senza chiamarestart(). - Il container come campo di istanza: con
@Containersu un campo nonstatic, il container viene avviato e fermato per ogni metodo di test. Funziona, ma il costo dell'avvio si paga a ogni test. - L'URL scritto a mano:
jdbc:postgresql://localhost:5432/testdbpresuppone una porta fissa, mentre quella del container è assegnata a ogni esecuzione.
I tre errori hanno la stessa radice: si dimentica che la porta e il momento di avvio sono decisi dal container, non dal test. Il rimedio per tutti è il listato sopra, dove ogni valore di connessione è letto dal container al momento dell'uso. Il secondo punto ha una conferma esplicita nella documentazione di Testcontainers per JUnit 5: i container su campi statici sono condivisi fra i metodi della classe, avviati una volta prima del primo test e fermati dopo l'ultimo; quelli su campi di istanza sono avviati e fermati per ogni metodo.
@Testcontainers
class LifecycleExample {
@Container
static PostgreSQLContainer<?> shared = new PostgreSQLContainer<>("postgres:16"); // un avvio per classe
@Container
PostgreSQLContainer<?> perTest = new PostgreSQLContainer<>("postgres:16"); // un avvio per metodo
@Test void a() { /* shared e perTest sono in esecuzione */ }
@Test void b() { /* shared e' lo stesso di a(); perTest e' un container nuovo */ }
}
Il listato mostra le due modalità fianco a fianco, ed è un buon modo di non confondersi: shared è lo stesso oggetto per a() e b(), perTest è nuovo a ogni metodo. Usa il campo di istanza solo se hai bisogno di uno stato pulito garantito a ogni test e accetti di pagare l'avvio ogni volta. Il costo dell'avvio non lo quantifico: dipende da macchina, immagine già scaricata e Docker, e non ho una misura da riportare.
Testare solo la persistenza con @DataJpaTest
Quando vuoi provare un adapter di persistenza e non l'intera applicazione, @DataJpaTest carica solo il layer dati ed è più leggero di @SpringBootTest. C'è però una trappola nelle impostazioni predefinite: se sul classpath c'è un database incorporato, @DataJpaTest lo configura al posto di quello che hai indicato. La documentazione di Spring Boot dice che per usare un database vero bisogna aggiungere @AutoConfigureTestDatabase(replace = Replace.NONE). Senza di essa il test passa su H2 e hai perso lo scopo di tutto l'esercizio. Serve anche @Import dell'adapter, perché @DataJpaTest non scansiona i normali @Component; per la struttura dell'adapter vedi JPA e Flyway in Clean Architecture.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // non sostituire con H2
@Import({UserJpaAdapter.class, UserJpaMapper.class}) // @DataJpaTest non scansiona i @Component
@Testcontainers
class UserJpaAdapterIT {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired UserJpaAdapter adapter;
@Test
void savedUserIsFoundByEmail() {
User saved = adapter.save(User.register(new Email("u@example.com"), "hash"));
assertEquals(saved.id(), adapter.findByEmail(new Email("u@example.com")).orElseThrow().id());
}
}
Questo test è il più utile per l'infrastructure layer: dimostra che l'adapter rispetta il contratto del port e che il mapping tra dominio ed entità funziona con le colonne e i tipi reali. La regola di business, invece, non va provata qui: ha un test unitario senza database. Vedi Testing completo in Clean Architecture per come dividere le due cose.
Il container condiviso da tutta la suite
Con decine di classi di test d'integrazione, avviare un container per classe diventa il costo dominante. Il backend di questo sito risolve con il singleton container pattern, descritto nella documentazione sul controllo manuale del ciclo di vita: una classe base astratta avvia i container in un blocco static, e tutte le classi di test la estendono. Il container parte una sola volta, quando la classe base viene caricata, e a fine suite lo ferma il container di servizio Ryuk che Testcontainers avvia insieme.
// IntegrationTestBase.java (estratto dal backend di questo sito)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT /* , properties = {...} */)
@ActiveProfiles("integration")
public abstract class IntegrationTestBase {
static final PostgreSQLContainer<?> POSTGRES;
static final GenericContainer<?> REDIS;
static {
POSTGRES = new PostgreSQLContainer<>(
DockerImageName.parse("postgis/postgis:16-3.5").asCompatibleSubstituteFor("postgres"))
.withDatabaseName("portfolio_test")
.withUsername("portfolio")
.withPassword("test");
POSTGRES.start();
REDIS = new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
REDIS.start();
}
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.data.redis.host", REDIS::getHost);
registry.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379));
}
}
Tre dettagli del listato sono scelte, non convenzioni. L'immagine è postgis/postgis:16-3.5 dichiarata compatibile con postgres tramite asCompatibleSubstituteFor, perché una migration usa l'estensione PostGIS e con l'immagine standard fallirebbe (lo dice il commento nel sorgente). Non c'è @Testcontainers né @Container: il commento nel sorgente spiega che l'estensione JUnit riavvierebbe un container statico per ogni sottoclasse concreta, creando più contesti Spring legati a porte diverse, con errori di connessione rifiutata. E Redis è un GenericContainer con la porta 6379 esposta, letta con getMappedPort; la proprietà in Spring Boot 3 è spring.data.redis.host, non il vecchio spring.redis.host.
// ArticleIntegrationTest.java (estratto dal backend di questo sito)
@SqlGroup({
@Sql(scripts = "/sql/seed-articles.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD),
@Sql(scripts = "/sql/clean-articles.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
})
class ArticleIntegrationTest extends IntegrationTestBase {
@Test
void listArticles_returnsPaginatedResult() {
ResponseEntity<Map> response = restTemplate.getForEntity("/api/v1/articles", Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// ... il corpo contiene items, total, page, size, totalPages ...
}
}
Il test concreto eredita tutto: nessuna annotazione di container, un TestRestTemplate già configurato sulla porta casuale, i dati inseriti e ripuliti con @Sql. Il prezzo del singleton è lo stato condiviso: poiché il database vive per tutta la suite, un test che dimentica di ripulire contamina i successivi, ed è per questo che ogni classe d'integrazione del backend ha il suo script di pulizia. Nel repository i file con @Tag("integration") sono 105 (misurato il 2026-11-12 con grep -rl '@Tag("integration")' backend/src/test | wc -l).
Database vero, servizi esterni finti
Un test d'integrazione non deve spedire una mail vera né chiamare un gateway di pagamento. Si tiene il database reale e si sostituisce solo il confine esterno con un mock. In Spring Boot 3.4 l'annotazione per farlo è @MockitoBean: @MockBean è deprecata dalla 3.4 a favore di quella di Spring Framework, e il wiki delle release notes avverte che le due non sono equivalenti (@MockitoBean non si usa su classi @Configuration). Nel backend di questo sito 55 file di test usano ancora @MockBean e 5 @MockitoBean (misurato il 2026-11-12 con grep -rl sulle due annotazioni): la migrazione è cominciata, non finita.
@SpringBootTest
@Testcontainers
class MockedEmailIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@MockitoBean EmailSender emailSender; // il database e' vero, il servizio esterno no
@Autowired RegisterUserUseCase registerUser;
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Test
void sendsWelcomeEmail() {
registerUser.execute(new RegisterUserRequest("user@example.com", "pwd"));
verify(emailSender).sendWelcome(any());
}
}
Qui il contenitore vero serve a provare la persistenza dell'utente e il mock isola il canale di invio: il test verifica che il caso d'uso chieda l'invio, non che un server SMTP risponda. Lo stesso approccio vale per qualunque porta verso l'esterno.
Domande frequenti
Testcontainers richiede Docker?
Richiede un runtime di container compatibile e attivo sulla macchina che esegue i test, Docker incluso. Se manca, i test d'integrazione non partono: nel backend di questo sito sono perciò separati dai test unitari con un'etichetta (@Tag("integration")) e si eseguono selezionando quel gruppo.
Meglio @DynamicPropertySource o @ServiceConnection?
Spring Boot offre @ServiceConnection su un campo container, che collega i servizi supportati senza elencare le proprietà. La documentazione di Spring Boot presenta @DynamicPropertySource come l'alternativa «un po' più prolissa ma più flessibile»: serve per proprietà che non hanno un servizio di connessione, come un URL personalizzato.
Posso provare le migration Flyway?
Sì, e il test base lo fa già: Flyway gira all'avvio del contesto sul database vuoto, e uno schema rotto fa fallire il test. Nel backend la configurazione di test aggiunge una cartella di migration usata solo nei test, per inserire un utente prima di una migration che lo richiede.
Che cosa sai fare adesso
Sai scrivere un test d'integrazione che usa il database vero senza porte scritte a mano, e sai riconoscere i tre errori che lo rendono muto o lento: il container non avviato, quello riavviato per ogni metodo, l'URL fisso. Sai quando @DataJpaTest ti inganna con H2 e come prevenirlo, perché le transazioni di test non coprono il server HTTP, e quando passare a un container condiviso dall'intera suite. Se vuoi il quadro d'insieme dei livelli di test, il passo successivo è Testing completo in Clean Architecture; per il modello di persistenza che stai provando, JPA e Flyway.
Fonti
- Testcontainers - JUnit 5 integration - container statici condivisi, di istanza riavviati per ogni metodo; consultata il 2026-11-12
- Testcontainers - Manual container lifecycle control - singleton container e ruolo di Ryuk; consultata il 2026-11-12
- Spring Boot Reference - Testcontainers -
@ServiceConnectione@DynamicPropertySource; consultata il 2026-11-12 - Spring Boot Reference - Testing Spring Boot Applications -
@DataJpaTest,Replace.NONE, transazioni conRANDOM_PORT; consultata il 2026-11-12 - Spring Framework - @DynamicPropertySource - il metodo deve essere
static; consultata il 2026-11-12 - Spring Boot 3.4 Release Notes - deprecazione di
@MockBean; consultata il 2026-11-12 - Sorgenti del backend:
IntegrationTestBase.java,ArticleIntegrationTest.java,application-integration.ymlsottobackend/src/test,pom.xml- letti il 2026-11-12

Commenti
Caricamento commenti...
Accedi per commentare