Article 4 de la série “De la spécification à l’exécution”. L’article précédent traitait de la répartition des rôles entre humain, IA et outils déterministes. On peut maintenant écrire des tests. Reste une question plus utile. Comment savoir s’ils valent quelque chose ?

Un cas souvent cité par les auteurs de Hypothesis résume bien le problème. Un parseur JSON de production affichait 95 % de coverage sur une suite de tests par exemples. Le score semblait bon. Pourtant, dès qu’on l’a soumis à Hypothesis avec des entrées Unicode arbitraires, il a cassé en moins de 30 secondes sur un caractère surrogate. Les tests existaient. Ils ne protégeaient pas ce qu’ils laissaient croire.

Je pars de là. Le coverage peut être utile. Il peut aussi rassurer trop vite. Si je veux savoir si une suite de tests vaut quelque chose, je dois regarder autre chose que le pourcentage affiché dans la CI.

Pourquoi le coverage seul ment

Le coverage mesure l’exécution, pas la vérification.

Trois niveaux reviennent souvent.

  • Le line coverage dit qu’une ligne a été exécutée. Cela ne dit pas si la sortie était correcte.
  • Le branch coverage ajoute la notion de branche. C’est déjà mieux, mais cela reste un indicateur de passage, pas de qualité.
  • Le MC/DC (Modified Condition / Decision Coverage) va plus loin. Il vérifie que chaque condition d’une expression booléenne influence le résultat de façon indépendante. C’est la norme dans les domaines critiques comme l’avionique ou l’automobile de sûreté.

Aucune de ces mesures ne dit si les assertions sont solides. Un test peut exécuter du code sans vraiment vérifier quoi que ce soit. Une suite peut aussi monter très haut en coverage avec des oracles faibles. Le papier de synthèse A Brief Survey on Oracle-based Test Adequacy Metrics le résume bien. Le coverage observe la structure. Il ne mesure pas la qualité de la vérification.

Avec l’IA, ce point devient plus visible. Si un agent peut générer cinquante tests en quelques secondes, le vrai sujet n’est plus le volume. C’est la qualité du volume. ThoughtWorks l’a rappelé dans le Technology Radar v33, en recentrant le débat sur le mutation testing pour les bases de code où l’IA produit beaucoup de test.

Je garde donc quatre axes.

Axe 1, coverage

Le coverage reste utile. Je l’active toujours, mais je ne lui laisse pas le rôle principal.

Je préfère le branch coverage au seul line coverage. La différence est nette dès que le code contient des branches non triviales.

LangageCommande
Pythonpytest --cov=src --cov-branch --cov-report=term-missing
TypeScript / JavaScriptvitest run --coverage
Gogo test -cover -covermode=atomic ./...

Je raisonne aussi par module, pas uniquement en global. Un module métier critique peut viser 75 %, parfois plus. Un code de câblage, comme un handler HTTP mince, peut rester plus bas. Un seuil global unique masque les écarts et pousse souvent à une chasse au 100 % qui coûte du temps sans améliorer la qualité.

Le point important est simple. Le coverage est un prérequis, pas une preuve.

Axe 2, mutation testing

C’est l’axe qui change le plus la lecture d’une suite de tests.

Le principe est mécanique. Je mute le code de production. Je remplace un == par !=, j’inverse un booléen, je supprime une ligne, je décale un index. Puis je relance les tests. Si les tests passent malgré la mutation, le mutant a survécu. Cela veut dire qu’aucun test n’a détecté le changement de comportement.

La métrique est simple.

score = mutants tués / mutants tués + mutants survivants

Elle mesure la force réelle de la suite. Pas sa taille. Pas son volume. Sa capacité à détecter une régression.

À l’ère IA, cet outil devient encore plus utile. Un agent peut produire beaucoup de tests en peu de temps. Le risque est de confondre quantité et protection réelle. Le mutation testing permet de séparer les deux.

Je garde une règle de lecture très simple.

  1. Un test manque, si la mutation correspond à un comportement jamais couvert.
  2. L’assertion est trop faible, si le code muté s’exécute mais n’est pas vraiment vérifié.
  3. Le mutant est équivalent au code d’origine, cas plus rare, mais réel, et je le marque comme tel avec justification.

Les outils varient selon le langage.

LangageOutil
Pythonmutmut
TypeScript / JavaScriptStrykerJS
Gogremlins
Java / KotlinPIT
Rustcargo-mutants
Rubymutant
.NETStryker.NET

Le coût reste réel. Le mutation testing est beaucoup plus lent que les tests normaux, souvent d’un facteur 10 à 100. Je ne l’exécute donc pas partout. Je le limite aux fichiers modifiés sur les PR, avec un run complet la nuit ou sur la branche principale, et des seuils par module.

Axe 3, les test smells

Un test peut passer, être couvert, et rester mal écrit.

Les test smells sont des anti-patterns dans le code de test. Ils ne cassent pas toujours la suite tout de suite, mais ils prédisent souvent une dégradation à venir. L’article de Garousi et Felderer, Smells in software test code: A survey of knowledge in industry and academia, reste une bonne base pour les repérer.

Je surveille surtout cinq cas.

SmellDescription
Assertion roulettePlusieurs assertions sans message clair
Eager testUn test vérifie trop de comportements à la fois
Mystery guestLe test dépend d’une ressource externe peu visible
Over-mockingMocks sur du code que je contrôle
Empty testTest vide ou quasi vide

Avec un agent IA dans la boucle, ces dérives apparaissent vite. L’agent aime parfois masquer la complexité derrière des mocks, ou empiler plusieurs vérifications dans le même test. La suite passe, puis devient difficile à maintenir.

Je m’appuie donc sur des outils statiques par langage.

  • En Python, ruff avec les règles PT, S et B couvre déjà une bonne partie du terrain. Pour aller plus loin, PyNose détecte plusieurs smells de test.
  • En TypeScript et JavaScript, je combine ESLint avec eslint-plugin-vitest ou eslint-plugin-jest, plus eslint-plugin-testing-library. Les règles expect-expect, valid-expect et no-conditional-expect restent les plus utiles.
  • En Go, golangci-lint avec testifylint, thelper, paralleltest, errcheck et bodyclose attrape déjà une bonne part des faux verts.

Je fais particulièrement attention à testifylint. Il repère les assert.Nil(t, err) qui devraient être des assert.NoError, ou les comparaisons à nil qui passent trop facilement. C’est un bruit petit à l’écran, mais grand dans le temps.

Axe 4, robustesse

Le dernier axe regarde la suite dans des conditions moins confortables.

Flakiness

Un test flaky ne raconte pas la même chose à chaque exécution. Il dépend du moment, de l’ordre d’exécution, d’un état partagé, ou d’une ressource externe. En général, ce n’est pas un test capricieux. C’est un test mal isolé.

Atlassian estime à environ 150 000 heures de développeur par an la perte moyenne liée aux tests flaky dans une organisation de taille moyenne. Je n’ai pas besoin du chiffre exact pour retenir l’idée. Une suite flaky coûte du temps et finit par réduire la confiance.

Je teste la stabilité avec des répétitions simples.

LangageCommande
Pythonpytest --count=10 -x avec pytest-repeat
TypeScriptvitest run --retry=3
Gogo test -shuffle=on -count=10 -race ./...

Pour aller plus loin, je garde en tête DeFlaker et FlakeFlagger. Les outils commerciaux de détection existent aussi, mais le plus important reste le diagnostic. Si un test est flaky, je cherche d’abord la source d’état partagé, la dépendance temporelle ou la ressource externe mal isolée.

Property-based testing

Le property-based testing couvre l’espace d’entrée plutôt qu’une liste de cas écrits à la main. C’est justement ce qui avait manqué au parseur JSON de l’accroche.

Je le vois comme l’arme principale contre les bords oubliés. Il complète bien l’axe coverage, et il corrige beaucoup de faiblesses des tests par exemples. Le sujet mérite son propre article, que je garde pour la suite de la série.

Mini-audit applicable ce week-end

Je peux faire un audit de départ sur un projet existant en moins d’une heure.

Python

pytest --cov=src --cov-branch --cov-report=term-missing
ruff check src tests
mutmut run --paths-to-mutate=src/<module_critique>
mutmut results
pytest --count=10 -x

TypeScript

pnpm exec vitest run --coverage
pnpm exec eslint . --max-warnings 0
pnpm exec stryker run --mutate "src/<module_critique>/**/*.ts"
pnpm exec vitest run --retry=3

Go

go test -cover -covermode=atomic ./...
golangci-lint run
gremlins unleash ./internal/<package_critique>/
go test -shuffle=on -count=10 -race ./...

Je ne cherche pas un score parfait. Je cherche un signal. Si le coverage est haut mais le mutation score bas, le test code fait illusion. Si les tests sont stables mais pleins de smells, je dois les simplifier. Si les tests sont bons sur le papier mais flaky, je dois d’abord rétablir la stabilité.

Trois points à retenir

1. Le coverage seul ne dit pas grand-chose. Il mesure l’exécution, pas la vérification.

2. Les quatre axes se complètent. Coverage, mutation testing, test smells et robustesse ne se remplacent pas.

3. L’IA augmente le volume, pas la valeur par défaut. Si je veux savoir si une suite vaut quelque chose, je dois mesurer autre chose que le pourcentage de couverture.

L’étape suivante est plus naturelle à confier à un agent IA. Le property-based testing explore l’espace d’entrée, trouve les cas limites, et colle bien à ce workflow. C’est le sujet de l’article 5.


Pour aller plus loin


Cet article est le quatrième d’une série de sept. L’article précédent traitait de la répartition des rôles. Le prochain parlera du property-based testing et de la façon dont il aide à attraper les bords oubliés.