Si vous avez déjà vu un plugin “marcher” en local puis casser silencieusement après une mise à jour mineure de WordPress 6.9.4, le problème vient rarement d’une seule ligne. Il vient d’un manque de tests reproductibles, et surtout d’un manque de séparation entre logique pure et intégration WordPress.
Ce qu’on va construire
Vous allez mettre en place une base de tests moderne (avril 2026) pour un plugin WordPress compatible PHP 8.1+ et WordPress 6.9.4+, avec deux niveaux :
- Tests unitaires (rapides) : testent votre logique PHP sans charger WordPress.
- Tests d’intégration (réalistes) : chargent WordPress et valident hooks, rôles/capacités, nonces, et interactions DB via
WP_UnitTestCase.
Le résultat final : un plugin d’exemple “BPCAB Demo” avec une architecture testable (services + DI simple), une suite PHPUnit, et un pipeline GitHub Actions qui exécute les tests sur une matrice PHP/WP.
À la fin, vous saurez :
- Structurer un plugin pour rendre la logique testable sans WordPress.
- Installer et configurer la WordPress test suite proprement via Composer.
- Écrire des tests fiables (unitaires + intégration) qui ne flakent pas.
- Automatiser l’exécution sur CI avec une matrice et des caches.
Résumé rapide
- On sépare domaine (PHP pur) et infrastructure (hooks WP, DB, REST, admin).
- Composer gère autoload et dépendances de dev (
phpunit/phpunit). - Deux suites PHPUnit : unit (sans WP) et integration (avec WP).
- Le bootstrap d’intégration charge WordPress via la WP test suite et configure la DB de test.
- Sur CI, on exécute une matrice PHP 8.1/8.2/8.3 + WP 6.9.4 (et éventuellement nightly si vous aimez vivre dangereusement).
Quand utiliser cette solution
- Vous maintenez un plugin “business critical” (e-commerce, membership, SEO, formulaires, synchronisation).
- Vous avez des régressions lors de mises à jour WordPress / PHP.
- Vous développez en équipe et vous voulez des PR qui cassent moins.
- Vous utilisez des hooks complexes (priorités, filtres cumulés, shortcodes, REST).
- Vous visez le dépôt WordPress.org et vous voulez un minimum de rigueur.
Quand ne PAS utiliser cette solution
- Votre “plugin” est un simple snippet de 15 lignes collé dans un plugin de snippets (et encore : un test unitaire peut rester utile, mais l’investissement est disproportionné).
- Vous êtes sur un projet one-shot sans maintenance (rare en WordPress, mais ça existe).
- Vous refusez d’introduire Composer : sans autoload et sans dépendances de dev, vous allez perdre du temps à bricoler.
- Vous n’avez pas d’environnement isolé (Docker/DB de test). Tester sur production sans sauvegarde, je l’ai vu, et ça finit mal.
Avant de commencer (prérequis)
Versions et environnement
- WordPress : 6.9.4 (cible) ou plus récent.
- PHP : 8.1 minimum (recommandé), 8.2/8.3 OK.
- Composer : 2.x.
- MySQL/MariaDB : une base dédiée aux tests (ex :
wp_tests).
Sauvegarde et isolation
- Ne pointez jamais la config de tests vers votre DB de production.
- Créez un utilisateur DB limité pour les tests si possible.
- Travaillez dans un repo Git : vous allez manipuler des fichiers de config et des scripts.
Ressources officielles utiles
- WP-CLI (Developer Resources) (utile mais optionnel).
- @wordpress/scripts (si vous testez aussi du JS, hors périmètre principal ici).
- Documentation PHPUnit.
- WordPress Core Handbook: PHPUnit.
- Repo wordpress-develop (source de la WP test suite).
Étape 1 : Créer un plugin testable (squelette + autoload)
Le gain réel vient d’ici : si votre logique est collée dans des callbacks anonymes, vous allez “tester WordPress” au lieu de tester votre code. Je sépare généralement :
- src/Domain : logique pure (test unitaire).
- src/Infrastructure : WordPress (hooks, options, REST, admin).
- src/Plugin : bootstrap et enregistrement des services.
1) Créez le dossier du plugin
Dans wp-content/plugins/, créez :
bpcab-demo/bpcab-demo/bpcab-demo.phpbpcab-demo/src/bpcab-demo/tests/
2) Fichier principal du plugin
Créez wp-content/plugins/bpcab-demo/bpcab-demo.php :
<?php
/**
* Plugin Name: BPCAB Demo (Testable)
* Description: Plugin d'exemple pour tests unitaires + intégration WordPress.
* Version: 0.1.0
* Requires at least: 6.9
* Requires PHP: 8.1
*/
declare(strict_types=1);
if (!defined('ABSPATH')) {
exit;
}
// Autoload Composer (en dev, et aussi en prod si vous packez vendor/).
$autoload = __DIR__ . '/vendor/autoload.php';
if (file_exists($autoload)) {
require_once $autoload;
}
add_action('plugins_loaded', static function (): void {
// Bootstrap minimal. En vrai, je préfère une classe Plugin + container.
$plugin = new BpcabDemoPluginPlugin(__FILE__);
$plugin->boot();
});
3) Une classe Plugin + un mini-container
Créez src/Plugin/Plugin.php :
<?php
declare(strict_types=1);
namespace BpcabDemoPlugin;
use BpcabDemoInfrastructureHooksHelloHook;
use BpcabDemoPluginContainerContainer;
final class Plugin
{
private string $pluginFile;
private Container $container;
public function __construct(string $pluginFile)
{
$this->pluginFile = $pluginFile;
$this->container = new Container();
}
public function boot(): void
{
// Enregistrement des services.
$this->container->set(HelloHook::class, function (): HelloHook {
return new HelloHook();
});
// Activation des intégrations WP.
$this->container->get(HelloHook::class)->register();
}
}
Créez src/Plugin/Container/Container.php :
<?php
declare(strict_types=1);
namespace BpcabDemoPluginContainer;
use RuntimeException;
final class Container
{
/** @var array<string, callable> */
private array $factories = [];
/** @var array<string, object> */
private array $instances = [];
/**
* @param callable():object $factory
*/
public function set(string $id, callable $factory): void
{
$this->factories[$id] = $factory;
}
public function get(string $id): object
{
if (isset($this->instances[$id])) {
return $this->instances[$id];
}
if (!isset($this->factories[$id])) {
throw new RuntimeException("Service introuvable: {$id}");
}
$instance = ($this->factories[$id])();
$this->instances[$id] = $instance;
return $instance;
}
}
4) Un exemple de logique pure + un hook WP
Créez src/Domain/Greeting.php :
<?php
declare(strict_types=1);
namespace BpcabDemoDomain;
final class Greeting
{
public function message(string $name): string
{
$name = trim($name);
if ($name === '') {
return 'Bonjour !';
}
// Cas réel : éviter les espaces multiples, et limiter la taille.
$name = preg_replace('/s+/', ' ', $name) ?? $name;
$name = mb_substr($name, 0, 60);
return "Bonjour {$name} !";
}
}
Créez src/Infrastructure/Hooks/HelloHook.php :
<?php
declare(strict_types=1);
namespace BpcabDemoInfrastructureHooks;
use BpcabDemoDomainGreeting;
final class HelloHook
{
public function register(): void
{
add_shortcode('bpcab_hello', [$this, 'shortcode']);
}
/**
* @param array<string,mixed> $atts
*/
public function shortcode(array $atts = []): string
{
$atts = shortcode_atts(
[
'name' => '',
],
$atts,
'bpcab_hello'
);
$greeting = new Greeting();
// Sécurité : sortie échappée.
return esc_html($greeting->message((string) $atts['name']));
}
}
Résultat attendu
Activez le plugin dans Extensions → Extensions installées. Sur une page, ajoutez :
[bpcab_hello name="Marie Curie"]
Vous devez voir “Bonjour Marie Curie !”.
Étape 2 : Installer PHPUnit + WordPress test suite (Composer)
Sur WordPress, la confusion classique : “j’ai installé PHPUnit, donc je peux tester”. Non. Pour les tests d’intégration, il vous faut aussi la WP test suite (les fichiers includes/bootstrap.php, factories, etc.).
1) Initialiser Composer
Dans le dossier du plugin :
cd wp-content/plugins/bpcab-demo
composer init
Je vous conseille d’entrer un package name type vendor/bpcab-demo, et de définir src/ comme source.
2) Ajouter PHPUnit et l’autoload PSR-4
Créez/éditez composer.json :
{
"name": "vendor/bpcab-demo",
"type": "wordpress-plugin",
"require": {
"php": ">=8.1"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"BpcabDemo\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"BpcabDemo\Tests\": "tests/"
}
},
"scripts": {
"test:unit": "phpunit -c phpunit.unit.xml",
"test:integration": "phpunit -c phpunit.integration.xml"
}
}
Puis :
composer install
composer dump-autoload
3) Récupérer la WordPress test suite
Vous avez plusieurs méthodes. En 2026, la plus simple reste :
- cloner wordpress-develop quelque part sur votre machine,
- utiliser son dossier
tests/phpunitcomme test suite.
Exemple (à adapter) :
mkdir -p ~/wp-tests
cd ~/wp-tests
git clone --depth=1 https://github.com/WordPress/wordpress-develop.git
Vous aurez ensuite :
~/wp-tests/wordpress-develop/tests/phpunit~/wp-tests/wordpress-develop/src(le core WP “développable”)
Source officielle sur l’approche PHPUnit côté core : Core Handbook: Automated Testing / PHPUnit.
Étape 3 : Écrire le bootstrap de tests et isoler l’environnement
On va créer deux configs PHPUnit :
- unit : pas de WordPress, rapide, pas de DB.
- integration : charge WordPress + DB de test.
1) Config PHPUnit “unit”
Créez phpunit.unit.xml à la racine du plugin :
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
bootstrap="tests/bootstrap-unit.php"
colors="true"
failOnRisky="true"
failOnWarning="true"
cacheDirectory=".phpunit.cache/unit"
>
<testsuites>
<testsuite name="unit">
<directory suffix="Test.php">tests/unit</directory>
</testsuite>
</testsuites>
<php>
<ini name="error_reporting" value="-1"/>
</php>
</phpunit>
Créez tests/bootstrap-unit.php :
<?php
declare(strict_types=1);
// Bootstrap minimal pour tests unitaires : autoload uniquement.
$autoload = dirname(__DIR__) . '/vendor/autoload.php';
if (!file_exists($autoload)) {
fwrite(STDERR, "Autoload introuvable. Lancez 'composer install'.n");
exit(1);
}
require_once $autoload;
2) Config PHPUnit “integration”
Créez phpunit.integration.xml :
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
bootstrap="tests/bootstrap-integration.php"
colors="true"
failOnRisky="true"
failOnWarning="true"
cacheDirectory=".phpunit.cache/integration"
>
<testsuites>
<testsuite name="integration">
<directory suffix="Test.php">tests/integration</directory>
</testsuite>
</testsuites>
<php>
<ini name="error_reporting" value="-1"/>
<!-- Paramètres DB : ne mettez jamais la prod ici -->
<env name="WP_TESTS_DB_NAME" value="wp_tests"/>
<env name="WP_TESTS_DB_USER" value="root"/>
<env name="WP_TESTS_DB_PASS" value=""/>
<env name="WP_TESTS_DB_HOST" value="127.0.0.1"/>
<!-- Chemins vers wordpress-develop (à adapter à votre machine) -->
<env name="WP_DEVELOP_DIR" value="/home/vous/wp-tests/wordpress-develop"/>
</php>
</phpunit>
Créez tests/bootstrap-integration.php :
<?php
declare(strict_types=1);
/**
* Bootstrap d'intégration WordPress.
* Hypothèse : vous avez cloné wordpress-develop et indiqué WP_DEVELOP_DIR.
*/
$autoload = dirname(__DIR__) . '/vendor/autoload.php';
if (!file_exists($autoload)) {
fwrite(STDERR, "Autoload introuvable. Lancez 'composer install'.n");
exit(1);
}
require_once $autoload;
$developDir = getenv('WP_DEVELOP_DIR');
if (!$developDir) {
fwrite(STDERR, "WP_DEVELOP_DIR manquant. Configurez-le dans phpunit.integration.xml.n");
exit(1);
}
$testsDir = rtrim($developDir, '/\') . '/tests/phpunit';
if (!is_dir($testsDir)) {
fwrite(STDERR, "Dossier tests WordPress introuvable: {$testsDir}n");
exit(1);
}
// Variables attendues par la WP test suite.
$_tests_dir = $testsDir;
// Charge les fonctions utilitaires de la suite.
require_once $_tests_dir . '/includes/functions.php';
/**
* Charger le plugin avant que WordPress ne finisse son bootstrap de tests.
* C'est le point standard (muplugins_loaded) utilisé par la suite.
*/
tests_add_filter('muplugins_loaded', static function (): void {
require dirname(__DIR__) . '/bpcab-demo.php';
});
// Démarre WordPress pour les tests.
require_once $_tests_dir . '/includes/bootstrap.php';
Résultat attendu
À ce stade, exécuter les tests ne donnera encore rien (pas de fichiers de tests), mais :
composer test:unit
doit au moins démarrer PHPUnit.
Étape 4 : Écrire de vrais tests unitaires (sans charger WordPress)
On teste BpcabDemoDomainGreeting. Ce test est rapide, déterministe, et ne dépend pas du core WP.
1) Créez le dossier et le test
Créez tests/unit/GreetingTest.php :
<?php
declare(strict_types=1);
namespace BpcabDemoTestsUnit;
use BpcabDemoDomainGreeting;
use PHPUnitFrameworkTestCase;
final class GreetingTest extends TestCase
{
public function testMessageSansNomRetourneUneFormeCourte(): void
{
$greeting = new Greeting();
$this->assertSame('Bonjour !', $greeting->message(''));
$this->assertSame('Bonjour !', $greeting->message(' '));
}
public function testMessageNormaliseLesEspaces(): void
{
$greeting = new Greeting();
$this->assertSame('Bonjour Ada Lovelace !', $greeting->message('Ada Lovelace'));
}
public function testMessageLimiteLaLongueurPourEviterDesSortiesAbsurdes(): void
{
$greeting = new Greeting();
$name = str_repeat('A', 1000);
$result = $greeting->message($name);
$this->assertStringStartsWith('Bonjour ', $result);
$this->assertStringEndsWith(' !', $result);
// "Bonjour " (8) + 60 + " !" (2) = 70
$this->assertSame(70, mb_strlen($result));
}
}
2) Lancez les tests unitaires
composer test:unit
Résultat attendu
Vous devez obtenir une suite verte. Si vous avez une erreur du type “Class not found”, c’est presque toujours :
- autoload PSR-4 incorrect dans
composer.json, - vous avez oublié
composer dump-autoload, - vous avez mis le fichier dans un mauvais dossier.
Étape 5 : Tests d’intégration WordPress (WP_UnitTestCase)
Maintenant on valide que le shortcode est bien enregistré et que le rendu est correct. Là, on charge WordPress, donc c’est plus lent, mais ça attrape les régressions “réelles”.
1) Créez un test d’intégration
Créez tests/integration/ShortcodeHelloTest.php :
<?php
declare(strict_types=1);
namespace BpcabDemoTestsIntegration;
use WP_UnitTestCase;
final class ShortcodeHelloTest extends WP_UnitTestCase
{
public function testShortcodeEstEnregistre(): void
{
// Le plugin est chargé via bootstrap-integration.php.
global $shortcode_tags;
$this->assertIsArray($shortcode_tags);
$this->assertArrayHasKey('bpcab_hello', $shortcode_tags);
}
public function testShortcodeRendLeTexteAttendu(): void
{
$html = do_shortcode('[bpcab_hello name="Marie Curie"]');
// esc_html() est appliqué, donc pas de HTML.
$this->assertSame('Bonjour Marie Curie !', $html);
}
}
2) Lancez les tests d’intégration
composer test:integration
Résultat attendu
Si la DB de test est accessible et que WP_DEVELOP_DIR est correct, vous obtenez une suite verte.
Si vous voyez une erreur “Error establishing a database connection”, ne corrigez pas “au hasard”. Vérifiez d’abord que :
- la base
wp_testsexiste, - l’utilisateur DB a les droits,
- vous n’avez pas mis
localhostalors que votre MySQL écoute sur127.0.0.1(ou l’inverse, selon config).
Étape 6 : Tester hooks, filtres, capacités et nonces
Les tests qui attrapent le plus de bugs en prod : ceux qui valident que vous avez branché vos hooks au bon endroit, avec la bonne priorité, et que la sécurité (nonce/capabilities) est respectée.
1) Ajouter une action admin sécurisée (exemple)
On ajoute un endpoint d’action admin très simple : il met à jour une option, mais uniquement pour les admins et avec nonce.
Créez src/Infrastructure/Admin/SettingsAction.php :
<?php
declare(strict_types=1);
namespace BpcabDemoInfrastructureAdmin;
final class SettingsAction
{
public const NONCE_ACTION = 'bpcab_demo_save';
public const OPTION_KEY = 'bpcab_demo_enabled';
public function register(): void
{
add_action('admin_post_bpcab_demo_save', [$this, 'handle']);
}
public function handle(): void
{
if (!current_user_can('manage_options')) {
wp_die('Accès refusé', 403);
}
check_admin_referer(self::NONCE_ACTION);
$enabled = isset($_POST['enabled']) && $_POST['enabled'] === '1';
update_option(self::OPTION_KEY, $enabled ? '1' : '0');
wp_safe_redirect(admin_url('options-general.php?page=bpcab-demo'));
exit;
}
}
Branchez-le dans src/Plugin/Plugin.php :
<?php
// ... (extrait) ...
use BpcabDemoInfrastructureAdminSettingsAction;
// ... dans boot() ...
$this->container->set(SettingsAction::class, function (): SettingsAction {
return new SettingsAction();
});
$this->container->get(SettingsAction::class)->register();
2) Tester capacité + nonce
Créez tests/integration/SettingsActionTest.php :
<?php
declare(strict_types=1);
namespace BpcabDemoTestsIntegration;
use BpcabDemoInfrastructureAdminSettingsAction;
use WP_UnitTestCase;
final class SettingsActionTest extends WP_UnitTestCase
{
public function setUp(): void
{
parent::setUp();
update_option(SettingsAction::OPTION_KEY, '0');
}
public function tearDown(): void
{
// Nettoyage : évite les tests interdépendants.
delete_option(SettingsAction::OPTION_KEY);
parent::tearDown();
}
public function testActionRefuseSansCapacite(): void
{
$userId = self::factory()->user->create(['role' => 'subscriber']);
wp_set_current_user($userId);
$_POST = [
'_wpnonce' => wp_create_nonce(SettingsAction::NONCE_ACTION),
'enabled' => '1',
];
// wp_die() jette une exception dans la suite de tests WP.
$this->expectException(WPDieException::class);
do_action('admin_post_bpcab_demo_save');
}
public function testActionRefuseSansNonceValide(): void
{
$userId = self::factory()->user->create(['role' => 'administrator']);
wp_set_current_user($userId);
$_POST = [
'_wpnonce' => 'nonce_invalide',
'enabled' => '1',
];
$this->expectException(WPDieException::class);
do_action('admin_post_bpcab_demo_save');
}
public function testActionMetAJourOptionAvecNonceEtCapacite(): void
{
$userId = self::factory()->user->create(['role' => 'administrator']);
wp_set_current_user($userId);
$_POST = [
'_wpnonce' => wp_create_nonce(SettingsAction::NONCE_ACTION),
'enabled' => '1',
];
// On évite la redirection réelle en interceptant wp_redirect.
add_filter('wp_redirect', static function (string $location): string {
// On renvoie une URL neutre, mais on laisse WordPress continuer.
return $location;
});
try {
do_action('admin_post_bpcab_demo_save');
} catch (WPDieException $e) {
// Certains environnements de test peuvent convertir exit en die.
// On tolère, l'essentiel est l'état final.
}
$this->assertSame('1', get_option(SettingsAction::OPTION_KEY));
}
}
Ce test illustre un point que je rencontre souvent : les handlers admin finissent par exit(). En test, ça peut se traduire par un wp_die ou une exception selon la suite. Le pattern : vous vérifiez l’état (option, post meta, etc.) plutôt que le flux exact de sortie.
Sources officielles utiles :
Étape 7 : Factories, fixtures, base de données et nettoyage
Les tests d’intégration qui “flakent” viennent souvent d’un état DB mal nettoyé, ou d’un test qui dépend d’un autre. La WP test suite fournit des factories, mais vous devez rester discipliné :
- créez vos posts/users/terms dans le test,
- nettoyez options/transients manuels dans
tearDown(), - n’utilisez pas un ID “en dur”.
Exemple : créer un post et tester un filtre
Ajoutons un filtre qui modifie le titre d’un post selon une option.
Créez src/Infrastructure/Hooks/TitlePrefixHook.php :
<?php
declare(strict_types=1);
namespace BpcabDemoInfrastructureHooks;
final class TitlePrefixHook
{
public const OPTION_PREFIX = 'bpcab_demo_title_prefix';
public function register(): void
{
add_filter('the_title', [$this, 'filterTitle'], 10, 2);
}
public function filterTitle(string $title, int $postId): string
{
$prefix = (string) get_option(self::OPTION_PREFIX, '');
$prefix = trim($prefix);
if ($prefix === '' || is_admin()) {
return $title;
}
// Évite de préfixer les titres vides.
if (trim($title) === '') {
return $title;
}
return $prefix . ' ' . $title;
}
}
Branchez-le dans Plugin.php comme pour les autres services.
Créez tests/integration/TitlePrefixHookTest.php :
<?php
declare(strict_types=1);
namespace BpcabDemoTestsIntegration;
use BpcabDemoInfrastructureHooksTitlePrefixHook;
use WP_UnitTestCase;
final class TitlePrefixHookTest extends WP_UnitTestCase
{
public function tearDown(): void
{
delete_option(TitlePrefixHook::OPTION_PREFIX);
parent::tearDown();
}
public function testPrefixAppliqueSurTitreFront(): void
{
update_option(TitlePrefixHook::OPTION_PREFIX, '[VIP]');
$postId = self::factory()->post->create([
'post_title' => 'Mon article',
'post_status' => 'publish',
]);
// Simule un contexte front.
$this->go_to(get_permalink($postId));
$this->assertFalse(is_admin());
$title = get_the_title($postId);
$this->assertSame('[VIP] Mon article', $title);
}
public function testNePrefixPasSiOptionVide(): void
{
update_option(TitlePrefixHook::OPTION_PREFIX, '');
$postId = self::factory()->post->create([
'post_title' => 'Mon article',
'post_status' => 'publish',
]);
$this->go_to(get_permalink($postId));
$this->assertSame('Mon article', get_the_title($postId));
}
}
Ce test vous protège contre :
- un mauvais hook (confusion action vs filtre),
- une priorité qui change le résultat (si un autre plugin préfixe aussi),
- un contexte admin/front non géré.
Étape 8 : Automatiser avec GitHub Actions (matrice WP/PHP)
Sans CI, les tests restent “optionnels”. Avec CI, une régression devient un échec de PR. C’est là que ça commence à payer.
1) Ajoutez un workflow GitHub Actions
Créez .github/workflows/tests.yml (à la racine du repo, pas dans wp-content si votre plugin est un repo dédié) :
name: Tests
on:
push:
pull_request:
jobs:
phpunit:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ["8.1", "8.2", "8.3"]
wp: ["6.9.4"]
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: wp_tests
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -proot"
--health-interval=10s
--health-timeout=5s
--health-retries=10
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
tools: composer:v2
coverage: none
- name: Cache Composer
uses: actions/cache@v4
with:
path: |
vendor
~/.composer/cache
key: composer-${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('composer.lock') }}
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Checkout wordpress-develop
run: |
mkdir -p $HOME/wp-tests
cd $HOME/wp-tests
git clone --depth=1 --branch ${{ matrix.wp }} https://github.com/WordPress/wordpress-develop.git
- name: Run unit tests
run: composer test:unit
- name: Run integration tests
env:
WP_DEVELOP_DIR: ${{ env.HOME }}/wp-tests/wordpress-develop
WP_TESTS_DB_NAME: wp_tests
WP_TESTS_DB_USER: root
WP_TESTS_DB_PASS: root
WP_TESTS_DB_HOST: 127.0.0.1
run: composer test:integration
Notes réalistes :
- Le tag
6.9.4danswordpress-developexiste si une branche/tag correspond. Sinon, utilisez--branch 6.9ou un commit. Adaptez selon la stratégie de release du core au moment où vous lisez ceci. - Si vous ajoutez WP nightly, attendez-vous à des échecs temporaires. Je l’utilise souvent en “allowed failure” sur des plugins critiques.
Référence : dépôt officiel core wordpress-develop.
Le résultat complet
Si vous voulez tout copier d’un coup, voici l’assemblage minimal (hors fichiers déjà présentés). Vérifiez bien les chemins.
Arborescence
bpcab-demo/
bpcab-demo.php
composer.json
phpunit.unit.xml
phpunit.integration.xml
src/
Domain/
Greeting.php
Infrastructure/
Admin/
SettingsAction.php
Hooks/
HelloHook.php
TitlePrefixHook.php
Plugin/
Plugin.php
Container/
Container.php
tests/
bootstrap-unit.php
bootstrap-integration.php
unit/
GreetingTest.php
integration/
ShortcodeHelloTest.php
SettingsActionTest.php
TitlePrefixHookTest.php
Personnalisation rapide
- Ajoutez une suite contract (tests de compat) si votre plugin expose des filtres publics.
- Ajoutez phpstan en dev si vous voulez verrouiller les types (très efficace avec une architecture “Domain/Infrastructure”).
- Déplacez la création des services dans un ServiceProvider si votre plugin grossit.
Adapter pour Divi 5 / Elementor / Avada
Les page builders n’empêchent pas de tester. Le point clé : testez votre logique et vos intégrations WordPress, pas l’UI du builder (qui relève plutôt de tests e2e).
Divi 5
- Si vous exposez un shortcode comme
[bpcab_hello], Divi l’utilise facilement via un module “Code” ou “Texte”. - Test d’intégration recommandé : do_shortcode() (déjà fait), et si vous ajoutez des assets, testez
wp_enqueue_scripts(hook + dépendances).
Elementor
- Pour un widget custom Elementor, je sépare la classe du widget (infrastructure) et un service “renderer” (domaine). Le renderer est unitaire, le widget est testé en intégration (en vérifiant que le hook d’enregistrement est appelé).
- Edge case que je vois souvent : le code d’enregistrement du widget s’exécute trop tôt. Testez la priorité sur
elementor/widgets/register(ou hook équivalent selon version Elementor).
Avada (Fusion Builder)
- Même logique : shortcodes/éléments Avada peuvent envelopper votre rendu. Le test utile : valider que votre shortcode retourne une chaîne stable et échappée.
- Si vous fournissez un “Fusion Element”, isolez la génération d’options (arrays) et testez-la en unitaire.
Vérification finale
- Dans le plugin : Extensions → activer “BPCAB Demo (Testable)”.
- Créer une page avec
[bpcab_hello name="Test"]: vous devez voir “Bonjour Test !”. - En CLI, depuis le dossier du plugin :
composer test:unit: suite verte, exécution très rapide.composer test:integration: suite verte, plus lente, DB utilisée.
- Sur GitHub : push → le workflow “Tests” passe sur les versions PHP de la matrice.
Si le résultat n’est pas celui attendu
| Symptôme | Cause probable | Vérification | Solution |
|---|---|---|---|
| PHPUnit: “Class not found” | Autoload PSR-4 mal configuré | Regardez composer.json et le namespace du fichier |
composer dump-autoload, corriger BpcabDemo\ → src/ |
| Integration: “Dossier tests WordPress introuvable” | WP_DEVELOP_DIR incorrect |
echo $WP_DEVELOP_DIR / vérifier le chemin |
Mettre le bon chemin vers wordpress-develop |
| Integration: “Error establishing a database connection” | DB de test inexistante ou credentials faux | Se connecter à MySQL avec les identifiants | Créer wp_tests, corriger host/user/pass |
| Le shortcode n’est pas enregistré en test | Plugin non chargé dans muplugins_loaded |
Vérifier tests/bootstrap-integration.php |
Corriger le require .../bpcab-demo.php (chemin) |
| Erreur PHP “Call to undefined function add_action()” en unit | Un test unitaire charge du code dépendant de WP | Regarder la stacktrace | Déplacer la logique dans Domain, mocker ou tester en intégration |
Problèmes que je vois souvent en dépannage :
- Le code est copié au mauvais endroit (ex :
tests/bootstrap-integration.phpmis danssrc/). - Un point-virgule oublié dans un fichier chargé par le bootstrap : PHPUnit s’arrête avant même d’afficher un test.
- Confusion action/filtre : vous utilisez
add_actionau lieu deadd_filter(ou inversement), et le test “ne voit rien”. - Vous testez sur production “juste pour voir” : la DB de test écrase des options. Ne le faites pas.
- Votre plugin est chargé trop tôt/trop tard : hooks non disponibles, ou dépendances non chargées.
Pièges et erreurs courantes
| Erreur | Cause | Solution |
|---|---|---|
| Tests d’intégration instables (flaky) | État global WP non nettoyé (options, transients, hooks ajoutés) | Nettoyer dans tearDown(), éviter les singletons globaux, limiter les side effects |
| “Headers already sent” | Un test déclenche une redirection / sortie avant buffering | Testez l’état final, interceptez wp_redirect, évitez d’asserter sur des headers |
| “Cannot modify header information” en CI uniquement | Différences d’extensions PHP / output buffering | Ne pas dépendre du flux HTTP en PHPUnit; utilisez des tests e2e pour ça |
| Un ancien tutoriel recommande des scripts obsolètes | Évolution de PHPUnit / WP test suite | Aligner sur les docs core actuelles et sur wordpress-develop (avril 2026) |
| Le plugin de snippets casse les tests | Code “runtime” chargé dans l’environnement de tests | Tester votre plugin dans un environnement minimal; désactiver les mu-plugins non nécessaires |
| Erreur liée à PHP trop ancien | CI ou machine locale sur PHP 7.x/8.0 | Monter à PHP 8.1+ (cf. versions supportées PHP) |
Variante / alternative
Option “sans WordPress test suite” (unit only)
Si votre plugin est principalement de la logique pure (calculs, parsing, génération de payloads API), vous pouvez vous limiter à :
- Composer + PHPUnit,
- tests unitaires sur
src/Domain, - tests d’intégration remplacés par une petite batterie de tests “smoke” manuels.
C’est acceptable pour un plugin interne simple, mais vous perdez la couverture des hooks, roles, nonces, et comportements WP.
Option “plus avancée” : tests d’intégration avec environnement éphémère
Pour des plugins complexes, je préfère souvent exécuter l’intégration dans un environnement jetable :
- Docker Compose (MySQL + WordPress + CLI),
- installation de WP en CI,
- tests PHPUnit + éventuellement Playwright/Cypress pour e2e.
C’est plus lourd, mais ça réduit les surprises avec des stacks proches de la prod.
Conseils sécurité, performance et maintenance
- Sécurité : testez systématiquement les chemins “refusés” (capabilities, nonces invalides). Les bugs de sécurité viennent souvent de “ça marche quand je suis admin”.
- Isolation : ne partagez jamais la DB de test avec autre chose. Même en local.
- Performance : gardez 80% de tests en unit. Les tests d’intégration doivent valider l’assemblage, pas refaire tous les cas.
- Maintenance : quand WordPress 6.9.x évolue, vous voulez que la CI vous dise “ça casse” avant vos utilisateurs.
- Compat : si vous supportez plusieurs versions WP, faites une matrice CI (ex : 6.7, 6.8, 6.9.4) et acceptez quelques ajustements conditionnels.
Pour aller plus loin
- Ajouter des tests sur REST API (routes, permissions callbacks, schémas).
- Tester vos migrations DB (création de tables,
dbDelta()) en intégration. - Mettre en place un Service Provider (container plus propre) et tester le wiring.
- Ajouter une étape static analysis (PHPStan/Psalm) + coding standards (PHPCS WordPress) sur CI.
- Si vous avez du JS (blocks), ajouter Jest/Vitest via @wordpress/scripts et séparer les pipelines.
Ressources
- WordPress Core Handbook – PHPUnit
- GitHub – WordPress/wordpress-develop
- WP_UnitTestCase (référence)
- Plugin Handbook – Nonces
- add_shortcode() (référence)
- Documentation officielle PHPUnit
- WordPress Core Trac (utile pour suivre les changements testing/CI côté core)
- PHP Manual
FAQ
Pourquoi séparer “unit” et “integration” au lieu de tout tester avec WordPress chargé ?
Parce que charger WordPress rend les tests lents, fragiles et plus difficiles à diagnostiquer. Je garde WordPress pour valider l’assemblage (hooks, rôles, DB), et je teste la logique métier en PHP pur.
Est-ce que PHPUnit 11 est obligatoire ?
Non, mais sur PHP 8.1+ c’est un choix cohérent et maintenu. Si votre stack est contrainte, adaptez la version dans composer.json. Référez-vous à la compatibilité dans la doc PHPUnit.
Pourquoi cloner wordpress-develop au lieu d’un WordPress “normal” ?
Parce que la WP test suite vit dans wordpress-develop/tests/phpunit. Vous pouvez bricoler autrement, mais vous allez réinventer des scripts déjà maintenus par le core.
Mes tests d’intégration sont très lents. Que faire ?
Réduisez leur nombre, concentrez-les sur les points d’intégration, et déplacez le reste en tests unitaires. Sur CI, utilisez des caches Composer et évitez de cloner trop d’historique (d’où --depth=1).
Comment tester un hook qui dépend d’un ordre/priorité ?
Testez l’effet final (output/état), et ajoutez un test qui vérifie la présence du callback avec has_action() / has_filter() si pertinent. Attention : ces fonctions valident l’inscription, pas l’exécution.
Comment tester un code qui fait exit ?
En général, vous testez l’état (option mise à jour, post créé) et vous interceptez ce que vous pouvez (filtre wp_redirect). Ne vous battez pas pour “asserter un exit”.
Je vois “Call to undefined function esc_html()” dans un test unitaire. Normal ?
Oui : esc_html() est une fonction WordPress. Si vous voulez un test unitaire, isolez la logique dans une classe PHP pure et testez l’échappement en intégration, ou injectez une stratégie d’échappement (plus avancé).
Je peux tester Elementor/Divi/Avada avec PHPUnit ?
Vous pouvez tester votre code d’intégration (ex : “mon widget s’enregistre sur le bon hook”), mais pas l’UI complète. Pour l’UI, utilisez des tests e2e (Playwright/Cypress) sur un site de test.
Dois-je committer vendor/ dans mon plugin ?
Pour un plugin distribué hors Composer, beaucoup de plugins embarquent vendor/. Pour un plugin interne géré via Composer, non. Dans tous les cas, sur CI vous installez les dépendances.
Comment gérer la compatibilité multi-versions WordPress ?
Ajoutez une matrice CI sur plusieurs versions WP, et gardez vos tests unitaires indépendants. Pour les changements core, suivez les tickets sur Trac et les PR sur GitHub.
Que faire si un vieux snippet de tutoriel ne marche plus sur WordPress 6.9.4 ?
Ne “forcez” pas. Alignez-vous sur la WP test suite actuelle (wordpress-develop), et adaptez votre bootstrap. Les vieux scripts d’installation de tests trouvés sur des blogs datés cassent souvent à cause de PHPUnit (API changée) et de chemins de suite.