Les tests étaient déjà écrits. Rien ne les a jamais lancés.
Une régression est arrivée en production, et le test qui l'aurait attrapée était déjà dans le repo. Il n'y avait aucun script de test, et le seul workflow CI ne se déclenchait jamais sur un push. Comment j'ai câblé tout ça.
Une régression est arrivée en production. Pas une exotique : un comportement qui marchait avant, qui a discrètement cessé de marcher, et un client l’a remarqué avant moi.
La partie humiliante est venue pendant le post-mortem. Le test qui l’aurait attrapée était déjà posé dans le dépôt. Il avait été écrit des mois plus tôt, il était correct, et il n’avait jamais tourné une seule fois de toute la vie du projet.
Voici la partie 3 d’une série sur le durcissement d’un SaaS construit en solo en production. Le setup et la liste complète des points faibles sont dans l’article pilier. Celui-ci parle du gain de qualité le moins cher qui existe : lancer les tests que vous avez déjà.
Trois couches de rien
Je croyais avoir “quelques tests”. Ce que j’avais vraiment, c’était cinq fichiers de test et aucun mécanisme pour les exécuter. L’échec avait trois couches, et chacune aurait suffi à elle seule.
Il n’y avait aucun script de test. Ni dans le package.json racine, ni dans celui de l’API. Les tests sont écrits avec le runner intégré de Bun :
import { describe, expect, test } from 'bun:test';
Donc la seule façon de les lancer était de savoir, de mémoire, qu’il fallait taper bun test dans le bon répertoire. Rien dans le projet ne vous le disait. Une nouvelle personne qui clone le dépôt n’avait aucun moyen de découvrir que la suite de tests existait.
Rien ne les déclenchait. Le repo a bien un répertoire .github/workflows/, ce qui me donnait le sentiment confortable que “la CI existe”. Mais le seul workflow dedans commence comme ceci :
on:
repository_dispatch:
types: [jira-ticket-moved-to-progress]
Il se déclenche quand un ticket Jira passe en “en cours”. C’est une automatisation utile, et elle n’a absolument rien à voir avec la justesse de mon code. Il n’y avait aucun déclencheur push et aucun déclencheur pull_request nulle part. Le code pouvait aller de mon éditeur à la production sans une seule vérification automatique.
Le linter ne tournait pas non plus. Il y a un très bon script lint dans le package.json racine. Même histoire : il ne tournait que quand je pensais à le lancer, c’est-à-dire rarement, et jamais avant un déploiement.
La leçon que je mettrais sur une affiche : un test qui ne tourne pas automatiquement n’existe pas. C’est un fichier. Les fichiers n’attrapent pas les régressions.
Étape 1 : rendre la suite exécutable
Avant d’automatiser quoi que ce soit, les tests ont besoin d’un point d’entrée évident. C’est un changement de deux lignes et c’est le plus rentable de tout l’article :
{
"scripts": {
"test": "bun test",
"lint": "eslint 'apps/**/*.{ts,js}' 'packages/**/*.{ts,js}'"
}
}
Maintenant bun run test marche depuis la racine, pour moi et pour n’importe qui d’autre, et il y a une commande unique que la CI peut appeler. Si vous ne retenez qu’une chose de cet article, retenez celle-ci : si votre commande de test est du savoir tribal, elle est déjà cassée.
Étape 2 : les faire tourner à chaque push et chaque pull request
Le workflow lui-même est court. Bun rend la mise en place triviale :
# .github/workflows/test.yml
name: test
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run lint
- run: bun run test
C’est tout. Chaque push sur main et chaque pull request lance maintenant le linter et la suite unitaire complète. Les cinq tests qui n’avaient jamais été exécutés se sont mis à s’exécuter, à chaque changement, pour toujours.
Notez le --frozen-lockfile. Il fait échouer la CI si le lockfile ne correspond pas au package.json, au lieu de résoudre silencieusement des versions de dépendances différentes de celles avec lesquelles vous développez et déployez. Sur un projet où le lockfile est commité, vous voulez ça.
Étape 3 : le test que je n’avais pas
Les tests unitaires sont bons pour la logique pure. Ils sont inutiles pour vous dire si l’application démarre vraiment, si les routes sont câblées, ou si l’authentification garde encore ce qu’elle doit garder. Toute cette catégorie de pannes n’était pas testée.
Le plus drôle, c’est que j’avais déjà un smoke test parfait, écrit. Il ne tournait juste qu’en production. Regardez le healthcheck du fichier compose :
healthcheck:
test: ['CMD', 'bun', '-e', "fetch('http://localhost:8000/health')..."]
C’est une assertion de bout en bout : démarrer l’app, taper une vraie route HTTP, attendre une bonne réponse. Docker l’exécute fidèlement contre la production, toutes les 30 secondes, après que j’ai déjà déployé. C’est-à-dire exactement au mauvais moment pour découvrir que l’app ne démarre pas.
J’ai donc déplacé cette assertion plus tôt, dans la CI, et ajouté la vérification que les tests unitaires rataient systématiquement :
// apps/api/src/__tests__/smoke.test.ts
import { describe, expect, test } from 'bun:test';
const BASE = process.env.API_URL ?? 'http://localhost:8000';
describe('api smoke', () => {
test("l'app démarre et répond sur /health", async () => {
const res = await fetch(`${BASE}/health`);
expect(res.ok).toBe(true);
});
test('une route protégée rejette encore les appels anonymes', async () => {
const res = await fetch(`${BASE}/api/crud/deal`);
expect(res.status).toBe(401);
});
});
Le deuxième test est celui qui a de la valeur. Il ne vérifie pas de logique métier, il vérifie que le middleware d’authentification est toujours attaché à la route. C’est précisément le genre de câblage que les tests unitaires ne remarquent jamais et qui casse pendant un refactor.
Le lancer en CI demande les vraies dépendances, que GitHub Actions vous donne sous forme de service containers :
smoke:
runs-on: ubuntu-latest
services:
db:
image: postgres:15
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: test
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
redis:
image: redis:7
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run --cwd apps/api server.ts &
- run: bunx wait-on http://localhost:8000/health
- run: bun test smoke
Commencez petit ici. Une suite smoke qui prouve que l’app démarre et que l’authentification est toujours appliquée attrape une part surprenante des vraies régressions, et elle coûte une trentaine de lignes. La couverture end-to-end complète de chaque parcours peut venir plus tard, ou jamais.
Ce que ça ne corrige toujours pas
Soyons honnête sur la limite : la CI tourne maintenant et elle me dit quand quelque chose est cassé. Elle ne m’empêche pas de merger quand même. Rien n’empêche un push direct sur main, et un pipeline rouge n’est aujourd’hui qu’une icône rouge que je suis libre d’ignorer.
Transformer “la CI signale” en “la CI bloque” demande de la branch protection et un status check requis, et ça vient avec un vrai pipeline de déploiement. C’est le prochain article de cette série.
À retenir
Je n’ai eu besoin d’écrire aucun nouveau test unitaire pour rendre ce projet nettement plus sûr. Les tests existaient. Ce qui manquait, c’était un script test pour pouvoir les lancer, et un déclencheur de workflow pour qu’ils soient lancés sans que j’y pense.
Si vous avez un repo avec des tests dedans, vérifiez deux choses tout de suite : est-ce que npm test ou bun run test fait vraiment quelque chose, et est-ce qu’un workflow se déclenche sur pull_request ? Si l’une des deux réponses est non, votre suite de tests est une décoration. La vue d’ensemble de la série contient le reste de la liste de durcissement.