Một testsuite cho mỗi plugin. DB thật. Lifecycle thật. Assertion hook thật.

Test của plugin chạy trong cùng process Pest / PHPUnit với suite của chính host — chúng share cùng boot, cùng database connection, cùng wipe RefreshDatabase giữa các test, và cùng trait CreatesApplication. File phpunit.xml của host reserve một testsuite được đặt tên cho mỗi plugin, plugin ship một base class PluginTestCase tự seed nó là active trước mỗi test, và pattern assertion dựa vào call Hook::collect() / Hook::fire() thật chứ không phải stub. Trang này đi qua việc đăng ký, pattern base-class, bẫy gate-cache per-request, và recipe integration cho lifecycle activate-test-delete.

Vì sao mỗi plugin một testsuite

Plugin extend host qua hook — chúng không stub hay mock integration. Một plugin đăng ký sending-server driver thật sự contribute qua Hook::add('register_sending_server_driver', ...); host thật sự iterate contribution đó qua Hook::collect() trong production. Các test hữu ích nhất làm điều tương tự — boot container Laravel thật, đăng ký service provider của plugin, hit một HTTP route thật, fire một lifecycle event thật. Stub chỉ chứng minh được đúng việc stub được wire xong.

Chạy test plugin trong process Pest / PHPUnit của chính host là cái làm điều đó khả thi. File tests/TestCase.php của host ship CreatesApplication, boot Laravel đúng theo cách production làm. Mọi test plugin kế thừa boot đó — bao gồm autoload autoloadWithoutDbQuery() của plugin, boot() của service provider plugin, và bất kỳ hook add_translation_file nào plugin contribute trong register().

Lý do testsuite được isolated theo từng plugin thay vì gộp vào một suite duy nhất là để execute có chọn lọc. Chạy chỉ một suite của một plugin trong dev local (php artisan test --testsuite="Plugin: acelle/ai") là vòng feedback nhanh. Matrix CI của host cũng có thể fan out: mỗi plugin một job, parallel.

Đăng ký trong phpunit.xml của host

File phpunit.xml ở root của host reserve một block <testsuite> cho mỗi plugin đang được manage. Ba plugin đang ship trong codebase hiện tại được đăng ký theo pattern này (acelle/ai, acelle/console, athena/evs trong số khác):

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

Thêm testsuite cho một plugin mới là một block, một line cho mỗi directory. php artisan test mặc định của host pick up mọi testsuite đã đăng ký; --testsuite="Plugin: yourvendor/yourplugin" isolate run về đúng suite của bạn.

Tác giả plugin edit phpunit.xml của host. Không có autodiscovery — thêm test vào directory tests/ của plugin sẽ không có tác dụng cho đến khi testsuite được đăng ký trong host. Đây là touchpoint duy nhất bên ngoài thư mục plugin mà một plugin mới cần; mọi thứ còn lại đều self-contained.

Test plugin nằm ở đâu

Convention từ acelle/ai mirror structure tests/ của chính host — folder Unit / Feature cộng thêm một PluginTestCase ở 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

Test Pest sống dưới dạng file .php ở bất cứ đâu trong Feature/ hoặc Unit/ và dùng uses() / test() / it() như bình thường. Test class thích extends hơn uses() cũng ok — PluginTestCase của plugin handle cả hai.

Base class PluginTestCase

Class Tests\TestCase của host boot Laravel qua CreatesApplication. Plugin nào cần thêm setup trước mỗi test — thường là seed row của chính nó là active để middleware không 404 route của plugin — ship một PluginTestCase extend từ class của host. Implementation đầy đủ của acelle/ai nằm ở 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();
    }
}

Test plugin reach vào base này qua hoặc uses() (style Pest) hoặc 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 { /* ... */ }
}

Bẫy gate cache per-request

Middleware gate route plugin (console.active, ai.active, v.v.) check Plugin::getByName($name)->isActive() mỗi request. Code production thật memoize lookup này nên một request không bao giờ hit DB nhiều lần. Trong test, memo đó là cái bẫy — test đầu setup một row plugin active, test thứ hai wipe DB bằng RefreshDatabase rồi re-seed, nhưng gate cache từ test đầu vẫn nói "active" (hoặc "inactive") trên row sai.

Fix là expose một static method resetCache() trên class gate và gọi nó từ PluginTestCase::setUp() của plugin sau khi seed chạy. acelle/ai làm điều đó với \Acelle\Ai\Support\PluginGate::resetCache() như trên. Mọi plugin ship middleware tương tự đều nên follow cùng pattern — không có nó, suite phụ thuộc vào order (test đầu pass, test thứ hai fail).

Pattern hook-under-test

Cách thẳng nhất để verify contribution hook của plugin là gọi cùng Hook::collect() mà host gọi trong production và assert trên result. Không mock; không stub; registration thật chạy qua boot plugin thật:

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

Hook EVENT cần shape khác — assert side-effect chứ không phải 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);
});

Hook BEHAVIOR (set / perform) test bằng cách gọi Hook::perform() và assert value trả về đến từ override của plugin bạn:

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

Chu trình activate → test → delete

Integration test đầy đủ cho một plugin có shape feature chạy nguyên cả lifecycle trong một test: register plugin (hoặc dùng row đã đăng ký sẵn của host), gọi $plugin->activate() để fire migration, exercise feature, rồi gọi $plugin->deleteAndCleanup() để verify rollback chạy sạch.

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

Test này bắt được một class bug mà unit test không bắt được: một listener hook delete_plugin_* bị thiếu trong boot(), một migration tạo table nhưng không drop được (không có method down()), một typo trong path argument cho artisan migrate. Run nó một lần mỗi release và lifecycle luôn xanh.

Fixture isolated theo plugin

Plugin ship factory hoặc seeder giữ chúng dưới tests/Fixtures/ trong folder plugin, namespace dưới prefix PSR-4 của plugin. Chúng reach được test của host chỉ nhờ được autoload — file composer.json của plugin đã declare namespace, và plugin loader của host đăng ký nó khi 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),
        ];
    }
}

Dùng chúng từ test giống y cách bạn dùng factory của chính host — LoyaltyAccountFactory::new()->create(). RefreshDatabase wipe row giữa các test, factory rebuild khi cần.

Pattern CI — một job cho mỗi plugin

Filter --testsuite là cái cho phép matrix CI per-plugin. Một workflow GitHub Actions hay GitLab CI duy nhất có thể fan out thành số job parallel bằng số plugin bạn có:

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

Mỗi suite có database SQLite hoặc MySQL riêng (connection test isolated theo từng job), chạy parallel, và fail độc lập — một test acelle/ai hỏng không block release acmecorp/loyalty. Tổng wall-clock time gần bằng suite dài nhất chứ không phải tổng của tất cả.

Chạy test plugin ở local

Ba command cover gần như mọi use case ở mức 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"

Binary ./vendor/bin/pest style Pest cũng work nếu team bạn thích hơn — cả hai cách invocation đều route qua cùng runner PHPUnit. ./vendor/bin/pest --testsuite="Plugin: acmecorp/loyalty" giống y artisan version về tác dụng.

Năm anti-pattern

1. Mock Hook::collect() thay vì để plugin đăng ký thật

Một collect đã mock có thể return bất cứ gì bạn muốn, bao gồm entry không bao giờ tồn tại nếu plugin boot bình thường. Fix: dùng singleton HookManager thật; để service provider của plugin đăng ký qua boot(); assert trên result thật.

2. Quên RefreshDatabase

Không có nó, row leak giữa các test — test thứ hai thấy loyalty account của test đầu và assert trên stale state. Fix: luôn include uses(\Illuminate\Foundation\Testing\RefreshDatabase::class) ở đầu mỗi file test plugin (hoặc trong PluginTestCase của plugin qua một trait).

3. Bỏ qua reset gate-cache

Failure phụ thuộc order, khó reproduce ở local, fail bất chợt trong CI. Fix: nếu plugin của bạn ship middleware active-check, expose một static resetCache() trên class gate và gọi nó từ PluginTestCase::setUp().

4. Test toàn bộ host application từ test của plugin

Test của plugin nên focus vào contribution của plugin. Hit /customers trong test của plugin loyalty là đang test host, không phải plugin — một upgrade host có thể flake test vì lý do không liên quan. Fix: hit route /plugins/acmecorp/loyalty/... mà plugin own, hoặc assert thẳng vào model / hook của plugin.

5. Share test fixture giữa các plugin qua folder tests/ của host

Hấp dẫn vì nó dedupe một Customer factory mà cả hai plugin cùng cần — nhưng upgrade host kế tiếp rename một column và silently break test của cả hai plugin. Fix: mỗi plugin own folder Fixtures/ của riêng nó; nếu hai plugin thật sự share một factory, ship một plugin "shared test helpers" thứ ba hoặc một Composer package và depend vào nó explicit.

Đi tiếp đến đâu

Testing là điểm dừng cuối của track Quality. Hai trang tiếp theo là các worked example nặng nhất trong docs: Sending drivers ship một MTA backend mới toanh dưới dạng plugin (Postal MTA, end-to-end), và Payment gateways ship một gateway theo region (Paddle) với contract của package Cashier. Sau đó, showcase acelle/ai đi qua plugin phức tạp chính tắc end-to-end như một bài đọc-hiểu.