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
-
Code source des exemples, avec JUnit 5.8 et Testcontainers 1.16