Conteneurs Docker pour les tests avec Spring

Dans la page dédiée à l’utilisation des conteneurs Docker pour les tests d’intégration, on voit comment Testcontainer est utilisé dans des tests JUnit pour démarrer et arrêter des conteneurs. On y voit aussi comment récupérer les métadonnées des conteneurs, comme les ports exposés ou l’URL JDBC pour les bases de données.

Ici, nous allons voir comment ces métadonnées peuvent être prises en compte pour l’initialisation d’un contexte Spring, ou comment la configuration de Spring peut être utilisée pour la création des conteneurs.

ApplicationContextInitializer

On a déjà implémenté un ApplicationContextInitializer dans la page sur les profils Spring. Une telle classe permet de modifier des informations avant la finalisation du contexte Spring et l’instanciation des beans. Nous pouvons donc en créer une spécifiquement pour les tests, ou pour certains tests.

public class TestContainersInitializer
	implements ApplicationContextInitializer<ConfigurableApplicationContext> {

  @Override
  public void initialize(ConfigurableApplicationContext applicationContext) {
    TestPropertyValues
        .of(Map.of(
            "spring.datasource.url", pg.getJdbcUrl(),
            "spring.datasource.username", pg.getUsername(),
            "spring.datasource.password", pg.getPassword()))
        .applyTo(applicationContext);
  }

}

Avec ça, on démarre les conteneurs avant l’initialisation du contexte Spring et on récupère les valeurs qui nous intéressent. La classe utilitaire TestPropertyValues permet de remplacer certaines propriétés, avant l’instanciation des beans.

Dans le test

L'ApplicationContextInitializer peut être déclaré commune classe interne du test, capable de lire les données des conteneur.

@Testcontainers
@SpringBootTest
@ContextConfiguration(initializers = {ContainerTest.ContainerInitializer.class})
public class ContainerTest {

  @Container
  static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:14");

  @Test
  void should_be_able_to_connect_to_pg() {
    //...
  }

  static class ContainerInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
    @Override
    public void initialize(ConfigurableApplicationContext applicationContext) {
      TestPropertyValues
          .of(Map.of(
              "spring.datasource.url", pg.getJdbcUrl(),
              "spring.datasource.username", pg.getUsername(),
              "spring.datasource.password", pg.getPassword()))
          .applyTo(applicationContext);
    }
  }

}

Le conteneur peut être déclaré comme static pour avoir une instance partagée par toutes les méthodes du test. Dans ce cas, la classe ContainerInitializer doit aussi être static.

Le conteneur peut être déclaré comme champ d’instance pour avoir une instance dédiée à chaque méthode du test. Dans ce cas, la classe ContainerInitializer doit aussi être non static.

Singleton

En faisant une classe ContainerInitializer indépendante et en déclarant le conteneur dans un champ static la classe, celui-ci devient un singleton, partagé entre toutes les classes de test.

public class TestContainersInitializer
    implements ApplicationContextInitializer<ConfigurableApplicationContext> {

  static final PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:14")
      .withDatabaseName("viseo3")
      .withUsername("chambersign_admin")
      .withPassword("secret")
      .withInitScript("init-db.sql");
  static final GenericContainer<?> smtp = new GenericContainer<>("munkyboy/fakesmtp")
      .withExposedPorts(25);

  @Override
  public void initialize(ConfigurableApplicationContext context) {
    if (!pg.isRunning()) {
      pg.start();
    }
    if (!smtp.isRunning()) {
      smtp.start();
    }

    TestPropertyValues
        .of(Map.of(
            "spring.datasource.url", pg.getJdbcUrl(),
            "spring.datasource.username", pg.getUsername(),
            "spring.datasource.password", pg.getPassword(),
            "spring.mail.port", smtp.getMappedPort(25).toString()
        ))
        .applyTo(context);
  }

}

On peut faire autrement, mais c’est moins bien

Ce qu’on a vu jusqu’à maintenant, ce sont des bonnes façons de faire. Voyons maintenant une fausse bonne idée : utiliser la configuration des beans Spring pour initialiser le conteneur.

La première condition pour que ça fonctionne, c’est que les données nécessaires à la configuration du conteneur soient disponibles dans les beans. Le seconde condition, c’est que les beans n’aient pas besoin de se connecter au conteneur pendant l’initialisation du contexte, puisque le conteneur n’est pas encore démarré.

Conteneur SMTP

Tout d’abord, voyons d’abord un cas facile, avec un serveur SMTP utilisé via les propriétés suivantes :

application-test.properties
sewatech.smtp.host=localhost
sewatech.smtp.port=8025

Nous pouvons injecter ces propriétés dans le test pour initialiser le conteneur. Par ailleur, on injecte le JavaMailSender de Spring pour tester l’envoi du mail.

@SpringBootTest
public class SmtpSenderTest {

  GenericContainer<?> smtp;

  @Value("${spring.mail.port}")
  int smtpPort;

  @Autowired
  JavaMailSender emailSender;

  @BeforeEach
  public void init() {
    smtp = new GenericContainer<>("ghusta/fakesmtp")
        .withExposedPorts(25)
        .withCreateContainerCmdModifier(
            cmd -> cmd.getHostConfig()
                      .withPortBindings(
                          new PortBinding(
                              Ports.Binding.bindPort(smtpPort),
                              ExposedPort.tcp(25))
                      )
        );
    smtp.start();
  }
  @AfterEach
  public void clean() {
    smtp.stop();
  }

  @Test
  void should_be_able_to_send_mail() {
    SimpleMailMessage message = new SimpleMailMessage();
    message.setFrom("author@jtips.info");
    message.setTo("reader@jtips.info");
    message.setSubject("Should work");
    message.setText("This email should be sent.");

    emailSender.send(message);
  }

}

Vous constatez que l’API de Testcontainers n’est pas du tout pratique pour fixer le port du conteneur. C’est normal parce que c’est une mauvaise pratique.

Si vous êtes conscient des limites d’une telle démarche, alors l’exemple ci-dessus peut vous convenir : ça fonctionne.

Serveur de base de données

Pour augmenter la difficulté, voyons comment ça peut marcher avec une base de données utilisée via JPA et une DataSource.

A priori, la technique devrait être similaire. Mais on constate vite quelques difficultés supplémentaires.

Tout d’abord, pour le port, il n’y a pas de propriété spring.datasource.port. Le port de connexion à la base de données est compris dans l’URL JDBC spring.datasource.url. Le port peut donc être extrait, comme le nom de la base de données.

@SpringBootTest
@TestPropertySource("classpath:application-test.properties")
public class DatabaseTest {

  @Value("${spring.datasource.url}")
  String datasourceUrl;
  @Value("${spring.datasource.username}")
  String datasourceUsername;
  @Value("${spring.datasource.password}")
  String datasourcePassword;

  @BeforeEach
  public void init() {
    Pattern pattern = Pattern.compile(".://(.):(.)/([^?])?.*");
    Matcher urlMatcher = pattern.matcher(datasourceUrl);
    if (urlMatcher.matches()) {
	  String databaseHost = urlMatcher.group(1);
	  String databasePort = urlMatcher.group(2);
      String databaseName = urlMatcher.group(3);

      new PostgreSQLContainer<>("postgres:14")
          .withDatabaseName(databaseName)
          .withUsername(datasourceUsername)
          .withPassword(datasourcePassword)
          .withCreateContainerCmdModifier(
              e -> e.getHostConfig()
                  .withPortBindings(
                      new PortBinding(
                          Ports.Binding.parse(databasePort),
                          new ExposedPort(5432))
                  )
          )
		  .start();
    }
  }

}

Les vrais problèmes commencent maintenant. En effet, ceci ne fonctionne pas parce qu’on démarre la base de données après la création de la DataSource et après l’initialisation de l'EntityManagerFactory.

Le premier règlage consiste à désactiver l’initialisation du schéma de la base de données.

Le second règlage est la désactivation de la lecture des métadonnées. Cette lecture permet à Hibernate de récupérer certaines informations qui doivent être remplacée par un surplus de configuration. Pour notre test, il faudra surtout ajouter le dialecte.

application-test.properties
spring.jpa.hibernate.ddl-auto=none

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQL10Dialect
spring.jpa.properties.hibernate.temp.use_jdbc_metadata_defaults=false

Conclusion

Dans le test, ça permet surtout de choisir ses conteneurs. Dans une classe indépendante, ça évite de se répéter. A l’envers, ça ne sert à rien, il vaut mieux passer par un initialiseur.

Références

  • Code source des exemples