なぜプラグインごとにテストスイートなのか
プラグインはフックを通じてホストを拡張します — 統合をスタブやモックで代替するわけではありません。送信サーバードライバーを登録するプラグインは、実際に Hook::add('register_sending_server_driver', ...) を通じて貢献します。ホストは本番でも実際にその貢献を Hook::collect() で反復します。最も有用なテストも同じことをします — 実 Laravel コンテナを boot し、プラグインのサービスプロバイダを登録し、実 HTTP ルートをヒットし、実ライフサイクルイベントを発火させる。スタブが証明できるのは、そのスタブ自体が結線されていることだけです。
プラグインテストをホスト自身の Pest / PHPUnit プロセスで走らせることが、それを可能にします。ホストの tests/TestCase.php は CreatesApplication を提供し、本番と全く同じ方法で Laravel を boot します。すべてのプラグインテストはその boot を継承します — プラグイン自身の autoloadWithoutDbQuery() オートロード、サービスプロバイダの boot()、register() でプラグインが貢献する add_translation_file フックも含めて。
単一スイートに混ぜずに、テストスイートをプラグインごとに分離する理由は選択実行です。ローカル開発中に 1 つのプラグインのスイートだけ走らせる (php artisan test --testsuite="Plugin: acelle/ai") のが高速フィードバックループになります。ホストの CI マトリクスもファンアウトできます: プラグインごとに 1 ジョブ、並列実行。
ホストの phpunit.xml に登録する
ホストのルート phpunit.xml は、管理下のプラグインごとに <testsuite> ブロックを予約します。コードベースで現在提供されている 3 つのプラグインがこのパターンで登録されています (なかでも 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>
新しいプラグインのテストスイートを追加するには、ブロック 1 つ、ディレクトリごとに 1 行です。ホストのデフォルト php artisan test は登録済みの全テストスイートをデフォルトで拾い、--testsuite="Plugin: yourvendor/yourplugin" で自分のスイートだけに実行を絞れます。
プラグイン作者がホストの phpunit.xml を編集します。 自動検出はありません — プラグインの tests/ ディレクトリにテストを追加しても、ホストにテストスイートが登録されるまで効果はありません。これは新規プラグインで必要となる、プラグインフォルダ外の唯一のタッチポイントです。それ以外はすべて自己完結します。
プラグインテストの置き場所
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 テストは Feature/ または Unit/ 配下の任意の .php ファイルとして配置し、通常どおり uses() / test() / it() を使います。uses() よりも extends を好むテストクラスでも動きます — プラグインの PluginTestCase が両方に対応します。
PluginTestCase ベースクラス
ホストの Tests\TestCase は CreatesApplication を通じて Laravel を boot します。すべてのテストの前に追加セットアップが必要なプラグイン — 典型的には、ミドルウェアがプラグインのルートを 404 にしないよう、自身の行を active としてシードする — は、ホストのものを継承する 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 キャッシュの落とし穴
プラグインルートをゲートするミドルウェア (console.active、ai.active など) は、すべてのリクエストで Plugin::getByName($name)->isActive() を確認します。実本番コードはこのルックアップをメモ化し、単一リクエスト内で DB に複数回アクセスしないようにします。テストではこのメモが落とし穴です — 最初のテストが active なプラグイン行をセットアップし、2 番目のテストが RefreshDatabase で DB をワイプして再シードしても、最初のテストの gate キャッシュは誤った行に対して「active」(または「inactive」) と言い続けます。
対処は、gate クラスに静的な resetCache() メソッドを露出し、シード実行後にプラグインの PluginTestCase::setUp() から呼ぶことです。acelle/ai は上記のとおり \Acelle\Ai\Support\PluginGate::resetCache() でこれを行います。同様のミドルウェアを提供するプラグインは同じパターンに従うべきです — これがないとスイートが順序依存になります (最初のテストはパス、2 番目で失敗)。
hooks-under-test パターン
プラグインのフック貢献を検証する最も直接的な方法は、ホストが本番で呼ぶのと同じ 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 フックは別の形が必要です — 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 フック (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 サイクル
機能形プラグインの完全な統合テストでは、ライフサイクル全体を 1 つのテストで走らせます: プラグインを登録 (またはホスト既登録の行を使用)、$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();
});
このテストは unit テストでは捕まえられない一群のバグを捕えます: boot() 内の delete_plugin_* フックリスナーの欠落、テーブルは作成できても削除できない Migration (down() メソッドなし)、artisan migrate のパス引数のタイポなど。リリースごとに 1 回実行すれば、ライフサイクルは健全に保たれます。
プラグイン分離 fixture
Factory や Seeder を提供するプラグインは、それらをプラグインフォルダ内の tests/Fixtures/ 配下に、プラグインの PSR-4 プレフィックスでネームスペース化して置きます。プラグインの composer.json がすでにネームスペースを宣言し、ホストのプラグインローダーが 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 パターン — プラグインごとに 1 ジョブ
プラグインごとの CI マトリクスを可能にするのは --testsuite フィルタです。単一の GitHub Actions または GitLab CI ワークフローを、プラグイン数だけの並列ジョブにファンアウトできます:
# .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 }}}"
各スイートは自身の SQLite または MySQL データベースを得 (テスト接続はジョブ単位で分離)、並列に実行し、独立して失敗します — acelle/ai のテストが壊れても acmecorp/loyalty のリリースをブロックしません。総ウォールクロック時間は、合計値ではなく最長スイート 1 本の所要時間に近づきます。
ローカルでプラグインテストを実行する
開発者レベルのユースケースのほぼすべては、3 つのコマンドでカバーできます:
# 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 版と効果は同一です。
5 つのアンチパターン
1. プラグインに実登録させず Hook::collect() をモックする
モック化された collect は任意の戻り値を返せます — プラグインが通常 boot しても存在しないはずのエントリも含めて。修正: 実 HookManager シングルトンを使い、プラグインのサービスプロバイダに boot() 経由で登録させ、実結果をアサートします。
2. RefreshDatabase を忘れる
これがないとテスト間で行がリークします — 2 番目のテストが最初のテストのロイヤリティアカウントを目にし、古い状態でアサートしてしまいます。修正: すべてのプラグインテストファイルの先頭に uses(\Illuminate\Foundation\Testing\RefreshDatabase::class) を必ず含めるか、プラグインの PluginTestCase に trait として組み込みます。
3. gate キャッシュリセットをスキップする
順序依存の失敗、ローカルでは再現困難、CI で断続的に失敗。修正: プラグインが active チェックミドルウェアを提供するなら、gate クラスに静的な resetCache() を露出し、PluginTestCase::setUp() から呼んでください。
4. プラグインテストからホストアプリケーション全体をテストする
プラグインのテストはプラグインの貢献にフォーカスすべきです。ロイヤリティプラグインのテストで /customers をヒットするのはホストのテストであり、プラグインのテストではありません — ホスト側のアップグレードが無関係な理由でテストを flake させかねません。修正: プラグインが所有する /plugins/acmecorp/loyalty/... ルートをヒットするか、プラグインのモデル/フックを直接アサートしてください。
5. ホストの tests/ フォルダ経由でプラグイン間でテスト fixture を共有する
2 つのプラグインが必要とする Customer ファクトリーを重複排除したくなりますが — 次のホストアップグレードがカラム名を変えるだけで両プラグインのテストがサイレントに壊れます。修正: 各プラグインが自身の Fixtures/ フォルダを所有します。2 つのプラグインが正当に Factory を共有したいなら、3 つめの「共有テストヘルパー」プラグインまたは Composer パッケージを提供し、明示的に依存してください。
次に読むページ
テストは Quality トラックの最終地点です。次の 2 ページはドキュメント内で最も重量級の作例です: 送信ドライバー は新規 MTA バックエンド (Postal MTA、エンドツーエンド) をプラグインとして提供し、決済ゲートウェイ はリージョン特化のゲートウェイ (Paddle) を Cashier パッケージの契約で提供します。それらの後、acelle/ai ショーケース が正典的に複雑なプラグインをエンドツーエンドの読解演習として辿ります。