为什么每个插件单独一个 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/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>
新增插件 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 — 会交付一个继承宿主基类的 PluginTestCase。acelle/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.active、ai.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 展示会作为阅读理解练习,端到端地走完这个标准的复杂插件。