Pourquoi une testsuite par plugin
Les plugins étendent l'hôte via des hooks — ils ne stubbent ni ne mockent l'intégration. Un plugin qui enregistre un driver de sending-server contribue réellement via Hook::add('register_sending_server_driver', ...) ; l'hôte itère réellement cette contribution via Hook::collect() en production. Les tests les plus utiles font de même — bootent un vrai container Laravel, enregistrent le service provider du plugin, frappent une vraie route HTTP, déclenchent un vrai événement de cycle de vie. Les stubs ne prouveraient que le fait que les stubs sont câblés.
Lancer les tests de plugin dans le processus Pest / PHPUnit propre à l'hôte est ce qui rend cela possible. Le tests/TestCase.php de l'hôte livre CreatesApplication, qui boote Laravel exactement comme en production. Chaque test de plugin hérite de ce boot — y compris l'autoload autoloadWithoutDbQuery() propre au plugin, le boot() de son service provider et tout hook add_translation_file que le plugin contribue dans register().
La raison pour laquelle les testsuites sont isolées par plugin plutôt que mélangées dans une suite unique est l'exécution sélective. Lancer juste la suite d'un plugin pendant le développement local (php artisan test --testsuite="Plugin: acelle/ai") est la boucle de feedback rapide. La matrice CI de l'hôte peut aussi se déployer en éventail : un job par plugin, en parallèle.
Enregistrement dans le phpunit.xml de l'hôte
Le phpunit.xml racine de l'hôte réserve un bloc <testsuite> pour chaque plugin sous gestion. Trois plugins qui sont livrés dans la base de code actuellement sont enregistrés via ce pattern (acelle/ai, acelle/console, athena/evs entre autres) :
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
<!-- Per-plugin testsuites -->
<testsuite name="Plugin: acelle/ai">
<directory>storage/app/plugins/acelle/ai/tests</directory>
</testsuite>
<testsuite name="Plugin: athena/evs">
<directory>storage/app/plugins/athena/evs/tests</directory>
</testsuite>
<testsuite name="Plugin: acelle/paddle">
<directory>storage/app/plugins/acelle/paddle/tests</directory>
</testsuite>
<testsuite name="Plugin: acelle/console">
<directory>storage/app/plugins/acelle/console/tests</directory>
</testsuite>
</testsuites>
Ajouter la testsuite d'un nouveau plugin tient en un bloc, une ligne par répertoire. Le php artisan test par défaut de l'hôte récupère chaque testsuite enregistrée par défaut ; --testsuite="Plugin: yourvendor/yourplugin" isole l'exécution à votre suite seule.
L'auteur du plugin modifie le phpunit.xml de l'hôte. Il n'y a pas d'autodiscovery — ajouter des tests dans le répertoire tests/ de votre plugin n'a aucun effet tant que la testsuite n'est pas enregistrée dans l'hôte. C'est l'unique point de contact hors du dossier de votre plugin qu'exige un nouveau plugin ; tout le reste est autonome.
Où résident les tests du plugin
La convention d'acelle/ai reflète la structure tests/ propre à l'hôte — dossiers Unit / Feature plus un PluginTestCase à la racine :
storage/app/plugins/acelle/ai/tests/
├── PluginTestCase.php ← shared base class for the plugin
├── Feature/
│ ├── AIHandler/ ← grouped by surface
│ └── PluginLifecycle/ ← lifecycle integration tests
├── Unit/ ← isolated unit tests, no Laravel boot
├── Fixtures/ ← test fixtures + factories
├── Snapshots/ ← Pest snapshot artefacts
└── Support/ ← test-only helpers
Les tests Pest résident en fichiers .php n'importe où sous Feature/ ou Unit/ et utilisent uses() / test() / it() normalement. Les classes de test qui préfèrent extends à uses() fonctionnent aussi — le PluginTestCase du plugin gère les deux.
La classe de base PluginTestCase
Le Tests\TestCase de l'hôte boote Laravel via CreatesApplication. Les plugins qui ont besoin d'une mise en place additionnelle avant chaque test — typiquement seeder leur propre ligne comme active pour que le middleware ne renvoie pas un 404 sur les routes du plugin — livrent un PluginTestCase qui étend celui de l'hôte. L'implémentation complète d'acelle/ai réside dans storage/app/plugins/acelle/ai/tests/PluginTestCase.php :
namespace Acelle\Ai\Tests;
use App\Model\Plugin;
use Tests\TestCase as BaseTestCase;
class PluginTestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->seedAcellAiPluginActive();
// Bust the per-request memoisation in PluginGate so each test
// observes the actual DB state set above (or set inline by the
// test). Without this the second test onward sees a stale
// cached value from the first test's boot.
if (class_exists(\Acelle\Ai\Support\PluginGate::class)) {
\Acelle\Ai\Support\PluginGate::resetCache();
}
}
protected function seedAcellAiPluginActive(): void
{
$plugin = Plugin::query()->where('name', 'acelle/ai')->first();
if (! $plugin) {
$plugin = new Plugin();
$plugin->name = 'acelle/ai';
$plugin->title = 'AcelleMail AI';
$plugin->version = '1.0.0';
}
$plugin->status = 'active';
$plugin->save();
}
}
Les tests du plugin atteignent cette base via soit uses() (style Pest), soit extends (style PHPUnit) :
// Pest style — file-level
uses(\Acelle\Ai\Tests\PluginTestCase::class, RefreshDatabase::class);
it('renders the chatbox bubble on app pages', function () {
$this->get('/dashboard')->assertSee('data-ai-chatbox');
});
// PHPUnit style — class-level
class MyTest extends \Acelle\Ai\Tests\PluginTestCase
{
public function test_something(): void { /* ... */ }
}
Le piège du cache de gate par requête
Le middleware qui garde les routes du plugin (console.active, ai.active, etc.) vérifie Plugin::getByName($name)->isActive() à chaque requête. Le code de production réel mémoise ce lookup pour qu'une requête unique ne frappe jamais la base plus d'une fois. Dans les tests, ce memo est le piège — le premier test met en place une ligne de plugin active, le second test wipe la base avec RefreshDatabase et reseed, mais le cache de gate du premier test dit encore « active » (ou « inactive ») sur la mauvaise ligne.
Le correctif est d'exposer une méthode statique resetCache() sur la classe gate et de l'appeler depuis le PluginTestCase::setUp() du plugin après l'exécution du seed. acelle/ai le fait avec \Acelle\Ai\Support\PluginGate::resetCache() comme montré ci-dessus. Tout plugin qui livre un middleware similaire doit suivre le même pattern — sans cela, la suite est dépendante de l'ordre (premier test passe, second échoue).
Pattern hooks-under-test
La façon la plus directe de vérifier les contributions de hooks d'un plugin est d'appeler le même Hook::collect() que l'hôte appelle en production et d'asserter sur le résultat. Pas de mocking ; pas de stubbing ; le vrai enregistrement passe par le vrai boot du plugin :
use App\Library\Facades\Hook;
it('contributes a sending-server driver entry', function () {
$entries = Hook::collect('register_sending_server_driver');
$myDriver = collect($entries)->firstWhere('type', 'postal');
expect($myDriver)->not->toBeNull();
expect($myDriver['driver'])->toBe(\AcmeCorp\Postal\Driver::class);
expect($myDriver['name'])->toBe('Postal MTA');
});
Les hooks EVENT exigent une forme différente — assertez sur les effets de bord plutôt que sur les résultats de collect :
it('awards welcome points when a customer is added', function () {
$customer = Customer::factory()->create();
Hook::fire('customer_added', [$customer]);
expect(LoyaltyAccount::where('customer_id', $customer->id)->first()->points)
->toBe(100);
});
Les hooks BEHAVIOR (set / perform) se testent en appelant Hook::perform() et en assertant que la valeur retournée provient de l'override de votre plugin :
it('overrides the import job dispatcher', function () {
Hook::setIfEmpty('dispatch_list_import_job', fn ($l, $f) => new \App\Jobs\ImportJob($l, $f));
// Plugin's own boot already called Hook::set with FasterImportJob.
$job = Hook::perform('dispatch_list_import_job', [$mailList, $filePath]);
expect($job)->toBeInstanceOf(\AcmeCorp\FastImport\FasterImportJob::class);
});
Cycle activate → test → delete
Le test d'intégration complet pour un plugin orienté fonctionnalité exécute le cycle de vie entier dans un seul test : enregistrer le plugin (ou utiliser la ligne déjà enregistrée de l'hôte), appeler $plugin->activate() pour déclencher la migration, exercer la fonctionnalité, puis appeler $plugin->deleteAndCleanup() pour vérifier que le rollback fonctionne proprement.
it('runs migration on activate and rolls back on delete', function () {
$plugin = Plugin::register('acmecorp/loyalty');
expect($plugin->status)->toBe('inactive');
expect(Schema::hasTable('acmecorp_loyalty_accounts'))->toBeFalse();
$plugin->activate();
expect($plugin->status)->toBe('active');
expect(Schema::hasTable('acmecorp_loyalty_accounts'))->toBeTrue();
$plugin->deleteAndCleanup();
expect(Schema::hasTable('acmecorp_loyalty_accounts'))->toBeFalse();
expect(Plugin::where('name', 'acmecorp/loyalty')->exists())->toBeFalse();
});
Ce test attrape une classe de bugs que les tests unitaires ne peuvent pas : un listener de hook delete_plugin_* manquant dans boot(), une migration qui crée une table mais ne peut pas la supprimer (pas de méthode down()), une coquille dans l'argument path de artisan migrate. Lancez-le une fois par release et le cycle de vie reste au vert.
Fixtures isolées par plugin
Les plugins qui livrent des factories ou des seeders les gardent sous tests/Fixtures/ dans le dossier du plugin, namespacés sous le préfixe PSR-4 du plugin. Ils atteignent les tests de l'hôte juste en étant autoloadés — le composer.json du plugin déclare déjà le namespace, et le loader de plugin de l'hôte l'enregistre pendant le boot.
// storage/app/plugins/acmecorp/loyalty/tests/Fixtures/LoyaltyAccountFactory.php
namespace Acmecorp\Loyalty\Tests\Fixtures;
use Acmecorp\Loyalty\Models\LoyaltyAccount;
use Illuminate\Database\Eloquent\Factories\Factory;
class LoyaltyAccountFactory extends Factory
{
protected $model = LoyaltyAccount::class;
public function definition(): array
{
return [
'customer_id' => \App\Model\Customer::factory(),
'points' => $this->faker->numberBetween(0, 10_000),
];
}
}
Utilisez-les depuis les tests de la même façon que vous utiliseriez les factories propres à l'hôte — LoyaltyAccountFactory::new()->create(). RefreshDatabase wipe les lignes entre tests, les factories reconstruisent au besoin.
Patterns CI — un job par plugin
Le filtre --testsuite est ce qui permet une matrice CI par plugin. Un unique workflow GitHub Actions ou GitLab CI peut se déployer en éventail sur autant de jobs parallèles que vous avez de plugins :
# .github/workflows/test.yml
jobs:
test:
strategy:
matrix:
suite:
- "Unit"
- "Feature"
- "Plugin: acelle/ai"
- "Plugin: athena/evs"
- "Plugin: acmecorp/loyalty"
steps:
- uses: actions/checkout@v4
- run: composer install --no-progress
- run: php artisan test --testsuite="${{{ matrix.suite }}}"
Chaque suite obtient sa propre base SQLite ou MySQL (la connexion de test est isolée par job), s'exécute en parallèle et échoue indépendamment — un test cassé d'acelle/ai ne bloque pas une release d'acmecorp/loyalty. Le wall-clock total reste proche de la suite la plus longue plutôt que de la somme.
Exécuter les tests de plugin en local
Trois commandes couvrent presque chaque cas d'usage au niveau développeur :
# Run every plugin's testsuite plus host Unit + Feature
php artisan test
# Run just one plugin's tests
php artisan test --testsuite="Plugin: acmecorp/loyalty"
# Run a single test by file path
php artisan test storage/app/plugins/acmecorp/loyalty/tests/Feature/AwardsTest.php
# Run a single Pest test by description
php artisan test --filter "awards welcome points"
Le binaire de style Pest ./vendor/bin/pest fonctionne aussi si votre équipe le préfère — les deux invocations passent par le même runner PHPUnit. ./vendor/bin/pest --testsuite="Plugin: acmecorp/loyalty" a un effet identique à la version artisan.
Cinq anti-patterns
1. Mocker Hook::collect() au lieu de laisser le plugin s'enregistrer réellement
Un collect mocké peut retourner ce que vous voulez, y compris des entrées qui n'existeraient jamais si le plugin bootait normalement. Correctif : utilisez le singleton HookManager réel ; laissez le service provider du plugin s'enregistrer via boot() ; assertez sur le vrai résultat.
2. Oublier RefreshDatabase
Sans lui, les lignes fuient entre les tests — le second test voit les comptes de fidélité du premier et asserte sur un état périmé. Correctif : incluez toujours uses(\Illuminate\Foundation\Testing\RefreshDatabase::class) en haut de chaque fichier de test de plugin (ou dans le PluginTestCase du plugin via un trait).
3. Sauter le reset du cache de gate
Échecs dépendants de l'ordre, difficiles à reproduire en local, échouent par intermittence en CI. Correctif : si votre plugin livre un middleware de vérification d'activation, exposez un resetCache() statique sur la classe gate et appelez-le depuis PluginTestCase::setUp().
4. Tester l'application hôte entière depuis un test de plugin
Les tests d'un plugin doivent se concentrer sur la contribution du plugin. Frapper /customers dans le test d'un plugin loyalty teste l'hôte, pas le plugin — une mise à jour hôte peut faire échouer le test pour des raisons sans rapport. Correctif : frappez les routes /plugins/acmecorp/loyalty/... que le plugin possède, ou assertez directement sur les modèles / hooks du plugin.
5. Partager des fixtures de test entre plugins via le dossier tests/ de l'hôte
Tentant parce que ça déduplique une factory Customer dont deux plugins ont besoin — mais la prochaine mise à jour hôte renomme une colonne et casse silencieusement les tests des deux plugins. Correctif : chaque plugin possède son propre dossier Fixtures/ ; si deux plugins partagent légitimement une factory, livrez un troisième plugin « shared test helpers » ou un package Composer et dépendez-en explicitement.
Où aller ensuite
Les tests sont le dernier arrêt sur la voie Qualité. Les deux pages suivantes sont les exemples travaillés les plus copieux de la doc : Drivers d'envoi livre un tout nouveau backend MTA sous forme de plugin (Postal MTA, end-to-end), et Gateways de paiement livre une gateway régionale (Paddle) avec les contrats du package Cashier. Ensuite, la démonstration acelle/ai parcourt le plugin complexe canonique end-to-end comme exercice de compréhension de lecture.