Coding

Da “Funziona sul mio computer” a una vera fiducia nei test con Testcontainers

Abbiamo usato Testcontainers con soddisfazione in diversi progetti, semplificando i test di integrazione grazie a servizi reali basati su Docker. Condividiamo la nostra esperienza e spieghiamo perché è diventata la nostra soluzione di riferimento e perché dovrebbe esserlo anche per te.

Alessio Pazzani Alessio Pazzani
6 giugno 2025 — 18 min

Come sviluppatori, project manager, CTO o professionisti IT, abbiamo tutti vissuto la frustrazione dei test. Scriviamo codice perfetto, creiamo suite di test complete e tutto funziona alla perfezione nel nostro ambiente locale. Poi facciamo il deploy in un ambiente diverso e improvvisamente:

“Ma sul mio computer funziona!”

Eppure, come per magia, il codice che girava perfettamente sul tuo laptop implode nel momento in cui finisce in un ambiente diverso. Le connessioni al database falliscono misteriosamente, i servizi di storage vanno in timeout in modo casuale, i message broker si rifiutano di collaborare… e la tua suite di test un tempo “perfetta” si trasforma in un incubo di debug.

Dopo una lunga e dolorosa esperienza possiamo confermare che i problemi di incoerenza tra ambienti sono spesso la causa di errori imprevisti durante i test.

La radice del problema: realtà dei test vs. ambienti di test

Anche se Docker ha rappresentato un passo avanti rivoluzionario grazie alla containerizzazione, molti team faticano ancora con i test perché:

  1. I mock e gli stub creano realtà false - semplicemente non si comportano come i sistemi reali
  2. I database in-memory non hanno molti dei vincoli e dei comportamenti del mondo reale
  3. Gli ambienti di test configurati manualmente derivano col tempo, creando incoerenza
  4. Le configurazioni specifiche per ambiente sono difficili da tracciare e riprodurre

Docker risolve già alcuni di questi problemi containerizzando le dipendenze e creando ambienti coerenti eseguibili ovunque. Ma Docker da solo introduce una nuova serie di sfide:

  • Gli sviluppatori devono diventare esperti di Dockerfile
  • La gestione del ciclo di vita dei container diventa complessa
  • I dati di test persistono tra un’esecuzione e l’altra, con il rischio di inquinare lo stato
  • Configurare casi di test specializzati richiede configurazioni di container personalizzate

È qui che entra in gioco Testcontainers: non per sostituire Docker, ma per sfruttarne la potenza affrontando al tempo stesso queste sfide aggiuntive.

Testcontainers: il compagno perfetto di Docker per il testing

Secondo il sito ufficiale, “Testcontainers è una libreria open source che fornisce istanze temporanee e leggere di database, message broker, browser web o praticamente qualsiasi cosa possa essere eseguita in un container Docker.”

Ma questa definizione sminuisce ciò che rende Testcontainers davvero speciale. Mentre Docker fornisce le fondamenta della containerizzazione, Testcontainers aggiunge lo strato di orchestrazione specifico per i test che trasforma il nostro approccio al testing di integrazione.

Come Testcontainers potenzia Docker per il testing

Testcontainers non si limita a usare i container Docker: costruisce attorno a essi un’astrazione specifica per il testing che risolve alcune sfide chiave:

  1. Configurazione guidata dal codice : invece di gestire Dockerfile e parametri da riga di comando, tutto è configurato direttamente nel codice del test
  2. Ambienti di test effimeri : i container vengono creati e distrutti per ogni test o suite di test, garantendo uno stato pulito
  3. Inizializzazione dei dati specifica per il test : carica esattamente i dati necessari al caso di test tramite script, migrazioni o dati di seed
  4. Gestione automatica del ciclo di vita : non serve avviare o fermare manualmente i container, né gestire la pulizia
  5. Interfaccia orientata ai test : progettata specificamente per gli scenari di test, non per la containerizzazione generica

Vediamo come funziona in pratica con un esempio su PostgreSQL:

import { PostgreSQLContainer } from "testcontainers";

const postgresContainer = new PostgreSQLContainer()
  .withUsername("user")
  .withPassword("password")
  .withInitScript("src/test/resources/init-test-db.sql");

beforeAll(async () => {
  await postgresContainer.start();
});

afterAll(async () => {
  await postgresContainer.stop();
});

Vedi la differenza? Con poche righe di codice abbiamo creato un’istanza PostgreSQL perfettamente funzionante, pre-caricata esattamente con lo schema e i dati necessari ai nostri test, senza dover gestire Docker direttamente.

Il ciclo di vita di Testcontainers: ambienti di test perfetti su richiesta

__wf_reserved_inherit

Per capire perché Testcontainers è uno strumento di testing così potente, ripercorriamo il suo ciclo di vita:

1. Inizializzazione del test

Quando il test inizia, Testcontainers:

  • Identifica le dipendenze necessarie
  • Scarica le immagini Docker appropriate se non sono disponibili in locale
  • Crea un container dedicato solo al tuo test

2. Configurazione del container

A differenza della gestione manuale dei container Docker, Testcontainers ti permette di:

  • Definire variabili d’ambiente, porte, volumi e reti in modo programmatico
  • Caricare dati di inizializzazione specifici per il test (script SQL, dati di seed, ecc.)
  • Impostare i parametri del container tramite un’API pulita e fluida

3. Avvio del container e health check

Prima dell’esecuzione del test, Testcontainers:

  • Avvia il container con la tua configurazione
  • Esegue gli health check per assicurarsi che i servizi siano pronti
  • Fornisce al tuo codice i dettagli di connessione

4. Esecuzione dei test contro servizi reali

Durante l’esecuzione del test:

  • Il tuo codice interagisce con implementazioni reali, non con mock
  • Le asserzioni vengono eseguite contro il comportamento reale dei servizi
  • Le dipendenze si comportano esattamente come in produzione

5. Pulizia completa

Dopo il completamento dei test:

  • I container vengono fermati e rimossi automaticamente
  • Tutti i dati di test vengono eliminati
  • Le risorse vengono liberate, senza lasciare tracce né inquinamento dello stato

Il nostro percorso con Testcontainers ci ha rivelato strategie più sfumate per un testing più intelligente. Non abbiamo semplicemente adottato un nuovo strumento: abbiamo scoperto un modo più preciso di pensare alle infrastrutture per il testing di integrazione.

La vera svolta è arrivata quando abbiamo iniziato a trattare i nostri ambienti di test non come simulacri della produzione, ma come repliche quasi perfette. Testcontainers ci ha permesso di incorporare l’infrastruttura di test direttamente nel codice dei test, con un cambiamento radicale nel modo in cui affrontiamo l’affidabilità dei test.

L’uso efficace di Testcontainers non riguarda lo strumento in sé, ma l’adozione di un insieme di principi architetturali che vanno oltre la containerizzazione tradizionale:

  • Inizializzazione dinamica dei servizi: la possibilità di caricare schemi specifici, dati di seed o script di configurazione direttamente all’avvio del container
  • Configurazione granulare dell’ambiente di test: definire in modo programmatico le condizioni di runtime esatte per ogni scenario di test
  • Meno boilerplate infrastrutturale: trasformare logiche di setup complesse in codice di test dichiarativo e leggibile
  • Dipendenze di test effimere e consapevoli del contesto: creare servizi non solo isolati, ma configurati in modo intelligente per i requisiti specifici di ogni test

Questi principi hanno trasformato il nostro testing da processo meccanico a un approccio flessibile e contestuale, che dà una fiducia reale nel comportamento del nostro software.

Per mettere in pratica questi principi architetturali è fondamentale comprendere i diversi livelli di astrazione offerti da Testcontainers. Questo permette di scegliere l’approccio più adatto a ogni scenario di test, trovando un equilibrio tra flessibilità e facilità d’uso.

Moduli generici e moduli specifici per tecnologia

Testcontainers offre due modi principali per lavorare con i container Docker:

  1. GenericContainer : la classe di base, capace di eseguire qualsiasi immagine Docker
  2. Moduli specifici per tecnologia : implementazioni preconfigurate per i servizi più comuni (PostgreSQL, MySQL, Redis, ecc.)

Vediamo come funziona in pratica prendendo PostgreSQL come esempio:

const postgresContainer = new GenericContainer("postgres:14")
  .withEnvironment({
    "POSTGRES_USER": "user",
    "POSTGRES_PASSWORD": "password",
    "POSTGRES_DB": "testdb"
  })
  .withExposedPorts(5432)
  .withCopyFileToContainer(
    { hostPath: "src/test/resources/init-test-db.sql", containerPath: "/docker-entrypoint-initdb.d/init.sql" }
  );

Questo approccio generico offre il controllo completo, ma richiede di conoscere dettagli specifici di Docker, come le variabili d’ambiente e i percorsi di inizializzazione.

Per semplificare il processo, Testcontainers fornisce moduli specifici per tecnologia come PostgreSQLContainer, che gestiscono questi dettagli al posto tuo:

const postgresContainer = new PostgreSQLContainer("postgres:14")
  .withUsername("user")
  .withPassword("password")
  .withDatabaseName("testdb")
  .withInitScript("src/test/resources/init-test-db.sql");

Entrambi gli approcci producono risultati equivalenti, ma il container specializzato riduce drasticamente il codice boilerplate, applicando automaticamente le best practice.

Strategie pratiche con Testcontainers che vale la pena adottare

La nostra esperienza in Buildo con Testcontainers ci ha portato a sviluppare e perfezionare diverse strategie efficaci per il testing di integrazione. Attraverso iterazioni continue e applicazioni sul campo abbiamo individuato approcci che producono costantemente test robusti e manutenibili. Qui sotto condividiamo queste pratiche collaudate, che si sono rivelate particolarmente preziose nel nostro flusso di lavoro.

Inizializzazione specifica per il test

Una delle funzionalità più potenti di Testcontainers è la possibilità di inizializzare i servizi con dati specifici per il test:

const dbContainer = new PostgreSQLContainer()
  .withInitScript("src/test/resources/init-test-db.sql");

const minioContainer = new GenericContainer("minio/minio:latest")
  .withExposedPorts(9000)
  .withCopyToContainer([
    { source: "src/test/resources/test-files", target: "/data" }
  ]);

Questa best practice garantisce che i test interagiscano con servizi configurati esattamente per lo scenario di test in questione: qualcosa di molto più difficile da ottenere con il solo Docker.

Configurazioni di container riutilizzabili

Invece di ripetere la configurazione dei container in ogni file di test, crea configurazioni riutilizzabili:

export function createTestDatabase(schema = "default-schema.sql") {
  return new PostgreSQLContainer()
    .withDatabase("testdb")
    .withUsername("testuser")
    .withPassword("testpass")
    .withInitScript(`src/test/resources/schemas/${schema}`);
}

const dbContainer = createTestDatabase("user-service-schema.sql");

Questo approccio garantisce una configurazione coerente, permettendo al tempo stesso personalizzazioni specifiche per il singolo test.

Reti di container per testare i microservizi

Testare i microservizi richiede spesso più container interdipendenti. Testcontainers rende la cosa gestibile:

const network = await new Network().start();

const dbContainer = await new PostgreSQLContainer()
  .withNetwork(network)
  .withNetworkAliases("database")
  .start();

const redisContainer = await new GenericContainer("redis:6")
  .withNetwork(network)
  .withNetworkAliases("cache")
  .start();

const appContainer = await new GenericContainer("my-service:latest")
  .withNetwork(network)
  .withEnvironment({
    "DB_HOST": "database",
    "REDIS_HOST": "cache"
  })
  .withExposedPorts(8080)
  .start();

Questa pratica diffusa crea un ambiente multiservizio realistico, che riproduce la topologia di produzione, il tutto gestito in modo programmatico dal codice del test.

Esempi reali, benefici reali

Per capire come Testcontainers trasformi il testing di integrazione nella pratica, seguiamo un esempio completo. Testeremo un servizio utenti che gestisce operazioni sul database: esattamente il tipo di scenario di integrazione in cui le incoerenze tra ambienti causano tipicamente problemi.

Passo 1: preparare le dipendenze di test

Per prima cosa definiamo le dipendenze di test e dichiariamo il nostro container:

import { Client } from "pg";
import { PostgreSQLContainer } from "testcontainers";
import { UserService } from "../src/services/user-service";

let postgresContainer: PostgreSQLContainer;
let pgDbClient: Client;
let userService: UserService;

Il setup è lineare: importiamo il client del database, il container PostgreSQL di Testcontainers e il servizio che vogliamo testare.

Passo 2: creare e configurare l’ambiente di test

La magia avviene nel setup del test, dove creiamo una vera istanza di PostgreSQL:

beforeAll(async () => {
  postgresContainer = new PostgreSQLContainer()
    .withInitScript("src/test/resources/user-service-schema.sql");

  await postgresContainer.start();

Ecco cosa lo rende potente: il metodo .withInitScript() carica automaticamente il nostro schema di test all’avvio del container. Nessuna configurazione manuale del database, nessun database di test condiviso con dati obsoleti da esecuzioni precedenti.

Passo 3: collegare il servizio a un’infrastruttura reale

Una volta avviato il container, colleghiamo il nostro servizio alla vera istanza PostgreSQL:

  pgDbClient = new Client({
    host: postgresContainer.getHost(),
    port: postgresContainer.getMappedPort(5432),
    user: postgresContainer.getUsername(),
    password: postgresContainer.getPassword(),
    database: postgresContainer.getDatabase(),
  });
  await pgDbClient.connect();

  userService = new UserService(pgDbClient);
});

Nota come Testcontainers fornisca dinamicamente tutti i dettagli di connessione. Il container potrebbe girare sulla porta 32768 o 45231: non hai bisogno di saperlo né di preoccupartene. Il servizio si collega alla porta assegnata da Docker, esattamente come farebbe in produzione.

Passo 4: testare contro un comportamento reale

Arriviamo al test vero e proprio, che viene eseguito contro il comportamento autentico di PostgreSQL:

test("should create and retrieve a user", async () => {
  const userId = await userService.createUser({
    name: "Nicolas Flamel",
    email: "nicolas@flamel.com"
  });

  const user = await userService.getUserById(userId);

  expect(user.name).toBe("Nicolas Flamel");
  expect(user.email).toBe("nicolas@flamel.com");
});

Questo test valida transazioni reali del database, l’applicazione dei vincoli, la gestione dei tipi di dato e tutte le sfumature che un database in-memory o un mock potrebbero non cogliere.

Passo 5: pulizia automatica

Infine, tutto viene ripulito automaticamente:

afterAll(async () => {
  await pgDbClient.end();
  await postgresContainer.stop();
});

Il container e tutti i suoi dati scompaiono completamente, senza lasciare tracce per le esecuzioni successive.

Oltre i database: testare sistemi completi

Testcontainers non si limita ai database. Puoi testare qualsiasi servizio containerizzato, abilitando un testing di integrazione completo su tutto il tuo stack tecnologico.

Message broker: testare architetture event-driven

Testare contro message broker reali come Kafka aiuta a validare serializzazione e deserializzazione degli eventi, ordinamento dei messaggi e scenari di gestione degli errori che i mock semplicemente non riescono a riprodurre:

Motori di ricerca: testare prestazioni delle query e indicizzazione

Motori di ricerca reali come Elasticsearch permettono di testare comportamenti complessi delle query, strategie di indicizzazione e caratteristiche prestazionali con carichi di dati diversi:

Sistemi di caching: testare rate limit e casi limite

Testare contro sistemi di caching reali come Redis aiuta a validare strategie di invalidazione della cache, politiche di scadenza, comportamenti di rate limiting e caratteristiche prestazionali difficili da simulare:

Container personalizzati: creare il proprio container

Quando le immagini standard non bastano, puoi creare container personalizzati estendendo GenericContainer. Questo ti permette di testare i tuoi servizi proprietari o configurazioni specifiche, mantenendo lo stesso approccio autentico al testing:

class MyCustomServiceContainer extends GenericContainer {
  constructor() {
    super(IMAGE);
  }

  public withCustomMethod(): this {
    return this;
  }

  public override async start(): Promise<StartedCustomContainer> {
    return new StartedCustomContainer(await super.start());
  }
}

Combinare servizi per il testing a livello di sistema

La vera potenza emerge combinando questi container per testare il comportamento completo del sistema. Invece di simulare le interazioni tra applicazione, database, cache e message broker, puoi testare l’intero flusso di dati:

await Promise.all([
  kafkaContainer.start(),
  elasticsearchContainer.start(),
  redisContainer.start(),
  postgresContainer.start(),
  myCustomServiceContainer.start()
]);

Questo approccio elimina il divario tra testing e realtà, garantendo che i test di integrazione riflettano fedelmente il comportamento del sistema quando tutti i componenti lavorano insieme. Non stai più testando ipotesi su come si comportano i servizi esterni: stai testando contro le loro implementazioni reali.

Da “Funziona sul mio computer” a “Funziona ovunque\”

Passare da “Funziona sul mio computer” a “Funziona ovunque” non è solo un sogno per chi sviluppa software: è un passo essenziale verso la costruzione di prodotti affidabili e scalabili.

Testcontainers è un cambio di paradigma: ci spinge a trattare i nostri test come ambienti reali, dinamici e isolati, calibrati sulle esigenze specifiche di ogni scenario.

Abbracciando questa filosofia trasformiamo la complessità infrastrutturale in codice leggibile e manutenibile, eliminando errori nascosti e instabilità legate all’ambiente.

In un mondo software sempre più distribuito e complesso, Testcontainers è la chiave per restituire fiducia e velocità al processo di sviluppo, trasformando i nostri test in una vera base per il successo del prodotto.

‍

Alessio Pazzani
Alessio Pazzani Software Engineer

Alessio is a full-stack Software Engineer at Buildo. He's interested in efficient development practices, continuously exploring new tools and methodologies to enhance software development. His primary focus lies in frontend development, crafting visually refined user interfaces.

NEWSLETTER

Ehi, c'è anche una Newslettero!
Sì, proprio con la o.

Ogni mese scegliamo articoli utili ma anche idee fresche e stimolanti. Proprio quello che vorremmo trovare noi in una newsletter!