Développement Web

Automatiser ses tests avec GitHub Actions

13 min de lecture

Un contrôle de syntaxe vous dit que le code se charge. Il ne vous dit pas qu’il fait ce qu’il doit faire. Le jour où vous renommez un champ et que le calcul du total part en vrille sans que personne ne s’en aperçoive, vous comprenez pourquoi on écrit des tests — et pourquoi on les fait tourner à chaque commit, pas seulement quand on y pense. Dans ce tutoriel, vous donnez à l’API Récolte une vraie suite de tests et vous la branchez sur GitHub Actions : à partir de maintenant, aucune pull request ne pourra être fusionnée si elle casse un test.

📍 Article principal de la série : GitHub Actions : le guide CI/CD pour bien démarrer
Ce tutoriel fait partie de la série « CI/CD avec GitHub Actions ». Pour la vue d’ensemble, lisez d’abord le guide principal.

🎯 Ce que vous allez apprendre

  • Isoler la logique métier dans un module testable, séparé du serveur HTTP.
  • Écrire des tests avec le runner natif de Node (node:test et node:assert), sans aucune dépendance externe.
  • Lancer la suite localement avec node --test et lire son rapport.
  • Faire tourner les tests automatiquement sur chaque push et chaque pull request via GitHub Actions.
  • Bloquer la fusion d’une PR tant que les tests ne passent pas, grâce à la protection de branche.
  • Conserver un rapport de test en artefact téléchargeable après chaque exécution.

🛠️ Ce que vous allez construire

On part du dépôt recolte-api du tutoriel précédent. On lui ajoute une vraie brique de logique : enregistrer une livraison et calculer la valeur totale d’une liste de livraisons (quantité × prix). On écrit les tests qui prouvent que ces calculs sont justes, puis on les intègre au pipeline. À la fin, votre onglet Actions affichera un job « tests » vert, et une PR fautive sera marquée non mergeable automatiquement.

Prérequis

  • Avoir suivi Créer son premier workflow : un dépôt avec ci.yml qui tourne.
  • Node.js 24 en local (le runner de test est stable depuis Node 20, donc aucune dépendance à installer).
  • Test express : si vous savez écrire une fonction JavaScript et l’exporter avec export, vous êtes prêt.
  • ⏱️ Temps estimé : ~40 minutes.

Étape 1 — Isoler une logique testable

La première règle du test n’a rien à voir avec un outil : elle concerne la structure du code. On ne teste pas facilement une fonction noyée dans un gestionnaire de route Express, car il faudrait démarrer un serveur, ouvrir un port, envoyer une requête HTTP… Beaucoup de friction pour vérifier une multiplication. La parade est d’extraire la logique métier dans un module pur, sans Express, qui prend des données en entrée et renvoie un résultat. Créons lib/livraisons.js.

// lib/livraisons.js
export function creerLivraison(producteur, produit, quantiteKg, prixUnitaire) {
  if (quantiteKg <= 0) {
    throw new Error("La quantité doit être positive");
  }
  return { producteur, produit, quantiteKg, prixUnitaire };
}

export function valeurTotale(livraisons) {
  return livraisons.reduce(
    (somme, l) => somme + l.quantiteKg * l.prixUnitaire,
    0
  );
}

Deux fonctions, zéro dépendance à HTTP. creerLivraison valide la quantité et construit un objet ; valeurTotale additionne quantité × prix sur une liste. C’est exactement le genre de code qui mérite des tests : des règles métier où une erreur coûte cher (un total faux, c’est une coopérative qui paie mal ses producteurs). Le serveur Express se contentera plus tard d’appeler ces fonctions ; lui n’a presque rien à tester.

Étape 2 — Écrire son premier test (quick win)

Node embarque depuis la version 20 un runner de test complet, stable, sans rien à installer. On importe test depuis node:test et les assertions depuis node:assert. Une convention de nommage suffit pour que Node trouve les fichiers : tout ce qui se termine par .test.js ou se trouve dans un dossier test/ est exécuté. Créons test/livraisons.test.js.

// test/livraisons.test.js
import test from "node:test";
import assert from "node:assert/strict";
import { creerLivraison, valeurTotale } from "../lib/livraisons.js";

test("valeurTotale additionne quantité × prix", () => {
  const livraisons = [
    creerLivraison("Awa Diallo", "maïs", 100, 250),
    creerLivraison("Moussa Traoré", "mil", 50, 300),
  ];
  assert.equal(valeurTotale(livraisons), 100 * 250 + 50 * 300);
});

test("valeurTotale d'une liste vide vaut zéro", () => {
  assert.equal(valeurTotale([]), 0);
});

test("creerLivraison refuse une quantité négative", () => {
  assert.throws(() => creerLivraison("Awa", "riz", -5, 200), /positive/);
});

Chaque test(...) décrit un comportement attendu en une phrase et le vérifie avec une assertion. assert.equal compare deux valeurs ; assert.throws vérifie qu’un appel lève bien une erreur. On importe node:assert/strict pour que les comparaisons soient strictes par défaut. Lancez la suite en local :

node --test

Node parcourt le projet, repère test/livraisons.test.js, exécute les trois tests et affiche un récapitulatif : le nombre de tests passés, échoués, et la durée. Vous devez voir pass 3 et fail 0. Modifiez volontairement une valeur attendue pour voir un échec : Node affiche alors la valeur reçue, la valeur attendue, et la ligne fautive. C’est votre boucle de rétroaction la plus rapide — bien avant GitHub.

Pendant que vous développez, deux raccourcis font gagner un temps fou. Lancer node --test --watch relance la suite à chaque sauvegarde de fichier : vous codez, vous voyez vert ou rouge instantanément, sans quitter l’éditeur. Et pour ne rejouer qu’un seul fichier — pratique quand la suite grossit — il suffit de le nommer : node --test test/livraisons.test.js. L’idée directrice est de raccourcir au maximum le cycle « j’écris → je vérifie » : plus il est court, plus vous attrapez les erreurs au moment où le contexte est encore frais dans votre tête.

Les calculs ne sont pas tout : une API fait aussi des choses asynchrones (lire un fichier, interroger une base). Le runner gère cela sans rien de spécial — une fonction de test async est attendue jusqu’à sa résolution. Anticipons le jour où creerLivraison deviendra asynchrone :

test("un calcul asynchrone se résout correctement", async () => {
  const total = await Promise.resolve(valeurTotale([
    creerLivraison("Fatou Sow", "arachide", 80, 400),
  ]));
  assert.equal(total, 32000);
});

Le mot-clé async devant la fonction et le await à l’intérieur suffisent : Node attend que la promesse soit tenue avant de juger le test. C’est le même outil, sans bibliothèque de promesses ni rappel imbriqué — un point qui simplifie énormément les tests d’une API réelle.

Point d’étapenode --test doit afficher tous vos tests au vert (fail 0) en local. Si Node répond « no test files found », vérifiez que le fichier est bien dans test/ ou se termine par .test.js.

Étape 3 — Brancher les tests sur le pipeline

Le code tourne en local, mais l’intérêt de la CI est qu’il tourne partout, tout le temps, sans y penser. On expose d’abord la suite comme un script npm standard, ce qui rend la commande indépendante de l’outil sous-jacent.

{
  "scripts": {
    "check": "node --check server.js",
    "test": "node --test"
  }
}

Reprenons le ci.yml du tutoriel précédent et ajoutons un step de test après l’installation des dépendances :

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 24
      - run: npm ci
      - name: Lancer les tests
        run: npm test

On a renommé le job tests pour que son nom soit parlant dans l’interface. Le step « Lancer les tests » exécute npm test, donc node --test. Si un seul test échoue, node --test renvoie un code de sortie non nul, le step vire au rouge et tout le job échoue. Poussez, ouvrez l’onglet Actions : le job « tests » doit passer au vert. Vous avez désormais un filet bien plus solide que le simple contrôle de syntaxe.

Étape 4 — Bloquer les pull requests qui cassent les tests

Un test vert, c’est bien ; un test qui empêche de fusionner du code cassé, c’est le but réel de l’intégration continue. Par défaut, GitHub affiche le résultat de la CI sur une pull request mais ne l’impose pas : on peut fusionner malgré un échec. On corrige cela avec la protection de branche.

Dans votre dépôt, allez dans Settings → Branches → Add branch ruleset (ou « Add rule » selon l’interface), ciblez la branche main, puis cochez Require status checks to pass before merging et sélectionnez le check tests. Désormais, le bouton de fusion d’une PR reste grisé tant que le job tests n’est pas vert. C’est une règle d’équipe matérialisée par l’outil : impossible d’introduire dans main du code qui casse un calcul, même par mégarde, même un vendredi soir.

Pour le vérifier, créez une branche, modifiez valeurTotale pour qu’elle renvoie n’importe quoi, poussez et ouvrez une PR. GitHub lance la CI, le test échoue, et la PR affiche clairement que la fusion est bloquée. Annulez la modification : la CI repasse au vert, la fusion se débloque. Vous venez de transformer une bonne intention en garantie automatique.

Étape 5 — Conserver un rapport de test en artefact

Quand un test échoue sur le runner et pas chez vous, vous voulez pouvoir récupérer ce qui s’est passé. Les artefacts sont des fichiers qu’un job produit et que GitHub conserve, téléchargeables depuis la page d’exécution. Demandons à Node d’écrire un rapport au format JUnit (un format XML universel, lu par la plupart des outils) et téléversons-le.

      - name: Lancer les tests et produire un rapport
        run: npm test -- --test-reporter=junit --test-reporter-destination=rapport-tests.xml
        # le `--` transmet les options à node --test

      - name: Téléverser le rapport
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: rapport-tests
          path: rapport-tests.xml

Deux détails comptent. Le -- dans npm test -- sert à passer les options au-delà du script, jusqu’à node --test : on lui demande le rapporteur junit écrit dans un fichier. Et surtout, if: always() sur le step de téléversement : sans lui, un job qui échoue s’arrête avant de téléverser, et vous perdez justement le rapport qui vous intéressait. always() force l’exécution du step même après un échec. Le rapport apparaît ensuite dans la section Artifacts de l’exécution, prêt à être téléchargé.

Point d’étape — Après un push, la page d’exécution doit afficher un artefact rapport-tests. Téléchargez-le : c’est un XML listant chaque test et son verdict. Provoquez un échec et vérifiez que l’artefact est tout de même produit, grâce à always().

🐞 Pièges fréquents

Symptôme / erreur Cause probable Correctif
no test files found Fichier mal nommé ou hors du dossier test/ Nommer en *.test.js ou placer dans test/
Cannot use import statement outside a module "type": "module" absent du package.json Ajouter "type": "module" ou renommer en .mjs
Les tests passent en local mais échouent dans la CI Dépendance installée globalement chez vous, absente du verrou Tout déclarer dans package.json ; la CI part d’une machine vierge
Le job réussit alors qu’un test a échoué Échec avalé par un || true ou un script qui masque le code de sortie Laisser node --test propager son code ; ne pas l’enrober
L’artefact est vide ou absent après un échec if: always() oublié sur le step de téléversement Ajouter if: always()

🌍 Adaptation au contexte ouest-africain

Le choix du runner natif n’est pas anodin ici. Beaucoup de tutoriels imposent Jest, Mocha ou Vitest, qui tirent des dizaines de paquets — donc des téléchargements lourds à chaque npm install, pénibles sur une connexion facturée au volume ou instable. Le runner de Node ne demande rien de plus : il est déjà dans le binaire que vous utilisez. Moins de dépendances, c’est aussi moins de surface de panne et des installations CI plus rapides — donc moins de minutes consommées sur un dépôt privé.

Pour un collectif qui code à plusieurs depuis Abidjan, Bamako ou Lomé avec des niveaux d’expérience variés, la protection de branche est une bouée : un junior ne peut pas casser la production par inadvertance, la machine refuse poliment. La revue humaine se concentre alors sur le fond, pas sur la chasse aux régressions évidentes.

✅ Récapitulatif

Vous avez transformé un simple contrôle de chargement en véritable garde-fou métier. Vous savez maintenant isoler la logique dans un module pur pour la rendre testable, écrire des tests avec le runner natif de Node sans aucune dépendance, et lire leur sortie. Vous avez branché ces tests dans GitHub Actions pour qu’ils s’exécutent à chaque push et chaque PR, puis verrouillé la branche main pour qu’un échec bloque la fusion. Enfin, vous conservez un rapport de test en artefact, même quand tout casse. Votre pipeline ne dit plus seulement « ça compile » : il dit « ça marche encore ».

🧾 Aide-mémoire

Élément Rôle
import test from "node:test" Runner de test intégré à Node
node:assert/strict Assertions en mode strict
node --test Découvre et exécute les fichiers de test
npm test -- --test-reporter=junit Produire un rapport JUnit
actions/upload-artifact@v7 Conserver un fichier après le job
if: always() Exécuter un step même après un échec
Require status checks Bloquer la fusion tant que la CI n’est pas verte

💪 À vous de jouer

1. Ajoutez une fonction livraisonsParProducteur(livraisons) qui regroupe les quantités par producteur, et écrivez son test.

2. Faites en sorte que la CI échoue aussi si la couverture de code tombe sous un seuil.

Voir une piste
# Couverture intégrée (encore expérimentale dans Node 24)
node --test --experimental-test-coverage

Le drapeau --experimental-test-coverage affiche un tableau de couverture par fichier. Comme il est encore expérimental, ne l’utilisez pas comme seul critère bloquant en production : traitez-le pour l’instant comme un indicateur, et combinez-le à un seuil manuel si besoin.

Tutoriels frères

Pour aller plus loin

FAQ

Faut-il installer Jest ou Mocha ?
Non. Le runner intégré à Node (node:test) couvre l’immense majorité des besoins : tests synchrones et asynchrones, sous-tests, hooks, mocks, rapports. On y ajoute un framework seulement pour des besoins très spécifiques.

Où placer mes fichiers de test ?
Dans un dossier test/, ou n’importe où avec un nom finissant par .test.js. Node découvre aussi *-test.js et *_test.js. La convention dossier test/ reste la plus lisible.

Pourquoi tester la logique et pas les routes HTTP ?
On teste en priorité ce qui contient des règles et des calculs — la logique métier. Les routes se contentent d’appeler cette logique ; on les couvre avec quelques tests d’intégration plus lourds, en moins grand nombre.

Combien de temps GitHub garde-t-il les artefacts ?
90 jours par défaut sur un dépôt public, paramétrable. Au-delà, ils sont supprimés automatiquement pour ne pas saturer le stockage.

La protection de branche ralentit-elle l’équipe ?
Au contraire : elle supprime les longues sessions de débogage causées par une régression entrée discrètement dans main. Le coût est de quelques minutes d’attente de CI ; le bénéfice, des heures non perdues.

Mots-clés : tests automatisés GitHub Actions, node:test, node –test, npm test CI, protection de branche, artefact GitHub Actions, intégration continue Node.js.

Partager