Mesurer si les tests valent quelque chose, quatre axes
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.
| Langage | Commande |
|---|---|
| Python | pytest --cov=src --cov-branch --cov-report=term-missing |
| TypeScript / JavaScript | vitest run --coverage |
| Go | go 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.
- Un test manque, si la mutation correspond à un comportement jamais couvert.
- L’assertion est trop faible, si le code muté s’exécute mais n’est pas vraiment vérifié.
- 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.
| Langage | Outil |
|---|---|
| Python | mutmut |
| TypeScript / JavaScript | StrykerJS |
| Go | gremlins |
| Java / Kotlin | PIT |
| Rust | cargo-mutants |
| Ruby | mutant |
| .NET | Stryker.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.
| Smell | Description |
|---|---|
| Assertion roulette | Plusieurs assertions sans message clair |
| Eager test | Un test vérifie trop de comportements à la fois |
| Mystery guest | Le test dépend d’une ressource externe peu visible |
| Over-mocking | Mocks sur du code que je contrôle |
| Empty test | Test 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,
ruffavec les règlesPT,SetBcouvre 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-vitestoueslint-plugin-jest, pluseslint-plugin-testing-library. Les règlesexpect-expect,valid-expectetno-conditional-expectrestent les plus utiles. - En Go,
golangci-lintavectestifylint,thelper,paralleltest,errchecketbodycloseattrape 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.
| Langage | Commande |
|---|---|
| Python | pytest --count=10 -x avec pytest-repeat |
| TypeScript | vitest run --retry=3 |
| Go | go 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
- A Brief Survey on Oracle-based Test Adequacy Metrics (arxiv 2212.06118) - arxiv.org
- Garousi V. & Felderer M. (2018), Smells in software test code: A survey of knowledge in industry and academia - sciencedirect.com
- ThoughtWorks Technology Radar v33 (2025) - thoughtworks.com/radar
- DeFlaker: Automatically Detecting Flaky Tests (ICSE 2018) - experts.illinois.edu
- FlakeFlagger: Predicting Flakiness Without Rerunning Tests (ICSE 2021) - jonbell.net
- mutmut - github.com/boxed/mutmut
- StrykerJS - stryker-mutator.io
- gremlins (Go) - github.com/go-gremlins/gremlins
- PyNose - github.com/JetBrains-Research/PyNose
- eslint-plugin-testing-library - github.com/testing-library/eslint-plugin-testing-library
- golangci-lint - golangci-lint.run
- Le repo public de la série - github.com/mwolff44/spec-to-tests
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.