Un client signale qu’une extension de réservation « fonctionne en local mais casse en intégration continue ». Premier réflexe : relancer le test incriminé seul, avec phpunit --filter test_disponibilite_creneau. Il passe. Relancé dans la suite complète, il échoue une fois sur trois, toujours après le même voisin. Ce genre de symptôme a un nom précis : la fuite d’état entre tests.
Ce n’est pas un bug de logique métier, c’est un bug d’hygiène de test. Un test antérieur laisse une trace — une variable statique, une option WordPress modifiée, un hook accroché — et cette trace influence le comportement du test suivant. Le correctif est presque toujours plus simple que le diagnostic.
Confirmer que le symptôme est bien une fuite d’état
Avant de creuser, il faut éliminer les fausses pistes : dépendance externe non mockée, horodatage réel, ou ordre aléatoire activé par --random-order. Le test décisif consiste à relancer exactement les deux mêmes méthodes, dans le même ordre, en isolant tout le reste :
phpunit --filter '(test_creneau_disponible|test_disponibilite_creneau)' tests/test-reservations.php
Si l’échec réapparaît de façon reproductible dans cet ordre précis mais jamais dans l’ordre inverse, la fuite d’état est confirmée. C’est un signal fort : le problème ne vient pas du test qui échoue, mais de celui qui l’a précédé.
Cartographier les suspects habituels
Dans une base de code WordPress, trois catégories de coupables reviennent sans cesse :
- Les propriétés statiques de classes, notamment les caches internes d’une classe de service instanciée une seule fois par la suite.
- Les hooks ajoutés avec
add_filter()ouadd_action()dans un test et jamais retirés avecremove_filter()dans sontearDown(). - Les options ou transients modifiés via
update_option()sans restauration, alors queWP_UnitTestCasene réinitialise que la base de données transactionnellement, pas les valeurs mises en cache par un objet PHP vivant en mémoire.

Un outil pratique pour accélérer la chasse : la méthode tearDown() peut afficher, en cas d’échec, l’état des hooks actifs sur un filtre suspect via $GLOBALS['wp_filter']. C’est brutal, mais redoutablement efficace en debug ponctuel — à retirer ensuite, bien sûr.
Localiser précisément la ligne fautive
Une fois la catégorie identifiée, la recherche se resserre. Pour un hook qui traîne, un grep -rn "add_filter" tests/ combiné à une relecture des tearDown() correspondants suffit souvent. Pour une propriété statique, il faut repérer le pattern singleton :
class Cache_Disponibilites {
private static ?array $cache = null;
public static function get(): array {
if (self::$cache === null) {
self::$cache = self::calculer();
}
return self::$cache;
}
}
Ce singleton, initialisé une fois par le premier test qui l’appelle, sert ensuite la même valeur à tous les tests suivants du même processus PHPUnit — même si la base de données, elle, a bien été réinitialisée entre chaque méthode.
Corriger sans casser la classe de production
La tentation est de supprimer le cache statique en production. Mauvaise idée : il existe probablement pour de bonnes raisons de performance. La correction se fait côté tests, avec une méthode de réinitialisation explicite :
class Cache_Disponibilites {
// ... code existant ...
public static function reset_pour_tests(): void {
self::$cache = null;
}
}
Puis, dans la classe de test :
protected function tearDown(): void {
Cache_Disponibilites::reset_pour_tests();
remove_all_filters('reservation_creneau_disponible');
parent::tearDown();
}
La méthode reset_pour_tests() est volontairement nommée pour ne jamais être confondue avec une méthode métier — un préfixe explicite évite qu’un développeur pressé l’appelle par erreur ailleurs.
Dans notre équipe, la règle est simple : tout ce qu’un test ajoute à l’état global — hook, option, cache statique — doit être retiré dans le même fichier, par la même méthode de test. Aucune exception, même pour « juste un filtre ».
Prévenir la récidive
Trois habitudes réduisent drastiquement le risque de revoir ce problème :
- Exécuter la suite avec
--order-by=randomrégulièrement en local, pas seulement en CI : un ordre différent à chaque exécution révèle vite les dépendances cachées. - Écrire une méthode
tearDown()systématique dans chaque classe de test qui touche un hook ou une classe à état, même si le test semble « propre ». - Ajouter un test de garde qui vérifie qu’aucun filtre inattendu n’est encore accroché après l’exécution de la suite d’un module donné.
Ce dernier point est souvent négligé, alors qu’il coûte quelques lignes :
public function test_aucun_filtre_residuel_apres_suite(): void {
$this->assertEmpty(
$GLOBALS['wp_filter']['reservation_creneau_disponible'] ?? null
);
}
En résumé
Une fuite d’état ne se manifeste presque jamais dans le test qui la provoque : elle attend patiemment le test suivant pour faire des dégâts. Le réflexe à adopter est donc de toujours suspecter un voisin avant de remettre en cause la logique du test qui échoue. Isoler, comparer l’ordre d’exécution, cartographier les singletons et les hooks, puis nettoyer méthodiquement dans tearDown() : cette séquence résout la grande majorité des cas rencontrés sur des projets d’agence multi-clients où les classes de service statiques sont légion. Ce sujet ne couvre volontairement pas l’isolation par processus séparé, qui répond à un problème voisin mais distinct — celui du temps d’exécution, pas de la reproductibilité.