[Laravel] Testbench の getPackageProviders() を larastan に自動的に読み取らせる方法

2021/12/03に公開

問題

Laravel 向けのライブラリやモジュラモノリス構成の Laravel アプリケーションを開発する際,テストユーティリティとして,orchestra/testbench を利用することが一般的になっている。このライブラリは laravel/laravel に相当する「ユーザがフレームワークに乗るために最低限用意する部分」を巻き取り, phpunit.xml とテストケースファイルだけで殆ど全てが完結するように面倒を見てくれる。

ところが,静的解析ツールの PHPStan とその Laravel プラグインである larastan を使用している場合,以下のような問題に衝突することがあるらしい。

https://nextat.co.jp/staff/archives/250

自分が検証した 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.neonparameters.bootstrapFiles に,都度用意した追加のブートストラップファイルを指定する
  • C. パッケージで据える phpstan.neon から includes で参照される共通のルールファイルの parameters.bootstrapFiles に, getPackageProviders() を自動解析するブートストラップファイルを指定する

B は A と実質的にはほぼ同じ解決法。デフォルトの動作を変更せず,部分的に設定のオーバーライドを行うという違いしかない。C はすでに共通化した PHPStan のルールを参照している場合,追加の記述を一切行う必要が無いというアドバンテージがある。今回は C の方法を紹介する。

PHPStan の共通ルールで指定するブートストラップファイル bootstrap.php

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

大まかな流れの解説を行うと,以下のようになる。

  1. カレントディレクトリの composer.json から, autoload-dev.psr-4 で指定されているテストコードの名前空間を拾う。
  2. spl_autoload_functions() の返り値から, Composer が登録した ClassLoader を探す。
  1. 見つけた ClassLoader を使って ClassFinder を生成し,以下の条件でクラスをフィルタリングする。
  • カレントディレクトリの composer.jsonautoload-dev.psr-4 で指定されたテストコードの名前空間である
  • Orchestra\Testbench\TestCase を継承している
  • インスタンス化可能な具象クラスである
  1. 見つけたテストケースそれぞれについて,リフレクションを使用して getPackageProviders() をコールし,それで得たサービスプロバイダクラス名をアプリケーションの register() メソッドで登録する

PHPStan の共通ルール rules.neon

#### ... 何かしらのルール記述 ... ####

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 特有の「サービスプロバイダ読み取りに関する問題」を解決することができた。

GitHubで編集を提案

Discussion