[Laravel] Testbench の getPackageProviders() を larastan に自動的に読み取らせる方法
問題
Laravel 向けのライブラリやモジュラモノリス構成の Laravel アプリケーションを開発する際,テストユーティリティとして,orchestra/testbench を利用することが一般的になっている。このライブラリは laravel/laravel に相当する「ユーザがフレームワークに乗るために最低限用意する部分」を巻き取り, phpunit.xml
とテストケースファイルだけで殆ど全てが完結するように面倒を見てくれる。
ところが,静的解析ツールの PHPStan とその Laravel プラグインである larastan を使用している場合,以下のような問題に衝突することがあるらしい。
自分が検証した larastan 1.0.2 では,上記ブログ記事のように「リレーションを定義した場合,コンフィグを用意しないと解析に失敗する」という現象は発生しなかった。但し,類似の問題としてタイトルの通り, Orchestra\Testbench\TestCase::getPackageProviders()
の内容を考慮した解析をしてくれないという問題に直面した。
今回自分がハマったのは,以下のようにカスタムファサードを用意するケースであった。Laravel の Ordered UUID は 2059年問題を抱えている ため,変換器 を使って西暦 10889 年までソート可能な UUID を用意したい事情があり,これをコントラクト・ファサードの両方からアクセスできるようにサービス登録を行っていた。
実際のコード
コントラクト
<?php
namespace Packages\Common\Id\Contracts;
interface IdGenerator
{
public function generate(): string;
}
実装
<?php
namespace Packages\Common\Id;
use Mpyw\UuidUlidConverter\Converter;
use Packages\Common\Id\Contracts\IdGenerator as IdGeneratorContract;
use Ulid\Ulid;
class IdGenerator implements IdGeneratorContract
{
public function generate(): string
{
return Converter::ulidToUuid((string)Ulid::generate());
}
}
実装をコントラクトにバインドするサービスプロバイダ
<?php
namespace Packages\Common\Id;
class IdServiceProvider extends \Illuminate\Support\ServiceProvider
{
public function register(): void
{
$this->app->singleton(Contracts\IdGenerator::class, IdGenerator::class);
}
}
アクセサをコントラクトに指定したファサード
<?php
namespace Packages\Common\Id\Facades;
use Packages\Common\Id\Contracts\IdGenerator;
/**
* @method static string generate()
*/
class Id extends \Illuminate\Support\Facades\Facade
{
public static function getFacadeAccessor(): string
{
return IdGenerator::class;
}
}
他のパッケージのテストケースでこのファサードを使用したコードを書いたところ,以下のようなエラーが出るようになってしまった。なんと,真上で使用を宣言しているサービスプロバイダを一切考慮せずに解析をかけてしまうのだ。これは困った。
<?php
namespace Packages\Domain\ExampleService;
use Packages\Common\Id\Facades\Id;
use Packages\Common\Id\IdServiceProvider;
class Test extends \Orchestra\Testbench\TestCase
{
protected function getPackageProviders($app): array
{
return [
IdServiceProvider::class,
];
}
public function test(): void
{
$id = Id::generate();
// ...
}
}
------ ----------------------------------------------------------------------
Line tests/Test.php
------ ----------------------------------------------------------------------
Internal error: Target [Packages\Common\Id\Contracts\IdGenerator] is
not instantiable.
Run PHPStan with --debug option and post the stack trace to:
https://github.com/phpstan/phpstan/issues/new?template=Bug_report.md
------ ----------------------------------------------------------------------
解決策
- A. 紹介したブログ記事のように, larastan 解析用
bootstrap/app.php
を設置する - B. パッケージで据える
phpstan.neon
のparameters.bootstrapFiles
に,都度用意した追加のブートストラップファイルを指定する - C. パッケージで据える
phpstan.neon
からincludes
で参照される共通のルールファイルのparameters.bootstrapFiles
に,getPackageProviders()
を自動解析するブートストラップファイルを指定する
B は A と実質的にはほぼ同じ解決法。デフォルトの動作を変更せず,部分的に設定のオーバーライドを行うという違いしかない。C はすでに共通化した PHPStan のルールを参照している場合,追加の記述を一切行う必要が無いというアドバンテージがある。今回は C の方法を紹介する。
bootstrap.php
PHPStan の共通ルールで指定するブートストラップファイル PHPStan の共通ルールパッケージ上で,以下のコードを用意する。このコードは以下のライブラリに依存している。
<?php
/** @noinspection PhpUnhandledExceptionInspection */
use Composer\Autoload\ClassLoader;
use Illuminate\Foundation\Application;
use Kcs\ClassFinder\Finder\ComposerFinder;
use Orchestra\Testbench\TestCase;
// カレントディレクトリにある composer.json の autoload-dev.psr-4 のキーを読み取る
$testNamespaces = array_keys((array)(
json_decode(
file_get_contents('composer.json'),
true,
)['autoload-dev']['psr-4'] ?? []
));
if (!$testNamespaces) {
return;
}
// vendor ディレクトリを参照しているオートローダを特定
$classLoader = (function (): ?ClassLoader {
foreach (spl_autoload_functions() as $fn) {
if (is_array($fn) && $fn[0] instanceof ClassLoader) {
// PHPStan の Phar オートローダを除外
$prefix = current(current($fn[0]->getPrefixesPsr4()) ?: []);
if ($prefix && !str_starts_with($prefix, 'phar://')) {
return $fn[0];
}
}
}
return null;
})();
// テスト用ネームスペース配下の Orchestra\Testbench\TestCase 継承クラスを検索
$finder = (new ComposerFinder($classLoader))
->inNamespace(array_map(fn (string $ns) => rtrim($ns, '\\'), $testNamespaces))
->subclassOf(TestCase::class)
->filter(fn (ReflectionClass $class) => $class->isInstantiable());
// テストで生成される Application インスタンス
$app = Application::getInstance();
foreach ($finder as $class) {
assert($class instanceof ReflectionClass);
// getPackageProviders メソッドをコール
$getPackageProviders = $class->getMethod('getPackageProviders');
$getPackageProviders->setAccessible(true);
$providers = $getPackageProviders->invoke(
$class->newInstanceWithoutConstructor(),
$app,
);
// 得られたサービスプロバイダを登録
foreach ($providers as $provider) {
$app->register($provider);
}
}
大まかな流れの解説を行うと,以下のようになる。
- カレントディレクトリの
composer.json
から,autoload-dev.psr-4
で指定されているテストコードの名前空間を拾う。 -
spl_autoload_functions()
の返り値から, Composer が登録したClassLoader
を探す。
- 見つけた
ClassLoader
を使ってClassFinder
を生成し,以下の条件でクラスをフィルタリングする。
- カレントディレクトリの
composer.json
のautoload-dev.psr-4
で指定されたテストコードの名前空間である Orchestra\Testbench\TestCase
を継承している- インスタンス化可能な具象クラスである
- 見つけたテストケースそれぞれについて,リフレクションを使用して
getPackageProviders()
をコールし,それで得たサービスプロバイダクラス名をアプリケーションのregister()
メソッドで登録する
rules.neon
PHPStan の共通ルール #### ... 何かしらのルール記述 ... ####
parameters:
bootstrapFiles:
- bootstrap.php
phpstan.neon
共通ルールファイルを参照する各パッケージの includes:
- path/to/rules.neon
parameters:
paths:
- src/
- tests/
- database/
excludePaths:
- vendor/*
注意事項
larastan が走査する際,
「テストケース1つ1つに対して登録されたサービスプロバイダの確認を行う」
ではなく,
「全てのテストケースから登録されたサービスプロバイダを最小公倍数として扱う」
という動きをするので,以下のようなケースでも PHPStan は通るようになってしまう。但し,これは False-Positive な動きではないので問題ないと考えた。
<?php
namespace Packages\Domain\ExampleService;
use Packages\Common\Id\Facades\Id;
use Packages\Common\Id\IdServiceProvider;
class OtherTest extends \Orchestra\Testbench\TestCase
{
protected function getPackageProviders($app): array
{
// 別のテストでこれを登録している
return [
IdServiceProvider::class,
];
}
}
<?php
namespace Packages\Domain\ExampleService;
use Packages\Common\Id\Facades\Id;
use Packages\Common\Id\IdServiceProvider;
class Test extends \Orchestra\Testbench\TestCase
{
public function test(): void
{
// 別の場所で1箇所でもサービスプロバイダが提供されていたらチェックは通ってしまう
$id = Id::generate();
// ...
}
}
まとめ
モジュラモノリスを開発する際にありがちな PHPStan 共通ルールの参照を生かし, DRY な形で larastan 特有の「サービスプロバイダ読み取りに関する問題」を解決することができた。
Discussion