Una testsuite per plugin. DB reale. Lifecycle reale. Asserzioni hook reali.

I test di plugin girano nello stesso processo Pest / PHPUnit della suite dell'host — condividono lo stesso boot, la stessa database connection, lo stesso wipe RefreshDatabase tra i test, e lo stesso trait CreatesApplication. Il phpunit.xml dell'host riserva una testsuite con nome per plugin, il plugin spedisce una base class PluginTestCase che si seeda come active prima di ogni test, e i pattern di asserzione si appoggiano su chiamate reali Hook::collect() / Hook::fire() piuttosto che su stub. Questa pagina copre la registrazione, il pattern della base class, il trap della gate-cache per-request e la recipe di integration per il lifecycle activate-test-delete.

Perché una testsuite per plugin

I plugin estendono l'host tramite hook — non fanno stub o mock dell'integrazione. Un plugin che registra un sending-server driver contribuisce realmente tramite Hook::add('register_sending_server_driver', ...); l'host itera realmente quel contributo tramite Hook::collect() in produzione. I test più utili fanno lo stesso — bootano un container Laravel reale, registrano il service provider del plugin, hittano una route HTTP reale, sparano un event di lifecycle reale. Gli stub proverebbero solo che gli stub sono cablati.

Eseguire i test del plugin nel processo Pest / PHPUnit dell'host è ciò che rende possibile tutto questo. Il tests/TestCase.php dell'host spedisce CreatesApplication, che boota Laravel esattamente come fa produzione. Ogni test plugin eredita quel boot — incluso l'autoload autoloadWithoutDbQuery() proprio del plugin, il boot() del suo service provider, e qualsiasi hook add_translation_file il plugin contribuisca in register().

Il motivo per cui le testsuite sono isolate per plugin piuttosto che mescolate in una singola suite è l'esecuzione selettiva. Eseguire solo la suite di un plugin durante lo sviluppo locale (php artisan test --testsuite="Plugin: acelle/ai") è il fast feedback loop. La matrix CI dell'host può anche fare fan out: un job per plugin, in parallelo.

Registrazione nel phpunit.xml host

Il phpunit.xml di root dell'host riserva un blocco <testsuite> per ogni plugin gestito. Tre plugin spediti nel codebase in questo momento sono registrati tramite questo pattern (acelle/ai, acelle/console, athena/evs tra gli altri):

<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>

Aggiungere la testsuite di un nuovo plugin è un blocco, una riga per directory. Il php artisan test di default dell'host raccoglie ogni testsuite registrata di default; --testsuite="Plugin: yourvendor/yourplugin" isola la run alla tua sola suite.

L'autore del plugin edita il phpunit.xml dell'host. Non c'è autodiscovery — aggiungere test alla directory tests/ del tuo plugin non ha effetto finché la testsuite non è registrata nell'host. Questo è il singolo touchpoint fuori dalla cartella del tuo plugin che un nuovo plugin richiede; tutto il resto è self-contained.

Dove vivono i test del plugin

La convenzione da acelle/ai mirrora la struttura tests/ propria dell'host — folder Unit / Feature più un PluginTestCase alla root:

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

I test Pest vivono come file .php ovunque sotto Feature/ o Unit/ e usano uses() / test() / it() normalmente. Anche le test class che preferiscono extends rispetto a uses() funzionano — il PluginTestCase del plugin gestisce entrambi.

La base class PluginTestCase

Il Tests\TestCase dell'host boota Laravel tramite CreatesApplication. I plugin che hanno bisogno di setup addizionale prima di ogni test — tipicamente seedare la propria row come active così il middleware non fa 404 sulle route del plugin — spediscono un PluginTestCase che estende quello host. L'implementazione completa di acelle/ai vive in 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();
    }
}

I test del plugin raggiungono questa base tramite uses() (stile Pest) o extends (stile 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 { /* ... */ }
}

Il trap della gate cache per-request

Il middleware che fa gate delle route del plugin (console.active, ai.active, ecc.) controlla Plugin::getByName($name)->isActive() a ogni request. Il codice di produzione reale memoizza questo lookup così una singola request non hitta mai il DB più di una volta. Nei test, quella memo è il trap — il primo test setta una row plugin attiva, il secondo test wipea il DB con RefreshDatabase e ri-seeda, ma la gate cache dal primo test dice ancora "active" (o "inactive") sulla row sbagliata.

La fix è esporre un metodo statico resetCache() sulla gate class e chiamarlo dal PluginTestCase::setUp() del plugin dopo che il seed gira. acelle/ai fa questo con \Acelle\Ai\Support\PluginGate::resetCache() come mostrato sopra. Qualsiasi plugin che spedisca un middleware simile dovrebbe seguire lo stesso pattern — senza, la suite è order-dependent (il primo test passa, il secondo fallisce).

Pattern hooks-under-test

Il modo più diretto di verificare i contributi hook di un plugin è chiamare la stessa Hook::collect() che l'host chiama in produzione e fare asserzioni sul risultato. Niente mocking; niente stubbing; la registrazione reale gira attraverso il boot reale del 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');
});

Gli EVENT hook hanno bisogno di una shape diversa — fai asserzioni sui side-effect piuttosto che sui collect-result:

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);
});

I BEHAVIOR hook (set / perform) si testano chiamando Hook::perform() e facendo asserzioni che il valore ritornato venga dall'override del tuo 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);
});

Ciclo activate → test → delete

Il full integration test per un plugin feature-shaped esegue l'intero lifecycle in un test: registra il plugin (o usa la row già registrata dell'host), chiama $plugin->activate() per sparare la migration, esercita la feature, poi chiama $plugin->deleteAndCleanup() per verificare che il rollback funzioni pulitamente.

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();
});

Questo test cattura una classe di bug che gli unit test non possono: un listener hook delete_plugin_* mancante in boot(), una migration che crea una tabella ma non può droppare (nessun metodo down()), un typo nell'argomento path di artisan migrate. Eseguilo una volta per release e il lifecycle resta verde.

Fixture isolate per plugin

I plugin che spediscono factory o seeder li tengono sotto tests/Fixtures/ nella cartella del plugin, namespaced sotto il prefisso PSR-4 del plugin. Raggiungono i test dell'host solo per il fatto di essere autoloaded — il composer.json del plugin dichiara già il namespace, e il plugin loader dell'host lo registra durante il 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),
        ];
    }
}

Usali dai test allo stesso modo in cui useresti le factory proprie dell'host — LoyaltyAccountFactory::new()->create(). RefreshDatabase wipea le row tra i test, le factory ricostruiscono al bisogno.

Pattern CI — un job per plugin

Il filtro --testsuite è ciò che abilita una matrix CI per-plugin. Un singolo workflow GitHub-Actions o GitLab-CI può fare fan out a tutti i job paralleli che hai plugin:

# .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 }}}"

Ogni suite ottiene il proprio database SQLite o MySQL (la connection di test è isolata per job), gira in parallelo e fallisce indipendentemente — un test acelle/ai rotto non blocca una release acmecorp/loyalty. Il wall-clock time totale resta vicino alla singola suite più lunga piuttosto che alla somma.

Eseguire i test plugin localmente

Tre comandi coprono quasi ogni use case a livello developer:

# 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"

Anche il binary stile Pest ./vendor/bin/pest funziona se il tuo team lo preferisce — entrambe le invocazioni passano attraverso lo stesso runner PHPUnit. ./vendor/bin/pest --testsuite="Plugin: acmecorp/loyalty" è identico in effetto alla versione artisan.

Cinque anti-pattern

1. Mockare Hook::collect() invece di lasciare che il plugin si registri davvero

Un collect mockato può ritornare qualsiasi cosa tu voglia, incluse entry che non esisterebbero mai se il plugin bootasse normalmente. Fix: usa il singleton HookManager reale; lascia che il service provider del plugin registri tramite boot(); fai asserzioni sul risultato reale.

2. Dimenticare RefreshDatabase

Senza di esso, le row leakano tra i test — il secondo test vede gli account loyalty del primo test e fa asserzioni su stato stale. Fix: includi sempre uses(\Illuminate\Foundation\Testing\RefreshDatabase::class) in cima a ogni file di test plugin (o nel PluginTestCase del plugin tramite un trait).

3. Skippare il reset della gate-cache

Fallimenti order-dependent, difficili da riprodurre localmente, falliscono in modo intermittente in CI. Fix: se il tuo plugin spedisce un middleware di active-check, esponi un resetCache() statico sulla gate class e chiamalo da PluginTestCase::setUp().

4. Testare l'intera host application da un test plugin

I test di un plugin dovrebbero focalizzarsi sul contributo del plugin. Hittare /customers nel test di un plugin loyalty sta testando l'host, non il plugin — un upgrade dell'host può flakare il test per ragioni non correlate. Fix: hitta le route /plugins/acmecorp/loyalty/... di cui il plugin è proprietario, o fai asserzioni direttamente sui model / hook del plugin.

5. Condividere fixture di test tra plugin tramite la cartella tests/ dell'host

Tentante perché deduplica una factory Customer di cui due plugin entrambi hanno bisogno — ma il prossimo upgrade dell'host rinomina una colonna e rompe silenziosamente i test di entrambi i plugin. Fix: ogni plugin possiede la propria cartella Fixtures/; se due plugin condividono legittimamente una factory, spedisci un terzo plugin "shared test helper" o un package Composer e dipendi da esso esplicitamente.

Dove andare dopo

Il testing è l'ultima fermata sulla track Quality. Le prossime due pagine sono gli esempi worked più pesanti nei doc: Sending drivers spedisce un backend MTA brand-new come plugin (Postal MTA, end-to-end), e Payment gateways spedisce un gateway regionale (Paddle) con i contract del package Cashier. Dopo, la showcase acelle/ai percorre il plugin complesso canonico end-to-end come esercizio di reading-comprehension.