Testcontainers: come utilizzare Docker nei nostri test d'integrazione

Il nome Docker dovrebbe essere ormai familiare ai più. E non mi riferisco solamente a chi si occupa principalmente di operations.

In effetti, i container oltre a semplificare sensibilmente il deployment di applicazioni in ambienti più o meno cloud-oriented, rappresentano un formidabile strumento di supporto allo sviluppo software.

In questo post vediamo come Docker possa essere un valido alleato anche nella fase di test delle nostre applicazioni. In particolare daremo un’occhiata al progetto Testcontainers, che, come il nome fa intuire, permette di sfruttare la potenza e la flessibilità dei container nei nostri test d’integrazione.

La libreria Testcontainers

Brevemente, Testcontainers consiste in una libreria Java che, integrandosi con JUnit, permette l’utilizzo di container (Docker) “usa e getta” nei nostri test case. La libreria permette infatti di automatizzare totalmente la creazione e il teardown dei container necessari ad eseguire i nostri test.

L’utilizzo di Testcontainers porta con sé alcuni vantaggi molto interessanti. Innanzitutto riusciamo ad ottenere test d’integrazione self-contained che, di fatto, non richiederanno la connessione a sistemi esterni, quali DBMS, message broker o web service, per citare alcuni esempi.

Inoltre, l’impiego di container che replichino i sistemi presenti in ambiente di produzione permetterà di ottenere test sicuramente più attendibili rispetto a quelli eseguiti utilizzando sistemi fake. Un esempio classico di questo approccio, spesso fallace, è l’utilizzo di database in memoria (vedi H2, HSQLDB o Fongo) per i test d’integrazione.

Prerequisiti

L’unico prerequisito per poter utilizzare la libreria Testcontainers è l’installazione di Docker sulla nostra macchina di sviluppo e, se stiamo usando una pipeline di CI, sulle macchine che eseguiranno la build della nostra applicazione.

Testcontainers nasce per essere utilizzato in accoppiata con JUnit 4, ma ad oggi può integrarsi anche con la versione 5 del framework (Jupiter). Inoltre per chi volesse utilizzare il linguaggio Groovy per implementare i propri test, è supportato anche il framework Spock.

L’integrazione con il framework di test fa sì che l’intero ciclo di vita dei container sia gestito dalla libreria durante l’esecuzione dei casi di test. Ciò non impedisce comunque di utilizzare Testcontainers con framework diversi da quelli citati; in questo caso però dovremo gestire noi stessi (tramite codice) il lifecycle dei container utilizzati.

Un caso d’uso

Per vedere in azione la libreria Testcontainers, la utilizzeremo in un progetto Kotlin in cui sia necessario testare un’ipotetica classe DAO; in questo esempio utilizzeremo JUnit Jupiter.

Supponendo che il nostro DBMS di produzione sia PostgreSQL, ci piacerebbe poter eseguire dei test d’integrazione su un’istanza del database che replichi quanto più possibile quella di produzione.

Cominciamo con l’aggiungere queste tre dipendenze al nostro progetto:

<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>testcontainers</artifactId>
  <version>1.15.1</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>junit-jupiter</artifactId>
  <version>1.15.1</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>postgresql</artifactId>
  <version>1.15.1</version>
  <scope>test</scope>
</dependency>

Nell’ordine:

  • testcontainers costituisce il core della libreria;
  • junit-jupiter è il modulo d’integrazione con la versione 5 (Jupiter) di JUnit;
  • postgresql è un modulo d’utilità, che fornisce strumenti specifici per l’interazione con database PostgreSQL.

La nostra classe DAO implementerà una classica logica CRUD, sulla base dei metodi definiti nell’interfaccia Dao:

interface Dao<in I, out O, ID> {
  fun findById(id: ID): O?
  fun findAll(): List<O>
  fun insert(input: I): ID
  fun update(id: ID, input: I)
  fun deleteById(id: ID)
}

Per brevità non riporto il codice della classe Trolls (il nostro DAO…); se siete curiosi, lo trovate qui.

Concentriamoci invece su come configurare il database (containerizzato) da lanciare nei nostri test.

La realizzazione di questo post non sarebbe stata possibile senza le preziose informazioni sui Troll messe a disposizione da Fandom. 😁 Grazie Fandom!

Setup dei test

Quello che segue è un corposo estratto della classe TrollsTest:

@Testcontainers
internal class TrollsTest {

  @Container
  private val postgreSQLContainer =
      PostgreSQLContainer<Nothing>("postgres:11.7")
          .apply {
              withDatabaseName(DB_NAME)
              withUsername(DB_USER)
              withPassword(DB_PASSWORD)
          }

  private lateinit var jdbi: Jdbi
  private lateinit var dao: Trolls

  @BeforeEach
  fun setup() {
      jdbi = buildJdbiFromTestContainer(postgreSQLContainer)
      dao = Trolls(jdbi)
      jdbi.useHandleUnchecked { 
        it.execute(ResourceFetcher
          .readResource("${TEST_TABLE_NAME}_test.sql")) 
      }
  }

  ...

  companion object {
    const val DB_NAME = "test_db"
    const val DB_USER = "test"
    const val DB_PASSWORD = "test"
    const val TEST_TABLE_NAME = "trolls"

    fun buildJdbiFromTestContainer(postgresContainer: PostgreSQLContainer<Nothing>): Jdbi =
          postgresContainer.run {
              Jdbi.create("jdbc:postgresql://$host:$firstMappedPort/$DB_NAME", username, password)
          }
  }
}

Come vedete, ho riportato solamente il codice necessario al setup dei test, che in questo momento è la parte che c’interessa maggiormente.

La prima cosa da notare è l’annotazione @Testcontainers, dichiarata a livello di classe. Utilizzando quest’annotazione, andiamo a registrare un’estensione di JUnit Jupiter che gestirà automaticamente il ciclo di vita dei container dichiarati nella classe di test.

L’annotazione @Testcontainers lavora in accoppiata con un’altra annotazione: @Container. Quest’ultima funge da marker, identificando i container che saranno gestiti dall’estenzione precedentemente citata.

Nel nostro caso, ho annotato il campo postgreSQLContainer, che rappresenta proprio l’istanza di PostgreSQL (versione 11.7) che andremo ad utilizzare per i test d’integrazione della nostra classe DAO.

Ciclo di vita dei container

Testcontainers permette di gestire in modo molto flessibile la creazione e la “distruzione” dei container di test in base alle esigenze specifiche del nostro progetto.

Di seguito riporto alcuni esempi che spiegano come configurare le tipologie di lifecycle previste dalla libreria.

Restarted container

Il ciclo di vita che assicura il massimo grado di indipendenza tra i vari casi di test è quello che prevede la creazione di un nuovo container all’inizio di ogni test case e la sua distruzione al termine dello stesso.

Possiamo ottenere facilmente un restarted container, come nel nostro progetto d’esempio, utilizzando l’integrazione tra Testcontainers e JUnit 5. In questo caso infatti sarà sufficiente che il campo annotato come @Container sia una variabile membro della classe di test:

@Testcontainers
internal class TestClass {

  @Container
  private val aTestContainer = ...

}

Chiaramente, questo tipo di approccio potrebbe richiedere l’utilizzo di un metodo @BeforeEach per inizializzare quanto necessario alla corretta esecuzione di ciascun caso di test (ad esempio, la connessione al database).

Shared container

Talvolta, la creazione di un container dedicato per ciascun caso di test potrebbe essere troppo onerosa in termini di risorse richieste, causando nel contempo tempi di esecuzione della test suite non accettabili.

In questo caso è possibile configurare Testcontainers in modo tale che i test case definiti in una classe di test condividano lo stesso container il quale, pertanto, sarà inizializzato solo una volta, nel momento in cui la classe viene istanziata.

Anche questa tipologia di lifecycle può essere gestita tramite l’integrazione con JUnit 5 e richiede l’utilizzo di una variabile statica, come al solito, annotata come @Container.

In Kotlin, la cosa si ottiene in modo simile:

@Testcontainers
internal class TestClass {

  companion object {
    @Container
    private val aTestContainer = ...
  }

}

In questo caso, contrariamente a quanto visto per i restarted container dovremo fare attenzione a ripristinare lo stato iniziale del container prima di ogni caso di test, così da non ottenere risultati fuorvianti.

Eventuali inizializzazioni globali e connessioni a database potranno invece essere dichiarate in un metodo @BeforeAll di JUnit.

Singleton container

L’ultima opzione che andiamo ad esaminare è applicabile nel caso in cui sia necessario condividere uno stesso container tra differenti classi di test.

Questo caso richiede una gestione manuale del ciclo di vita del container di test, che nella fattispecie andrà utilizzato come singleton.

Il “trucco” sta dichiarare una classe astratta simile a questa:

abstract class TestcontainersBaseTest {

  companion object {
    val aTestContainer = ...

    init {
      aTestContainer.start()
    }
  }

}

Applicando questo pattern, tutte le classi che estenderanno TestcontainersBaseTest potranno avere accesso allo stesso container di test.

Il container in questione verrà creato ed avviato una sola volta, nel momento in cui TestcontainersBaseTest verrà caricata dal class loader. Il teardown del container verrà invece gestito automaticamente da Testcontainers una volta che tutti i test previsti dalla test suite saranno stati eseguiti.

Anche in questo caso, come per gli shared container, sarà necessario predisporre dei metodi @BeforeEach e/o @AfterEach, così da inizializzare e “ripulire” correttamente il container di test prima di (o dopo) ogni nuovo test case.

In conclusione

L’utilizzo dei container Docker nei test d’integrazione può semplificarci notevolmente la vita, soprattutto se stiamo sviluppando progetti che prevedono molte interazioni con sistemi esterni.

Testcontainers ci aiuta ulteriormente, automatizzando gran parte delle attività di setup, lasciandoci comunque grande flessibilità per quanto riguarda la configurazione dei container utilizzati.

Alla prossima,

David

(Photo by Noel Broda on Unsplash)