# Tests d'intégration Go avec Docker : vraies bases de données, pas des mocks

> Neuvième article de la série Testing Go. Limites des mocks, taxonomie unit/intégration/end-to-end, Testcontainers-Go pour lancer de vraies dépendances dans go test, module postgres.Run et module redis.Run, le cycle de vie complet (pull d'image, wait strategy, Ryuk qui tue les orphelins), TestMain pour partager un container entre tests, build tag //go:build integration pour isoler les tests qui coûtent cher, et les pièges d'isolation entre tests qui partagent un container.


*Ou : Pourquoi ton code qui passe contre un mock peut encore exploser sur la première vraie migration*

Tu as écrit un `UserRepository` avec une méthode `Save(ctx, user)`. Le test unitaire mocke l'interface `DB`, vérifie que `Save` appelle `db.Exec` avec la bonne requête, et passe. Tu as 100 % de coverage sur le repository. Tu merges.

Le lendemain, la CI déclenche le déploiement et plante à la migration. Ta colonne `created_at` est déclarée `TIMESTAMP WITHOUT TIME ZONE`, ta librairie Go envoie du `TIMESTAMPTZ`, PostgreSQL refuse l'insertion avec une erreur de type. Ton test unitaire n'avait jamais vu cette erreur parce que ton mock ne sait rien de PostgreSQL. Il sait seulement qu'on a appelé `db.Exec` avec une string qui commence par `INSERT`.

Pendant huit articles, on a testé ce qui est *dans* le code Go : [table-driven](/blog/table-driven-tests-go/), [subtests](/blog/subtests-trun-go/), [httptest](/blog/httptest-go-tester-apis-sans-serveur/), [mocks](/blog/interfaces-mocking-go/), [Testify](/blog/testify-assertions-lisibles-go/), [fuzzing](/blog/fuzzing-natif-go/), [benchmarks](/blog/benchmarks-profiling-go/). Chaque fois qu'une dépendance externe apparaissait, on la *mockait*. C'était juste. C'était pragmatique. C'était aussi systématiquement insuffisant.

Le moment où un mock arrête de suffire, c'est le moment où le comportement que tu testes dépend d'un détail du vrai système : le dialecte SQL, les conventions d'encodage, la précision des timestamps, le TTL d'un cache, le comportement d'un index sous concurrence. Aucune de ces choses ne se mocke honnêtement. Toutes font péter des services en prod quand personne ne les teste.

C'est là que Testcontainers-Go entre en scène : lancer un vrai PostgreSQL, un vrai Redis, un vrai Kafka, à l'intérieur d'un `go test`. Pas dans un environnement Docker Compose à part. Pas via un script bash. Dans ton fichier `_test.go`, à côté de `TestUserRepository`.

---

## Tests d'intégration Go avec Testcontainers-Go : PostgreSQL et Redis dans `go test`

### Unitaire, intégration, end-to-end : où tracer la ligne

Petit rappel taxonomique, vite fait.

Un **test unitaire** isole une fonction avec toutes ses dépendances mockées. Il répond à « est-ce que cette fonction fait ce que je crois ? ». Il tourne en millisecondes. C'est ce que tu écris en masse.

Un **test d'intégration** fait tourner plusieurs composants ensemble — ton code + une vraie dépendance (base, cache, service externe). Il répond à « est-ce que mon composant parle correctement à cet autre composant ? ». Il tourne en secondes, le temps de démarrer les dépendances. Tu en écris moins, mais tu en écris pour les interactions critiques.

Un **test end-to-end** fait tourner le système entier : API, frontend, DB, queues, tout. Il répond à « est-ce que l'utilisateur peut accomplir ce qu'il veut ? ». Il tourne en dizaines de secondes ou en minutes. Tu en écris le minimum vital.

La pyramide classique dit : beaucoup d'unitaires, une poignée d'intégration, une poignée d'e2e. Ce que l'IA génère systématiquement : beaucoup d'unitaires, zéro intégration, zéro e2e. Résultat : un coverage qui monte, des bugs qui atterrissent en prod. Le chaînon manquant, c'est l'intégration — et jusqu'en 2017, c'était aussi le plus mal outillé.

---

### Testcontainers-Go : Docker dans un `_test.go`

Testcontainers est né côté Java en 2015 comme un projet OSS de Richard North pour tester des apps JVM ciblant de vraies bases[^1]. L'idée était brutalement simple : ton test a besoin d'un PostgreSQL ? Lance-le dans un container Docker, attends qu'il soit prêt, donne au test la chaîne de connexion, tue-le quand le test finit. Dix ans plus tard, la même idée a été portée sur Go, Python, Rust, .NET, Node, et le projet est hébergé par une fondation basée chez Docker depuis 2023.

En Go, ça donne ça :

```go
import (
    "context"
    "database/sql"
    "testing"

    _ "github.com/lib/pq"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
)

func TestUserRepository_Save(t *testing.T) {
    ctx := context.Background()

    pg, err := postgres.Run(ctx, "postgres:16-alpine",
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
        postgres.BasicWaitStrategies(),
    )
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() {
        _ = testcontainers.TerminateContainer(pg)
    })

    dsn, err := pg.ConnectionString(ctx, "sslmode=disable")
    if err != nil {
        t.Fatal(err)
    }
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        t.Fatal(err)
    }
    defer db.Close()

    // ... le test lui-même, contre `db`, comme si c'était ta DB de prod
}
```

Le module `postgres` est une surcouche spécialisée. Il connaît les images officielles PostgreSQL, les variables d'env attendues (`POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`), et la stratégie d'attente idiomatique (`BasicWaitStrategies` vérifie que `pg_isready` répond *et* qu'une requête SQL simple tourne). Il existe des modules équivalents pour Redis, MySQL, MongoDB, Kafka, RabbitMQ, ElasticSearch, MinIO, Localstack — la liste fait une soixantaine d'entrées en 2026 et couvre à peu près tout ce contre quoi tu pourrais vouloir tester[^2].

La signature `Run(ctx, image, opts...)` est devenue le standard dans la v0.32.0 (mi-2024) ; avant ça, les modules exposaient `RunContainer()`, aujourd'hui déprécié. Si tu vois du vieux code avec `postgres.RunContainer(...)`, il fonctionne encore mais la nouvelle API `Run()` est plus courte et idiomatique. Quand l'IA te génère un test d'intégration avec l'ancienne signature, ça veut juste dire qu'elle a appris sur des exemples datant de 2023 — migre, et dis-lui.

---

### Le cycle de vie : `Run`, `ConnectionString`, `Terminate` (et Ryuk, le nettoyeur)

Quand ton test appelle `postgres.Run(ctx, "postgres:16-alpine", ...)`, cinq choses se passent, dans cet ordre :

1. **Pull de l'image** si elle n'est pas en cache local. Premier run lent. Suivants instantanés.
2. **Démarrage du container** avec les variables d'env, volumes, ports exposés configurés.
3. **Attente de readiness** : la wait strategy bloque jusqu'à ce que PostgreSQL accepte vraiment des connexions. Sans ça, 30 % de tes tests échoueraient sur un « connection refused » parce que le container tourne mais que le process `postgres` n'a pas fini son init.
4. **Retour d'un `*postgres.Container`** avec des méthodes pour récupérer host, port, URL de connexion via `ConnectionString(ctx, "sslmode=disable")`.
5. **Démarrage silencieux de Ryuk**, un container side-car dont on va parler dans une seconde.

Quand le test finit, `testcontainers.TerminateContainer(pg)` arrête le container et nettoie les ressources. Si tu oublies le cleanup, le container reste en vie après la fin du test — jusqu'à ce que Ryuk le voie et le tue.

**Ryuk**, c'est le truc qui rend Testcontainers *fiable* plutôt que *juste malin*[^3]. C'est un container séparé (image `testcontainers/ryuk`) lancé automatiquement la première fois que tu utilises Testcontainers dans un process, qui surveille ce process via une connexion TCP. Dès que le process de test meurt — même brutalement, même avec `kill -9`, même si tu fermes ton terminal sans cleanup — Ryuk détecte la déconnexion et supprime tous les containers labellisés avec l'ID de session. Tu ne peux pas accumuler des containers zombies sur ta machine, même quand tes tests crashent.

Le seul moyen de se retrouver avec un zombie, c'est de tuer Docker lui-même avant que Ryuk n'ait le temps d'agir. C'est rare. Le nom « Ryuk » est une référence au *shinigami* de Death Note — le dieu de la mort qui vient réclamer ce qui lui revient. C'est le genre de naming qui fait réaliser que l'équipe Testcontainers a de l'humour.

![Un container principal PostgreSQL entouré d'un container side-car Ryuk qui surveille la connexion TCP du test, prêt à nettoyer](/images/tests-integration-testcontainers-go-ryuk.original.webp "Ryuk : le side-car qui tue les containers orphelins dès que ton process de test meurt. Même avec kill -9, il n'y a pas de zombie.")

---

### Tester un repository PostgreSQL sur une vraie DB

Prenons un repository concret. Une fonction `Save` qui insère un user, respecte une contrainte d'unicité sur l'email, et doit renvoyer un `ErrDuplicate` en cas de conflit.

```go
// user.go
type User struct {
    ID    int64
    Email string
    Name  string
}

var ErrDuplicate = errors.New("email already exists")

type UserRepository struct {
    db *sql.DB
}

func (r *UserRepository) Save(ctx context.Context, u *User) error {
    const q = `INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id`
    err := r.db.QueryRowContext(ctx, q, u.Email, u.Name).Scan(&u.ID)
    if err != nil {
        var pgErr *pq.Error
        if errors.As(err, &pgErr) && pgErr.Code == "23505" {
            return ErrDuplicate
        }
        return err
    }
    return nil
}
```

Un test unitaire avec un mock de `*sql.DB` validerait que `QueryRowContext` est appelé avec la bonne requête. Il ne validerait *pas* que la contrainte d'unicité existe côté base, ni que le code d'erreur `23505` est bien ce que PostgreSQL renvoie pour une violation de contrainte unique (c'est le cas : `unique_violation` dans la spec PostgreSQL). Avec Testcontainers, on teste les deux en même temps.

```go
func TestUserRepository_Save_Duplicate(t *testing.T) {
    ctx := context.Background()

    pg, err := postgres.Run(ctx, "postgres:16-alpine",
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
        postgres.BasicWaitStrategies(),
    )
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() { _ = testcontainers.TerminateContainer(pg) })

    dsn, _ := pg.ConnectionString(ctx, "sslmode=disable")
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        t.Fatal(err)
    }

    // Migration : tu peux utiliser golang-migrate, sqlc, ou juste exécuter le DDL
    _, err = db.ExecContext(ctx, `
        CREATE TABLE users (
            id SERIAL PRIMARY KEY,
            email TEXT UNIQUE NOT NULL,
            name TEXT NOT NULL
        )`)
    if err != nil {
        t.Fatal(err)
    }

    repo := &UserRepository{db: db}

    // Premier insert : doit passer
    u1 := &User{Email: "alice@example.com", Name: "Alice"}
    if err := repo.Save(ctx, u1); err != nil {
        t.Fatalf("first save: %v", err)
    }

    // Deuxième insert avec le même email : doit renvoyer ErrDuplicate
    u2 := &User{Email: "alice@example.com", Name: "Alice bis"}
    err = repo.Save(ctx, u2)
    if !errors.Is(err, ErrDuplicate) {
        t.Fatalf("expected ErrDuplicate, got %v", err)
    }
}
```

Ce test tourne en 2-3 secondes, dont la quasi-totalité est le démarrage du container. C'est cinquante fois plus lent qu'un unit test mocké. Mais ce qu'il valide, aucun mock honnête ne peut le valider : la contrainte SQL `UNIQUE` est vraiment en place après migration, le code d'erreur PostgreSQL `23505` est correctement détecté par `lib/pq`, le type `pq.Error` expose bien un champ `Code`. Le jour où tu upgrade le driver et qu'il change la structure de `pq.Error`, le test casse avant la prod plutôt qu'à minuit un vendredi.

---

### `TestMain()` : un container, vingt tests

Le problème avec l'approche « un container par test », c'est que 3 secondes par test × vingt tests = une minute. C'est assez pour que tout le monde commence à skip les tests d'intégration « juste pour aller plus vite ». Solution : un seul container par package, partagé entre les tests, via `TestMain`[^4].

```go
var testDB *sql.DB

func TestMain(m *testing.M) {
    ctx := context.Background()

    pg, err := postgres.Run(ctx, "postgres:16-alpine",
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
        postgres.BasicWaitStrategies(),
    )
    if err != nil {
        log.Fatalf("start postgres: %v", err)
    }

    dsn, _ := pg.ConnectionString(ctx, "sslmode=disable")
    testDB, err = sql.Open("postgres", dsn)
    if err != nil {
        log.Fatalf("open db: %v", err)
    }

    if err := runMigrations(testDB); err != nil {
        log.Fatalf("migrate: %v", err)
    }

    code := m.Run()

    testDB.Close()
    _ = testcontainers.TerminateContainer(pg)
    os.Exit(code)
}
```

`TestMain` est le point d'entrée officiel du package `testing` quand tu veux contrôler le lifecycle global. Tu l'écris une fois par package de tests. Il prépare un container, lance les migrations, expose le `*sql.DB` au niveau package, puis appelle `m.Run()` qui exécute tous les `Test*` du package. À la fin, cleanup.

Le piège : maintenant tous tes tests partagent la même DB. Si `TestA` insère un user et `TestB` compte les users, `TestB` voit celui de `TestA`. Trois stratégies classiques pour gérer ça :

1. **Truncate entre tests** — `t.Cleanup(func() { testDB.Exec("TRUNCATE users CASCADE") })`. Simple, lent si beaucoup de tables.
2. **Transaction par test** — chaque test ouvre une transaction et la rollback à la fin. Rapide, mais ne fonctionne pas si ton code sous test utilise ses propres transactions.
3. **Database par test** — `testDB.Exec("CREATE DATABASE test_" + t.Name())`, isolation parfaite. Propre, mais coûteux en DDL et pénible en migrations.

Le plus pragmatique pour démarrer : truncate. Tu optimises plus tard si le temps de test devient un problème.

---

### `//go:build integration` : séparer les tests qui coûtent cher

Les tests d'intégration sont lents. `go test ./...` qui prend 30 secondes en local, c'est correct. `go test ./...` qui prend trois minutes, c'est une invitation à ne plus lancer les tests. La parade : séparer les tests qui coûtent cher derrière un build tag, et les lancer uniquement quand c'est pertinent (CI, avant un merge, pre-release).

Go a un système de build tags intégré. Un fichier qui commence par `//go:build integration` — suivi d'une ligne vide obligatoire, puis `package ...` — ne sera compilé que si tu passes `-tags integration` à `go test` ou `go build`[^5].

```go
//go:build integration

package user_test

import (
    "context"
    "database/sql"
    "testing"

    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
)

func TestUserRepository_Integration_Save(t *testing.T) {
    // ... test d'intégration ici
}
```

Convention d'usage (pas imposée par Go mais répandue dans la communauté) : suffixer ces fichiers en `_integration_test.go`. Résultat :

- `go test ./...` → ne compile que les fichiers sans le tag `integration`, tests rapides uniquement.
- `go test -tags integration ./...` → compile tout, tests rapides + intégration.

Dans ta CI, tu configures deux jobs. Le premier lance les unit tests, prend 30 secondes, bloque les PR. Le second lance les tests d'intégration, prend trois minutes, bloque les merges vers `main`. Les deux tournent en parallèle, donc le temps total d'attente n'augmente pas, mais les feedbacks rapides restent rapides.

---

### Tester un cache Redis (le second exemple)

Le même pattern s'applique à Redis, avec le module `redis`. La spécificité, c'est que Redis a des comportements qu'aucun mock raisonnable ne peut reproduire correctement : TTL réel, expiration d'eviction LRU, atomicité des opérations multi-key, pipeline.

```go
import (
    redisClient "github.com/redis/go-redis/v9"
    "github.com/testcontainers/testcontainers-go/modules/redis"
)

func TestCache_TTLExpiration(t *testing.T) {
    ctx := context.Background()

    rd, err := redis.Run(ctx, "redis:7-alpine")
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() { _ = testcontainers.TerminateContainer(rd) })

    uri, _ := rd.ConnectionString(ctx)
    opts, _ := redisClient.ParseURL(uri)
    client := redisClient.NewClient(opts)
    defer client.Close()

    cache := NewCache(client)

    if err := cache.Set(ctx, "key", "value", 2*time.Second); err != nil {
        t.Fatal(err)
    }

    // Juste après, la valeur est là
    got, err := cache.Get(ctx, "key")
    if err != nil || got != "value" {
        t.Fatalf("before expiration: got %q err=%v, want 'value'", got, err)
    }

    // Après 3 secondes, elle ne doit plus l'être
    time.Sleep(3 * time.Second)
    _, err = cache.Get(ctx, "key")
    if !errors.Is(err, ErrNotFound) {
        t.Fatalf("expected ErrNotFound after TTL, got %v", err)
    }
}
```

Un mock de Redis te laisserait écrire *« j'ai appelé `SET` avec un TTL de 2 secondes, je te fais confiance »*. Un vrai Redis te fait *attendre les deux secondes* et vérifier que la clé a vraiment expiré. Si ton code utilise `SETEX` au lieu de `SET ... EX`, ou s'il envoie le TTL en millisecondes là où Redis attend des secondes, le test attrape le bug. Aucun mock ne l'attraperait jamais — c'est exactement le genre de détail que « j'ai bien appelé la méthode avec les bons arguments » ne couvre pas.

C'est un test un peu lent (3 secondes d'attente). C'est acceptable parce qu'il est derrière `//go:build integration` et qu'il ne tourne pas à chaque sauvegarde de fichier. Il tourne au commit et en CI. C'est assez.

---

### Le piège : l'isolation entre tests qui partagent un container

On l'a mentionné plus haut, mais ça mérite son propre paragraphe parce que c'est le bug que tout le monde fait une fois. Quand tu utilises `TestMain` avec un container partagé, *l'ordre de tes tests n'est pas garanti isolé*. Go peut exécuter tes tests en parallèle si tu mets `t.Parallel()`, et même sans ça, l'ordre alphabétique n'est pas l'ordre d'écriture dans le fichier.

Si `TestA` insère un user avec `id=1` et `TestB` assume qu'`id=1` n'existe pas, un jour `TestB` tournera avant `TestA` et tu auras un *flaky test* — un test qui passe parfois, échoue parfois, sans raison apparente. Les flaky tests sont pires que les tests cassés : ils érodent la confiance dans la suite entière. Personne ne regarde vraiment pourquoi ils échouent, tout le monde re-clique « retry » et merge quand ça repasse au hasard.

La parade minimale : `t.Cleanup(truncate)` dans chaque test qui écrit dans la DB. Ou bien, chaque test crée un identifiant unique (`email := fmt.Sprintf("test-%s@example.com", t.Name())`) et ne cherche jamais les rows des autres tests. Ou bien, chaque test ouvre une transaction et la rollback à la fin. Les trois fonctionnent. Choisis-en une par package et tiens-la.

> **Junior Jules :** J'ai ajouté un test d'intégration ciblant Postgres, ça marche localement.
>
> **Senior Sam :** Et en CI ?
>
> **Junior Jules :** Ça marche aussi. Enfin, la moitié du temps.
>
> **Senior Sam :** *La moitié du temps ?*
>
> **Junior Jules :** Parfois ça dit que `user_id=1` existe déjà. Je relance, ça passe.
>
> **Senior Sam :** Tu as un `TestMain` qui démarre un container partagé, et aucun cleanup entre tests.
>
> **Junior Jules :** J'avais mis un `TRUNCATE` dans le premier test.
>
> **Senior Sam :** Seulement dans le premier. Les suivants dépendent de l'ordre d'exécution — et l'ordre alphabétique des fonctions `Test*`, c'est pas l'ordre d'écriture dans le fichier.
>
> **Junior Jules :** Qu'est-ce que je fais ?
>
> **Senior Sam :** `t.Cleanup(func() { testDB.Exec("TRUNCATE users CASCADE") })` dans chaque test qui écrit. Tu relances ta CI dix fois. Tu vérifies que ça passe dix fois d'affilée. C'est ça, un test d'intégration fiable : il passe *déterministiquement*. Un test non déterministe n'a aucune valeur — c'est un générateur de bruit avec une barre verte intermittente.

---

### Ce que tu peux faire maintenant

1. Choisis **un repository** de ton projet actuel qui parle à une DB ou à un cache. Ajoute `github.com/testcontainers/testcontainers-go` à tes deps, plus le module spécifique (`modules/postgres`, `modules/redis`, etc.).
2. Écris un test d'intégration minimaliste : un container, une migration, un scénario de bout en bout. Mets-le dans un fichier suffixé `_integration_test.go` avec `//go:build integration` en tête. Tu as maintenant ton premier test d'intégration Go.
3. Sépare tes jobs CI : un pour `go test ./...` (unit), un pour `go test -tags integration ./...` (tout). Les deux tournent en parallèle ; les feedbacks rapides restent rapides.
4. Utilise `TestMain` pour partager un container entre tous les tests d'un package dès que tu as plus de deux tests d'intégration. Fais tourner les migrations dans `TestMain`. Nettoie entre tests avec `t.Cleanup` et `TRUNCATE ... CASCADE`.
5. Pour chaque fonction qui a un comportement *dépendant du vrai système* — contraintes SQL, TTL de cache, ordre de transaction, comportement d'un index sous concurrence — écris le test d'intégration qui le valide. Ce sont les tests qui attrapent les bugs qu'aucune autre couche ne peut attraper. Les autres, tu peux les laisser à Claude.

---

### La suite

On a parcouru la série complète pour valider le comportement du code Go : [table-driven](/blog/table-driven-tests-go/), [subtests](/blog/subtests-trun-go/), [httptest](/blog/httptest-go-tester-apis-sans-serveur/), [mocks](/blog/interfaces-mocking-go/), [Testify](/blog/testify-assertions-lisibles-go/), [fuzzing](/blog/fuzzing-natif-go/), [benchmarks](/blog/benchmarks-profiling-go/), intégration. Neuf articles. Tu peux écrire des tests qui valident la correction, la performance, la résilience aux inputs chaotiques, et l'interaction avec de vraies bases.

Il reste un sujet qui traverse tous les autres et qui mérite sa propre discussion : la **coverage**. Combien de pourcents de ton code est couvert par les tests ? Est-ce que 100 % est mieux que 80 % ? Est-ce que l'IA — qui adore générer des tests pour gonfler la coverage — produit du signal ou du bruit ? Dans le prochain article, on regarde `go test -cover`, les pièges du 100 %, et pourquoi une coverage élevée peut parfaitement coexister avec des tests qui ne testent rien.

<div class="next-article">
<a href="/blog/code-coverage-piege-100-go/">
<span class="next-article__label">Article suivant de la série</span>
<p class="next-article__title">Code Coverage en Go : le piège du 100 % et ce que ça veut vraiment dire</p>
<p class="next-article__desc">go test -cover en profondeur, les lignes couvertes mais pas vraiment testées, pourquoi 80 % intelligent bat 100 % stupide, et comment l'IA fausse cette métrique plus souvent qu'elle ne l'améliore.</p>
</a>
</div>

[^1]: Projet Testcontainers initial créé en 2015 par Richard North pour la JVM. La fondation Testcontainers, hébergée chez Docker Inc. depuis 2023 (date de l'acquisition d'AtomicJar par Docker), maintient aujourd'hui des implémentations pour Java, Go, Python, .NET, Node.js, Rust et Haskell. Le portage Go, initié en 2017, a suivi un chemin en parallèle du reste de l'écosystème pendant plusieurs années avant la consolidation d'API — `Run()` standardisé, modules unifiés autour d'un `*Container` embarquant `testcontainers.Container` — amorcée en v0.32.0 (2024). Voir le [repo GitHub](https://github.com/testcontainers/testcontainers-go) et la [doc officielle](https://golang.testcontainers.org/).

[^2]: Liste officielle des modules : [golang.testcontainers.org/modules](https://golang.testcontainers.org/modules). Chaque module encapsule la connaissance spécifique au système sous-jacent (image par défaut, variables d'env, ports exposés, stratégie d'attente idiomatique). Pour un système sans module dédié, l'API générique `testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{...})` fonctionne, avec la contrepartie que tu dois configurer manuellement tout ce que le module aurait fait pour toi — image, env, ports, wait strategy.

[^3]: Ryuk (pourquoi « Ryuk » ? c'est une référence au *shinigami* de Death Note, le dieu de la mort qui vient réclamer ce qui lui revient — nommage assumé par l'équipe Testcontainers) est un container utilitaire qui se connecte à ta session de test via TCP et surveille la liveness du process. Documentation officielle : [Resource Reaper](https://golang.testcontainers.org/features/garbage_collector/). Implémentation dans le container [`testcontainers/ryuk`](https://github.com/testcontainers/moby-ryuk). Tu peux le désactiver via la variable d'env `TESTCONTAINERS_RYUK_DISABLED=true` si tu as des raisons légitimes (test dans un environnement sans permissions Docker socket croisées, par exemple), mais alors le cleanup des containers orphelins devient ta responsabilité entière.

[^4]: `TestMain(m *testing.M)` est documenté dans le [package testing](https://pkg.go.dev/testing#hdr-Main). Règle à connaître : si tu déclares un `TestMain`, *tu dois* appeler `m.Run()` toi-même — Go ne le fait pas implicitement à ta place. Le code de sortie renvoyé par `os.Exit(code)` où `code := m.Run()` devient le code de sortie du binaire de test. Ton setup peut donc modifier ce code (par exemple, signaler une erreur de setup avec un code non-zéro même si tous les tests passent individuellement, pour que la CI rapporte un échec clair).

[^5]: Les build tags Go sont documentés dans [cmd/go — Build constraints](https://pkg.go.dev/cmd/go#hdr-Build_constraints). Depuis Go 1.17, la syntaxe préférée est `//go:build integration` (avec ligne vide avant `package`), remplaçant l'ancienne syntaxe `// +build integration`. Les deux sont supportées par les versions récentes, et `gofmt` ajoute automatiquement la nouvelle syntaxe à côté de l'ancienne si tu utilises encore l'ancienne. La convention de nommage `_integration_test.go` n'est pas imposée par le langage — c'est un usage communautaire répandu parce qu'il rend le fichier facile à identifier quand on navigue dans un package.


