Tests et qualité logicielle
Tests d'intégration avec de vraies dépendances : Testcontainers, WireMock et contrats
Intermédiaire10 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Un test d'intégration justifie son coût en exerçant ce qu'un test unitaire ne peut pas atteindre : un vrai dialecte SQL, un vrai sérialiseur, un vrai broker. Ce guide, indépendant de tout framework, explique comment choisir la frontière testée, lancer les dépendances avec Testcontainers, garder un état propre d'un test à l'autre, simuler les appels HTTP sortants avec WireMock et verrouiller les API grâce aux contrats pilotés par le consommateur.
Ce que vous allez apprendre
- Identifier précisément la frontière qu'exerce un test et ce que signifierait son échec
- Lancer depuis un test de vraies bases de données, de vrais brokers et d'autres dépendances avec Testcontainers
- Choisir une stratégie de remise à zéro de l'état qui isole les tests sans les ralentir
- Simuler les appels HTTP sortants avec WireMock, y compris les scénarios de panne et de latence
- Vérifier la compatibilité des API par des tests de contrat pilotés par le consommateur plutôt que par des suites de bout en bout partagées
Un test unitaire prouve que votre logique est cohérente avec elle-même. Il ne peut pas vous dire que le SQL est valide pour le moteur sur lequel vous déployez, que le JSON envoyé par votre client correspond à ce qu'attend le fournisseur, que la migration passe, que le message que vous publiez peut être désérialisé par le consommateur, ni que la frontière de transaction que vous avez tracée commite réellement. Ce sont des questions d'intégration, et chacune d'elles a provoqué un incident de production quelque part cette semaine.
Les tests d'intégration sont aussi la couche où les suites déraillent de la façon la plus coûteuse : lentes, dépendantes de l'ordre d'exécution, gourmandes en environnement et flaky. Ce qui distingue une suite qui se rentabilise d'une suite supprimée au bout d'un an tient à un petit nombre de décisions : quelle frontière couvre chaque test, d'où viennent ses dépendances et comment l'état est remis à zéro. Ce guide est indépendant de tout framework ; pour la mécanique propre à Spring, voyez le guide des tests avec Spring Boot et le guide des tests avec Spring Framework.
Nommer d'abord la frontière
« Test d'intégration » recouvre des tests dont la portée va de deux classes à un déploiement complet. Avant d'en écrire un, répondez à une seule question : si ce test passe au rouge, qu'est-ce que j'apprends ? Un test dont l'échec peut signifier onze choses différentes est un détecteur de fumée sans adresse.
Les frontières qui méritent d'être testées séparément sont généralement celles-ci :
- La persistance. Votre code de repository face au vrai moteur de base de données, avec le vrai schéma produit par les vraies migrations. Détecte le SQL invalide, les erreurs de mapping, les index manquants sur une contrainte d'unicité et les migrations qui échouent sur une table non vide.
- La sérialisation. Un graphe d'objets qui passe par le vrai sérialiseur, aller et retour. Détecte les formats de date, la gestion des null, le comportement face aux propriétés inconnues et le champ que quelqu'un a renommé.
- Le HTTP sortant. Votre client face à un stub que vous contrôlez. Détecte la construction des URL, les en-têtes, l'authentification, le comportement des timeouts et des retries, et le mapping des erreurs.
- La messagerie. Publication et consommation via un vrai broker. Détecte la sérialisation, les en-têtes, le partitionnement, la sémantique d'acquittement et la redélivraison.
- L'application assemblée. Le service démarré tel qu'il sera déployé, exercé via son API publique. Détecte les problèmes de câblage, de configuration et d'aspects transverses.
Chacune a un sujet différent et un échec qui veut dire autre chose. Les regrouper dans un seul « test d'intégration » qui démarre tout et clique dans un navigateur vous donne la somme de leurs coûts et aucune de leurs vertus de diagnostic.
Contrat ou intégration ?
Une autre distinction épargne bien des souffrances dans un système composé de plusieurs services. Le test d'intégration demande si deux composants fonctionnent ensemble maintenant, dans un même processus ou un même environnement. Le test de contrat demande si l'accord entre eux tient toujours, et il y répond sans que les deux composants tournent jamais en même temps.
La distinction compte parce que les tests d'intégration entre services ne passent pas à l'échelle. Un environnement partagé où chaque équipe déploie avant de lancer les tests de bout en bout devient une file d'attente, puis un goulot d'étranglement, puis une source d'échecs dont personne n'est responsable. Le test de contrat remplace l'essentiel de tout cela par deux suites exécutables indépendamment et un fichier qui circule entre elles.
De vraies dépendances avec Testcontainers
Testcontainers démarre des conteneurs Docker depuis JUnit et les arrête ensuite, ce qui supprime la dernière excuse pour tester contre des substituts.
@Testcontainers
class OrderRepositoryIT {
@Container
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:17-alpine")
.withDatabaseName("shop")
.withReuse(true);
static DataSource dataSource;
@BeforeAll
static void migrate() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(POSTGRES.getJdbcUrl());
config.setUsername(POSTGRES.getUsername());
config.setPassword(POSTGRES.getPassword());
dataSource = new HikariDataSource(config);
Flyway.configure().dataSource(dataSource).load().migrate();
}
@Test
void findsOrdersPlacedInAWindowInPlacedOrder() {
OrderRepository repository = new JdbcOrderRepository(dataSource);
repository.save(anOrder().placedAt("2026-01-05T10:00:00Z").build());
repository.save(anOrder().placedAt("2026-01-06T10:00:00Z").build());
repository.save(anOrder().placedAt("2026-02-01T10:00:00Z").build());
List<Order> found = repository.placedBetween(
Instant.parse("2026-01-01T00:00:00Z"), Instant.parse("2026-01-31T23:59:59Z"));
assertThat(found).hasSize(2)
.extracting(Order::placedAt)
.isSorted();
}
}
Trois détails font l'essentiel du travail. Déclarer le conteneur static le démarre une fois pour toute la classe plutôt qu'une fois par méthode de test. Exécuter les vraies migrations signifie que le test les valide aussi. Et figer un tag d'image précis — postgres:17-alpine, pas postgres:latest — garde la suite reproductible quand quelqu'un reconstruira l'image le mois prochain.
À l'échelle d'une suite entière, un conteneur singleton démarré une fois dans un initialiseur statique et jamais arrêté est encore plus rapide : la JVM s'arrête et le reaper de Testcontainers supprime le conteneur. withReuse(true), combiné à l'option de réutilisation du fichier local testcontainers.properties, garde un conteneur en vie entre deux exécutions locales, ce qui réduit le coût de démarrage pendant le développement alors que la CI repart toujours de zéro.
Au-delà des bases de données, il existe des modules pour Kafka, RabbitMQ, Redis, Elasticsearch, LocalStack, ainsi qu'un conteneur générique qui exécute n'importe quelle image avec la stratégie d'attente de votre choix :
@Container
static final GenericContainer<?> LEGACY_PRICING =
new GenericContainer<>("registry.example.com/pricing:3.4")
.withExposedPorts(8080)
.withEnv("PRICING_MODE", "test")
.waitingFor(Wait.forHttp("/health").forStatusCode(200));
Les stratégies d'attente comptent plus qu'il n'y paraît. Un conteneur dont le port est ouvert mais dont l'application n'a pas fini de démarrer produit exactement le genre d'échec intermittent qui ruine la confiance dans une suite.
Empêcher les tests de se contaminer
Dès que les tests partagent une vraie base de données, l'isolation n'est plus gratuite. Trois stratégies, avec leurs vrais compromis :
Le rollback transactionnel. Enveloppez chaque test dans une transaction et annulez-la. C'est l'option la plus rapide et elle ne demande aucun code de nettoyage, mais elle masque tout ce qui se passe au commit — contraintes différées, hooks de commit, triggers — et elle ne fonctionne pas quand le code testé ouvre sa propre transaction ou que la requête franchit une frontière de processus.
Un état propre entre les tests. Videz les tables concernées (ou restaurez une base modèle) avant chaque test. Plus lent, mais le test voit exactement ce que verrait la production. Un seul TRUNCATE ... RESTART IDENTITY CASCADE sur la poignée de tables en jeu est généralement assez rapide.
Des données uniques par test. Donnez à chaque test son propre tenant, client ou préfixe de clé, pour que les collisions soient impossibles et qu'il n'y ait rien à nettoyer. C'est la seule stratégie qui résiste à une exécution parallèle agressive, et elle oblige aussi le code à être sûr vis-à-vis des tenants, ce qui est souvent une vraie exigence.
Quelle que soit votre stratégie, appliquez-la uniformément. Les suites où certaines classes font un rollback et d'autres vident les tables échouent de façon dépendante de l'ordre d'exécution, et diagnostiquer cela coûte plus cher que le temps que l'une ou l'autre stratégie avait fait gagner.
Des données de test lisibles
Les tests d'intégration demandent plus de données de préparation que les tests unitaires, et c'est dans cette préparation qu'ils deviennent illisibles. Les deux patterns qui fonctionnent :
// Builder with meaningful defaults: the test states only what it cares about.
Order order = anOrder()
.forCustomer(aCustomer().premium().build())
.withLine("SKU-1", 2, "24.00")
.placedAt("2026-01-05T10:00:00Z")
.build();
Un test data builder fournit des valeurs par défaut valides pour chaque champ et laisse chaque test surcharger les deux ou trois qui comptent pour lui. Quand une colonne obligatoire apparaît, un seul builder change au lieu de soixante tests. Un object mother va plus loin et nomme des scénarios entiers — Orders.shippedInternationalOrder() —, ce qui convient aux cas qui reviennent dans de nombreux tests.
Évitez l'alternative qui apparaît toujours en premier : un fichier de fixtures SQL partagé, chargé avant la suite, contenant une centaine de lignes dont trente tests dépendent de manière non documentée. Personne ne peut en supprimer une ligne, donc il ne fait que grossir, et lire un test ne vous apprend rien sur la raison pour laquelle il passe.
Simuler le HTTP sortant
Quand le système testé appelle l'API de quelqu'un d'autre, cette dépendance doit passer sous votre contrôle. WireMock fait tourner un serveur HTTP que vous programmez :
class PricingClientTest {
@RegisterExtension
static WireMockExtension wiremock = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
private PricingClient client;
@BeforeEach
void setUp() {
client = new PricingClient(wiremock.baseUrl(), Duration.ofMillis(500));
}
@Test
void readsThePriceFromTheResponseBody() {
wiremock.stubFor(get(urlPathEqualTo("/v1/prices/SKU-1"))
.withHeader("Accept", equalTo("application/json"))
.willReturn(okJson("""
{"sku":"SKU-1","amount":"24.00","currency":"EUR"}
""")));
assertThat(client.priceOf("SKU-1")).isEqualTo(Money.of("24.00", "EUR"));
wiremock.verify(getRequestedFor(urlPathEqualTo("/v1/prices/SKU-1"))
.withHeader("X-Request-Id", matching("[0-9a-f-]{36}")));
}
@Test
void failsFastWhenTheProviderIsSlow() {
wiremock.stubFor(get(anyUrl()).willReturn(ok().withFixedDelay(2_000)));
assertThatThrownBy(() -> client.priceOf("SKU-1"))
.isInstanceOf(PricingUnavailableException.class);
}
@Test
void retriesOnceOnA503() {
wiremock.stubFor(get(anyUrl()).inScenario("flaky")
.whenScenarioStateIs(STARTED)
.willReturn(serviceUnavailable())
.willSetStateTo("recovered"));
wiremock.stubFor(get(anyUrl()).inScenario("flaky")
.whenScenarioStateIs("recovered")
.willReturn(okJson("""
{"sku":"SKU-1","amount":"24.00","currency":"EUR"}
""")));
assertThat(client.priceOf("SKU-1")).isEqualTo(Money.of("24.00", "EUR"));
wiremock.verify(2, getRequestedFor(anyUrl()));
}
}
Tout l'intérêt est dans les scénarios de panne. N'importe quel client peut fonctionner face à un stub qui ne renvoie que le cas nominal ; ce qui casse en production, c'est le timeout jamais configuré, le retry qui a martelé un fournisseur déjà à la peine, l'erreur 500 remontée sous forme de NullPointerException. Les délais, l'injection de pannes et la machine à états des scénarios de WireMock rendent ces chemins testables. Inspecter le corps d'une requête capturée est aussi bien plus simple avec un affichage formaté : le formateur JSON est pratique quand un rapport de non-correspondance de stub tient sur une seule longue ligne.
Messagerie et asynchronisme
Les tests de broker suivent le même schéma avec une difficulté de plus : le résultat arrive plus tard. Démarrez le broker dans un conteneur, publiez via votre vrai producteur et interrogez le résultat avec une attente bornée plutôt qu'avec un sleep.
@Test
void publishesAnOrderPlacedEventConsumersCanRead() {
orderService.place(anOrder().withLine("SKU-1", 1, "24.00").build());
await().atMost(Duration.ofSeconds(5))
.untilAsserted(() -> {
List<OrderPlaced> received = testConsumer.drain();
assertThat(received).hasSize(1);
assertThat(received.getFirst().sku()).isEqualTo("SKU-1");
});
}
Un Thread.sleep(3000) au même endroit serait à la fois plus lent sur une machine rapide et flaky sur un runner de CI chargé. Testez aussi les cas d'échec propres à la messagerie : un message empoisonné impossible à désérialiser, une livraison en double qui doit être idempotente et un consommateur qui échoue en cours de traitement et ne doit pas acquitter.
Contrats pilotés par le consommateur
Pour la compatibilité entre services, le test de contrat remplace l'environnement de bout en bout partagé. Le consommateur déclare ce dont il a besoin, cette déclaration devient un fichier, et le fournisseur la vérifie.
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "pricing-service", pactVersion = PactSpecVersion.V4)
class PricingContractTest {
@Pact(consumer = "checkout-service")
RequestResponsePact priceExists(PactDslWithProvider builder) {
return builder
.given("a price exists for SKU-1")
.uponReceiving("a request for the price of SKU-1")
.path("/v1/prices/SKU-1")
.method("GET")
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.stringType("sku", "SKU-1")
.decimalType("amount", 24.00)
.stringMatcher("currency", "[A-Z]{3}", "EUR"))
.toPact();
}
@Test
@PactTestFor(pactMethod = "priceExists")
void theClientReadsThePrice(MockServer mockServer) {
PricingClient client = new PricingClient(mockServer.getUrl(), Duration.ofSeconds(1));
assertThat(client.priceOf("SKU-1")).isEqualTo(Money.of("24.00", "EUR"));
}
}
Deux propriétés font fonctionner le dispositif. Le consommateur spécifie des matchers, pas des valeurs littérales — il a besoin d'un code de devise à trois lettres, pas spécifiquement de EUR —, si bien que le fournisseur reste libre de changer ses données sans casser le contrat. Et la clause given nomme un provider state que le test de vérification du fournisseur met en place avant de rejouer la requête. Un fournisseur qui ajoute des champs reste compatible ; un fournisseur qui supprime ou renomme un champ lu par le consommateur échoue à la vérification avant le déploiement, c'est-à-dire exactement au moment où vous voulez le savoir. Les conventions de conception qui rendent une API facile à tester par contrat sont celles que recommandent les bonnes pratiques générales de conception d'API : noms de champs stables, évolutions additives, versionnement explicite.
Maîtriser le coût
Les suites d'intégration se dégradent minute après minute. Ce qui aide réellement :
- Démarrer les conteneurs une seule fois. Des conteneurs statiques par classe, ou un singleton pour toute la suite. Les conteneurs par méthode sont de loin la cause la plus fréquente d'un build de vingt minutes.
- Limiter les configurations distinctes. Chaque combinaison unique de propriétés, de profils et de beans surchargés crée un contexte d'application séparé qu'il faut construire de zéro.
- Paralléliser. Activez l'exécution parallèle de JUnit et rendez les tests assez indépendants pour y survivre ; l'isolation par données uniques et l'exécution parallèle se renforcent mutuellement.
- Découper la suite. Les tests unitaires rapides à chaque commit, les tests d'intégration au merge, les parcours de bout en bout les plus lents la nuit. Une convention de nommage (
*Testvs*IT) permet à l'outil de build de les séparer sans annotations. - Utiliser de petites images. Les variantes Alpine et les images préchauffées font gagner des secondes à chaque démarrage.
- Supprimer les tests qui ne justifient plus leur temps d'exécution. Un test qui n'a jamais échoué pour une vraie raison en deux ans et qui duplique ce que trois autres couvrent déjà est un passif.
Quand les tests d'intégration deviennent flaky
Un test qui échoue par intermittence détruit plus de valeur qu'il n'en crée, parce que l'équipe apprend à relancer plutôt qu'à enquêter. Dans les suites d'intégration, les causes sont prévisibles : un état partagé laissé par un test précédent, un port fixe qui entre en collision en exécution parallèle, une attente qui supposait un timing donné, une dépendance à la date du jour, une hypothèse sur l'ordre de lignes renvoyées par une requête sans ORDER BY, ou un conteneur qui acceptait des connexions avant d'être prêt.
Traitez chacun comme un défaut du test, pas comme un bruit de fond. Reproduisez-le en lançant la suite en boucle, dans un ordre aléatoire, sur une machine chargée : les conditions dans lesquelles il échoue vous renseignent sur la cause. La politique qui empêche la situation de dériver est décrite dans le guide de stratégie d'automatisation des tests : quarantaine, ticket, correction dans un délai fixé, ou suppression.
Points clés à retenir
- Décidez quelle frontière couvre chaque test avant de l'écrire ; un test dont l'échec peut tout signifier ne diagnostique rien.
- Testez la persistance face au vrai moteur avec les vraies migrations : Testcontainers a fait disparaître toute raison de lui substituer une base en mémoire.
- Choisissez une seule stratégie d'isolation — rollback, vidage des tables ou données uniques — et appliquez-la partout ; les données uniques sont la seule qui survit à l'exécution parallèle.
- Construisez les données de préparation avec des builders et des object mothers, pas avec un fichier de fixtures partagé qui ne fait que grossir.
- Simulez le HTTP sortant avec WireMock et consacrez l'essentiel de l'effort aux timeouts, aux retries et aux réponses d'erreur plutôt qu'au cas nominal.
- Remplacez les suites de bout en bout inter-services par des contrats pilotés par le consommateur, qui vérifient la compatibilité sans que les deux services tournent en même temps.
Sources et lectures complémentaires
Questions fréquentes
- Pourquoi ne pas utiliser H2 plutôt qu'un conteneur pour les tests de base de données ?
- Parce qu'une base en mémoire est un autre produit. Même en mode de compatibilité, H2 diffère de PostgreSQL ou d'Oracle dans la gestion des types, le support du JSON, la couverture des fonctions de fenêtrage, le comportement des verrous, l'allocation des séquences, la syntaxe d'upsert et les codes d'erreur. Une suite qui passe sur H2 et échoue sur le vrai moteur est pire que pas de suite du tout, car elle a donné une confiance qui n'était pas méritée. Les conteneurs ont rendu cette substitution inutile.
- Comment empêcher les tests d'intégration d'interférer les uns avec les autres ?
- Choisissez une stratégie et appliquez-la partout. Le rollback transactionnel est le plus rapide, mais il masque le comportement au commit et ne fonctionne pas au-delà d'une frontière de processus. Vider les tables entre les tests est plus lent, mais honnête. Générer des clés uniques par test, pour que les données ne puissent tout simplement pas entrer en collision, offre souvent le meilleur des deux et reste compatible avec l'exécution parallèle. Ce qui ne marche jamais, c'est de supposer que le test précédent a fait le ménage derrière lui.
- Que vérifie réellement un test de contrat piloté par le consommateur ?
- Deux moitiés. Côté consommateur, le test exécute votre client contre un serveur simulé construit à partir des attentes que vous avez déclarées, et produit un fichier de contrat qui enregistre les requêtes que vous émettez et les champs de réponse dont vous dépendez. Côté fournisseur, un test distinct rejoue ces requêtes contre le vrai fournisseur et vérifie que les réponses les satisfont toujours. Ensemble, ils prouvent la compatibilité sans jamais déployer les deux services dans un même environnement.
- Les tests d'intégration sont-ils forcément lents ?
- Plus lents que les tests unitaires, oui ; lents dans l'absolu, non. Les coûts dominants sont le démarrage des conteneurs et la création du contexte, deux choses qui se corrigent : réutilisez un conteneur statique pour toute la suite, limitez le nombre de configurations distinctes, exécutez les classes en parallèle et prenez les plus petites images utilisables. Une suite bien réglée de quelques centaines de tests adossés à une base de données qui s'exécute en deux ou trois minutes est un objectif réaliste.