Aller au contenu principal
La newsletter arrive ! Premier envoi très bientôt : laissez votre adresse pour la recevoir.

Pest 5 : la migration qui a fait avouer ma suite verte.

Migration Pest 5 sur un blog Symfony : extensions PHPStan jamais chargées, Xdebug fantôme, gate divisé par 50. Ce qu'une suite verte peut cacher.

8 min de lecture
Sommaire · 6

Je lis les release notes de Pest 5, sorti le 28 juillet, comme on lit une carte de restaurant : tout me fait envie. Un moteur qui ne rejoue que les tests impactés par le diff, des tests d'architecture, un vrai navigateur, des evals pour la couche IA. Deux jours plus tard, la migration est mergée.

Ce billet ne raconte pas les features. Il raconte ce que la migration a déterré : on va voir pourquoi une suite de tests verte peut mentir, et comment une migration de runner l'a forcée à avouer.

En 2025, j'avais comparé les tests à une partie de Cluedo : on cherche le coupable dans le code. Cette fois, l'enquête a remonté jusqu'à l'enquêteur.

Pour aller plus loin

Ce que la suite verte cachait

La migration a livré ce qu'elle promettait : cinq testsuites, 314 fichiers de test, 26 commits. Mais le rendement du chantier n'est pas venu de la couverture ajoutée. Il est venu de la couverture déjà là, exécutée autrement : dans un autre ordre, un autre runtime, avec un autre lecteur. Et contrairement aux failles qu'une IA avait trouvées en juin, aucune des découvertes qui suivent n'a demandé de regard extérieur, ni d'écrire un seul test.

Le commentaire de phpstan.neon jurait que les extensions doctrine, phpunit et symfony étaient chargées par l'image QA. C'était faux : l'image n'embarque pas l'installeur d'extensions, ce qui n'avait pas été vu depuis des mois. La même passe a sorti tests/ de l'ombre : 46 000 lignes exclues à la fois de phpstan.neon et de rector.php, qu'aucun outil n'avait jamais lues. Première lecture, 174 findings.

Pendant ce temps, castor test:unit sortait en 1 depuis des semaines, et le résumé annonçait sereinement « 1 847 passed ». Pest n'affiche pas les notices PHPUnit ; il a fallu un pest --log-events-text pour comprendre que la conversion avait supprimé 93 attributs #[AllowMockObjectsWithoutExpectations]. De toute façon, ces attributs n'avaient nulle part où s'accrocher : une closure Pest ne les porte pas. Un gate rouge que je lisais vert.

Le sharding a fait tomber un alibi. Découpée en quatre morceaux pour la CI, la suite a cassé sur un test qui passait depuis toujours. BaseUrlServiceTest posait un Request::setTrustedHosts(['^.*$']) statique et ne le restaurait jamais ; CanonicalUrlServiceTest en dépendait sans le savoir, ses requêtes en example.test auraient dû lever une SuspiciousOperationException. Le fichier passait par le seul hasard de l'ordre alphabétique, B avant C. Le sharding n'avait pas été mis en place pour ça.

La dernière déposition est venue du navigateur. Au premier run, assertNoAccessibilityIssues() a remonté un contraste de 3,59, pour un seuil à 4,5, sur le badge EcoIndex du pied de page. Douze pixels en gras, donc hors exemption « grand texte », sur toutes les pages du site. Le JavaScript s'exécutait pour la première fois sous un vrai Chromium, et la première chose qu'il a vue, c'est une violation WCAG que les autres suites ne pouvaient physiquement pas voir.

Le drapeau qui n'a jamais fonctionné

La migration avait sorti PHPStan et Rector de l'image QA vers le vendor du projet. Corollaire : ces analyses sont entrées dans le container de dev, celui qui charge Xdebug. Mesure faite à l'époque : 2 611 secondes avec, 1 160 sans, facteur 2,25. J'ai posé le correctif qui s'imposait, un drapeau -d xdebug.mode=off, et je suis passé à autre chose.

Ce drapeau n'a jamais rien désactivé. En Xdebug 3, la variable d'environnement XDEBUG_MODE prime sur tout réglage ini, y compris un -d de ligne de commande. Et mon .env.local la posait à debug, injectée dans le container par compose. La sonde tient en une ligne :

Terminal
php -d xdebug.mode=off -r 'var_dump(xdebug_info("mode"));'
# array(1) { [0]=> string(5) "debug" }

Chaque analyse statique depuis ce correctif a tourné sous débogueur actif. Le drapeau avait un alibi en béton : la suite était verte avec lui, verte sans lui. Rien ne pouvait le trahir. J'avais vérifié mon correctif sur son intention, jamais sur son effet.

Le correctif fiable agit un étage plus haut : l'environnement du process, pas l'ini. Toutes les lanes d'analyse passent désormais par un wrapper qui pose la variable sur le docker compose exec, seule forme que rien ne peut redéfinir en aval :

PHP
// .castor/docker.php : Xdebug neutralisé au niveau process
function php_qa(array $args, ?Context $c = null): Process
{
    return compose(
        ['exec', '-T', '-e', 'XDEBUG_MODE=off', 'php', 'php', '-d', 'memory_limit=-1', ...$args],
        $c ?? context(),
    );
}

Et cette fois, la vérification s'est faite sur l'effet, au chronomètre. On y vient.

Une heure de gate, décomposée

Le drapeau démasqué, restait à expliquer l'addition. Deux jours après le merge de la migration, le gate complet coûtait environ une heure ; un run témoin de castor qa:stan, lancé en conditions réelles, a été tué après plus de 80 minutes sans terminer. Cette heure se décomposait en quatre gaspillages, chacun avec sa preuve.

  • Xdebug actif sur toutes les lanes d'analyse : le drapeau inerte ci-dessus, facteur 2,25 à froid.
  • Le watcher de FrankenPHP : ~338 000 fichiers scannés à ~1 Hz, dont 10 Go de worktrees aspirés par le bind mount (un .dockerignore n'a aucun effet sur un bind mount). Coût constaté : ~53 % de CPU du container, à vide.
  • La sur-souscription CPU : huit lanes Castor en parallèle, multipliées par des analyseurs non plafonnés, jusqu'à un worker par cœur chacun, sur 12 vCPU.
  • Le result cache PHPStan, sacrifié : la config de l'audit hebdo héritait du tmpDir principal et invalidait le cache du run suivant. 19 minutes de pénalité à froid.

Cinquième invité surprise, côté tests : l'image php-test chargeait Xdebug sans xdebug.mode, donc en develop par défaut. Toute la suite fonctionnelle payait une instrumentation que personne n'avait décidée.

Les chiffres d'après, même code, même machine, même jour (12 vCPU, 16 Go, Docker Desktop macOS) : castor qa complet en 1 min 07 à froid, 15 secondes à chaud, qa:stan seul en 1,5 seconde à chaud. Rien n'a été retiré de la suite ; il y a même une lane de plus.

Sortir l'image QA, et le dire

En mai, j'ai publié un billet qui recommandait jakzal/phpqa : sortir les outils de QA de son vendor Composer via une image Docker dédiée. Je l'avais écrit sans avoir vérifié ce que l'image chargeait vraiment. Deux mois plus tard, je fais machine arrière, mesures en main : tout est revenu dans le vendor du projet, et c'est acté dans un ADR.

Trois défauts condamnent l'image, tous structurels. Des versions opaques : la CI et le task runner ont dérivé (1.122.2 d'un côté, 1.123.3 de l'autre) sans que rien ne le signale. Un environnement divergent : composer-unused y classait ext-imagick « unused » là où le natif le classe « used », et c'est le natif qui a raison, le pipeline médias s'en sert. Des erreurs silencieuses, enfin : les extensions PHPStan jamais chargées de la première section.

L'image a rendu service pendant cinq ans. La bascule s'est faite à parité prouvée, outil par outil, sur le même HEAD : php-cs-fixer à zéro finding des deux côtés, deptrac identique, et le rapport de complexité de phpinsights sorti byte-identique entre les deux versions. Le baseline du ratchet a survécu sans re-baseline.

Le billet de mai reste en ligne. Dans cette enquête, c'est la pièce à conviction, et c'est moi qu'elle accuse. L'erreur n'était pas de recommander l'outil : l'erreur était de le recommander sans avoir sondé ce qu'il exécutait. La même faute que le drapeau, mais publiée.

Ce qu'une suite rapide trouve

Restait à savoir si ce chantier valait plus qu'un chronomètre. La thèse de la migration prévoyait que le dividende ne se renouvellerait pas : la phase de découverte était close. Une semaine plus tard, il s'est renouvelé quand même, par un autre levier : le coût d'exécution. Une suite à une heure ne tourne pas. Une suite à une minute tourne, et trouve.

La preuve, dans la même journée. Un drift de complexité accumulé sur la branche principale, invisible parce que le ratchet ne tourne qu'en pull request. 37 symboles que le vérificateur de dépendances ne connaissait plus, la whitelist en retard sur des mois de code. Et 60 dépréciations résorbées dans la foulée qui, en disparaissant, ont démasqué 3 avertissements qu'elles recouvraient : PHPUnit n'affiche qu'un statut par test, et « deprecated » recouvre « warning ». Réparer un étage a révélé l'étage du dessous.

Cerise sur le YAML : l'unique avertissement yamllint du gate, un banal « missing starting space in comment », cachait un bug réel. Dans un scalaire non quoté, #1903) démarre un commentaire, et le nom d'une tâche Ansible était silencieusement tronqué depuis des mois.

Le mot de la fin

On passe nos journées à sonder notre code, et jamais notre juge. Le drapeau inerte n'est pourtant pas une faute de Xdebug : l'ordre de priorité entre variable d'environnement et ini est documenté. La faute, c'est la pratique : poser un correctif sans sonder son effet, dans une pièce où personne ne vérifie que le détecteur de mensonges est branché. La salle d'interrogatoire aussi mérite son enquête.

Le prochain suspect est déjà en garde à vue : un nouveau gardien d'architecture tourne en période d'essai sur le repo, en parallèle de l'ancien, verdict fin août. Et votre gate à vous, il met combien de temps ? Vous avez sondé ses drapeaux récemment ?

Une coquille, une erreur dans ce billet ? Signale-la-moi.

Ce billet est publié sous licence Creative Commons BY-NC-SA 4.0 (attribution, pas d'usage commercial, partage dans les mêmes conditions).

Vous aimerez aussi

Newsletter

Un mail quand il y a quelque chose à dire

Pas de tracking, pas de pub, pas de "10 astuces pour…". Juste les nouveaux billets et parfois une réflexion en plus.

Activez uniquement ce que vous souhaitez. Vos choix sont conservés 6 mois.

Strictement nécessaires

Indispensables au fonctionnement du site (session, sécurité, préférence d'affichage). Aucune donnée n'est partagée à des tiers et aucun consentement n'est requis.

Toujours actif

Mesure d'audience

Statistiques via Google Analytics (GA4) : pages vues, source du trafic, navigateur et interactions clés. Dépose des cookies de mesure, activés seulement avec votre accord (Consent Mode). Sans publicité ciblée, sans Google Signals, sans partage commercial.

Contenus externes

Affiche les GIF animés hébergés par Giphy (CDN aux États-Unis). À l'affichage d'un GIF, votre adresse IP et votre navigateur sont transmis à Giphy. Sans votre accord, les GIF ne s'affichent pas.