Publié par et

Il y a 3 semaines -

Temps de lecture 10 minutes

Améliorez vos tests d’intégration grâce à Testcontainers

Introduction

On ne doute plus de l’importance des tests dans une application robuste. Citons tout d’abord les tests unitaires qui, comme leur nom l’indique, permettent de tester unitairement chaque brique de l’application (au niveau d’un service, d’une classe, d’une méthode…). Viennent ensuite les tests d’intégration permettant quant à eux de tester les liens entre les différentes briques de l’application, que ce soit des applications différentes, des services, des bases de données, des files de message… Ils sont indispensables pour vérifier le bon fonctionnement de bout en bout de l’application.

Nous pouvons prendre pour exemple une simple application Web exposant des APIs permettant la recherche de données dans une base SQL. Pour nos tests d’intégration, nous devons requêter cette base contenant des données viables. Il peut cependant être compliqué de tester le bon interfaçage avec des gros systèmes extérieurs tels que les systèmes d’échange de messages, AWS, etc. Il est rapidement coûteux de créer un environnement uniquement pour des tests automatiques. D’autre part, avoir un système centralisé sur lequel les tests se connectent empêche chaque test de facilement gérer ses propres données et de les rendre indépendants.

Testcontainers simplifie cela en nous offrant une bibliothèque Java permettant d’instancier dans des containers Docker de nombreux systèmes comme des bases de données, les services AWS, Redis, Elasticsearch…
Chaque test peut ainsi lancer son propre container Docker avec les systèmes dont il dépend et y insérer son propre jeu de données. Il devient alors très simple de tester les interactions entre notre système et celui lancé via Testcontainers.

Testcontainers est nativement compatible avec JUnit 4 et 5 ainsi que Spock.

Fonctionnalités

Dépendance

La librairie est séparée en plusieurs JARs :

  • un JAR « cœur » pour les fonctionnalités génériques et le support de docker-compose
  • plusieurs JARs pour les modules proposés par Testcontainers (nous détaillerons ces modules dans la suite de l’article)

Au moment de la rédaction, Testcontainers est disponible en version 1.11.1.

Pour ajouter la dépendance à votre projet :

Via Maven :

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <version>1.11.1</version>
    <scope>test</scope>
</dependency>

Via Gradle :

testCompile "org.testcontainers:testcontainers:1.11.1"

Cycle de vie

L’instanciation d’un container Docker avec Testcontainers se fait extrêmement simplement :

new GenericContainer("couchbase:6.0.1")

Il suffit ensuite d’appeler les méthodes start() ou stop() de ce GenericContainer pour démarrer ou arrêter un container de Couchbase en version 6.0.1. Testcontainers s’occupera bien évidemment de télécharger l’image si nécessaire et effectuera aussi le nettoyage des containers grâce à Ryuk à l’arrêt de la JVM.

Il est aussi possible de déclarer un container comme @Rule pour JUnit 4. Ainsi, le container va automatiquement démarrer au début du test, puis s’arrêter à la fin :

@Rule
public GenericContainer redis = new GenericContainer("redis:5.0.3").withExposedPorts(6379);

L’intégration dans JUnit 5 / Jupiter est fournie avec l’annotation @Testcontainers. Cette extension va ensuite chercher les champs annotés @Container pour savoir quel container démarrer :

@Testcontainers
class TestcontainersJUnit5 {

    @Container
    private static final GenericContainer REDIS = new GenericContainer("redis:5.0.3").withExposedPorts(6379);

	...
}

On remarque dans l’exemple précédent que Testcontainers permet d’exposer des ports sur le container.
D’autres actions sont également possibles :

  • exécuter des commandes via withCommand
  • faire le lien entre un fichier ou un dossier du Classpath et le container en utilisant withClasspathResourceMapping
  • définir des variables d’environnement avec withEnv

Une fois le container démarré, on peut récupérer l’adresse IP et les ports de celui-ci (pratique pour instancier un client tiers par exemple) :

String ipAddress = redis.getContainerIpAddress();
Integer port = redis.getMappedPort(6379);

L’un des gros avantages de Testcontainers est de pouvoir définir une stratégie d’attente afin de savoir si notre container est prêt.

Soit via HTTP :

try (var nginx = new GenericContainer("nginx:1.9.4")
        .withExposedPorts(80)
        .waitingFor(Wait.forHttp("/"))) {

    nginx.start();

    // Requête HTTP vers le container
}

Ou encore par log :

GenericContainer containerWithLogWait = new GenericContainer("redis:5.0.3")
    .withExposedPorts(6379)
    .waitingFor(
        Wait.forLogMessage(".*Ready to accept connections.*\\n", 1)
    );

Utilisation d’image à la volée

L’utilisation de Dockerfile, le fichier texte contenant les commandes pour assembler une image Docker, est également possible grâce à Testcontainers.

En utilisant un ImageFromDockerfile lors de la création de notre GenericContainer :

GenericContainer dslContainer = new GenericContainer(
    new ImageFromDockerfile()
            .withFileFromString("folder/someFile.txt", "hello")
            .withFileFromClasspath("test.txt", "mappable-resource/test-resource.txt")
            .withFileFromClasspath("Dockerfile", "mappable-dockerfile/Dockerfile"))

Ou alors avec le Dockerfile DSL proposé par Testcontainers :

new GenericContainer(
        new ImageFromDockerfile()
                .withDockerfileFromBuilder(builder ->
                        builder
                                .from("alpine:3.2")
                                .run("apk add --update nginx")
                                .cmd("nginx", "-g", "daemon off;")
                                .build()))
                .withExposedPorts(80);

Modules

En plus d’un GenericContainer, Testcontainers propose différents modules dans des dépendances séparées apportant des fonctionnalités supplémentaires.

Il existe des modules pour la plupart des systèmes de gestion de bases de données, Elasticsearch, Kafka, LocalStack (pour les services d’AWS), etc. Nous vous invitons à vous rendre sur la documentation de Testcontainers pour y découvrir la liste des modules qu’offre la librairie.

Nous allons vous présenter quelques-uns de ces modules.

Docker Compose

Compose est l’outil de Docker permettant de démarrer plusieurs containers liés les uns aux autres.

Pour utiliser un fichier docker-compose.yml avec Testcontainers, il faut instancier un DockerComposeContainer de cette manière :

DockerComposeContainer environment = new DockerComposeContainer(new File("src/test/resources/compose-test.yml"))

DockerComposeContainer expose les méthodes getServiceHost et getServicePort afin de récupérer l’adresse IP et le port d’un service défini dans le docker-compose :

String redisUrl = environment.getServiceHost("redis_1", REDIS_PORT) + ":" + environment.getServicePort("redis_1", REDIS_PORT);

Il est aussi possible de définir des stratégies d’attente différentes pour les différents services du docker-compose :

public static DockerComposeContainer environment =
    new DockerComposeContainer(new File("src/test/resources/compose-test.yml"))
            .withExposedService("redis_1", REDIS_PORT, Wait.forListeningPort())
            .withExposedService("elasticsearch_1", ELASTICSEARCH_PORT, 
                Wait.forHttp("/all")
                    .forStatusCode(200)
                    .forStatusCode(401)
                    .usingTls());

Bases de données

C’est probablement l’un des besoins les plus courants lorsqu’on fait des tests d’intégration : interagir avec une base de données. On a souvent recours à plusieurs solutions pour avoir une base de données utilisable pour nos tests :

  • déployée sur un serveur : cela peut rapidement coûter cher et rendre les tests fragiles car dépendants d’un autre serveur et du réseau
  • déployée en local ou dans une VM : il faut garantir la réinitialisation de la base entre chaque test
  • en mémoire comme H2 : ce type de base est rapide et efficace mais empêche l’utilisation de fonctionnalités spécifiques du SGBD de l’application

Testcontainers offre la possibilité d’instancier des containers pour différents systèmes de gestion de base de données, qu’ils soient relationnels (MySQL, Postgres, Oracle…) ou NoSQL (Cassandra ou Couchbase).

Ces containers spécifiques offrent de nouvelles méthodes liées au SGBD utilisé et est ainsi plus avantageux que l’utilisation d’un simple GenericContainer.

Par exemple, pour une base relationnelle comme Microsoft SQL Server :

    @Rule
    public MSSQLServerContainer mssqlserver = new MSSQLServerContainer();

    @Test
    public void someTestMethod() {
        String url = mssqlserver.getJdbcUrl();
	    ...
	}

LocalStack

Si vous utilisez AWS régulièrement, vous avez potentiellement rencontré des difficultés à écrire des tests d’intégration avec les différents services offerts par Amazon.

Une des solutions possibles est l’utilisation de LocalStack, bibliothèque permettant de simuler la plupart des services AWS et ainsi effectuer des tests d’intégration. Son installation n’est cependant pas forcément aisée et peut obliger chaque développeur, pour pouvoir l’utiliser, à configurer soit LocalStack directement, soit un container Docker.

Testcontainers offre directement un container Docker intégrant LocalStack, rendant très simple et rapide la rédaction des tests en lien avec AWS.

Prenons par exemple un service basique qui envoie des messages dans une file SQS :

public class SqsService {

    public static final String QUEUE_NAME = "myQueueUrl";

    private final AmazonSQS sqs;

    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    public SqsService(AmazonSQS sqs) {
        this.sqs = sqs;
    }

    public void sendMessage(CustomMessage customMessage) throws JsonProcessingException {
        sqs.sendMessage(getQueueUrl(), OBJECT_MAPPER.writeValueAsString(customMessage));
    }

    private String getQueueUrl(){
        return sqs.getQueueUrl(QUEUE_NAME).getQueueUrl();
    }
}

Après avoir importé la bonne dépendance, nous devons déclarer un container Docker dans notre test, initialiser un client amazonSQS et enfin créer notre file SQS :

public class SqsServiceTest {

    @Rule
    public LocalStackContainer localstack = new LocalStackContainer().withServices(SQS);
    
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    @Test
    public void should_send_message_in_sqs_queue() throws JsonProcessingException, org.testcontainers.shaded.com.fasterxml.jackson.core.JsonProcessingException {
        // Given
        AmazonSQS amazonSQS = AmazonSQSClient
                .builder()
                .withEndpointConfiguration(localstack.getEndpointConfiguration(SQS))
                .withCredentials(localstack.getDefaultCredentialsProvider()).build();
        String queueUrl = amazonSQS.createQueue(SqsService.QUEUE_NAME).getQueueUrl();
        SqsService sqsService = new SqsService(amazonSQS);
        CustomMessage customMessage = new CustomMessage(1, "firstContent", "secondContent");

        // When
        sqsService.sendMessage(customMessage);

        // Then
        List<Message> messagesInSqsQueue =  amazonSQS.receiveMessage(queueUrl).getMessages();
        assertThat(messagesInSqsQueue).hasSize(1);
        assertThat(messagesInSqsQueue.get(0).getBody())
                .isEqualTo(OBJECT_MAPPER.writeValueAsString(customMessage));
    }
}

Notre client amazonSQS, paramétré grâce à LocalStack, se comporte exactement de la même manière que le client classique. Nous devons donc utiliser les méthodes du SDK Amazon dans notre test afin de vérifier que nos services fonctionnent. Dans notre cas, nous avons lu les messages dans la queue pour vérifier que sqsService y avait bien envoyé un message.

Il faut donc être prudent quant à notre utilisation du client afin de ne pas se retrouver avec des tests en échec, non pas à cause d’une erreur dans le service testé, mais à cause d’une mauvaise utilisation d’AWS directement dans nos tests.

Conclusion

Testcontainers nous offre une bibliothèque à la fois efficace et très simple d’utilisation. Les nombreux modules proposés nous permettent de l’utiliser avec de nombreuses technologies, dont plusieurs habituellement complexes à intégrer dans des tests.

Il faut toutefois noter que Testcontainers n’offre qu’un moyen d’interagir avec des systèmes complexes, mais laisse au développeur la gestion des jeux de données, leur automatisation et leur centralisation. Vous devrez vous-même écrire le code permettant de remplir un index Elasticsearch ou d’envoyer des messages dans une file SQS directement dans les tests.

L’utilisation de Testcontainers pourrait s’avérer moins efficace que d’autres solutions, par exemple des bases de données embarquées. Cependant, la solution proposée ici correspond en tout point à la réalité et augmente ainsi l’utilité et l’efficacité des tests, offrant même la possibilité d’effectuer des tests de résilience.

Quant à l’impact sur la durée d’exécution de vos tests, celui-ci sera légèrement rallongé, d’autant plus à la première exécution (le temps de télécharger les images Docker). Tout dépendra bien évidemment de votre utilisation de Testcontainers. Préférez par exemple un container singleton que vous démarrerez au début de l’exécution de vos tests, mais veillez cependant à bien garantir l’idempotence de vos tests.

Ainsi, Testcontainers est un outil très pratique que nous vous conseillons pour vos tests d’intégration avec des systèmes complexes.

Publié par et

Commentaire

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Xebian, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.