Québec, Canada

403-1381 1re Avenue

+1 581.849.27.96

bdgouthiere@gmail.com

Code Coverage en Go : le piège du 100 % et ce que ça veut vraiment dire

Ou : Pourquoi la seule métrique que tout le monde regarde est aussi celle qui ment le plus

Ta PR affiche 100 % de coverage. Tous les fichiers. Toutes les lignes. Le rapport HTML généré par go tool cover est uniformément vert, comme une pelouse anglaise juste tondue. Le CI te félicite. Tu merges. Une semaine plus tard, un utilisateur signale que le formulaire d’inscription accepte les emails sans @. Tu ouvres le code, tu trouves la fonction ValidateEmail, tu regardes les tests. Il y en a quatre. Chacun appelle ValidateEmail(...) avec un input différent. Aucun ne vérifie le retour.

Tes tests exécutent la fonction. Ils ne la testent pas. go test -cover ne voit pas la différence, parce que go test -cover ne cherche pas de différence à voir.

Cette histoire — la coverage qui monte pendant que la qualité s’effondre — n’est pas un cas pathologique. C’est le mode de fonctionnement normal d’une équipe qui a fixé un seuil de coverage dans le CI et confié la génération des tests à l’IA. L’IA est extrêmement bonne pour couvrir du code. Elle est médiocre pour le tester. La distinction est invisible dans le chiffre que le CI affiche — et c’est le problème entier.

Dans les neuf articles précédents de cette série, on a construit un arsenal : table-driven, subtests, httptest, mocks, Testify, fuzzing, benchmarks, intégration. Beaucoup d’outils pour écrire des tests qui valident quelque chose. Il reste à parler de l’outil qui mesure ces tests — et surtout, de ce qu’il ne mesure pas.


Code coverage en Go : ce que go test -cover mesure vraiment (et ce qu’il ignore)

Les commandes que tout le monde connaît

Rappel rapide pour les nouveaux arrivants. Go intègre la coverage depuis la 1.2, et l’API n’a quasiment pas bougé depuis. Le flag -cover active l’instrumentation :

1
go test -cover ./...

Pour obtenir un rapport exploitable, on combine -coverprofile (écrit un fichier de profil) avec go tool cover (lit le profil et affiche quelque chose de lisible) :

1
2
3
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out   # résumé texte par fonction
go tool cover -html=coverage.out   # rapport HTML navigable, ligne par ligne

Le rapport HTML colorie chaque ligne : vert si elle a été exécutée pendant les tests, rouge sinon. C’est joli. C’est rassurant. C’est aussi à peu près tout ce que fait l’outil.

Go mesure des basic blocks, pas des branches

La première chose à comprendre — celle que beaucoup de développeurs découvrent en touchant à du code concurrent — c’est que Go ne mesure pas la branch coverage. Il mesure la statement coverage, plus précisément la couverture des basic blocks : des séquences d’instructions qui s’exécutent ensemble, typiquement délimitées par des accolades1.

Rob Pike avait documenté ce choix dès l’article fondateur de 2013 sur le blog officiel Go : l’instrumentation se fait par réécriture du code source, ce qui est portable et simple, mais perd le niveau de détail qu’une instrumentation binaire traditionnelle apporterait. Conséquence visible : dans une expression comme f() && g(), les deux appels apparaîtront couverts exactement autant de fois, même si le short-circuit n’a jamais évalué g() pour certains inputs. Le bloc qui contient l’expression s’est exécuté, donc l’expression entière est marquée couverte. La branche non prise est invisible.

L’équipe Go a explicitement refusé d’ajouter la branch coverage native — voir l’issue golang/go#28888, fermée avec un argumentaire clair : la complexité ajoutée n’est pas jugée proportionnée au bénéfice, et les outils tiers comme gobco couvrent ce besoin pour les équipes qui en ont vraiment besoin. C’est un choix philosophique cohérent avec le reste du langage : une stdlib simple, quelques outils tiers pour les cas avancés.

Les trois modes -covermode

Le flag -covermode accepte trois valeurs :

  • set (défaut) : chaque basic block est marqué booléen — il s’est exécuté ou pas. Suffisant pour savoir « cette ligne a-t-elle été couverte ? ». Très peu coûteux.
  • count : chaque basic block est incrémenté à chaque exécution. Permet la heatmap dans le rapport HTML (les blocs les plus exécutés apparaissent plus foncés). Peu coûteux aussi.
  • atomic : comme count, mais le compteur utilise sync/atomic. Obligatoire dès que les tests contiennent t.Parallel() ou qu’on active -race — sinon les écritures concurrentes sur le même bloc corrompent les compteurs2.

Règle simple à mémoriser : si tes tests parallélisent, atomic. Sinon, set. count ne sert que si tu veux lire la heatmap, ce qui est plus rare qu’on ne le croit.

1
go test -race -coverprofile=coverage.out -covermode=atomic ./...

Cette ligne est probablement la forme qui convient à 90 % des projets Go 2026 : -race attrape les data races, -covermode=atomic garde les compteurs de coverage honnêtes pendant ce temps. Mets-la dans ton Makefile et oublie.


Les trois pièges qui font qu’une coverage de 100 % ne prouve rien

Maintenant qu’on sait ce que Go mesure, parlons de ce qu’il ne mesure pas — et de ce que l’IA exploite sans le savoir.

Piège n°1 : la ligne couverte sans assertion.

Le plus banal, le plus répandu, celui que l’IA adore :

1
2
3
4
5
6
func TestValidateEmail(t *testing.T) {
    ValidateEmail("bob@example.com")
    ValidateEmail("")
    ValidateEmail("not-an-email")
    ValidateEmail("foo@bar")
}

Chaque appel couvre la fonction. Le rapport affiche 100 %. Et pourtant, aucun de ces tests ne vérifie quoi que ce soit. La fonction pourrait retourner nil sur tous les cas, ou paniquer silencieusement, ou retourner l’opposé de ce qu’elle devrait — le test passerait quand même. C’est littéralement un test qui exécute la fonction, pas qui la teste.

Go ne peut pas détecter ça. Il ne sait pas ce qu’est une assertion. Il sait qu’une ligne a été exécutée, et c’est tout ce qu’il sait.

Piège n°2 : le test qui exerce l’implémentation, pas le contrat.

Variante plus subtile. Le test vérifie que la fonction fait ce qu’elle fait, au lieu de vérifier qu’elle fait ce qu’elle devrait. Exemple : une fonction ComputeTotal qui somme un slice. Le test :

1
2
3
if got := ComputeTotal([]int{1, 2, 3}); got != 6 {
    t.Errorf("want 6, got %d", got)
}

Bien. Maintenant, imagine que l’IA a implémenté ComputeTotal avec un bug : elle retourne len(items)*2. Sur l’input [1, 2, 3], len*2 = 6. Test vert. Coverage verte. L’IA, en générant les tests, a choisi un input qui par coïncidence passe sur les deux implémentations. Le test valide la coïncidence, pas le contrat.

Ce piège est particulièrement insidieux avec le code généré : l’IA tend à produire des tests qui couvrent les inputs qu’elle a elle-même imaginés — inputs qu’elle a donc aussi anticipés quand elle a écrit le code. Les angles morts du code et des tests se recouvrent exactement. C’est ce qui justifie le fuzzing : aller chercher les inputs que personne n’a imaginés.

Piège n°3 : le seuil CI qui devient un objectif.

Tu mets minimum_coverage: 90 dans ton CI. Une PR arrive à 89.4 %. Le CI la bloque. Le développeur ajoute un test qui exécute les trois lignes manquantes sans rien vérifier (voir piège n°1). La coverage passe à 90.2 %. Le CI verdit. La PR merge. Rien de ce qui motivait le seuil n’a été servi.

C’est la loi de Goodhart appliquée au testing. Formulée d’abord par Charles Goodhart en 1975 sur la politique monétaire britannique — « toute régularité statistique observée tendra à s’effondrer dès qu’une pression est exercée sur elle à des fins de contrôle » — et popularisée en 1997 par l’anthropologue Marilyn Strathern3 dans la formule qui orne aujourd’hui les slides de management : « when a measure becomes a target, it ceases to be a good measure ». Dès qu’un indicateur devient un seuil, les gens optimisent pour le seuil, pas pour ce que l’indicateur mesurait à l’origine.

La coverage est un indicateur de diagnostic. À partir du moment où tu en fais une règle de mérite, elle cesse de t’informer. L’équipe qui imposait 90 % pour « garantir la qualité » récolte précisément le résultat inverse : une coverage élevée, une qualité identique à celle d’avant, et un coût humain en plus pour jouer le jeu du chiffre.

Loi de Goodhart appliquée au testing : quand un indicateur devient un seuil, les gens optimisent pour le seuil, pas pour ce que l'indicateur mesurait à l'origine.

Le paper que personne ne lit : Inozemtseva & Holmes, 2014

Si tu veux une source académique solide à citer en réunion quand quelqu’un défend un seuil de 90 %, elle existe. Laura Inozemtseva et Reid Holmes ont publié en 2014 à ICSE le paper « Coverage Is Not Strongly Correlated With Test Suite Effectiveness », primé ACM Distinguished Paper la même année, et désigné Most Influential Paper ICSE N-10 en 2024 — dix ans après, la communauté de la génie logiciel considère que son apport reste structurant4.

Leur résultat central, en une phrase : une fois qu’on contrôle la taille du test suite, la corrélation entre coverage et effectivité détection-de-bugs devient faible à modérée. Autrement dit, l’apparente corrélation « plus de coverage = meilleurs tests » vient principalement du fait que plus de tests = plus de coverage, et plus de tests = plus de bugs attrapés. C’est la taille qui porte le signal, pas la coverage.

Implication pratique : augmenter artificiellement la coverage d’un test suite existant (en ajoutant des tests qui couvrent sans assertion) n’améliore pas mesurablement sa capacité à détecter des bugs. C’est ce que l’étude a montré expérimentalement sur cinq gros projets Java. Il n’y a pas de raison de penser que Go ferait exception.

Ce qui rend ce résultat utile à connaître, ce n’est pas qu’il te permet de dire « la coverage ne sert à rien » — elle sert, comme diagnostic. C’est qu’il te donne un argument solide contre la tyrannie du seuil. Si le seuil de coverage n’améliore pas la qualité de détection des bugs, mais coûte du temps humain à être atteint, le seuil est un impôt négatif sur l’équipe.


Coverage intelligente : couvrir les chemins critiques d’abord

La bonne manière d’utiliser go test -cover, c’est comme un outil de diagnostic, pas comme un juge.

Workflow concret : après chaque vague de rédaction de tests, tu génères le rapport HTML et tu le lis.

1
2
go test -race -coverprofile=coverage.out -covermode=atomic ./...
go tool cover -html=coverage.out

Ce que tu cherches, ce ne sont pas les zones rouges — il y en a toujours, et c’est bien. Ce que tu cherches, ce sont les zones rouges dans les chemins critiques : validation d’input, parsing de formats externes, authentification, logique monétaire, code de migration. Là où un bug coûte très cher. Pour ces zones, tu vises effectivement une coverage proche de 100 %, mais avec des tests qui assertent.

Partout ailleurs — les fonctions utilitaires, les getters/setters, les branches d’erreur triviales — une coverage de 50 ou 60 % est parfaitement acceptable. Tu as mieux à faire que d’écrire un test qui vérifie qu’un return err retourne bien l’erreur.

Cette hiérarchisation est invisible dans le chiffre global total coverage: 73.4%. Ce chiffre ne dit rien. Un projet à 73 % dont les chemins critiques sont à 100 % est infiniment plus fiable qu’un projet à 95 % dont les chemins critiques sont à 60 %. go tool cover -func=coverage.out te donne la ventilation par fonction — c’est cette ventilation qui te renseigne vraiment, pas le total.


diff-cover et gocover-cobertura : exiger la coverage sur le diff, pas sur le total

Si tu tiens à mettre une contrainte de coverage dans ton CI — parce que ton org l’exige, parce qu’une régression silencieuse t’a déjà blessé — remplace la contrainte sur le total par une contrainte sur le diff.

L’idée est simple : chaque PR doit avoir une coverage élevée sur les lignes qu’elle ajoute ou modifie, mais la coverage totale du projet n’est pas un objectif en soi. C’est pratiquement l’opposé philosophique du seuil global : au lieu d’imposer un idéal à tout le code, tu imposes une hygiène au code nouveau. Le projet converge naturellement vers un état fiable sans que personne ne vive la sueur du « 89.4 % ».

L’outil canonique est diff-cover, maintenu par Josh Bachmann en Python, installable via pip install diff-cover. Il lit un rapport de coverage au format Cobertura XML et un git diff, et te sort un rapport de coverage restreint aux lignes du diff.

Pour Go, il faut convertir ton profil coverage.out en Cobertura XML. L’outil standard est gocover-cobertura (le fork boumenot est celui qui consolide les PRs abandonnées des autres forks, et qui reste actif en 2026). Chaîne complète :

1
2
3
go test -race -coverprofile=coverage.out -covermode=atomic ./...
gocover-cobertura < coverage.out > coverage.xml
diff-cover coverage.xml --compare-branch=origin/main --fail-under=80

La contrainte --fail-under=80 est appliquée uniquement aux lignes du diff. Les PRs qui touchent peu de code passent trivialement ; les PRs qui ajoutent du code non testé sont bloquées. C’est un tradeoff bien plus équilibré qu’un seuil global, et il s’aligne sur une règle humaine évidente : si tu ajoutes du code, tu l’accompagnes de tests.


Mutation testing : la seule métrique qui attrape vraiment les tests vides

Dernier outil de cette revue, et le plus brutal : le mutation testing. L’idée, en une ligne : pour tester la qualité de ton test suite, on modifie subtilement ton code de production (un > devient >=, un true devient false, une soustraction devient une addition) et on vérifie qu’au moins un test échoue. Si aucun test n’échoue, la mutation a survécu — et c’est le signal qu’un bug réel de ce type passerait aussi inaperçu.

Un test qui « couvre » sans asserter laissera survivre toutes les mutations. Le mutation testing est, à ma connaissance, le seul outil qui distingue automatiquement un vrai test d’un test décoratif.

L’écosystème Go a deux options actives en 2026 :

  • Gremlins — pensé pour les petits modules Go (microservices, TDD, quality gate CI). Semver encore en 0.x.x, pas de garantie de stabilité d’API, mais développement actif.
  • go-mutesting — fork Avito du projet original de Fabian Zimmski. Le repo d’origine est en pause depuis des années ; le fork Avito est celui qui continue.

Le mutation testing coûte cher à l’exécution — tu relances toute ta suite de tests pour chaque mutation — donc ce n’est pas un outil de CI par PR. C’est plutôt un outil de vérification périodique : une fois par mois sur un package critique, une fois avant une release majeure. Ce qu’il te rend en échange : une métrique honnête de la qualité de tes tests, que la coverage ne te donnera jamais.


Dialogue : la PR bloquée à 89.4 %

Junior Jules : Ma PR est bloquée. Le CI me dit 89.4 % de coverage, il en faut 90. Je dois ajouter un test.

Senior Sam : Montre.

(Jules pousse un commit de plus. Nouveau test.)

Junior Jules : Voilà, ça passe. 90.2 %.

Senior Sam : Ton test vérifie quoi ?

Junior Jules : Euh… il appelle la fonction. Avec un input qui déclenche la branche d’erreur.

Senior Sam : Il vérifie que l’erreur retournée est bien ErrInvalidInput ?

Junior Jules : Non. Il vérifie juste que err != nil.

Senior Sam : Donc si je change la fonction pour qu’elle retourne ErrOutOfMemory au lieu de ErrInvalidInput, ton test passe quand même.

Junior Jules : Techniquement oui.

Senior Sam : Ton test ne teste rien. Il couvre la ligne. C’est différent. Et il vient d’être mergé parce que tu avais un seuil à atteindre, pas un bug à attraper.

Junior Jules : Je fais quoi alors ?

Senior Sam : Tu reverts le commit bidon. Tu ouvres go tool cover -html=coverage.out, tu regardes sont les 10.6 % rouges. Tu décides lesquels sont des chemins critiques — et ceux-là, tu leur écris des tests qui assertent la bonne erreur, la bonne valeur de retour, le bon état final. Les autres, tu les laisses rouges. Et tu vas voir ton lead pour lui dire que le seuil à 90 % fait baisser la qualité de tes tests, pas monter. Avec le paper Inozemtseva 2014 en appui.

Junior Jules : Et s’il refuse ?

Senior Sam : Alors tu fais ce que l’équipe fait déjà. Mais au moins tu sauras que tu fais du théâtre de coverage, et pas du testing.


Ce que tu peux faire maintenant

  1. Ajoute -race -covermode=atomic à ta commande de test standard. C’est la seule forme qui reste honnête quand tes tests parallélisent, et tu ne perds rien en la mettant partout.
  2. Génère le rapport HTML une fois ce mois-ci sur ton projet : go tool cover -html=coverage.out. Lis-le. Pas le chiffre global — la ventilation. Identifie les chemins critiques sous-couverts. Écris-leur des tests qui assertent, pas des tests qui exécutent.
  3. Si ton CI impose un seuil global de coverage, plaide pour le remplacer par une contrainte diff-cover sur les PR. Même exigence, infiniment moins de théâtre.
  4. Audite un package avec du mutation testing une fois par trimestre. Gremlins ou go-mutesting, peu importe. Tu découvriras des zones où le score de mutation est catastrophique alors que la coverage affiche 95 %. Ce sont précisément tes tests vides.
  5. La règle de revue à graver dans le mur : quand tu lis un test en code review, cherche les assertions. Pas la coverage. Un test sans assertion n’est pas un test. Quand l’IA t’en propose un, refuse-le avec la même énergie que s’il s’agissait d’un return nil sans commentaire.

La suite

On vient de démonter la métrique la plus mal utilisée du testing Go. Il reste un sujet transversal que cette série n’a pas encore ouvert, et c’est probablement celui qui fera péter le plus de services en prod sous du code IA : la concurrence. L’IA sait écrire des goroutines. Elle les teste très rarement sous pression. Et Go, lui, vient avec un -race detector qui a attrapé plus de bugs que tous les code reviews humains réunis.

Dans le prochain article, on regarde comment écrire des tests qui déclenchent activement des race conditions, comment interpréter la sortie du -race detector, et pourquoi t.Parallel() combiné à -race est le combo le plus sous-utilisé du testing Go — celui qui transforme ta CI en Hubble Telescope à bugs concurrents.


  1. L’instrumentation Go coverage est documentée dans l’article fondateur de Rob Pike The cover story (2 décembre 2013). Le compilateur ne marque pas « des lignes » au sens littéral mais des basic blocks — séquences d’instructions sans branchement interne. Ça a des conséquences visibles : une ligne contenant a := f(); b := g() est traitée comme un bloc unique, et une expression comme x && y()y() a un effet de bord sera comptée entièrement si x est true, ou pas du tout si x est false. L’équipe Go a refusé d’étendre l’outil vers la branch coverage dans l’issue golang/go#28888, en renvoyant les équipes concernées vers gobco pour ce besoin spécifique. ↩︎

  2. La raison technique : en mode set et count, l’incrémentation du compteur est une écriture mémoire banale, non synchronisée. Si deux goroutines exécutent le même bloc simultanément (ce qui est exactement ce qui se passe avec t.Parallel()), tu as une data race qui peut corrompre le compteur. En mode atomic, l’incrémentation passe par sync/atomic.AddUint32 et devient sûre. Le coût runtime est réel — quelques nanosecondes par bloc — mais négligeable à côté de la perte d’information qui arrive quand tes compteurs divergent silencieusement. Discussion complète dans l’issue golang/vscode-go#594↩︎

  3. L’histoire de la formulation moderne de la loi de Goodhart vaut le détour. Charles Goodhart, économiste britannique, écrit en 1975 une phrase technique sur la politique monétaire de la Banque d’Angleterre : « Any observed statistical regularity will tend to collapse once pressure is placed upon it for control purposes ». C’est dense, clinique, et personne ne la retient. Vingt ans plus tard, l’universitaire comptable Keith Hoskin en propose une reformulation plus percutante (1996), que l’anthropologue Marilyn Strathern cite en 1997 dans un article sur l’évaluation universitaire britannique : « When a measure becomes a target, it ceases to be a good measure ». C’est cette version — qui n’est donc ni strictement de Goodhart ni à l’origine sur la politique monétaire — qui est devenue l’adage qu’on cite aujourd’hui. Source : Wikipedia — Goodhart’s law↩︎

  4. Laura Inozemtseva et Reid Holmes, Coverage Is Not Strongly Correlated With Test Suite Effectiveness, Proceedings of ICSE 2014. PDF intégral disponible sur cs.ubc.ca. Méthodologie : l’étude applique du mutation testing à cinq projets Java open-source mature (Apache POI, Closure Compiler, HSQLDB, JFreeChart, Joda-Time), génère des sous-ensembles aléatoires du test suite de chaque projet, mesure coverage et mutation score sur chaque sous-ensemble, puis teste la corrélation. Résultat : corrélation forte sans contrôle de taille, faible à modérée une fois la taille du suite contrôlée. Le paper a reçu l’ACM Distinguished Paper Award à ICSE 2014 et le Most Influential Paper Award à ICSE 2024 (décerné dix ans après à un paper dont l’impact s’est confirmé). ↩︎