Chapitre 621 minIntermédiaire
Tests automatisés dans la CI et protection de branche
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- lancer des tests d'intégration contre une base jetable ;
- publier les résultats et fixer un seuil de couverture ;
- diagnostiquer et traiter un test instable ;
- configurer une protection de branche qui bloque la fusion d'un code rouge.
4 exercices corrigés1 schéma
Les tests automatisés dans la CI sont les tests unitaires, d'intégration et de bout en bout que le pipeline exécute à chaque modification, sans intervention humaine ; leur échec rend le pipeline rouge. Associés à une règle de protection de branche, ils deviennent une barrière : une pull request dont les tests échouent ne peut pas être fusionnée. Ce chapitre montre comment préparer un projet pour la CI, tester contre une vraie base de données, publier les résultats, fixer un seuil de couverture, traiter les tests instables et configurer la protection de la branche main.
Quels tests, dans quel pipeline
Tous les tests n'ont pas le même coût. On les range en pyramide : beaucoup de tests unitaires rapides à la base, moins de tests d'intégration, très peu de tests de bout en bout au sommet (les types de tests et la pyramide).
| Type | Ce qu'il vérifie | Durée | Quand le lancer |
|---|---|---|---|
| Unitaire | Une fonction ou une classe isolée : calculerIndemnite(6000) renvoie 2 250 | Quelques millisecondes chacun | À chaque pull request |
| Intégration | Plusieurs composants ensemble : la route POST /notes enregistre bien en base | De 10 ms à 1 s chacun | À chaque pull request, avec une base de test |
| Bout en bout (E2E) | Un parcours utilisateur dans un vrai navigateur : se connecter, saisir une note, la valider | Plusieurs secondes chacun | Avant la fusion sur main ou en recette, parfois chaque nuit |
| Fumée (smoke) | Le service déployé répond | Quelques secondes | Juste après chaque déploiement |
| Acceptation | Les scénarios du cahier de recette, avec l'utilisateur référent | Variable | En recette, avant la production |
Préparer le projet : des scripts standards
Le pipeline doit appeler des commandes simples, les mêmes qu'en local. On les déclare dans package.json :
{
"scripts": {
"lint": "eslint . --max-warnings 0",
"format:check": "prettier --check .",
"test": "jest",
"test:integration": "jest --config jest.integration.config.js --runInBand",
"build": "node scripts/build.js"
}
}
--max-warnings 0fait échouer ESLint au moindre avertissement : sinon, les avertissements s'accumulent et plus personne ne les lit.--runInBandexécute les tests d'intégration un par un, dans le même processus : ils partagent une base de données et ne doivent pas se marcher dessus.- La CI appelle
npm run lint,npm test,npm run test:integration. Si l'équipe change d'outil de test, le workflow ne change pas.
Les autres écosystèmes suivent la même logique : un Makefile ou des scripts Composer (composer test) pour PHP, dotnet test pour C#, pytest pour Python.
Tester avec une vraie base de données
Les tests d'intégration de sorbier-frais ont besoin d'une base MySQL. Sur GitHub Actions, la clé services démarre un conteneur à côté du job, le temps du job :
jobs:
tests-integration:
runs-on: ubuntu-latest
timeout-minutes: 15
services:
mysql:
image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: frais_test
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -proot"
--health-interval=5s
--health-timeout=3s
--health-retries=10
env:
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_USER: root
DB_PASSWORD: root
DB_NAME: frais_test
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- name: Créer les tables
run: mysql -h 127.0.0.1 -uroot -proot frais_test < sql/schema.sql
- name: Lancer les tests d'intégration
run: npm run test:integration
| Ligne | Rôle |
|---|---|
image: mysql:8.4 | La même version majeure qu'en production |
ports: - 3306:3306 | Le port de MySQL est publié sur le runner : les étapes s'y connectent par 127.0.0.1:3306 |
options: --health-cmd=… | GitHub attend que mysqladmin ping réponde avant de lancer les étapes ; sans cela, les tests démarrent avant que MySQL soit prêt et échouent avec ECONNREFUSED |
env: du job | L'application lit sa configuration dans les variables d'environnement, jamais en dur dans le code |
mysql … < sql/schema.sql | Le client mysql est préinstallé sur les runners Ubuntu ; il crée les tables à partir du script versionné |
Le mot de passe root n'est pas un secret : la base est vide, créée pour le job, détruite à la fin et inaccessible de l'extérieur. Les données de production ne vont jamais dans une base de test : on utilise un jeu de données fictif versionné (les jeux de données de test).
Publier les résultats des tests
Un journal de 2 000 lignes n'est pas le meilleur moyen de savoir quel test a échoué. Deux formats standards permettent aux outils d'afficher les résultats :
- JUnit XML pour le résultat des tests : nom, durée, réussite ou échec, message d'erreur. Presque tous les outils savent le produire et le lire.
- Cobertura ou LCOV pour la couverture du code.
Avec Jest, on ajoute le paquet jest-junit (npm install --save-dev jest-junit) et on configure jest.config.js :
module.exports = {
testEnvironment: 'node',
reporters: ['default', ['jest-junit', { outputDirectory: 'reports', outputName: 'junit.xml' }]],
collectCoverageFrom: ['src/**/*.js'],
coverageReporters: ['text', 'lcov', 'cobertura', 'json-summary'],
coverageThreshold: {
global: { lines: 80, branches: 70 },
},
};
Dans GitHub Actions, on conserve les rapports comme artefacts, même en cas d'échec, et on écrit un résumé lisible sur la page de l'exécution :
- name: Tests avec couverture
run: npm test -- --ci --coverage
- name: Conserver les rapports
if: always()
uses: actions/upload-artifact@v7
with:
name: rapports-tests
path: |
reports/
coverage/
retention-days: 14
- name: Résumé de la couverture
if: always()
run: |
echo '### Couverture des tests' >> "$GITHUB_STEP_SUMMARY"
node -e "const t = require('./coverage/coverage-summary.json').total; console.log('Lignes ' + t.lines.pct + ' %, branches ' + t.branches.pct + ' %')" >> "$GITHUB_STEP_SUMMARY" || true
Le rapporteur json-summary écrit les totaux dans coverage/coverage-summary.json ; une ligne de Node.js les ajoute au résumé. Le || true est assumé : si les tests ont planté avant de produire le fichier, le résumé est un confort, il ne doit pas ajouter une seconde erreur à la première. GitLab, lui, lit directement le JUnit XML et la couverture Cobertura et les affiche dans la merge request (artefacts et rapports dans GitLab).
La couverture de code et son seuil
La couverture de code est la part du code exécutée au moins une fois par les tests : lignes, branches (chaque côté d'un if), fonctions. Un seuil dans la configuration fait échouer les tests quand la couverture descend en dessous :
Jest: "global" coverage threshold for lines (80%) not met: 76.47%
Le seuil empêche qu'une fonctionnalité entière arrive sans test. Mais une couverture élevée ne prouve pas que les tests sont bons : un test qui appelle une fonction sans rien vérifier (sans expect) augmente la couverture sans rien tester. Fixe un seuil réaliste (70 à 80 % des lignes pour une API), monte-le progressivement, et regarde surtout les branches non couvertes du code que tu viens de modifier. Le chapitre Couverture de code et tests de mutation du cours Tests montre comment mesurer si les tests vérifient vraiment quelque chose.
La qualité du code : lint et quality gate
Le lint et le formatage sont les vérifications de qualité les plus simples (vérifications statiques). Les équipes plus grandes ajoutent une plateforme d'analyse comme SonarQube (installé dans l'entreprise) ou SonarQube Cloud (gratuit pour les projets publics). À chaque analyse, elle mesure la couverture, les duplications, la complexité, les erreurs probables et les failles connues, puis applique une porte qualité (quality gate) : un ensemble de conditions, par exemple « pas de nouveau bug, couverture du nouveau code d'au moins 80 %, moins de 3 % de lignes dupliquées ». Si la porte est fermée, le contrôle est rouge et la pull request bloquée.
La porte qualité porte sur le nouveau code : on n'exige pas de reprendre tout l'historique, mais on interdit d'ajouter de la dette technique. C'est un bon argument pour l'indicateur « Évaluer la qualité d'une solution applicative » de l'E6 SLAM.
Les tests instables
Un test instable (flaky test) réussit ou échoue au hasard, sans que le code change. C'est le pire ennemi d'une CI : après trois fausses alertes, l'équipe relance sans lire, puis fusionne malgré le rouge.
| Cause fréquente | Exemple | Remède |
|---|---|---|
| Dépendance à l'heure ou à la date | Un test qui calcule « le mois dernier » échoue le 1er janvier | Simuler l'horloge (jest.useFakeTimers() et jest.setSystemTime()) |
| Ordre des tests | Un test suppose qu'un autre a déjà inséré une note | Chaque test prépare et nettoie ses propres données |
| Asynchrone mal attendu | Un await oublié : l'assertion passe avant la fin de l'enregistrement | Toujours await les promesses ; avec TypeScript, activer la règle no-floating-promises de typescript-eslint |
| Réseau externe | Un test appelle une vraie API de géolocalisation | Simuler le service externe (mock) |
| Ressource pas prête | La base n'a pas fini de démarrer | Contrôle de santé (--health-cmd), attente explicite |
| Délais trop courts | Un timeout de 1 s qui suffit sur ton poste, pas sur le runner | Ne pas dépendre de la vitesse de la machine |
La relance automatique des tests en échec (jest.retryTimes(2)) masque le problème sans le résoudre. Quand un test est instable, on le corrige tout de suite ; si c'est impossible, on le met en quarantaine (désactivé, avec un ticket et un responsable), plutôt que de laisser toute l'équipe s'habituer au rouge.
Faire de la CI une barrière : la protection de branche
Un pipeline rouge que l'on peut ignorer ne protège rien. Sur GitHub, une règle de protection de branche ou un ruleset rend les contrôles obligatoires. Dans Settings, Rules, Rulesets, New branch ruleset, cible la branche main et active (protéger la branche main) :
| Réglage | Effet |
|---|---|
| Restrict deletions | Personne ne peut supprimer main |
| Require a pull request before merging | Plus de push direct sur main : tout passe par une pull request |
| Required approvals : 1 | Une autre personne doit approuver la pull request |
| Dismiss stale pull request approvals when new commits are pushed | Un nouveau commit annule l'approbation : on ne fait pas approuver une version puis fusionner une autre |
| Require status checks to pass | Les jobs choisis (Lint et formatage, Tests unitaires, tests-integration) doivent être verts |
| Require branches to be up to date before merging | La branche doit contenir le dernier état de main, pour que les tests aient tourné sur le code réellement fusionné |
| Require conversation resolution before merging | Tous les commentaires de revue doivent être traités |
| Block force pushes | Personne ne peut réécrire l'historique de main |
Les rulesets sont la forme récente des règles de protection : plusieurs règles peuvent s'appliquer à la même branche, on peut les activer en mode Evaluate pour observer avant d'imposer, et une liste de contournement (bypass list) précise qui peut passer outre. Laisse cette liste vide sur un projet d'équipe : la règle vaut aussi pour les administrateurs.
Disponibilité. Les règles de protection et les rulesets sont disponibles pour les dépôts publics avec l'offre gratuite, et pour les dépôts privés avec les offres payantes, dont GitHub Pro, offert aux étudiants par le GitHub Student Developer Pack.
Désigner les relecteurs : CODEOWNERS. Le fichier .github/CODEOWNERS associe des chemins à des responsables, automatiquement demandés en revue ; avec le réglage Require review from Code Owners, leur approbation devient obligatoire :
# Fichier .github/CODEOWNERS
* @lina-sorbier @hugo-sorbier
/.github/workflows/ @lina-sorbier
/sql/ @hugo-sorbier
La ligne sur .github/workflows/ est une protection importante : modifier un workflow, c'est modifier ce qui s'exécute avec les secrets du dépôt.
Sur GitLab, l'équivalent se règle en deux endroits : Settings, Repository, Protected branches (qui peut pousser et fusionner sur main), et Settings, Merge requests, case Pipelines must succeed (une merge request ne peut être fusionnée que si son pipeline réussit).
Exercices
Exercice 1 sur 4 Lire un test en échec Réussi À revoir
La pull request de Lina affiche une croix rouge. Voici la fin du journal de l'étape Tests unitaires :
FAIL tests/indemnites.test.js
● calculerIndemnite › applique 0,25 € par km au-delà de 5 000 km
expect(received).toBe(expected) // Object.is equality
Expected: 2250
Received: 2400
14 | test('applique 0,25 € par km au-delà de 5 000 km', () => {
> 15 | expect(calculerIndemnite(6000)).toBe(2250);
| ^
16 | });
Tests: 1 failed, 41 passed, 42 total
Error: Process completed with exit code 1.
Le barème interne de Sorbier Logistique rembourse 0,40 € par kilomètre jusqu'à 5 000 km, puis 0,25 € par kilomètre au-delà.
- Quel test échoue, dans quel fichier, à quelle ligne ?
- Le résultat attendu est-il juste ? Que fait probablement le code ?
- Que doit faire Lina ?
Voir la réponseMasquer la réponse de l'exercice 1
- Le test « applique 0,25 € par km au-delà de 5 000 km » du groupe
calculerIndemnite, danstests/indemnites.test.js, ligne 15. Les 41 autres tests passent. - Attendu : 5 000 × 0,40 + 1 000 × 0,25 = 2 000 + 250 = 2 250 €, le test est juste. Le code renvoie 2 400 = 6 000 × 0,40 : il applique le premier taux à tous les kilomètres et oublie la seconde tranche.
- Reproduire en local (
npx jest tests/indemnites.test.js), corriger la fonction, vérifier que tous les tests passent, puis pousser un nouveau commit : le pipeline repart seul sur la pull request. Elle ne doit surtout pas modifier la valeur attendue du test pour le faire passer.
Tu avais trouvé ?
Exercice 2 sur 4 Une pull request bloquée pour toujours Réussi À revoir
La règle de protection de main exige le contrôle tests (22). Hugo a remplacé Node.js 22 par Node.js 26 dans la matrice du job tests. Depuis, toutes les pull requests affichent « Expected — Waiting for status to be reported » et restent bloquées, alors que les trois jobs de la matrice sont verts. Explique et propose deux solutions.
Voir la réponseMasquer la réponse de l'exercice 2
Le contrôle exigé s'appelle tests (22), mais plus aucun job ne porte ce nom : la matrice produit maintenant tests (24) et tests (26). GitHub attend indéfiniment un résultat qui n'arrivera jamais.
- Solution rapide : modifier la règle de protection pour exiger
tests (24)ettests (26). - Solution durable : ajouter un job de synthèse au nom fixe, qui dépend de la matrice, et n'exiger que lui :
tests-ok:
needs: tests
if: always()
runs-on: ubuntu-latest
steps:
- run: test "${{ needs.tests.result }}" = "success"
Le if: always() fait tourner tests-ok même si un job de la matrice échoue : il est alors rouge, au lieu d'être ignoré (un contrôle ignoré compte comme réussi pour la protection de branche, ce qui laisserait passer l'échec).
Tu avais trouvé ?
Exercice 3 sur 4 Diagnostiquer un test instable Réussi À revoir
Le test suivant échoue environ une fois sur cinq dans la CI, jamais sur le poste de Hugo :
test('une note créée apparaît dans la liste', async () => {
creerNote({ montant: 42.5, motif: 'Taxi' }); // insère la note en base, renvoie une promesse
const notes = await listerNotes(); // lit toutes les notes
expect(notes).toHaveLength(1);
});
- Trouve la cause.
- Corrige le test.
- Quelle règle de lint aurait signalé le problème ?
Voir la réponseMasquer la réponse de l'exercice 3
creerNoten'est pas attendue (awaitmanquant). La lecture part sans attendre la fin de l'insertion : selon la charge de la machine, la note est déjà en base ou pas encore. Sur le poste de Hugo, rapide et peu chargé, l'insertion gagne presque toujours la course ; sur un runner partagé, pas toujours.- On attend l'insertion avant de lire :
test('une note créée apparaît dans la liste', async () => {
await creerNote({ montant: 42.5, motif: 'Taxi' });
const notes = await listerNotes();
expect(notes).toHaveLength(1);
});
Le test suppose aussi que la table est vide au départ : il doit vider la table avant chaque test (beforeEach), sinon il dépend de l'ordre d'exécution.
- La règle
@typescript-eslint/no-floating-promises(avec TypeScript), qui signale une promesse ni attendue ni traitée. En JavaScript pur, ESLint n'a pas d'équivalent fiable : la revue de code et l'habitude duawaitrestent indispensables.
Tu avais trouvé ?
Exercice 4 sur 4 Configurer la barrière Réussi À revoir
L'équipe veut que : personne ne pousse directement sur main ; chaque pull request soit approuvée par une autre personne ; les jobs Lint et formatage et Tests unitaires soient verts ; une approbation soit annulée si l'auteur pousse un nouveau commit ; les modifications des workflows soient relues par Lina. Liste les réglages et le fichier à créer.
Voir la réponseMasquer la réponse de l'exercice 4
Un ruleset sur main (ou une règle de protection) avec :
- Require a pull request before merging, Required approvals : 1 ;
- Dismiss stale pull request approvals when new commits are pushed ;
- Require status checks to pass, avec
Lint et formatageetTests unitaires; - Require review from Code Owners ;
- et, en bonne pratique, Block force pushes et Restrict deletions, liste de contournement vide.
Le fichier .github/CODEOWNERS contient au moins la ligne /.github/workflows/ @lina-sorbier (avec le vrai identifiant GitHub de Lina).
Tu avais trouvé ?