« Une dernière chose... » Dans un manoir qu'on appelle un serveur de production, chaque bug est un crime. Une NullPointerException retrouvée dans la bibliothèque. La victime : l'expérience utilisateur. Le coupable : le Service ? Le Controller ? La base de données ?
Ce billet fait le tour complet des enquêtes que je mène sur ce blog : les tests unitaires, les fonctionnels, les tests JavaScript, la contre-enquête par mutation, la ronde du gardien après chaque déploiement. Avec les chiffres réels du repo, le code réel, et la raison d'être de chaque enquête. Y compris ce que je ne teste pas, et pourquoi.
Sortez votre loupe.
L'interrogatoire : le test unitaire
Le test unitaire, c'est l'interrogatoire du suspect dans une salle blanche. Pas de fenêtre, pas d'avocat, pas de complices. Le suspect, c'est une classe PHP. Ici, PostNavigationService, le service qui choisit les billets « Vous aimerez aussi » en bas des articles.
En production, ce service s'appuie sur la base de données, sur la recommandation sémantique de Meilisearch et sur un cache. Dans la salle d'interrogatoire, aucun des trois n'entre. On les remplace par des doublures, des acteurs qui suivent un script : les mocks.
// tests/Unit/Service/Content/PostNavigationServiceTest.php (élagué)
public function testGetRelatedPostsUsesCategoryFirst(): void
{
$post = $this->createPost('current');
$catPost = $this->createPost('catPost');
$post->category = new Category();
// Le script de la doublure : « si on te demande les billets
// de la même catégorie, réponds celui-ci. »
$this->postRepository->expects($this->once())
->method('findRecentByCategoryExcluding')
->willReturn([$catPost]);
$results = $this->postNavigationService->getRelatedPosts($post, 3);
$this->assertSame([$catPost], $results);
}Et le détail qui fait un bon interrogatoire : vérifier ce que le suspect n'a pas fait.
// Un billet sans catégorie ne doit jamais déclencher
// la recherche par catégorie.
$this->postRepository->expects($this->never())
->method('findRecentByCategoryExcluding');expects($this->never()), c'est demander au témoin : « êtes-vous certain que l'accusé n'a jamais téléphoné à son complice ? ». Si le téléphone a sonné, le test échoue.
Pourquoi c'est la meilleure salle du commissariat : quand ce test échoue, le coupable est connu à 100 %. Pas le réseau, pas la base : ils n'étaient pas dans la pièce. Et l'interrogatoire dure quelques millisecondes. Au moment où j'écris, ce repo en compte 1 333, répartis dans 182 fichiers. C'est la population la plus nombreuse du manoir, et c'est voulu.
La reconstitution : le test fonctionnel
Le test fonctionnel, c'est la reconstitution sur les lieux. Cette fois on ne mocke presque rien : vrai kernel Symfony, vraie base PostgreSQL, vraie requête HTTP. L'inspecteur se déguise en visiteur et sonne à la porte.
// tests/Functional/Regression/SmokeTest.php
$crawler = $kernelBrowser->request('GET', $url);
$this->assertResponseIsSuccessful();
$this->assertGreaterThanOrEqual(1, $crawler->filter('header')->count());
$this->assertGreaterThanOrEqual(1, $crawler->filter('footer')->count());Deux subtilités que la doc ne raconte pas.
D'abord, le manoir de la reconstitution est éphémère. Les fonctionnels tournent dans une stack Docker jetable : un PostgreSQL monté en tmpfs, c'est-à-dire en RAM, détruit à la fin de la session. On reconstruit le décor à chaque campagne d'enquête, et on ne laisse jamais la reconstitution contaminer la vraie scène de crime, la base de dev.
Ensuite, les enquêtes se succèdent dans le même décor, sans remise à zéro entre deux tests. Chaque reconstitution doit donc tolérer les traces de la précédente : slugs uniques, et jamais d'assertion du type « le manoir est vide ». C'est un compromis assumé. La remise à zéro par test coûte du temps machine, la tolérance aux traces coûte de la discipline.
Qu'est-ce qu'on reconstitue ? 218 scénarios dans 58 fichiers. Le chemin nominal, bien sûr : une page d'article répond 200 et affiche son titre. Mais aussi les serrures : un test vérifie que chaque réponse porte ses en-têtes de sécurité (X-Frame-Options, Referrer-Policy...), un autre que la pagination reste utilisable au clavier. Si ça plante, le coupable peut être n'importe où dans la chaîne Routeur → Controller → Service → Twig. C'est moins précis qu'un interrogatoire, mais c'est le seul moyen de vérifier que les suspects se parlent.
Les témoins du navigateur : les tests JavaScript
Longtemps, je n'ai interrogé personne côté navigateur. Et j'ai eu droit au crime parfait : pas de cadavre, pas d'erreur console, rien. Juste un bouton qui ne faisait rien.
L'histoire est écrite dans le repo. Les contrôleurs Stimulus de l'admin doivent être enregistrés à la main dans admin.js. Si on oublie l'appel register(), Stimulus ignore silencieusement l'identifiant : la fonctionnalité est morte, et rien ne le signale. Un widget du dashboard a vécu exactement ça, cassé en production de l'issue #992 jusqu'à son retrait en #1381. Personne dans le manoir n'avait rien vu.
Depuis, le front a ses témoins : 542 cas Vitest dans 79 fichiers. Dont un test-garde-fou qui liste les contrôleurs du dossier et vérifie que chacun a bien son enregistrement :
// tests/Js/Unit/admin/controllers/admin-controllers-are-registered.test.mjs
it.each(controllers)("%s is registered in admin.js", (file) => {
expect(isRegistered(identifierFor(file), adminJs)).toBe(true);
});Le reste des témoins couvre la logique pure : le scoring des quiz, le stockage du consentement cookies, les helpers CSRF. Des fonctions sans effet de bord, interrogées en salle blanche elles aussi. Le principe ne change pas, seule la langue du suspect change.
La contre-enquête : tester le détective
Question dérangeante : qui vérifie que mes 1 333 interrogatoires posent les bonnes questions ? Une couverture de code à 90 % prouve qu'un inspecteur est passé dans la pièce. Elle ne prouve pas qu'il aurait remarqué le cadavre.
Le mutation testing renverse le jeu. Infection prend le code de production et y commet de faux crimes, un par un : il inverse un > en >=, supprime un appel de méthode, remplace un true par false. Puis il relance toute la suite. Si un mutant survit, c'est-à-dire si aucun test ne hurle, c'est qu'aucun inspecteur ne surveillait cette pièce-là.
Sur ce repo, le seuil est fixé à 70 % de mutants tués (--min-msi=70). Et c'est une enquête d'audit, pas un réflexe quotidien : commettre des centaines de faux crimes et rejouer la suite complète à chaque fois, c'est long. On ne fait pas d'exercice incendie tous les matins.
La ronde du gardien : le smoke test
Toutes les enquêtes précédentes se déroulent avant le déploiement. Reste le vrai manoir, celui où vivent les visiteurs. Après chaque mise en production, le playbook de déploiement fait une ronde : une requête HTTP depuis le serveur lui-même, qui doit répondre par la redirection HTTPS attendue, puis une requête HTTPS publique qui doit répondre 200, avec quelques tentatives le temps que le serveur démarre.
Ce n'est pas une enquête, c'est une ronde de nuit : on vérifie que la porte s'ouvre et que la lumière du hall s'allume. Ça n'attrapera jamais un crime subtil. Mais ça sonne l'alarme dans la minute si le déploiement a muré la porte d'entrée.
La caméra de surveillance que je n'ai pas posée
Il manque un étage à cette pyramide : le test End-to-End, celui qui pilote un vrai Chrome, clique sur les vrais boutons, exécute le vrai JavaScript. Symfony Panther ou Playwright font ça très bien.
Je n'en ai pas. C'est un choix, et le bouton muet de tout à l'heure en montre exactement le prix : une caméra E2E braquée sur le dashboard aurait vu qu'il ne se passait rien au clic. J'ai préféré combler le trou avec un garde-fou unitaire de trois lignes plutôt qu'avec une caméra à plusieurs secondes par plan, fragile au moindre changement de CSS. Sur un blog où le JavaScript reste une couche d'appoint, l'essentiel du rendu est du HTML que les reconstitutions couvrent déjà. Le jour où ce site embarquera un parcours critique piloté par le navigateur, la caméra deviendra rentable. Pas avant.
Une borne, pendant qu'on y est : ce billet ne couvre pas les experts qui travaillent avant le crime, PHPStan, Deptrac et toute l'analyse statique. Eux n'attendent pas le cadavre : ils empêchent le suspect d'entrer dans le manoir. Ça mérite une enquête à part entière.
Pour aller plus loin
Igor PHP : voir ce que PHPStan ne voit pas en worker mode
Pour aller plus loin
jakzal/phpqa : sortir la QA Symfony de son vendor/
Le mot de la fin
La pyramide des tests, version chiffrée de ce blog :
- 1 333 interrogatoires (tests unitaires PHP) : rapides, précis, un coupable unique.
- 542 témoins côté navigateur (Vitest) : la logique front, plus un garde-fou né d'un crime parfait.
- 218 reconstitutions (tests fonctionnels) : les composants se parlent, les serrures tiennent.
- Une contre-enquête d'audit (mutation testing) : on teste le détective lui-même.
- Une ronde à chaque déploiement (smoke) : la porte d'entrée s'ouvre.
- Zéro caméra E2E : assumé, et réévalué à chaque nouveau parcours critique.
Si vous ne deviez retenir qu'une phrase : tester, c'est réduire la liste des suspects avant le crime. Un bug finira toujours par franchir la grille. La question, c'est le nombre de pièces à fouiller ce jour-là. Sur du code couvert, l'enquête dure dix minutes. Sur du code nu, elle dure un week-end, et c'est le visiteur qui découvre le corps.
Et chez vous, c'est quoi la pièce du manoir où aucun inspecteur n'a jamais mis les pieds ?
Une coquille, une erreur dans ce billet ? Signale-la-moi.