Conteneurs Docker pour les tests d’intégration

Pour les tests d’intégration, on a besoin d’accéder à des bases de données, brokers de messages,…​ Docker nous facilite la tâche si on l’intègre aux tests.

Pour la base de données, on a souvent utilisé des bases embarquées comme Derby ou H2. Avec Docker, on peut facilement démarrer une base de données PostgreSQL sur la machine de test. Docker nous permet aussi des démarrer d’autre types de serveurs : broker de messages, serveur d’e-mail,…​

Maven nous propose des solutions pour faire cette intégration au niveau du build. Ce qu’on recherche ici, c’est une intégration directe dans le test lui-même. C’est Testcontainers qui va nous permettre de faire ça.

Testcontainers

Testcontainers sert à démarrer et arrêter des conteneurs Docker depuis les tests d’intégration.

Il offre une API pour configurer le conteneur : exposition de ports, volumes,…​ Il permet aussi de récupérer les informations lorsque celle-ci sont générées automatiquement.

  GenericContainer<?> smtp = new GenericContainer<>("ghusta/fakesmtp")
                                    .withExposedPorts(25);
  smtp.start();

  EmailSender emailSender = new EmailSender("localhost", smtp.getMappedPort(25));
  // ...

  smtp.stop();
Testcontainers est un client Docker, il faut que le deamon Docker soit lancé sur la machine.

Utilisation avec JUnit

Grâce à son API, on peut utiliser Testcontainers avec n’importe quel outil de tests. Par exemple, avec JUnit 5, on peut démarrer et arrêter un conteneur dans des méthodes annotées @BeforeAll et @AfterAll pour un conteneur commun à toutes les méthodes de test de la classe. On peut démarrer et arrêter le conteneur dans des méthodes annotées @BeforeEach et @AfterEach pour une instance propre à chaque méthode de test de la classe.

class ContainerTest {

  GenericContainer<?> smtp
             = new GenericContainer<>("ghusta/fakesmtp")
                    .withExposedPorts(25);

  @BeforeEach
  void initClass() {
    smtp.start();
  }
  @AfterEach
  void cleanClass() {
    smtp.stop();
  }

  @Test
  void should_be_able_to_send_mail() throws MessagingException {
    Email email = Email.builder()
        .from("author@jtips.info")
        .to("reader@jtips.info")
        .subject("Should work")
        .content("This email should be sent.")
        .build();

    EmailSender emailSender
        = new EmailSender("localhost", smtp.getMappedPort(25));
    emailSender.send(email);
  }

}

Conteneurs typés

La classe GenericContainer permet de démarrer n’importe quel conteneur. Testcontainers propose des modules avec des conteneurs plus typés, et une API plus pratique.

  • Bases de données SQL : PostgreSQLContainer, MySQLContainer, MariaDBContainer, MSSQLServerContainer, OracleContainer,…​

  • Bases de données noSQL : CassandraContainer, MongoDBContainer, CouchbaseContainer,…​

  • Messages : RabbitMQContainer, KafkaContainer, PulsarContainer

  • …​

Par exemple, pour l’image officielle postgres, il faut passer des variables d’environnement pour initialiser la base. La classe PostgreSQLContainer offre des méthodes pour renseigner tout ça.

  PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:14")
                                        .withDatabaseName("jtips")
                                        .withUsername("jtips")
                                        .withPassword("jtipspwd");

Et une fois le conteneur démarré, on peut utiliser des méthodes pour accéder aux métadonnées du conteneur. Par exemple, on a l’URL JDBC.

Connection connection = DriverManager.getConnection(
                                        pg.getJdbcUrl(),
                                        pg.getUsername(),
                                        pg.getPassword());

Annotations JUnit 5

@Testcontainers
class ContainerTest {

  @Container
  static GenericContainer<?> smtp = new GenericContainer<>("ghusta/fakesmtp")
      .withExposedPorts(25);

  @Container
  PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:14")
      .withDatabaseName("jtips")
      .withUsername("jtips")
      .withPassword("jtipspwd");

  @Test
  void test() {
    assertThat(smtp.isRunning()).isTrue();
    assertThat(pg.isRunning()).isTrue();
  }

}

Intégration avec JUnit 4

L’intégration avec JUnit 4 se fait via les annotations @Rule et @ClassRule.

public class ContainerTest {

  @ClassRule
  static GenericContainer<?> smtp = new GenericContainer<>("ghusta/fakesmtp")
      .withExposedPorts(25);

  @Rule
  PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:14")
      .withDatabaseName("jtips")
      .withUsername("jtips")
      .withPassword("jtipspwd");

  @Test
  void test() {
    assertThat(smtp.isRunning()).isTrue();
    assertThat(pg.isRunning()).isTrue();
  }

}

Fonctionnement interne

Comme c’est déjà écrit plus haut, Testcontainers est un client Docker. Il a donc besoin d’un démon pour fonctionner.

Par défaut, il fonctionne avec un démon local, mais peut aussi se connecter à distance en utilisant la variable d’environnement DOCKER_HOST. Il peut aussi s’interfacer avec Docker Machine.

Ce fonctionnement a aussi des impacts lorsqu’on veut l’utiliser en intégration continue, puisque les environnements de CI fonctionnent souvent en conteneur. Il faut donc passer par des techniques de Docker in Docker.

Lorsqu’on démarre n conteneurs, Testcontainers en démarre n+1. Il a toujours son conteneur interne, sur l’image testcontainers/ryuk, qui gère le ménage dans les conteneurs, réseaux, volumes et images.

IMAGE                       COMMAND                  STATUS    PORTS             NAMES
munkyboy/fakesmtp:latest    "/bin/sh -c 'java -j…"   Up 4 s.   49386->25/tcp     distracted_knuth
postgres:14                 "docker-entrypoint.s…"   Up 5 s.   49385->5432/tcp   sweet_cerf
testcontainers/ryuk:0.3.3   "/app"                   Up 6 s.   49384->8080/tcp   testcontainers-ryuk-f7d65182

Autres fonctionnalités

  • Docker composes

  • Images dynamiques

  • Lecture et redirection des logs du conteneur

  • …​

Références