Québec, Canada

403-1381 1re Avenue

+1 581.849.27.96

bdgouthiere@gmail.com

Stratégie de test complète en Go : monter la pyramide sur un projet API REST réel

Ou : Comment assembler onze outils de testing Go sur un seul repo sans transformer ton CI en machine à café

Une pyramide de tests, dessinée correctement, contient trois étages. Une pyramide de tests, dans la moitié des repos Go que j’ai vus, contient trois étages du même type — des centaines de tests qui appellent une fonction, vérifient son retour, et n’ont jamais touché à une base de données ni à une socket HTTP. C’est rapide. C’est confortable. Ça donne 100 % de coverage. Et ça laisse passer le bug d’intégration qui te réveille le dimanche matin parce qu’une migration Postgres en prod a un comportement que personne n’a jamais exercé. La pyramide n’est pas un dessin esthétique, c’est une distribution de risques — et la distribuer correctement est le travail spécifique du dev senior à l’heure où l’IA produit le volume de tests.

Cet article est la pierre angulaire de la série. Dans les onze articles précédents, on a regardé chaque outil de la écosystème de testing Go en isolation : pourquoi le testing est ta vraie valeur ajoutée, table-driven, subtests, httptest, interfaces et mocking, Testify, fuzzing natif, benchmarks et profiling, intégration avec Testcontainers, code coverage, et concurrence avec -race. Chacun pris séparément est facile. La question qui reste — et celle qu’aucune IA ne résout pour toi — c’est comment ils s’assemblent dans un seul repo qu’on pourrait pousser en prod demain.

On va construire l’exemple complet : une API REST minimale qui gère des utilisateurs, persiste sur Postgres, met en cache sur Redis, expose trois endpoints HTTP. Cinq cents lignes de code. Une vingtaine de fichiers de tests. Un Makefile qui sépare ce qui doit être rapide de ce qui doit être exhaustif. Un workflow GitHub Actions qui fait passer la suite complète sans approximation. Et une checklist de revue de PR qu’on peut sortir devant un junior pour transformer son backlog de pull requests générées par IA en backlog de pull requests vérifiées.


Stratégie de test complète en Go : architecture, build tags, Makefile, CI, et checklist de revue de PR IA

Le projet : API REST users avec Postgres et Redis

Le repo qu’on construit ressemble à ça :

go-testing-synthese/
├── cmd/
│   └── api/
│       └── main.go              # entrypoint, ~30 lignes
├── internal/
│   ├── handlers/
│   │   ├── users.go             # HTTP handlers
│   │   └── users_test.go        # unit + httptest
│   ├── service/
│   │   ├── users.go             # logique métier
│   │   ├── users_test.go        # unit, table-driven
│   │   └── mocks_test.go        # mocks manuels des interfaces
│   ├── repository/
│   │   ├── users.go             # accès Postgres
│   │   ├── users_integration_test.go  # Testcontainers
│   │   ├── cache.go             # accès Redis
│   │   └── cache_integration_test.go  # Testcontainers
│   └── domain/
│       ├── user.go              # types et interfaces
│       └── user_test.go         # validations, fuzz
├── tests/
│   └── e2e/
│       └── api_test.go          # tests end-to-end HTTP
├── Makefile
├── go.mod
└── .github/
    └── workflows/
        └── ci.yml

Quatre couches, chacune avec ses tests à elle. C’est la structure qu’on va défendre.

L’idée — empruntée à n’importe quel guide d’architecture hexagonale ou clean architecture mais formulée en termes de testabilité — c’est que chaque couche a une frontière de test naturelle. Le domain ne dépend de rien et se teste en isolation pure. Le service dépend du repository via une interface qu’on peut mocker. Le repository parle à de vraies infrastructures et se teste avec Testcontainers. Les handlers parlent au service qu’on peut soit mocker soit instancier réellement. Et l’intégration de bout en bout passe par les tests/e2e/. Les outils sont déjà tous décrits dans les articles précédents — la nouveauté, c’est qu’on les fait coexister.

La pyramide appliquée : qui teste quoi, et combien

La pyramide originale est attribuée à Mike Cohn, qui l’a publiée dans son livre Succeeding with Agile en 2009, et popularisée par un article de Martin Fowler en 20121. La forme est triviale — une base large d’unitaires rapides, une strate intermédiaire de tests d’intégration, un sommet fin de tests end-to-end. La distribution chiffrée canonique tourne autour de 70/20/10 ou 80/15/5 selon les écoles, mais ces ratios sont indicatifs : ce qui compte vraiment, c’est la logique de placement.

Voici comment je distribue les tests sur ce projet :

CoucheType de testVolume cibleOutils utilisésVitesse d’exécution
domain/Unit + fuzz~30 %testing, table-driven, testing.FMillisecondes
service/Unit avec mocks~40 %testing, table-driven, mocks manuels via interfaces, parfois Testify< 1 seconde total
repository/Intégration~20 %Testcontainers Postgres + Redis, build tag30-60 secondes
handlers/Unit avec httptest~5 %httptest.NewRecorder, mocks de service< 1 seconde
tests/e2e/End-to-end~5 %Vraie API + vraie DB en Testcontainers1-3 minutes

Le 70/20/10 sort gratuitement de cette logique, sans qu’on ait à le forcer. La logique métier dans service/ est l’endroit où l’IA génère le plus de code (et le plus d’erreurs), donc c’est là qu’on met le volume de tests unitaires. Le repository/ est l’endroit où la fragilité vient du SQL et des migrations, donc on y met de l’intégration mais peu — un test par requête importante suffit. Les e2e sont chers en temps d’exécution et fragiles à maintenir, donc on en garde le strict minimum (un par parcours utilisateur critique). C’est une distribution de risques, pas un dogme.

La pyramide n'est pas un dessin esthétique : c'est une distribution de risques selon le coût et la fragilité de chaque couche.

Build tags : //go:build integration et //go:build e2e

Tous les tests ne doivent pas tourner ensemble. Quand tu modifies de la logique métier, tu veux la boucle de feedback la plus courte possible — go test ./internal/service/... en moins d’une seconde. Tu ne veux pas attendre que Postgres démarre dans un container. Quand tu modifies du SQL, là tu veux le container.

La solution canonique en Go est le build tag, ajouté à un fichier de test pour le rendre invisible par défaut au compilateur. Depuis Go 1.17, la syntaxe officielle est //go:build (en remplacement de l’ancienne // +build qui reste supportée pour compatibilité)2 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
//go:build integration

package repository

import (
    "context"
    "testing"

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

func TestUserRepository_Integration(t *testing.T) {
    // démarre un container Postgres, applique les migrations,
    // exécute les tests sur la vraie DB
}

Sans tag, go test ./... ignore ce fichier complètement — le compilateur ne le compile même pas. Avec go test -tags=integration ./..., il devient visible. C’est le mécanisme exact qu’on a vu dans l’article sur Testcontainers ; la nouveauté ici c’est qu’on en a deux strates :

  • //go:build integration pour les tests qui parlent à des vraies infrastructures via Testcontainers
  • //go:build e2e pour les tests qui démarrent l’API entière et lui parlent en HTTP

Cette séparation a un impact direct sur le Makefile et sur la CI — les développeurs locaux exécutent les unitaires en boucle, l’intégration une fois avant de pousser, et les e2e seulement en CI.

Le Makefile pragmatique : rapide en local, exhaustif en CI

Voici la cible que je recommande, condensée :

 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
.PHONY: test test-integration test-e2e test-all bench fuzz cover lint

# Unitaires uniquement, rapide, race detector activé
test:
	go test -short -race -count=1 ./...

# Intégration : démarre Postgres + Redis via Testcontainers
test-integration:
	go test -race -tags=integration -count=1 ./internal/repository/...

# End-to-end : API complète + DB réelle
test-e2e:
	go test -race -tags=e2e -count=1 ./tests/e2e/...

# Tout : unitaires + intégration + e2e + coverage atomique
test-all:
	go test -race -tags="integration e2e" -covermode=atomic \
		-coverprofile=coverage.out ./...

# Benchmarks ciblés (uniquement les hot paths identifiés)
bench:
	go test -bench=. -benchmem -run=^$$ ./internal/service/... ./internal/repository/...

# Fuzz les fonctions de validation (durée bornée pour CI)
fuzz:
	go test -fuzz=. -fuzztime=30s ./internal/domain/...

# Coverage HTML lisible localement
cover: test-all
	go tool cover -html=coverage.out -o coverage.html

# Lint
lint:
	golangci-lint run ./...

Quelques décisions méritent d’être motivées :

  • -short sur make test active le pattern if testing.Short() { t.Skip(...) } que tu peux ajouter aux tests qui auraient pu être unitaires mais sont lents (typiquement parce qu’ils itèrent beaucoup). Ça permet à un dev de dire « ce test est techniquement unitaire mais pas en mode boucle de feedback » sans avoir à le déplacer derrière un build tag.
  • -race est partout, même sur les unitaires. Le coût (2 à 20× CPU selon la documentation officielle de ThreadSanitizer) est invisible sur une suite de quelques centaines de tests qui prennent moins d’une seconde à la base. Le bénéfice — détection garantie des data races dans le code testé — est non négociable.
  • -count=1 à chaque cible désactive le cache de tests Go. Sans ça, go test peut retourner « tests passent » sans avoir réellement réexécuté le code si les inputs n’ont pas changé. En CI on veut toujours une exécution fraîche.
  • -covermode=atomic sur test-all parce que le mode par défaut a ses propres data races sous parallélisation, comme on l’a vu dans l’article sur la coverage et sur le -race.
  • Les benchmarks ne tournent jamais en CI normale. Ils ne sont pas reproductibles entre runs (variations CPU, bruit système), donc on les exécute manuellement avec make bench et on les compare avec benchstat comme expliqué dans l’article benchmarks.
  • Le fuzzing est borné à 30 secondes sur la cible CI. Le fuzzing infini (mode développement) reste manuel — on lance go test -fuzz=FuzzValidateEmail -fuzztime=10m quand on travaille sur la fonction.

Le résultat : un développeur lance make test toutes les trente secondes pendant qu’il code, ça tourne en deux secondes, ça l’arrête immédiatement si une data race apparaît. Avant de pousser, il lance make test-integration une fois — soixante secondes de patience pour la sécurité d’avoir tapé une vraie DB. Le CI fait make test-all dans son coin et bloque la PR si quoi que ce soit échoue.

GitHub Actions : services natifs ou Testcontainers ?

Il y a deux écoles pour faire tourner Postgres et Redis dans GitHub Actions :

  1. Services GitHub Actions natifs (services: dans le workflow YAML) — l’action lance Postgres et Redis dans des containers, ils sont disponibles sur localhost avec un port mappé.
  2. Testcontainers depuis le test Go — le test Go lui-même démarre les containers via l’API Docker du runner.

Sur ce projet je recommande Testcontainers pour les tests d’intégration et un service natif Postgres uniquement si tu as besoin d’une fixture partagée pour les e2e. La raison : Testcontainers garantit que go test -tags=integration tourne identiquement en local et en CI, sans configuration externe. Si tu utilises services:, ton test doit lire des variables d’environnement pour connaître l’host/port — ça marche en CI, ça demande un docker-compose.yml séparé en local.

Le workflow minimal :

 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
47
48
name: CI
on: [push, pull_request]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.24'
      - run: make lint
      - run: make test
      - run: go test -short -race -covermode=atomic \
              -coverprofile=coverage.out ./...
      - uses: codecov/codecov-action@v4
        with:
          file: ./coverage.out

  integration:
    runs-on: ubuntu-latest
    needs: unit
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.24'
      - run: make test-integration

  e2e:
    runs-on: ubuntu-latest
    needs: integration
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.24'
      - run: make test-e2e

  fuzz:
    runs-on: ubuntu-latest
    needs: unit
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.24'
      - run: make fuzz

Quatre jobs, chaînage explicite des dépendances. L’intégration ne tourne que si les unitaires passent (économie de runtime CI). Le fuzzing tourne en parallèle des intégrations parce qu’il est indépendant. Si un job échoue, le pipeline s’arrête au plus tôt.

Le runner GitHub Actions ubuntu-latest a Docker préinstallé3, donc Testcontainers démarre des containers Postgres et Redis sans configuration supplémentaire. Tu n’as ni services: ni docker-compose.yml à maintenir.

Et sur GitLab CI ?

Si ta CI tourne sur GitLab — ce qui couvre une bonne partie des équipes en Europe — la stratégie est rigoureusement identique, seul le fichier de pipeline change. La nuance importante : les runners GitLab partagés ne sont pas tous configurés avec un démon Docker accessible, donc démarrer Testcontainers nécessite typiquement le service docker:dind (Docker-in-Docker). Sur des runners self-hosted avec Docker en mode socket (/var/run/docker.sock monté), tu peux t’en passer — mais c’est une décision d’admin, pas une décision de dev.

Le .gitlab-ci.yml équivalent au workflow GitHub plus haut :

 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
47
48
49
stages: [lint, test, integration, e2e]

variables:
  GO_VERSION: "1.24"

.go-base:
  image: golang:${GO_VERSION}
  cache:
    paths: [.cache/go-build, .cache/go-mod]

lint:
  stage: lint
  extends: .go-base
  script:
    - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
    - golangci-lint run ./...

unit:
  stage: test
  extends: .go-base
  script:
    - go test -short -race -covermode=atomic -coverprofile=coverage.out ./...
  coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/'

integration:
  stage: integration
  extends: .go-base
  services:
    - name: docker:24-dind
      alias: docker
  variables:
    DOCKER_HOST: tcp://docker:2375
    DOCKER_TLS_CERTDIR: ""
    TESTCONTAINERS_HOST_OVERRIDE: docker
  script:
    - go test -race -tags=integration ./internal/repository/...

e2e:
  stage: e2e
  extends: .go-base
  services:
    - name: docker:24-dind
      alias: docker
  variables:
    DOCKER_HOST: tcp://docker:2375
    DOCKER_TLS_CERTDIR: ""
    TESTCONTAINERS_HOST_OVERRIDE: docker
  script:
    - go test -race -tags=e2e ./tests/e2e/...

Trois différences à connaître par rapport à GitHub Actions :

  • services: docker:dind — sur runners partagés, c’est le moyen standard d’avoir un démon Docker disponible. La variable TESTCONTAINERS_HOST_OVERRIDE indique à Testcontainers où joindre les containers démarrés (sinon il essaie localhost, qui pointe vers le job runner et pas vers le service dind).
  • Pas de needs: global comme dans GitHub — le chaînage se fait via stages: qui exécutent séquentiellement. Pour paralléliser à l’intérieur d’un stage, tu déclares plusieurs jobs dans le même stage (le lint et un éventuel unit-fast peuvent vivre dans lint).
  • Coverage parsée par regex dans la clé coverage: — GitLab affiche le pourcentage dans l’UI de la PR (équivalent au badge Codecov côté GitHub). Le format du regex doit matcher la sortie de go tool cover : ici total: (statements) 75.3% est extrait par le pattern fourni.

Le code Go testé reste exactement le même. Les build tags //go:build integration et //go:build e2e fonctionnent à l’identique, le Makefile est portable d’un fournisseur de CI à l’autre, et la pyramide de tests est strictement indépendante de la plateforme. C’est précisément la valeur d’avoir séparé la stratégie de test de l’implémentation CI — tu changes de fournisseur sans réécrire ta suite.

Coverage stratégique : couvrir les frontières, pas les lignes

L’article sur la coverage défendait que viser 100 % de coverage est une erreur de pilotage. Voici comment ça se traduit concrètement sur ce projet :

PackageCible coverageJustification
internal/domain/90-100 %Logique pure sans dépendance, facile à tester, c’est là que vivent les invariants métier
internal/service/75-90 %La couche où le risque métier se concentre. On accepte de ne pas couvrir certains chemins de logging ou de propagation d’erreur évidents
internal/repository/60-75 %Couverture par les tests d’intégration. Les chemins d’erreur DB sont coûteux à tester de manière exhaustive ; on couvre les requêtes nominales et les contraintes (unicité, FK)
internal/handlers/60-80 %Le wiring HTTP est largement boilerplate. On couvre status codes, body parsing, error mapping. Pas la sérialisation JSON triviale
cmd/api/0 %C’est le main. Tester un main n’a aucun ROI ; ce qu’il fait de signifiant doit être délégué à un package internal et testé là

Le seuil global qui en découle est autour de 75 %. Ne mets pas 75 % comme objectif dans la CI — mets-le comme observation. Si la coverage tombe en dessous de 70 %, c’est un signal pour aller voir où. Si elle monte à 95 %, c’est probablement le signal qu’on teste du boilerplate sans valeur ajoutée. Ce n’est jamais une métrique de pilotage, c’est un outil de diagnostic.

Fuzzing minimal mais présent : un target par parser d’input externe

Le fuzzing en Go (testing.F, intégré depuis Go 1.18) brille spécifiquement sur les fonctions qui prennent un input non-trusté et le parsent. Sur ce projet, ça veut dire :

  • La fonction de validation d’email (domain.ValidateEmail)
  • La fonction de désérialisation du body JSON (handlers.parseUserRequest)
  • Tout endpoint qui accepte un query param utilisateur

Un seul fuzz target par fonction de parsing externe est suffisant pour la CI. Tu n’as pas besoin de fuzzer toute ta logique métier — tu as besoin de fuzzer les frontières où des inputs hostiles peuvent arriver. C’est la différence entre fuzzer pour la sécurité (attrape les inputs malformés qui font paniquer) et fuzzer pour la correction (attrape les inputs valides qui produisent des résultats inattendus). Les deux sont utiles ; le premier est gratuit en CI, le second tu le lances manuellement quand tu refactores la fonction concernée.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
//go:build go1.18

package domain

import "testing"

func FuzzValidateEmail(f *testing.F) {
    f.Add("user@example.com")
    f.Add("")
    f.Add("plainstring")

    f.Fuzz(func(t *testing.T, email string) {
        // On ne vérifie pas la correction, on vérifie l'absence de panic.
        // Un test de propriété viendrait ici, mais le contrat minimal
        // c'est : ne jamais paniquer, peu importe l'input.
        _ = ValidateEmail(email)
    })
}

C’est trivial. Ça tient en 30 secondes par CI run. Et si une PR introduit un parser d’email qui panique sur un slice d’octets exotiques, tu le sais avant le déploiement. C’est le ratio coût/bénéfice qui justifie d’en mettre un, même petit.

Race detector activé partout : c’est gratuit, branche-le

L’article sur la concurrence défendait que -race est l’outil avec le meilleur ratio signal/bruit de tout l’écosystème Go : zéro faux positif documenté. Sur ce projet, le flag est branché sur toutes les cibles make. C’est intentionnel.

Le coût en local : invisible sur les unitaires (quelques pourcent sur une suite qui tourne en deux secondes). Le coût en CI : 2 à 5× sur l’intégration et les e2e — toujours acceptable, rarement le facteur limitant du temps de pipeline. Le bénéfice : si une PR introduit une data race quelque part dans le code testé, la CI échoue. Pas une incantation, une garantie.

Les seuls tests où je désactive -race ce sont les benchmarks, et c’est par nécessité technique : -race invalide les mesures. C’est une exception, pas un précédent.

Anti-patterns IA récurrents : la synthèse des onze articles

Onze articles, onze patterns que l’IA répète. Je les rassemble ici parce que c’est exactement ce qu’on cherche dans une revue de PR :

  1. Tests qui gonflent la coverage sans assertion utile — beaucoup d’invocations, peu de if got != want. Vu dans l’intro.
  2. Table-driven tests sans nommer les cas — debugging cauchemar. Vu dans l’article table-driven.
  3. Subtests pas isolés — fuites d’état entre t.Run, fixtures globales modifiées. Vu dans subtests-trun-go.
  4. Handlers HTTP testés uniquement sur le happy path — pas de cas d’erreur, pas de status codes alternatifs. Vu dans httptest.
  5. Mocks qui testent l’implémentation au lieu du contrat — la PR casse à chaque refactor. Vu dans interfaces et mocking.
  6. Surutilisation de Testify : assert partout au lieu de require quand un échec invalide la suite. Vu dans Testify.
  7. Pas de fuzz target sur les parsers d’input externe — un seul oubli et les bugs de parsing remontent en prod. Vu dans fuzzing.
  8. Optimisations « plus rapide » sans benchmark — l’IA aime réécrire en map ce qui était déjà optimal en slice. Vu dans benchmarks et profiling.
  9. Mocks de DB au lieu de tests d’intégration — l’IA évite Testcontainers parce qu’elle a appris à mocker. Vu dans intégration.
  10. Coverage 100 % comme objectif — chaque ligne couverte mais aucune assertion. Vu dans coverage.
  11. Goroutines sans synchronisation — le go func() qui partage de la mémoire sans mutex. Vu dans concurrence.

C’est la liste qu’on peut imprimer et laisser à côté du clavier d’un junior qui review du code IA. C’est aussi la liste qui devrait servir de prompt système quand tu demandes à l’IA de relire son propre code.

La checklist de revue d’une PR Go générée par IA

Quand une PR arrive sur le repo, voici les questions dans l’ordre. La règle implicite : si une réponse est « non », on bloque la PR jusqu’à ce qu’elle devienne « oui ».

  • Les tests _test.go sont à côté du code, pas dans un dossier tests/ séparé (sauf e2e qui sont dans tests/e2e/ par convention)
  • Tout code avec plus de 2 cas testés utilise un table-driven test, avec un champ name ou un nom de cas dans la map
  • Toute interface définie dans le service a un mock manuel (struct + champ fonctionnel) ou un mock Testify, pas un auto-mock magique
  • Tout code qui touche à la persistance a au moins un test d’intégration via Testcontainers, derrière //go:build integration
  • Tout parser d’input externe (JSON body, query param, env var sensible) a un fuzz target minimal
  • La CI passe go test -race ./... — pas une PR ne traverse sans
  • La coverage globale ne baisse pas sur cette PR (rapport Codecov ou équivalent)
  • Aucune nouvelle goroutine n’est introduite sans soit un mutex/channel pour son état partagé, soit un test qui exerce sa concurrence
  • Aucun test ne dépend de l’ordre d’exécution avec un autre — t.Parallel() est ajouté quand pertinent et la suite passe avec -shuffle=on
  • Les optimisations de performance sont accompagnées d’un benchmark + comparaison benchstat avant/après — pas de promesse non vérifiée

Cette checklist tient sur une page A4. Elle prend cinq à dix minutes à appliquer sur une PR de taille raisonnable. Elle attrape la vaste majorité des bugs que l’IA introduit. Et elle remplace progressivement, dans la tête du dev senior, le réflexe de relire chaque ligne par le réflexe de vérifier que les bonnes garanties structurelles sont en place.


Dialogue : « Mais j’ai 100 % de coverage »

Junior Jules : Sam, j’ai fini la PR sur users. 847 tests verts, 100 % de coverage, 2,3 secondes de run.

Senior Sam : Tu as branché -race ?

Junior Jules : Euh… non, juste go test ./....

Senior Sam : Tu as un test d’intégration qui touche à Postgres ?

Junior Jules : Non, j’ai mocké le repository. C’est mieux non, ça va plus vite.

Senior Sam : Tu as fuzzé ton handler de création d’utilisateur ?

Junior Jules : Non, j’ai testé une dizaine de cas que j’ai trouvés.

Senior Sam : Donc tu as 100 % de coverage sur du code qui n’a jamais touché à la vraie DB, qui n’a jamais subi un input qu’on n’avait pas anticipé, et qui n’a jamais été stressé concurrentiellement. C’est un beau dessin. Ce n’est pas une stratégie de test.

Junior Jules : L’IA a écrit la moitié des tests. Ils sont structurés, ils sont propres.

Senior Sam : L’IA écrit des tests qui passent. C’est son métier. Ce qu’elle ne fait pas, c’est décider mettre les tests. Cette décision-là, c’est la nôtre. Voilà la checklist. Reviens quand chaque case est cochée.

Junior Jules : Ça va prendre une demi-journée.

Senior Sam : Ça va prendre une demi-journée. Ça va aussi t’éviter trois jours de bisection en prod dans deux mois. C’est un échange acceptable.


Ce que tu peux faire maintenant

  1. Adopte la séparation unit / integration / e2e dans tout nouveau projet Go. Les build tags //go:build integration et //go:build e2e sont gratuits, supportés depuis Go 1.17, et ils débloquent une boucle de feedback locale rapide sans sacrifier l’exhaustivité du CI.
  2. Écris ton Makefile avec test, test-integration, test-all comme cibles séparées. Tu auras une cible rapide pour la boucle locale et une cible exhaustive pour la CI, sans avoir à choisir entre les deux.
  3. Branche -race sur toutes les cibles sauf les benchmarks. Le coût est invisible, la garantie est forte, c’est l’arbitrage le plus rentable de la écosystème Go.
  4. Configure ta CI avec quatre jobs : lint+unit, integration, e2e, fuzz. Chaînage explicite, parallélisation maximale, échec au plus tôt. C’est un fichier YAML d’une trentaine de lignes qui change la qualité de tes pipelines.
  5. Vise une coverage par couche, pas globale. 90+ sur le domain, 75-90 sur le service, 60-75 sur le repository, et zéro sur le cmd/. Si tu vois 95 % global, suspect du boilerplate testé pour rien.
  6. Ajoute un fuzz target par parser d’input externe. Trente secondes de fuzzing par CI run, c’est suffisant pour attraper les paniques sur input malformé.
  7. Imprime la checklist de revue de PR et accroche-la à côté de ton clavier. Quand une PR arrive, applique-la dans l’ordre. Tu n’as pas besoin de relire chaque ligne, tu as besoin de vérifier chaque garantie.

Ce que la série a essayé de transmettre

Douze articles, onze outils, une seule idée. L’IA est un excellent générateur de code Go en 2026 — et un générateur médiocre de stratégie de test. La distinction est nette : générer un test qui passe, c’est mécaniquement faisable à partir d’une signature de fonction. Décider quel test écrire, à quel niveau, contre quelles infrastructures, avec quelle garantie de non-régression, c’est une décision d’architecture qui demande de comprendre le risque métier, le coût d’exécution, la fragilité de chaque couche, et la dynamique de l’équipe qui maintient le code. Aucune IA actuelle ne fait ça à un niveau utile. Toutes les IA actuelles produisent volontiers les tests qui découlent d’une stratégie déjà définie.

Cette asymétrie est la valeur ajoutée du dev senior en 2026. Pas écrire les tests — l’IA les écrit. Pas relire les tests — l’IA peut les relire. Décider la stratégie de test, lire les warnings de -race, identifier les couches de la pyramide, choisir entre mock et integration, calibrer le fuzzing, fixer les seuils de coverage par package — c’est la couche au-dessus, et c’est exactement celle où les modèles atteignent leurs limites. C’est aussi celle où ton expérience accumulée pèse le plus, parce que les bons arbitrages dépendent de tout ce que tu as vu casser dans les dix années précédentes.

Pour aller plus loin, trois sujets que la série n’a pas couverts mais qui méritent ta lecture :

  • Mutation testing avec go-gremlins/gremlins4 — qui mute systématiquement ton code et vérifie que tes tests détectent les mutations. C’est le test des tests.
  • Property-based testing avec pgregory.net/rapid5 — qui complète le fuzzing natif avec une approche stateful et des stratégies de minimisation (shrinking) avancées.
  • Contract testing entre microservices avec Pact ou équivalent — quand ton API Go est consommée par d’autres services, vérifier que ton contrat ne casse pas les leurs sans avoir à tout tester de bout en bout.

La série s’arrête ici. La structure qu’on vient de décrire — quatre couches, build tags séparés, Makefile pragmatique, CI à quatre jobs, checklist de revue — est volontairement reproductible : chaque snippet de l’article peut être recopié dans un projet réel sans modification majeure. C’est le geste le plus utile qu’un article puisse faire : pas de te dire quoi penser, mais de te donner une structure que tu peux modifier.

Bon code. Et bons tests.


  1. La pyramide de tests apparaît sous sa forme imprimée dans Mike Cohn, Succeeding with Agile: Software Development Using Scrum, Addison-Wesley, 2009. Le concept aurait été dessiné en conversation avec Lisa Crispin dès 2003-2004, et réinventé indépendamment par Jason Huggins vers 2006. La diffusion massive vient de l’article de Martin Fowler de 2012 puis de The Practical Test Pyramid de Ham Vocke en 2018. Les ratios chiffrés (70/20/10, 80/15/5, etc.) ne sont pas dans la formulation originale — ils sont apparus dans la littérature pratique des années 2010 et restent indicatifs : la pyramide ne dit pas combien de chaque type de test, elle dit que les couches les plus rapides et stables doivent être les plus volumineuses↩︎

  2. La syntaxe //go:build a été introduite dans Go 1.17 (sortie le 16 août 2021) en remplacement progressif de // +build, qui reste supporté indéfiniment pour compatibilité avec le code ancien. Le détail de la transition est documenté dans la proposal officielle et l’outil go fix peut migrer automatiquement un module en suivant // +build vers la nouvelle syntaxe. La motivation principale : la nouvelle syntaxe utilise &&, ||, ! et parenthèses comme une expression Go normale, là où l’ancienne avait sa propre grammaire ad hoc qui produisait des silencieux refus de compilation difficiles à diagnostiquer (typiquement quand on oubliait la ligne vide entre le commentaire de build et le package). ↩︎

  3. Les runners GitHub Actions ubuntu-latest ont Docker préinstallé et un démon en cours d’exécution accessible sur le socket Unix par défaut. C’est ce qui permet à Testcontainers de démarrer des containers depuis le test Go sans configuration supplémentaire — l’API Docker est juste là. Pour les runners Windows et macOS la situation est différente (pas de démon Docker par défaut), donc en pratique on confine les jobs Testcontainers aux runners Linux. Voir la documentation officielle des runners GitHub-hosted pour la liste exacte des outils préinstallés selon l’image. ↩︎

  4. go-gremlins/gremlins est l’outil de mutation testing maintenu pour Go en 2026. Il opère en mutant systématiquement le code source (changement d’opérateurs arithmétiques, de comparaison, inversion de conditionnelles, suppression de branches) et en relançant la suite de tests sur chaque mutation. Une mutation KILLED signifie que les tests ont attrapé le bug introduit ; une mutation LIVED signifie que la suite est aveugle à cette modification. Le score de mutations attrapées (le mutation score) est une métrique nettement plus discriminante que la coverage — il mesure ce que tes tests assertent, pas juste ce qu’ils exécutent. Le coût en temps est important (multiplié par le nombre de mutations testées), donc on l’exécute typiquement en hebdomadaire ou en preview avant un release majeur, pas sur chaque PR. ↩︎

  5. pgregory.net/rapid est la bibliothèque de property-based testing la plus mature pour Go en 2026, développée par Gregory Petrosyan (alias flyingmutant sur GitHub). Elle apporte ce que testing.F ne fait pas : génération de structures complexes (slices, maps, structs imbriquées), modélisation de machines à états avec invariants, et minimisation automatique des contre-exemples (shrinking) — quand un test échoue, rapid simplifie l’input qui a causé l’échec jusqu’au plus petit qui le reproduit, ce qui rend le débogage trivial. C’est l’inspiration directe de Hypothesis (Python) adaptée à Go. La complémentarité avec le fuzzing natif est claire : testing.F est meilleur pour trouver des inputs qui font crasher un parser (coverage-guided), rapid est meilleur pour vérifier des propriétés algébriques (pour tout x, encode(decode(x)) == x). ↩︎