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


É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.php
  • bpcab-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 ExtensionsExtensions 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/phpunit comme 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_tests existe,
  • l’utilisateur DB a les droits,
  • vous n’avez pas mis localhost alors que votre MySQL écoute sur 127.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.4 dans wordpress-develop existe si une branche/tag correspond. Sinon, utilisez --branch 6.9 ou 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

  1. Dans le plugin : Extensions → activer “BPCAB Demo (Testable)”.
  2. Créer une page avec [bpcab_hello name="Test"] : vous devez voir “Bonjour Test !”.
  3. 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.
  4. 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.php mis dans src/).
  • 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_action au lieu de add_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


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.