Chapitre 1223 minIntermédiaire
Couverture de code, tests de mutation et tests dans la CI
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- mesurer et lire la couverture de code d'un projet ;
- expliquer pourquoi 100 % de couverture ne prouve pas l'absence de bug ;
- évaluer la qualité des tests avec un outil de mutation ;
- lancer les tests à chaque push dans GitHub Actions.
4 exercices corrigés
La couverture de code (code coverage) est la part du code source exécutée au moins une fois par les tests, mesurée en lignes, en instructions, en branches et en fonctions. Elle montre ce qu'aucun test ne touche, mais elle ne dit pas si les tests vérifient quelque chose : une couverture de 100 % peut cacher des tests faibles. Les tests de mutation mesurent justement la qualité des tests. Ce chapitre mesure la couverture de Prêt'Matos avec Vitest, xUnit et PHPUnit, en montre les limites avec un outil de mutation, puis fait tourner les tests à chaque push dans la CI.
Mesurer la couverture avec Vitest
Vitest mesure la couverture avec le moteur intégré à Node.js (V8). Le paquet s'installe une fois :
npm install --save-dev @vitest/coverage-v8
npx vitest run --coverage
% Coverage report from v8
------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
------------------|---------|----------|---------|---------|-------------------
All files | 90.52 | 85.1 | 83.33 | 91.76 |
app.js | 94.11 | 81.48 | 87.5 | 96.87 | 26
depot-memoire.js | 95 | 71.42 | 92.3 | 92.3 | 8
depot-mysql.js | 0 | 100 | 0 | 0 | 3-18
jeu-de-test.js | 100 | 100 | 100 | 100 |
rappels.js | 100 | 100 | 100 | 100 |
regles.js | 100 | 100 | 100 | 100 |
------------------|---------|----------|---------|---------|-------------------
Le rapport s'écrit aussi en HTML dans coverage/index.html : ouvre-le dans un navigateur, clique sur un fichier, et chaque ligne apparaît en vert (exécutée), en rouge (jamais exécutée) ou en jaune (une branche d'une condition n'a jamais été prise). C'est la vue la plus utile pour trouver les trous.
Les quatre mesures
| Mesure | Ce qu'elle compte | Exemple dans le rapport |
|---|---|---|
| Instructions (statements) | Les instructions exécutées au moins une fois | 94,11 % de app.js |
| Branches | Pour chaque condition (if, ? :, ??, &&), chaque issue possible prise au moins une fois | 81,48 % de app.js : certaines conditions n'ont été testées que dans un sens |
| Fonctions | Les fonctions appelées au moins une fois | 87,5 % de app.js : une fonction de route n'est jamais appelée |
| Lignes | Les lignes exécutées au moins une fois | 96,87 % de app.js |
Que nous apprend ce rapport ?
app.js, ligne 26 : c'est le corps de la routeGET /api/materiels. Aucun test d'intégration ne l'appelle. Le trou est réel : une route sans test.depot-memoire.js, ligne 8 :listerMateriels, pour la même raison.depot-mysql.js, 0 % : ses tests sont sautés quand aucune base MySQL n'est configurée (chapitre 8). Dans la CI, avec la base, ils tourneront. Le rapport local ne doit pas faire paniquer, mais il rappelle que ce fichier n'est testé que là.regles.js, 100 % partout. Cela veut-il dire que ses tests sont parfaits ? La suite du chapitre montre que non.
La couverture en C# et en PHP
| Langage | Outil | Commande |
|---|---|---|
| C# (.NET) | Coverlet, ajouté par le paquet coverlet.collector | dotnet test --collect:"XPlat Code Coverage" produit un fichier coverage.cobertura.xml dans TestResults/ ; l'outil ReportGenerator le transforme en rapport HTML |
| PHP | PHPUnit, avec l'extension Xdebug ou PCOV | XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text (ou --coverage-html coverage/) ; le code mesuré est celui déclaré dans <source> de phpunit.xml |
| JavaScript | Vitest avec @vitest/coverage-v8 ; Jest avec --coverage | npx vitest run --coverage |
Pour Prêt'Matos en C#, la commande donne une couverture de 100 % des lignes et des branches de PretMatos.Domaine (53 lignes, 14 branches). Sans extension de couverture installée, PHPUnit affiche un avertissement et ne mesure rien : c'est une erreur fréquente le jour où l'on veut une capture pour le dossier E6.
Ce que la couverture ne dit pas
Voici une autre suite de tests pour regles.js, écrite trop vite :
// Des tests qui exécutent toutes les lignes de verifierReservation… sans tester les limites.
test('accepte', () => {
expect(verifierReservation({ materiel: 'PC-07', debut: '2027-03-20', fin: '2027-03-22' }, existantes, '2027-03-08').acceptee).toBe(true);
});
test('fin avant début', () => {
expect(verifierReservation({ materiel: 'PC-07', debut: '2027-03-20', fin: '2027-03-01' }, existantes, '2027-03-08').acceptee).toBe(false);
});
test('passé', () => {
expect(verifierReservation({ materiel: 'PC-07', debut: '2027-03-01', fin: '2027-03-02' }, existantes, '2027-03-08').acceptee).toBe(false);
});
test('trop long', () => {
expect(verifierReservation({ materiel: 'PC-07', debut: '2027-03-20', fin: '2027-04-30' }, existantes, '2027-03-08').acceptee).toBe(false);
});
test('conflit', () => {
expect(verifierReservation({ materiel: 'PC-07', debut: '2027-03-11', fin: '2027-03-12' }, existantes, '2027-03-08').acceptee).toBe(false);
});
Avec un dernier test qui appelle une fois chacune des autres fonctions, cette suite atteint 100 % en instructions, branches, fonctions et lignes sur regles.js. Pourtant :
- elle ne teste aucune valeur limite : un prêt de 42 jours est refusé, mais rien ne vérifie que 15 jours passent et que 16 non. Remplacer
> DUREE_MAX_JOURSpar>= DUREE_MAX_JOURS(refuser les prêts de 15 jours) ne ferait échouer aucun test ; - elle ne vérifie aucun motif : un message vide passerait.
La couverture dit qu'une ligne a été exécutée, pas qu'un test vérifie son résultat. C'est sa limite fondamentale, et c'est pourquoi viser 100 % de couverture comme un objectif en soi conduit à écrire des tests creux.
Mesurer la qualité des tests avec les tests de mutation
Un test de mutation introduit volontairement de petits défauts dans le code, un à la fois, et relance les tests pour chacun. Chaque version modifiée s'appelle un mutant : un > changé en >=, un true en false, une chaîne vidée, une condition remplacée par true. Si un test échoue, le mutant est tué : les tests ont vu le défaut. Si tous les tests passent, le mutant survit : les tests n'ont pas vu la différence. Le score de mutation est la part des mutants tués.
En JavaScript, l'outil de référence est Stryker, qui sait piloter Vitest :
npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner
{
"testRunner": "vitest",
"mutate": ["src/regles.js"],
"reporters": ["clear-text", "html", "progress"]
}
Ce fichier stryker.config.json dit quoi muter ; npx stryker run lance l'analyse. Sur la suite faible ci-dessus, couverte à 100 %, le verdict tombe :
File | total | covered | # killed | # timeout | # survived | # no cov | # errors |
-----------|--------|---------|----------|-----------|------------|----------|----------|
regles.js | 81.54 | 81.54 | 53 | 0 | 12 | 0 | 0 |
Douze mutants survivent sur 65. Parmi eux, celui-ci :
[Survived] EqualityOperator
src/regles.js:35:7
- if (dureeEnJours(demande.debut, demande.fin) > DUREE_MAX_JOURS) {
+ if (dureeEnJours(demande.debut, demande.fin) >= DUREE_MAX_JOURS) {
Exactement le défaut annoncé : refuser les prêts de 15 jours ne fait échouer aucun test.
Et la suite soignée du chapitre 4, avec ses valeurs limites ? Elle fait mieux, sans être parfaite : 92,31 %, 60 mutants tués, 5 survivants. Les survivants sont instructifs :
[Survived] EqualityOperator
src/regles.js:25:58
- return numeroDeJour(a.debut) <= numeroDeJour(b.fin) && numeroDeJour(b.debut) <= numeroDeJour(a.fin);
+ return numeroDeJour(a.debut) <= numeroDeJour(b.fin) && numeroDeJour(b.debut) < numeroDeJour(a.fin);
[Survived] EqualityOperator
src/regles.js:29:7
- if (numeroDeJour(demande.fin) < numeroDeJour(demande.debut)) {
+ if (numeroDeJour(demande.fin) <= numeroDeJour(demande.debut)) {
[Survived] BooleanLiteral
src/regles.js:30:24
- return { acceptee: false, motif: 'La date de fin précède la date de début.' };
+ return { acceptee: true, motif: 'La date de fin précède la date de début.' };
(et deux autres mutants du même type que le dernier, sur les motifs « début passé » et « 16 jours »).
| Mutant survivant | Ce qu'il révèle | Le test qui le tue |
|---|---|---|
<= devient < dans la seconde comparaison du chevauchement | La position 9, « commence le dernier jour », n'est pas testée : c'est le bug de PC-07 du chapitre 1 | Une demande du 14 au 18 contre une réservation du 10 au 14 : chevauche |
< devient <= dans « fin avant début » | Aucun test ne demande une réservation d'un seul jour à verifierReservation (début = fin) | Une demande du 20 au 20 mars : acceptée |
acceptee: false devient true (trois fois) | Les tests de refus vérifient le motif, mais pas acceptee | Comparer le résultat entier : toEqual({ acceptee: false, motif: '…' }) |
Avec ces trois compléments, Stryker annonce 100 % : les 65 mutants sont tués. Le test de mutation a trouvé, automatiquement, le cas limite qu'un humain avait oublié, et celui-là même qui avait causé un incident en production.
Fixer un seuil raisonnable
Un seuil de couverture fait échouer la commande de test si la couverture descend en dessous d'une valeur. Avec Vitest :
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
include: ['src/**/*.js'],
exclude: ['src/server.js', 'src/serveur-recette.js'],
thresholds: {
lines: 80,
branches: 75,
},
},
},
});
Si un seuil n'est pas atteint, Vitest affiche un message de ce type et la commande échoue :
ERROR: Coverage for branches (72.4%) does not meet global threshold (75%)
| Bonne pratique | Pourquoi |
|---|---|
| Un seuil réaliste (70 à 80 % des lignes pour une application) | Un seuil à 100 % pousse à tester des lignes sans intérêt et à écrire des tests creux |
Exclure ce qui n'a pas de logique (le démarrage server.js, les fichiers de configuration) | Ils font baisser la moyenne sans rien apporter |
| Regarder la couverture du code modifié dans chaque pull request | Une nouvelle fonctionnalité sans test se voit tout de suite, même si la moyenne globale reste bonne |
| Ne jamais baisser le seuil pour faire passer une pull request | C'est débrancher l'alarme ; ajoute les tests manquants |
Lancer les tests dans la CI
L'intégration continue (CI) rejoue automatiquement les tests à chaque push et à chaque pull request, dans un environnement propre. C'est elle qui transforme ta suite de tests en filet de sécurité pour toute l'équipe : avec une règle de protection de branche, une pull request dont les tests échouent ne peut pas être fusionnée. Le cours Intégration continue détaille tout cela ; voici un workflow GitHub Actions minimal pour Prêt'Matos :
# .github/workflows/tests.yml
name: Tests
on:
push:
branches: [main]
pull_request:
jobs:
tests-js:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run test:couverture
- name: Conserver le rapport de couverture
if: always()
uses: actions/upload-artifact@v7
with:
name: couverture
path: coverage/
tests-e2e:
runs-on: ubuntu-latest
needs: tests-js
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx vite build
- run: npx playwright test
- name: Conserver le rapport Playwright
if: failure()
uses: actions/upload-artifact@v7
with:
name: rapport-playwright
path: playwright-report/
tests-csharp:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.x
- run: dotnet test --collect:"XPlat Code Coverage"
| Élément | Rôle |
|---|---|
on: push et pull_request | Les tests tournent sur main et sur chaque pull request |
npm ci | Installe exactement les versions de package-lock.json, dans un dossier vierge |
npm run test:couverture | Lance Vitest avec la couverture : si un test ou un seuil échoue, l'étape échoue et le job est rouge |
needs: tests-js | Les tests de bout en bout, plus lents, ne tournent que si les tests unitaires et d'intégration passent |
npx playwright install --with-deps chromium | Installe le navigateur et les bibliothèques système dont il a besoin sur le runner Linux |
upload-artifact | Garde le rapport de couverture, et le rapport Playwright (avec ses traces) quand les tests de bout en bout échouent |
tests-csharp | Le même principe pour la partie .NET, en parallèle |
La variable d'environnement CI vaut true sur les runners de GitHub : la configuration Playwright du chapitre 9 active alors les relances et refuse de réutiliser un serveur existant. Pour brancher la base MySQL de test, publier les résultats des tests dans la pull request et protéger la branche main, suis le chapitre Tests automatisés dans la CI du cours Intégration continue. Pour GitLab, le principe est identique dans un fichier .gitlab-ci.yml (GitLab CI).
Exercices
Exercice 1 sur 4 Lire un rapport de couverture Réussi À revoir
Voici la couverture d'un projet d'étudiant :
| Fichier | % Instructions | % Branches | % Fonctions | % Lignes | Lignes non couvertes |
|---|---|---|---|---|---|
calculs.js | 100 | 50 | 100 | 100 | |
routes.js | 72 | 60 | 66,66 | 72 | 40-58 |
config.js | 0 | 100 | 0 | 0 | 1-12 |
- Comment
calculs.jspeut-il avoir 100 % des lignes et 50 % des branches ? - Que suggèrent les lignes 40 à 58 de
routes.js? - Faut-il écrire des tests pour
config.js?
Voir la réponseMasquer la réponse de l'exercice 1
- Toutes les lignes ont été exécutées, mais certaines conditions n'ont été testées que dans un sens. Par exemple,
if (total > 100) remise = 0.1;sur une seule ligne : un test avec un total de 150 exécute la ligne, mais aucun ne vérifie le castotal <= 100. La moitié des issues possibles n'a jamais été prise. - Un bloc de 19 lignes jamais exécuté : probablement une route entière sans test (et les deux tiers des fonctions seulement sont appelées). C'est le premier endroit où ajouter un test d'intégration.
- Probablement pas : un fichier qui lit des variables d'environnement et exporte des constantes n'a pas de logique à tester. On l'exclut de la mesure (
coverage.exclude) pour que le chiffre global reflète le code qui compte. S'il contenait une logique (une valeur par défaut calculée, une validation), elle mériterait un test.
Tu avais trouvé ?
Exercice 2 sur 4 100 % de couverture et un bug Réussi À revoir
La fonction suivante est couverte à 100 % (lignes et branches) par les deux tests qui la suivent. Elle contient pourtant un bug. Trouve-le, puis écris le test qui l'aurait détecté.
export function estEnRetard(pret, aujourdhui) {
if (pret.rendu) return false;
return aujourdhui >= pret.fin;
}
test("un prêt rendu n'est jamais en retard", () => {
expect(estEnRetard({ fin: '2027-03-14', rendu: true }, '2027-03-20')).toBe(false);
});
test('un prêt non rendu après sa date de fin est en retard', () => {
expect(estEnRetard({ fin: '2027-03-14', rendu: false }, '2027-03-20')).toBe(true);
});
Voir la réponseMasquer la réponse de l'exercice 2
La comparaison >= déclare le prêt en retard le jour même de sa date de fin, alors que le salarié a jusqu'au soir du 14 pour le rendre. Il faut >. Aucun des deux tests ne teste la limite : ils couvrent les deux branches avec des dates éloignées.
test("un prêt n'est pas en retard le jour de sa date de fin", () => {
expect(estEnRetard({ fin: '2027-03-14', rendu: false }, '2027-03-14')).toBe(false);
});
Ce test échoue avec le code actuel et passe avec >. Un outil de mutation aurait signalé le problème : le mutant qui remplace >= par > survivait aux deux tests d'origine.
Tu avais trouvé ?
Exercice 3 sur 4 Interpréter un mutant survivant Réussi À revoir
Stryker signale ce mutant survivant dans rappels.js :
[Survived] ArithmeticOperator
- date.setUTCDate(date.getUTCDate() + 1);
+ date.setUTCDate(date.getUTCDate() - 1);
Que peux-tu en déduire sur les tests de lendemain ? Propose un test qui tue ce mutant.
Voir la réponseMasquer la réponse de l'exercice 3
Si remplacer « le lendemain » par « la veille » ne fait échouer aucun test, c'est qu'aucun test ne vérifie le résultat de lendemain. La fonction est sans doute appelée par d'autres tests (elle est couverte), mais avec des doublures qui acceptent n'importe quelle date, ou sans vérifier la date demandée au dépôt. Un test direct suffit :
test('le lendemain du 13 mars est le 14 mars', () => {
expect(lendemain('2027-03-13')).toBe('2027-03-14');
});
On peut aussi renforcer le test d'envoyerRappels avec expect(depot.pretsARendreLe).toHaveBeenCalledWith('2027-03-14'), comme au chapitre 7.
Tu avais trouvé ?
Exercice 4 sur 4 Compléter un workflow Réussi À revoir
Un étudiant a ce workflow. La CI est toujours verte, même quand un test échoue sur son poste. Trouve les deux erreurs.
name: Tests
on: [push]
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm install
- run: npm test || true
Voir la réponseMasquer la réponse de l'exercice 4
- Il manque
actions/checkout: le runner démarre vide, le code du dépôt n'est jamais récupéré.npm installtourne dans un dossier sanspackage.jsonetnpm testn'a rien à tester (selon les cas, il échoue, ou ne lance rien). || trueforce le code de retour à 0 : même si les tests échouent, l'étape est verte. C'est précisément ce qui rend la CI inutile.
Version corrigée, avec npm ci pour une installation reproductible :
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm test
Tu avais trouvé ?