Eine Test-Suite pro Plugin. Echte DB. Echter Lifecycle. Echte Hook-Assertions.

Plugin-Tests laufen im selben Pest- / PHPUnit-Prozess wie die Suite des Hosts — sie teilen denselben Boot, dieselbe Datenbankverbindung, denselben RefreshDatabase-Wipe zwischen Tests und denselben CreatesApplication-Trait. Die phpunit.xml des Hosts reserviert eine benannte Test-Suite pro Plugin, das Plugin liefert eine Basisklasse PluginTestCase, die sich vor jedem Test selbst als aktiv seedet, und die Assertion-Muster setzen auf echte Hook::collect()- / Hook::fire()-Aufrufe statt auf Stubs. Diese Seite behandelt die Registrierung, das Basisklassen-Muster, die per-Request-Gate-Cache-Falle und das Rezept zur Lifecycle-Integration activate-test-delete.

Warum eine Test-Suite pro Plugin

Plugins erweitern den Host über Hooks — sie stubben oder mocken die Integration nicht. Ein Plugin, das einen Sending-Server-Driver registriert, trägt tatsächlich über Hook::add('register_sending_server_driver', ...) bei; der Host iteriert diesen Beitrag in der Produktion tatsächlich über Hook::collect(). Die nützlichsten Tests tun dasselbe — booten einen echten Laravel-Container, registrieren den Service Provider des Plugins, treffen eine echte HTTP-Route, feuern ein echtes Lifecycle-Event. Stubs würden nur beweisen, dass die Stubs verdrahtet sind.

Plugin-Tests im hostseitigen Pest- / PHPUnit-Prozess laufen zu lassen, ist das, was das ermöglicht. Die tests/TestCase.php des Hosts liefert CreatesApplication aus, das Laravel exakt so bootet, wie die Produktion es tut. Jeder Plugin-Test erbt diesen Boot — einschließlich des autoloadWithoutDbQuery()-Autoloads des Plugins, des boot() seines Service Providers und jedes add_translation_file-Hooks, den das Plugin in register() beiträgt.

Der Grund, warum Test-Suites pro Plugin isoliert sind und nicht zu einer einzigen Suite gemischt werden, ist selektive Ausführung. Nur die Suite eines Plugins während der lokalen Entwicklung laufen zu lassen (php artisan test --testsuite="Plugin: acelle/ai") ist die schnelle Feedback-Schleife. Die CI-Matrix des Hosts kann ebenfalls ausfächern: ein Job pro Plugin, parallel.

Registrierung in der Host-phpunit.xml

Die Root-phpunit.xml des Hosts reserviert einen <testsuite>-Block für jedes verwaltete Plugin. Drei Plugins, die aktuell im Codebase ausgeliefert werden, sind über dieses Muster registriert (unter anderem acelle/ai, acelle/console, athena/evs):

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

Eine neue Plugin-Testsuite hinzuzufügen ist ein Block, eine Zeile pro Verzeichnis. Das Standard-php artisan test des Hosts greift sich jede registrierte Test-Suite; --testsuite="Plugin: yourvendor/yourplugin" isoliert den Lauf auf Ihre Suite.

Der Plugin-Autor editiert die phpunit.xml des Hosts. Es gibt keine Auto-Discovery — Tests in das tests/-Verzeichnis Ihres Plugins zu legen, hat keinerlei Effekt, bis die Test-Suite im Host registriert ist. Das ist der einzige Berührungspunkt außerhalb Ihres Plugin-Ordners, den ein neues Plugin benötigt; alles andere ist self-contained.

Wo Plugin-Tests liegen

Die Konvention aus acelle/ai spiegelt die tests/-Struktur des Hosts — Unit-/Feature-Ordner plus eine PluginTestCase im 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

Pest-Tests liegen als .php-Dateien überall unter Feature/ oder Unit/ und verwenden uses() / test() / it() wie üblich. Test-Klassen, die extends gegenüber uses() bevorzugen, funktionieren ebenfalls — der PluginTestCase des Plugins handhabt beides.

Die PluginTestCase-Basisklasse

Der Tests\TestCase des Hosts bootet Laravel über CreatesApplication. Plugins, die zusätzliches Setup vor jedem Test benötigen — typischerweise das Seeden ihrer eigenen Zeile als active, damit die Middleware die Plugin-Routen nicht mit 404 ablehnt — liefern eine PluginTestCase aus, die die des Hosts erweitert. Die vollständige Implementation von acelle/ai liegt unter 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();
    }
}

Plugin-Tests greifen über entweder uses() (Pest-Stil) oder extends (PHPUnit-Stil) auf diese Basis zu:

// 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 { /* ... */ }
}

Die per-Request-Gate-Cache-Falle

Die Middleware, die Plugin-Routen absichert (console.active, ai.active usw.), prüft Plugin::getByName($name)->isActive() bei jedem Request. Produktionscode memoisiert diesen Lookup, sodass ein einzelner Request die DB nicht mehr als einmal trifft. In Tests ist genau diese Memoisierung die Falle — der erste Test richtet eine aktive Plugin-Zeile ein, der zweite Test wischt die DB mit RefreshDatabase und seedet erneut, aber der Gate-Cache aus dem ersten Test sagt immer noch „active" (oder „inactive") für die falsche Zeile.

Der Fix ist, eine statische Methode resetCache() auf der Gate-Klasse anzubieten und sie aus der PluginTestCase::setUp() des Plugins nach dem Seed-Lauf aufzurufen. acelle/ai macht das mit \Acelle\Ai\Support\PluginGate::resetCache() wie oben gezeigt. Jedes Plugin, das eine ähnliche Middleware ausliefert, sollte demselben Muster folgen — ohne das ist die Suite reihenfolge-abhängig (erster Test besteht, zweiter fällt durch).

Muster für Hooks unter Test

Der direkteste Weg, die Hook-Beiträge eines Plugins zu verifizieren, ist, dasselbe Hook::collect() aufzurufen, das der Host in der Produktion aufruft, und das Ergebnis zu prüfen. Kein Mocking; kein Stubbing; die echte Registrierung läuft durch den echten Plugin-Boot:

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

EVENT-Hooks brauchen eine andere Form — Seiteneffekte prüfen statt Collect-Ergebnisse:

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

BEHAVIOR-Hooks (set / perform) testet man durch Aufruf von Hook::perform() und der Prüfung, dass der zurückgegebene Wert aus dem Override Ihres Plugins stammt:

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

Activate → test → delete-Zyklus

Der vollständige Integrationstest für ein feature-förmiges Plugin durchläuft den gesamten Lifecycle in einem Test: das Plugin registrieren (oder die bereits registrierte Zeile des Hosts verwenden), $plugin->activate() aufrufen, um die Migration zu feuern, das Feature ausüben, dann $plugin->deleteAndCleanup() aufrufen, um zu prüfen, dass das Rollback sauber funktioniert.

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

Dieser Test fängt eine Klasse von Bugs ab, die Unit-Tests nicht erwischen können: ein fehlender delete_plugin_*-Hook-Listener im boot(), eine Migration, die eine Tabelle erstellt, sie aber nicht droppen kann (keine down()-Methode), ein Tippfehler im Pfad-Argument für artisan migrate. Lassen Sie ihn einmal pro Release laufen, und der Lifecycle bleibt grün.

Plugin-isolierte Fixtures

Plugins, die Factories oder Seeder ausliefern, halten diese unter tests/Fixtures/ im Plugin-Ordner, namespaced unter dem PSR-4-Präfix des Plugins. Sie erreichen die Tests des Hosts allein dadurch, dass sie autogeladen sind — die composer.json des Plugins deklariert den Namespace bereits, und der Plugin-Loader des Hosts registriert ihn beim 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),
        ];
    }
}

Verwenden Sie sie aus Tests wie die hostseitigen Factories — LoyaltyAccountFactory::new()->create(). RefreshDatabase wischt die Zeilen zwischen Tests, Factories bauen bei Bedarf neu auf.

CI-Muster — ein Job pro Plugin

Der --testsuite-Filter ist das, was eine per-Plugin-CI-Matrix ermöglicht. Ein einziger GitHub-Actions- oder GitLab-CI-Workflow kann auf so viele parallele Jobs ausfächern, wie Sie Plugins haben:

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

Jede Suite erhält ihre eigene SQLite- oder MySQL-Datenbank (die Test-Connection ist pro Job isoliert), läuft parallel und schlägt unabhängig fehl — ein kaputter acelle/ai-Test blockiert kein acmecorp/loyalty-Release. Die gesamte Wall-Clock-Zeit liegt nahe an der längsten Einzel-Suite statt an der Summe.

Plugin-Tests lokal ausführen

Drei Befehle decken nahezu jeden Entwickler-Use-Case ab:

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

Das Pest-Binary ./vendor/bin/pest funktioniert ebenfalls, falls Ihr Team es bevorzugt — beide Invocations routen über denselben PHPUnit-Runner. ./vendor/bin/pest --testsuite="Plugin: acmecorp/loyalty" ist in der Wirkung identisch mit der artisan-Variante.

Fünf Anti-Patterns

1. Hook::collect() mocken, statt das Plugin tatsächlich registrieren zu lassen

Ein gemocktes collect kann zurückgeben, was Sie wollen — auch Einträge, die niemals existieren würden, wenn das Plugin normal bootet. Fix: Verwenden Sie das echte HookManager-Singleton; lassen Sie den Service Provider des Plugins über boot() registrieren; prüfen Sie das echte Ergebnis.

2. RefreshDatabase vergessen

Ohne das lecken Zeilen zwischen Tests — der zweite Test sieht die Loyalty Accounts des ersten Tests und prüft gegen veralteten Zustand. Fix: Fügen Sie immer uses(\Illuminate\Foundation\Testing\RefreshDatabase::class) am Anfang jeder Plugin-Testdatei ein (oder in der PluginTestCase des Plugins über einen Trait).

3. Den Gate-Cache-Reset überspringen

Reihenfolge-abhängige Fehlschläge, lokal schwer zu reproduzieren, in CI sporadisch. Fix: Wenn Ihr Plugin eine Active-Check-Middleware ausliefert, bieten Sie ein statisches resetCache() auf der Gate-Klasse an und rufen Sie es aus PluginTestCase::setUp() auf.

4. Die gesamte Host-Anwendung aus einem Plugin-Test heraus testen

Die Tests eines Plugins sollten sich auf den Beitrag des Plugins konzentrieren. /customers in einem Loyalty-Plugin-Test zu treffen, testet den Host, nicht das Plugin — ein Host-Upgrade kann den Test aus unbezogenen Gründen flaky machen. Fix: Treffen Sie die Routen /plugins/acmecorp/loyalty/..., die das Plugin besitzt, oder prüfen Sie direkt gegen Modelle / Hooks des Plugins.

5. Test-Fixtures plugin-übergreifend über den tests/-Ordner des Hosts teilen

Verlockend, weil es eine Customer-Factory dedupliziert, die zwei Plugins beide brauchen — aber das nächste Host-Upgrade benennt eine Spalte um und bricht still die Tests beider Plugins. Fix: Jedes Plugin besitzt seinen eigenen Fixtures/-Ordner; wenn zwei Plugins legitim eine Factory teilen, liefern Sie ein drittes „Shared Test Helpers"-Plugin oder ein Composer-Paket aus und hängen Sie explizit davon ab.

Wie es weitergeht

Testing ist die letzte Station auf dem Quality-Track. Die nächsten beiden Seiten sind die schwergewichtigsten ausgearbeiteten Beispiele in den Docs: Sending Driver liefert ein brandneues MTA-Backend als Plugin (Postal MTA, Ende-zu-Ende), und Payment Gateways liefert eine regionale Gateway (Paddle) mit den Cashier-Paket-Verträgen. Danach behandelt die acelle/ai-Showcase das kanonische komplexe Plugin Ende-zu-Ende als Verständnis-Übung.