121 lines
4.2 KiB
Plaintext
121 lines
4.2 KiB
Plaintext
---
|
|
title: Tests e2e avec Testcontainers
|
|
description: Tester contre une infrastructure réelle, isolée et détruite après chaque run.
|
|
---
|
|
|
|
Docker permet aux tests de démarrer une infrastructure proche de la production, de l'utiliser, puis de la supprimer.
|
|
|
|
## Le problème des tests d'intégration
|
|
|
|
Un backend qui utilise PostgreSQL, un stockage S3 et un serveur SMTP a besoin de ces dépendances pendant ses tests.
|
|
|
|
Deux solutions classiques ont des limites :
|
|
|
|
- **mocker les services** : rapide, mais ne teste pas le vrai SQL ni les vraies contraintes ;
|
|
- **partager une base de test** : réaliste, mais l'état fuit entre les runs et les exécutions parallèles entrent en collision.
|
|
|
|
Testcontainers donne à chaque suite ses propres conteneurs : une vraie base PostgreSQL, un vrai MinIO et un vrai Mailpit, isolés puis supprimés automatiquement.
|
|
|
|
## Niveau backend : Testcontainers en code
|
|
|
|
```ts
|
|
const [pgContainer, mailpitContainer, minioContainer] =
|
|
await Promise.all([
|
|
new PostgreSqlContainer("postgres:16-alpine")
|
|
.withStartupTimeout(30_000)
|
|
.start(),
|
|
|
|
new GenericContainer("axllent/mailpit:latest")
|
|
.withExposedPorts(1025, 8025)
|
|
.start(),
|
|
|
|
new GenericContainer("minio/minio:latest")
|
|
.withCommand(["server", "/data"])
|
|
.withEnvironment({
|
|
MINIO_ROOT_USER: "minioadmin",
|
|
MINIO_ROOT_PASSWORD: "minioadmin123",
|
|
})
|
|
.withExposedPorts(9000)
|
|
.start(),
|
|
]);
|
|
|
|
process.env.POSTGRES_HOST = pgContainer.getHost();
|
|
process.env.POSTGRES_PORT = pgContainer.getPort().toString();
|
|
process.env.POSTGRES_USER = pgContainer.getUsername();
|
|
```
|
|
|
|
L'application NestJS démarre ensuite contre ces services réels.
|
|
|
|
### Même image que la production
|
|
|
|
Le test utilise `postgres:16-alpine`, comme la production. Il teste le vrai moteur et ses contraintes, pas une base en mémoire approximative.
|
|
|
|
### Ports dynamiques
|
|
|
|
Testcontainers publie les ports internes sur des ports libres choisis automatiquement. `getPort()` renvoie le port attribué. Deux runs simultanés ne se gênent pas.
|
|
|
|
### Isolation par run
|
|
|
|
Les données peuvent être nettoyées entre les tests, puis les conteneurs sont arrêtés à la fin. Chaque suite repart d'une infrastructure vierge.
|
|
|
|
## Le test reste orienté métier
|
|
|
|
```ts
|
|
test("mes notifications sont triées par date décroissante", async () => {
|
|
await driver.givenImAuthenticated();
|
|
await driver.givenIHaveNotifications();
|
|
await driver.whenIListMyNotifications();
|
|
await driver.thenMyNotificationsAreReturnedSortedDesc();
|
|
});
|
|
```
|
|
|
|
Le test pilote l'application par HTTP comme un vrai client. L'infrastructure Docker reste cachée dans le setup.
|
|
|
|
## Niveau navigateur : une stack Compose par worker
|
|
|
|
Les tests Playwright peuvent lancer la stack de production complète, accompagnée d'un overlay e2e :
|
|
|
|
```ts
|
|
const ports = getWorkerPorts(workerInfo.workerIndex);
|
|
|
|
execSync(
|
|
"docker compose " +
|
|
"--project-name " + ports.projectName + " " +
|
|
"--env-file .env.prod " +
|
|
"-f docker-compose.prod.yml " +
|
|
"-f docker-compose.local-e2e.yml " +
|
|
"up -d --build --force-recreate",
|
|
{ env: envOverride },
|
|
);
|
|
|
|
await use(ports);
|
|
|
|
execSync(
|
|
"docker compose --project-name " +
|
|
ports.projectName +
|
|
" down -v",
|
|
);
|
|
```
|
|
|
|
Trois notions rendent le parallélisme possible :
|
|
|
|
1. un `--project-name` unique donne à chaque worker son propre réseau ;
|
|
2. des ports hôte décalés évitent les collisions ;
|
|
3. l'overlay Compose réutilise la stack de production et ne modifie que la configuration de test.
|
|
|
|
Plusieurs copies de la production peuvent ainsi tourner côte à côte dans la CI, puis être détruites avec leurs volumes.
|
|
|
|
## Comparaison
|
|
|
|
| Critère | Mocks | Base partagée | Testcontainers |
|
|
| --- | --- | --- | --- |
|
|
| Vrai SQL et vraies contraintes | Non | Oui | Oui |
|
|
| Proche de la production | Non | Partiellement | Oui, mêmes images |
|
|
| Isolation entre les runs | Oui | Non | Oui |
|
|
| Setup manuel | Non | Oui | Non, décrit en code ou Compose |
|
|
| Parallélisable | Oui | Difficile | Oui, grâce aux ports et réseaux |
|
|
|
|
<Callout title="Pourquoi le compromis fonctionne" type="info">
|
|
La légèreté des conteneurs permet de démarrer l'infrastructure en quelques secondes. Les réseaux l'isolent, les ports dynamiques évitent les collisions et down -v la détruit proprement.
|
|
</Callout>
|