Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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 :

bash
npm install --save-dev @vitest/coverage-v8
npx vitest run --coverage
text
 % 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

MesureCe qu'elle compteExemple dans le rapport
Instructions (statements)Les instructions exécutées au moins une fois94,11 % de app.js
BranchesPour chaque condition (if, ? :, ??, &&), chaque issue possible prise au moins une fois81,48 % de app.js : certaines conditions n'ont été testées que dans un sens
FonctionsLes fonctions appelées au moins une fois87,5 % de app.js : une fonction de route n'est jamais appelée
LignesLes lignes exécutées au moins une fois96,87 % de app.js

Que nous apprend ce rapport ?

  • app.js, ligne 26 : c'est le corps de la route GET /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

LangageOutilCommande
C# (.NET)Coverlet, ajouté par le paquet coverlet.collectordotnet test --collect:"XPlat Code Coverage" produit un fichier coverage.cobertura.xml dans TestResults/ ; l'outil ReportGenerator le transforme en rapport HTML
PHPPHPUnit, avec l'extension Xdebug ou PCOVXDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text (ou --coverage-html coverage/) ; le code mesuré est celui déclaré dans <source> de phpunit.xml
JavaScriptVitest avec @vitest/coverage-v8 ; Jest avec --coveragenpx 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 :

js
// 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_JOURS par >= 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 :

bash
npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner
json
{
  "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 :

text
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 :

text
[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 :

text
[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 survivantCe qu'il révèleLe test qui le tue
<= devient < dans la seconde comparaison du chevauchementLa position 9, « commence le dernier jour », n'est pas testée : c'est le bug de PC-07 du chapitre 1Une 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 accepteeComparer 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 :

js
// 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 :

text
ERROR: Coverage for branches (72.4%) does not meet global threshold (75%)
Bonne pratiquePourquoi
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 requestUne 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 requestC'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 :

yaml
# .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émentRôle
on: push et pull_requestLes tests tournent sur main et sur chaque pull request
npm ciInstalle exactement les versions de package-lock.json, dans un dossier vierge
npm run test:couvertureLance Vitest avec la couverture : si un test ou un seuil échoue, l'étape échoue et le job est rouge
needs: tests-jsLes tests de bout en bout, plus lents, ne tournent que si les tests unitaires et d'intégration passent
npx playwright install --with-deps chromiumInstalle le navigateur et les bibliothèques système dont il a besoin sur le runner Linux
upload-artifactGarde le rapport de couverture, et le rapport Playwright (avec ses traces) quand les tests de bout en bout échouent
tests-csharpLe 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% LignesLignes non couvertes
calculs.js10050100100
routes.js726066,667240-58
config.js0100001-12
  1. Comment calculs.js peut-il avoir 100 % des lignes et 50 % des branches ?
  2. Que suggèrent les lignes 40 à 58 de routes.js ?
  3. Faut-il écrire des tests pour config.js ?
Voir la réponseMasquer la réponse de l'exercice 1
  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 cas total <= 100. La moitié des issues possibles n'a jamais été prise.
  2. 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.
  3. 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é.

js
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.

js
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 :

text
[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 :

js
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.

yaml
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
  1. Il manque actions/checkout : le runner démarre vide, le code du dépôt n'est jamais récupéré. npm install tourne dans un dossier sans package.json et npm test n'a rien à tester (selon les cas, il échoue, ou ne lance rien).
  2. || true force 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 :

yaml
    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é ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Tests de performance, de charge et de sécuritéRevoir le sommaire du cours