Self-hosted email marketing with full source code. Pay once, own forever. Get AcelleMail — $74 →

One testsuite per plugin. Real DB. Real lifecycle. Real hook assertions.

Plugin tests run in the same Pest / PHPUnit process as the host's own suite — they share the same boot, the same database connection, the same RefreshDatabase wipe between tests, and the same CreatesApplication trait. The host's phpunit.xml reserves a named testsuite per plugin, the plugin ships a PluginTestCase base class that seeds itself as active before every test, and the assertion patterns lean on real Hook::collect() / Hook::fire() calls rather than stubs. This page covers the registration, the base-class pattern, the per-request gate-cache trap, and the activate-test-delete lifecycle integration recipe.

Why a testsuite per plugin

Plugins extend the host through hooks — they do not stub or mock the integration. A plugin that registers a sending-server driver actually contributes through Hook::add('register_sending_server_driver', ...); the host actually iterates that contribution through Hook::collect() in production. The most useful tests do the same — boot a real Laravel container, register the plugin's service provider, hit a real HTTP route, fire a real lifecycle event. Stubs would prove only that the stubs are wired up.

Running plugin tests in the host's own Pest / PHPUnit process is what makes that possible. The host's tests/TestCase.php ships CreatesApplication, which boots Laravel exactly the way production does. Every plugin test inherits that boot — including the plugin's own autoloadWithoutDbQuery() autoload, its service provider's boot(), and any add_translation_file hook the plugin contributes in register().

The reason testsuites are isolated per plugin rather than mixed into a single suite is selective execution. Running just one plugin's suite during local development (php artisan test --testsuite="Plugin: acelle/ai") is the fast feedback loop. The host's CI matrix can also fan out: one job per plugin, parallel.

Registering in the host phpunit.xml

The host's root phpunit.xml reserves a <testsuite> block for each plugin under management. Three plugins that ship in the codebase right now are registered via this pattern (acelle/ai, acelle/console, athena/evs among others):

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

Adding a new plugin's testsuite is one block, one line per directory. The host's default php artisan test picks up every registered testsuite by default; --testsuite="Plugin: yourvendor/yourplugin" isolates the run to your suite alone.

The plugin author edits the host's phpunit.xml. There is no autodiscovery — adding tests to your plugin's tests/ directory has no effect until the testsuite is registered in the host. This is the single touchpoint outside your plugin folder that a new plugin requires; everything else is self-contained.

Where plugin tests live

The convention from acelle/ai mirrors the host's own tests/ structure — Unit / Feature folders plus a PluginTestCase at the 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 live as .php files anywhere under Feature/ or Unit/ and use uses() / test() / it() as normal. Test classes that prefer extends over uses() work too — the plugin's PluginTestCase handles both.

The PluginTestCase base class

The host's Tests\TestCase boots Laravel through CreatesApplication. Plugins that need additional setup before every test — typically seeding their own row as active so middleware does not 404 the plugin's routes — ship a PluginTestCase that extends the host one. The full acelle/ai implementation lives at 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 = 'Acelle AI';
            $plugin->version = '1.0.0';
        }
        $plugin->status = 'active';
        $plugin->save();
    }
}

Plugin tests reach into this base via either uses() (Pest style) or extends (PHPUnit style):

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

The per-request gate cache trap

The middleware that gates plugin routes (console.active, ai.active, etc.) checks Plugin::getByName($name)->isActive() on every request. Real production code memoises this lookup so a single request never hits the DB more than once. In tests, that memo is the trap — the first test sets up an active plugin row, the second test wipes the DB with RefreshDatabase and re-seeds, but the gate cache from the first test still says "active" (or "inactive") on the wrong row.

The fix is to expose a resetCache() static method on the gate class and call it from the plugin's PluginTestCase::setUp() after the seed runs. acelle/ai does this with \Acelle\Ai\Support\PluginGate::resetCache() as shown above. Any plugin that ships a similar middleware should follow the same pattern — without it, the suite is order-dependent (first test passes, second one fails).

Hooks-under-test pattern

The most direct way to verify a plugin's hook contributions is to call the same Hook::collect() the host calls in production and assert on the result. No mocking; no stubbing; the real registration runs through the real 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 need a different shape — assert side-effects rather than collect-results:

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) test by calling Hook::perform() and asserting the returned value comes from your plugin's override:

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 cycle

The full integration test for a feature-shaped plugin runs the entire lifecycle in one test: register the plugin (or use the host's already-registered row), call $plugin->activate() to fire the migration, exercise the feature, then call $plugin->deleteAndCleanup() to verify the rollback works cleanly.

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

This test catches a class of bugs that unit tests cannot: a missing delete_plugin_* hook listener in boot(), a migration that creates a table but cannot drop it (no down() method), a typo in the path argument to artisan migrate. Run it once per release and the lifecycle stays green.

Plugin-isolated fixtures

Plugins that ship factories or seeders keep them under tests/Fixtures/ in the plugin folder, namespaced under the plugin's PSR-4 prefix. They reach the host's tests just by being autoloaded — the plugin's composer.json already declares the namespace, and the host's plugin loader registers it during 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),
        ];
    }
}

Use them from tests the same way you would use the host's own factories — LoyaltyAccountFactory::new()->create(). RefreshDatabase wipes the rows between tests, factories rebuild as needed.

CI patterns — one job per plugin

The --testsuite filter is what enables a per-plugin CI matrix. A single GitHub-Actions or GitLab-CI workflow can fan out to as many parallel jobs as you have 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 }}}"

Each suite gets its own SQLite or MySQL database (the test connection is isolated per job), runs in parallel, and fails independently — a broken acelle/ai test does not block an acmecorp/loyalty release. Total wall-clock time stays close to the longest single suite rather than the sum.

Running plugin tests locally

Three commands cover almost every developer-level use case:

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

The Pest-style ./vendor/bin/pest binary works too if your team prefers it — both invocations route through the same PHPUnit runner. ./vendor/bin/pest --testsuite="Plugin: acmecorp/loyalty" is identical in effect to the artisan version.

Five anti-patterns

1. Mocking Hook::collect() instead of letting the plugin actually register

A mocked collect can return whatever you want, including entries that would never exist if the plugin booted normally. Fix: use the real HookManager singleton; let the plugin's service provider register through boot(); assert on the real result.

2. Forgetting RefreshDatabase

Without it, rows leak between tests — the second test sees the first test's loyalty accounts and asserts on stale state. Fix: always include uses(\Illuminate\Foundation\Testing\RefreshDatabase::class) at the top of every plugin test file (or in the plugin's PluginTestCase via a trait).

3. Skipping the gate-cache reset

Order-dependent failures, hard to reproduce locally, fail intermittently in CI. Fix: if your plugin ships an active-check middleware, expose a static resetCache() on the gate class and call it from PluginTestCase::setUp().

4. Testing the entire host application from a plugin test

A plugin's tests should focus on the plugin's contribution. Hitting /customers in a loyalty plugin's test is testing the host, not the plugin — a host upgrade can flake the test for unrelated reasons. Fix: hit /plugins/acmecorp/loyalty/... routes the plugin owns, or assert directly on the plugin's models / hooks.

5. Sharing test fixtures across plugins through the host's tests/ folder

Tempting because it deduplicates a Customer factory two plugins both need — but the next host upgrade renames a column and silently breaks both plugins' tests. Fix: each plugin owns its own Fixtures/ folder; if two plugins legitimately share a factory, ship a third "shared test helpers" plugin or a Composer package and depend on it explicitly.

Where to go next

Testing is the last stop on the Quality track. The next two pages are the heaviest worked examples in the docs: Sending drivers ships a brand-new MTA backend as a plugin (Postal MTA, end-to-end), and Payment gateways ships a regional gateway (Paddle) with the Cashier package contracts. After those, the acelle/ai showcase walks the canonical complex plugin end-to-end as a reading-comprehension exercise.