Québec, Canada

403-1381 1re Avenue

+1 581.849.27.96

bdgouthiere@gmail.com

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

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, subtests, httptest, mocks, Testify, fuzzing, benchmarks. 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 bases1. 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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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 tester2.

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 malin3. 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.

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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 TestMain4.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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 testst.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 testtestDB.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 build5.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
//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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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, subtests, httptest, mocks, Testify, fuzzing, benchmarks, 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.


  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 et la doc officielle↩︎

  2. Liste officielle des modules : 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. Implémentation dans le container testcontainers/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. 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)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. 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. ↩︎