每个插件一个 test suite。真实数据库。真实生命周期。真实 Hook 断言。

插件测试与宿主自身的测试套件运行在同一个 Pest / PHPUnit 进程中 — 它们共享同样的 boot、同样的数据库连接、测试间同样的 RefreshDatabase 清理,以及同样的 CreatesApplication trait。宿主的 phpunit.xml 为每个插件保留一个命名 test suite,插件交付一个 PluginTestCase 基类,在每次测试前将自身标记为 active,断言模式则依赖真实的 Hook::collect() / Hook::fire() 调用而非桩。本页讲解注册、基类模式、每次请求的 gate cache 陷阱,以及 activate-test-delete 生命周期集成的范式。

为什么每个插件单独一个 test suite

插件通过 Hook 扩展宿主 — 它们并不打桩或模拟这个集成。一个注册发送服务器 driver 的插件,确实会通过 Hook::add('register_sending_server_driver', ...) 做出贡献;宿主在生产环境中也确实会通过 Hook::collect() 迭代该贡献。最有用的测试做的也是同一件事 — 启动一个真实的 Laravel 容器,注册插件的 service provider,命中一条真实的 HTTP route,触发一个真实的生命周期事件。打桩只能证明桩被接通了。

让插件测试运行在宿主自身的 Pest / PHPUnit 进程中正是这一切的前提。宿主的 tests/TestCase.php 交付了 CreatesApplication,它以与生产完全相同的方式启动 Laravel。每个插件测试都继承这一 boot — 包括插件自身的 autoloadWithoutDbQuery() 自动加载、其 service provider 的 boot(),以及插件在 register() 中贡献的任何 add_translation_file Hook。

test suite 之所以是按插件隔离的,而非混在一个套件里,原因在于选择性执行。本地开发期间仅运行一个插件的 suite(php artisan test --testsuite="Plugin: acelle/ai")就是快速反馈循环。宿主的 CI 矩阵也可以扇出:每个插件一个 job,并行。

在宿主 phpunit.xml 中注册

宿主根目录的 phpunit.xml 为每个受管理的插件保留一个 <testsuite> 块。当前在代码库中交付的三个插件就是按这一模式注册的(acelle/aiacelle/consoleathena/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>

新增插件 test suite 就是一个块、每个目录一行。宿主默认的 php artisan test 会默认拾取每个已注册的 test suite;--testsuite="Plugin: yourvendor/yourplugin" 则可将运行隔离到仅您的 suite。

插件作者要编辑宿主的 phpunit.xml。没有自动发现 — 在您插件的 tests/ 目录添加测试,在宿主未注册 test suite 之前不会产生任何效果。这是一个新插件所需的、唯一超出插件文件夹范围的接触点;其他一切都自包含。

插件测试存放位置

来自 acelle/ai 的约定镜像了宿主自身的 tests/ 结构 — Unit / Feature 文件夹加上根部的 PluginTestCase

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 测试以 .php 文件形式存放在 Feature/Unit/ 下的任何位置,并照常使用 uses() / test() / it()。偏好 extends 而非 uses() 的测试类也能工作 — 插件的 PluginTestCase 两者都能处理。

PluginTestCase 基类

宿主的 Tests\TestCase 通过 CreatesApplication 启动 Laravel。需要在每次测试前进行额外 setup 的插件 — 通常是把自己的行作为 active 写入种子,以便 middleware 不会对插件路由返回 404 — 会交付一个继承宿主基类的 PluginTestCaseacelle/ai 的完整实现位于 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();
    }
}

插件测试通过 uses()(Pest 风格)或 extends(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 { /* ... */ }
}

每次请求的 gate cache 陷阱

为插件路由把关的 middleware(console.activeai.active 等)会在每次请求时检查 Plugin::getByName($name)->isActive()。真实的生产代码会把这个查找记忆化,以保证单次请求不会多次命中数据库。在测试中,这个记忆就是陷阱 — 第一个测试设置了 active 的插件行,第二个测试用 RefreshDatabase 清空数据库并重新写入种子,但来自第一个测试的 gate cache 仍然在错误的行上说"active"(或"inactive")。

修复方法是在 gate 类上暴露一个 resetCache() 静态方法,并在 seed 运行后在插件的 PluginTestCase::setUp() 中调用它。acelle/ai 就是按照上文展示的方式使用 \Acelle\Ai\Support\PluginGate::resetCache() 做这件事。任何交付了类似 middleware 的插件都应遵循相同模式 — 否则套件就会有顺序依赖(第一个测试通过,第二个失败)。

hooks-under-test 模式

验证插件 Hook 贡献最直接的方式,是调用与生产环境中宿主相同的 Hook::collect() 并对结果做断言。无需 mock,无需打桩;真实的注册流程通过真实的插件 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 Hook 需要不同的形态 — 对副作用而非 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);
});

BEHAVIOR Hook(set / perform)通过调用 Hook::perform() 并断言返回值来自您插件的覆盖来测试:

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

一个特性形态插件的完整集成测试,会在一个测试里跑完整个生命周期:注册插件(或使用宿主已注册的行)、调用 $plugin->activate() 触发 migration、行使该特性,然后调用 $plugin->deleteAndCleanup() 以验证回滚干净。

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

这种测试能捕获单元测试无法捕获的一类 bug:boot() 中缺失的 delete_plugin_* Hook 监听器、一个能创建表但无法删除(没有 down() 方法)的 migration、传给 artisan migrate 的 path 参数中的拼写错误。每次发布跑一次,生命周期就能保持绿色。

插件隔离的 fixture

交付 factory 或 seeder 的插件,将它们放在插件文件夹的 tests/Fixtures/ 下,namespace 在插件的 PSR-4 前缀下。只要被自动加载,它们就能触达宿主的测试 — 插件的 composer.json 已经声明了 namespace,宿主的插件加载器会在 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),
        ];
    }
}

在测试中使用它们的方式和使用宿主自己的 factory 一样 — LoyaltyAccountFactory::new()->create()RefreshDatabase 在测试间清空行,factory 按需重新构建。

CI 模式 — 每个插件一个 job

--testsuite 过滤器正是启用按插件 CI 矩阵的关键。一个 GitHub-Actions 或 GitLab-CI 工作流可以扇出到您拥有的插件数量那么多的并行 job:

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

每个 suite 拿到自己的 SQLite 或 MySQL 数据库(测试连接按 job 隔离),并行运行,独立失败 — 一个坏掉的 acelle/ai 测试不会阻塞 acmecorp/loyalty 的发布。总墙钟时间接近最长的那个单一 suite,而不是所有 suite 的总和。

在本地运行插件测试

三条命令覆盖几乎所有开发者级别的用例:

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

如果您的团队偏好的话,Pest 风格的 ./vendor/bin/pest 二进制也能工作 — 两种调用方式都路由到同一个 PHPUnit 运行器。./vendor/bin/pest --testsuite="Plugin: acmecorp/loyalty" 与 artisan 版本效果完全相同。

五个反模式

1. mock Hook::collect() 而不是让插件真的去注册

mock 出来的 collect 可以返回任何您想要的内容,包括那些插件正常 boot 时根本不会存在的条目。修复:使用真实的 HookManager 单例;让插件的 service provider 通过 boot() 注册;对真实结果做断言。

2. 忘记 RefreshDatabase

没有它,行会在测试间泄漏 — 第二个测试会看到第一个测试的积分账户,并对陈旧状态做断言。修复:始终在每个插件测试文件顶部加入 uses(\Illuminate\Foundation\Testing\RefreshDatabase::class)(或通过 trait 加入插件的 PluginTestCase)。

3. 跳过 gate cache 重置

顺序依赖的失败,难以在本地复现,在 CI 中间歇性失败。修复:如果您的插件交付了一个 active-check middleware,请在 gate 类上暴露一个静态 resetCache(),并在 PluginTestCase::setUp() 中调用它。

4. 从插件测试中测试整个宿主应用

插件的测试应聚焦于插件的贡献。在积分插件的测试里命中 /customers,测试的是宿主,而非插件 — 宿主升级可能因为不相干的原因让测试变得不稳定。修复:命中插件拥有的 /plugins/acmecorp/loyalty/... 路由,或者直接对插件的模型/Hook 做断言。

5. 通过宿主的 tests/ 文件夹在插件间共享测试 fixture

看起来很诱人,因为它去重了两个插件都需要的 Customer factory — 但下一次宿主升级重命名某个列时,会悄无声息地破坏两个插件的测试。修复:每个插件拥有自己的 Fixtures/ 文件夹;如果两个插件确实合理地共享同一个 factory,请交付第三个"shared test helpers"插件或一个 Composer 包,并显式依赖它。

下一步去哪里

测试是 Quality 轨道的最后一站。接下来的两页是文档中最厚重的实战示例:发送 driver 将一个全新的 MTA 后端作为插件交付(Postal MTA,端到端),支付网关将一个区域性网关(Paddle)连同 Cashier 包契约一起交付。之后,acelle/ai 展示会作为阅读理解练习,端到端地走完这个标准的复杂插件。