# Fuite mémoire sur 3 000 tests PHPUnit : ce que coûte l’isolation

> Isoler chaque test dans son propre processus règle les fuites mémoire, mais à quel prix en temps d'exécution ? Le calcul sur une grosse suite d'agence.

- Auteur : Clément Hadrot
- Publié le : 2023-10-06
- Mis à jour le : 2023-10-06
- Catégorie : Tests
- URL : https://wpmoderne.dev.wordpress-developpement.fr/tests/fuite-memoire-3000-tests-phpunit-isolation/

## L’essentiel

- Un même processus PHP accumule des fuites mémoire invisibles test après test
- L'isolation par processus règle le symptôme, jamais la cause
- Le compromis se mesure en minutes ajoutées par exécution complète

La suite de tests d'une plateforme de gestion locative que nous maintenons depuis plusieurs années a fini par dépasser 3 000 méthodes de test, réparties sur une centaine de fichiers. À partir d'un certain volume, le pipeline échouait de façon intermittente avec une erreur `Allowed memory size exhausted`, toujours vers la fin de l'exécution, jamais au début. Le diagnostic a rapidement pointé vers une fuite mémoire progressive, accumulée test après test dans le même processus PHP, plutôt qu'un test isolé anormalement gourmand.

La solution la plus radicale — isoler chaque test dans son propre processus PHP, via l'option `--process-isolation` de PHPUnit — règle effectivement le symptôme. Mais elle a un coût qu'il faut mesurer avant de l'adopter aveuglément sur l'ensemble de la suite.

## Confirmer la nature du problème

Avant toute décision d'architecture, il faut confirmer que la mémoire croît bien de façon cumulative au fil de l'exécution, plutôt que d'être consommée massivement par un seul test isolé. Un rapport de mémoire par test, activable via une extension PHPUnit ou un simple hook de log, révèle la tendance :

```
protected function tearDown(): void {
    parent::tearDown();
    fwrite(STDERR, sprintf(
        "[%s] Mémoire: %s Mo\n",
        static::class,
        round(memory_get_usage(true) / 1024 / 1024, 1)
    ));
}
```

Sur ce projet, le rapport a confirmé une croissance quasi linéaire de 45 Mo en début de suite à 340 Mo en fin d'exécution, sans pic isolé attribuable à un test précis — la signature typique d'une fuite cumulative plutôt que d'un test unique disproportionné.

## Identifier les sources probables de fuite

- Des hooks WordPress accrochés par des tests successifs sans être retirés, qui accumulent des fermetures (closures) référençant des données de test entre les exécutions.
- Des propriétés statiques de classes de service, déjà évoquées dans le contexte des fuites d'état, qui retiennent en mémoire des objets volumineux au-delà de la durée du test qui les a créés.
- Le cache d'objet WordPress, qui peut grossir de façon significative si de nombreux tests créent du contenu sans jamais le vider explicitement.

## La solution radicale : l'isolation par processus

PHPUnit propose une option qui exécute chaque test dans un processus PHP séparé, garantissant qu'aucune fuite ne peut se propager d'un test à l'autre, puisque la mémoire est intégralement libérée à la fin de chaque processus :

```
phpunit --process-isolation
```

> L'essentiel à retenir : Un même processus PHP accumule des fuites mémoire invisibles test après test ; L'isolation par processus règle le symptôme, jamais la cause ; Le compromis se mesure en minutes ajoutées par exécution complète

Cette option a effectivement supprimé toute erreur de mémoire épuisée. Mais le temps total d'exécution de la suite est passé de 4 minutes à 31 minutes — chaque processus PHP devant réinitialiser intégralement l'environnement WordPress de test à chaque méthode, un coût fixe non négligeable multiplié par 3 000.

## Le compromis retenu : isolation ciblée, pas systématique

Plutôt que d'appliquer l'isolation à l'ensemble de la suite, l'annotation `@runInSeparateProcess` permet de la réserver aux classes de test identifiées comme sources probables de fuite, tout en laissant le reste de la suite s'exécuter dans un seul processus partagé :

```
/**
 * @runInSeparateProcess
 */
class Test_Import_Masse_Locataires extends WP_UnitTestCase {
    // Tests créant de gros volumes de données, source de fuite identifiée
}
```

Sur ce projet, seules quatre classes sur une centaine ont nécessité cette annotation, ramenant le temps total d'exécution à 6 minutes — un compromis jugé largement acceptable au regard de la stabilité retrouvée du pipeline.

## Corriger la cause plutôt que masquer le symptôme

L'isolation par processus, même ciblée, reste un traitement du symptôme. Sur deux des quatre classes concernées, un audit plus approfondi a permis d'identifier et de corriger la véritable fuite — un hook non retiré dans un `tearDown()` manquant — rendant l'isolation de processus superflue une fois le correctif appliqué :

```
protected function tearDown(): void {
    remove_all_actions('locataire_importe');
    parent::tearDown();
}
```

> L'isolation par processus est un filet de sécurité légitime pour les cas où la cause profonde résiste au diagnostic, jamais une excuse pour ne pas chercher cette cause dans les classes où elle reste raisonnablement accessible.

## Mesurer avant de généraliser une solution

| Approche | Temps total (3 000 tests) | Fuite mémoire |
| --- | --- | --- |
| Aucune isolation | 4 minutes | Échec intermittent en fin de suite |
| Isolation systématique | 31 minutes | Aucun échec |
| Isolation ciblée (4 classes) | 6 minutes | Aucun échec |

## Ce sujet ne couvre pas

Cette analyse porte sur le compromis entre isolation et temps d'exécution face à une fuite mémoire. La mesure de couverture de code, qui ajoute elle aussi un coût de performance à l'exécution de la suite mais pour une raison entièrement différente, est traitée séparément par ailleurs.

## En résumé

Sur une suite dépassant plusieurs milliers de tests, l'isolation systématique par processus garantit la stabilité mais peut multiplier le temps d'exécution par sept ou huit, un coût rarement acceptable au quotidien. Cibler l'isolation sur les classes réellement identifiées comme sources de fuite, tout en cherchant activement à corriger la cause plutôt qu'à la masquer indéfiniment, offre le meilleur compromis observé sur ce projet.
