Sur un projet repris auprès d’une autre agence, le dossier tests contenait un mélange non trié de tests unitaires purs, de tests nécessitant une base de données WordPress complète, et de scénarios Playwright, le tout piloté par un unique fichier phpunit.xml et un unique script npm nommé sobrement test. Résultat concret : lancer « les tests » pendant le développement d’une fonction pure prenait près de deux minutes, temps de démarrage d’une base MySQL complète compris, pour vérifier une simple fonction de formatage de prix qui n’avait besoin d’aucune base de données.
Réorganiser ce dossier a demandé moins d’une journée, pour un gain de confort quotidien qui s’est fait sentir immédiatement : les tests unitaires purs se lancent désormais en une poignée de secondes, indépendamment du reste, ce qui change concrètement la fréquence à laquelle l’équipe les exécute pendant qu’elle code.
L’arborescence retenue
tests/
├── unit/
│ ├── bootstrap.php
│ └── Calcul/
│ └── CalculTarifTest.php
├── integration/
│ ├── bootstrap.php
│ └── Rest/
│ └── RouteDevisTest.php
├── e2e/
│ ├── playwright.config.ts
│ └── specs/
│ └── tunnel-commande.spec.ts
├── phpunit-unit.xml.dist
└── phpunit-integration.xml.dist
Le principe directeur est simple : un dossier par niveau de test, chacun avec son propre bootstrap et sa propre configuration, plutôt qu’un seul fichier de configuration qui tente de tout couvrir avec des groupes ou des filtres complexes à retenir.
Deux bootstraps, deux besoins réels
Le bootstrap des tests unitaires purs ne charge que l’autoload Composer du projet, sans jamais toucher à WordPress lui-même :
<?php
// tests/unit/bootstrap.php
require_once dirname( __DIR__, 2 ) . '/vendor/autoload.php';
Le bootstrap des tests d’intégration, lui, charge la suite de tests officielle de WordPress avec sa base de données dédiée, exactement comme le ferait un projet classique basé sur WP_UnitTestCase :
<?php
// tests/integration/bootstrap.php
$_tests_dir = getenv( 'WP_TESTS_DIR' ) ?: '/tmp/wordpress-tests-lib';
require_once $_tests_dir . '/includes/functions.php';
tests_add_filter( 'muplugins_loaded', function () {
require dirname( __DIR__, 2 ) . '/mon-extension.php';
} );
require $_tests_dir . '/includes/bootstrap.php';

Deux fichiers de configuration PHPUnit distincts
Chaque niveau reçoit son propre fichier de configuration, avec son propre bootstrap et son propre dossier de tests ciblé, ce qui rend la commande à taper explicite et évite tout filtre de groupe à retenir :
<!-- tests/phpunit-unit.xml.dist -->
<phpunit bootstrap="unit/bootstrap.php">
<testsuites>
<testsuite name="unit">
<directory>unit</directory>
</testsuite>
</testsuites>
</phpunit>
<!-- tests/phpunit-integration.xml.dist -->
<phpunit bootstrap="integration/bootstrap.php">
<testsuites>
<testsuite name="integration">
<directory>integration</directory>
</testsuite>
</testsuites>
</phpunit>
Les commandes exposées, documentées par leur nom même
Plutôt qu’un unique script test ambigu, on expose des scripts Composer nommés explicitement selon leur portée, chacun invoquant le bon fichier de configuration :
{
"scripts": {
"test:unit": "phpunit -c tests/phpunit-unit.xml.dist",
"test:integration": "phpunit -c tests/phpunit-integration.xml.dist",
"test:e2e": "playwright test --config=tests/e2e/playwright.config.ts",
"test:all": [
"@test:unit",
"@test:integration",
"@test:e2e"
]
}
}
Un développeur qui modifie une fonction de calcul pure sait qu’il lui suffit de composer test:unit pendant son travail courant, réservant composer test:all aux vérifications avant de pousser sa pull request, sans avoir à connaître par cœur quel dossier correspond à quel besoin.
Côté JavaScript et Gutenberg
La même logique s’applique aux tests Jest sur les blocs, avec un fichier jest.config.js qui cible spécifiquement les fichiers de test unitaires JS, séparé de la configuration Playwright utilisée pour l’E2E, chacun invocable indépendamment via un script npm dédié.
Éviter le piège du dossier fourre-tout « helpers »
Une tentation fréquente consiste à créer un unique dossier tests/helpers partagé entre tous les niveaux de tests, qui finit par accumuler des fonctions utilitaires couplées implicitement à un seul niveau alors qu’elles sont censées être génériques. On préfère un sous-dossier d’aides spécifique à chaque niveau (tests/unit/Helpers, tests/integration/Helpers), et on ne remonte une fonction vers un dossier vraiment partagé qu’après avoir constaté un besoin réel dans au moins deux niveaux distincts.
Une arborescence de tests que l’équipe doit expliquer verbalement à chaque nouveau contributeur n’est pas assez claire : le nom des dossiers et des scripts doit porter la réponse à lui seul.
Intégration à la CI, en jobs séparés
Cette séparation se reflète naturellement dans le pipeline de CI, avec un job dédié par niveau de test, ce qui permet de paralléliser leur exécution et d’identifier immédiatement, dans les résultats affichés sur la pull request, si l’échec provient d’une simple fonction de calcul ou d’un scénario d’intégration plus lourd à diagnostiquer.
En résumé
Séparer clairement l’arborescence, les bootstraps et les configurations selon les trois niveaux classiques de tests, unitaire, intégration et E2E, n’est pas un exercice de pure esthétique d’organisation : c’est ce qui détermine directement la fréquence à laquelle l’équipe lance réellement les tests pendant son travail quotidien. Un projet où lancer un test unitaire simple prend deux minutes finit toujours par voir cette pratique abandonnée en pratique, quelle que soit la bonne volonté affichée au démarrage du projet.