🚧

PHPUnitとLaravel `php artisan test` の違い

2024/02/08に公開

こんにちは😊 kesojiです。

弊社ではこれまでLaravelのテストを vendor/bin/phpunit を直接叩いて実行していました。
php artisan test については、 「出力形式が変わっているなぁ」程度にしか思っていませんでした。

しかし最近、 テストの並列実行 をやろうとした時に、
php artisan test --parallel で実行しました。 そこでふと疑問に思ったので、何が異なるのか確認してみることにしました。

エントリーポイント

php artisan test を実行すると、 laravel/framework ではなく nunomaduro/collisionTestCommand.php が呼ばれます。

handleメソッドを見て行きます。 始めの方は、 バージョンや依存関係のチェックですね。

...が、後でシンプルバージョンを見せるので、コードはアコーディオン内に折りたたんでおきます。興味がある方は開いて見て下さい。

handleメソッドの始めの方
$phpunitVersion = \PHPUnit\Runner\Version::id();

if ((int) $phpunitVersion[0] === 1) {
    throw new RequirementsException('Running PHPUnit 10.x or Pest 2.x requires Collision 7.x.');
}

if ((int) $phpunitVersion[0] < 9) {
    throw new RequirementsException('Running Collision 6.x artisan test command requires at least PHPUnit 9.x.');
}

$laravelVersion = (int) \Illuminate\Foundation\Application::VERSION;

// @phpstan-ignore-next-line
if ($laravelVersion < 9) {
    throw new RequirementsException('Running Collision 6.x artisan test command requires at least Laravel 9.x.');
}

if ($this->option('coverage') && ! Coverage::isAvailable()) {
    $this->output->writeln(sprintf(
        "\n  <fg=white;bg=red;options=bold> ERROR </> Code coverage driver not available.%s</>",
        Coverage::usingXdebug()
            ? " Did you set <href=https://xdebug.org/docs/code_coverage#mode>Xdebug's coverage mode</>?"
            : ''
    ));

    $this->newLine();

    return 1;
}

if ($this->option('parallel') && ! $this->isParallelDependenciesInstalled()) {
    if (! $this->confirm('Running tests in parallel requires "brianium/paratest". Do you wish to install it as a dev dependency?')) {
        return 1;
    }

    $this->installParallelDependencies();
}
handleメソッドのメイン部分

このへんから実際の実行です。

$options = array_slice($_SERVER['argv'], $this->option('without-tty') ? 3 : 2);

$this->clearEnv();

$parallel = $this->option('parallel');

$process = (new Process(array_merge(
    // Binary ...
    $this->binary(),
    // Arguments ...
    $parallel ? $this->paratestArguments($options) : $this->phpunitArguments($options)
),
    null,
    // Envs ...
    $parallel ? $this->paratestEnvironmentVariables() : $this->phpunitEnvironmentVariables(),
))->setTimeout(null);

try {
    $process->setTty(! $this->option('without-tty'));
} catch (RuntimeException $e) {
    $this->output->writeln('Warning: '.$e->getMessage());
}

$exitCode = 1;

try {
    $exitCode = $process->run(function ($type, $line) {
        $this->output->write($line);
    });
} catch (ProcessSignaledException $e) {
    if (extension_loaded('pcntl') && $e->getSignal() !== SIGINT) {
        throw $e;
    }
}

if ($exitCode === 0 && $this->option('coverage')) {
    if (! $this->usingPest() && $this->option('parallel')) {
        $this->newLine();
    }

    $coverage = Coverage::report($this->output);

    $exitCode = (int) ($coverage < $this->option('min'));

    if ($exitCode === 1) {
        $this->output->writeln(sprintf(
            "\n  <fg=white;bg=red;options=bold> FAIL </> Code coverage below expected:<fg=red;options=bold> %s %%</>. Minimum:<fg=white;options=bold> %s %%</>.",
            number_format($coverage, 1),
            number_format((float) $this->option('min'), 1)
        ));
    }
}

$this->newLine();

return $exitCode;

上記のメイン部分から

  • 最後の部分はカバレッジオプションが指定されていた場合なので削り、
  • try-catchも本筋ではないので削り、
  • TTYかどうかもテストには関係ないので削る

と、こんな感じ↓になります。 これを細かく見て行きます。

$options = (php artisan test 以降のオプション部分がarrayで入っている)

$this->clearEnv();

$parallel = $this->option('parallel');

$process = (new Process(
    // 実行コマンド
    array_merge(
        $this->binary(),
        $parallel ? $this->paratestArguments($options) : $this->phpunitArguments($options)
    ),
    // ワーキングディレクトリ(nullは現在のディレクトリ)
    null,
    // 環境変数
    $parallel ? $this->paratestEnvironmentVariables() : $this->phpunitEnvironmentVariables(),
))->setTimeout(null);


$exitCode = $process->run(function ($type, $line) {
    $this->output->write($line);
});

各関数の詳細: clearEnv()

どうやら環境変数をクリアするようです。へぇ。。。へぇ。。。?

/**
 * Clears any set Environment variables set by Laravel if the --env option is empty.
 *
 * @return void
 */
protected function clearEnv()
{
    if (!$this->option('env')) {
        $vars = self::getEnvironmentVariables(
            // @phpstan-ignore-next-line
            $this->laravel->environmentPath(),
            // @phpstan-ignore-next-line
            $this->laravel->environmentFile()
        );

        $repository = Env::getRepository();

        foreach ($vars as $name) {
            $repository->clear($name);
        }
    }
}

$this->laravel->environtFile() は、 APP_ENV="testing" であれば、 .env.testing, .env の順に走査します。
ファイルで定義した環境変数がここで全てクリアされました。

おそらく TestCase.php で再度定義しなおされるとは思いますが...?

各関数の詳細: binary()

/**
  * Get the PHP binary to execute.
  *
  * @return array
  */
protected function binary()
{
    if ($this->usingPest()) {
        $command = $this->option('parallel') ? ['vendor/pestphp/pest/bin/pest', '--parallel'] : ['vendor/pestphp/pest/bin/pest'];
    } else {
        $command = $this->option('parallel') ? ['vendor/brianium/paratest/bin/paratest'] : ['vendor/phpunit/phpunit/phpunit'];
    }

    if ('phpdbg' === PHP_SAPI) {
        return array_merge([PHP_BINARY, '-qrr'], $command);
    }

    return array_merge([PHP_BINARY], $command);
}

実行ファイルを取得します。 オプションや設定に応じて

  • Pest
  • Pest --parallel
  • paratest
  • phpunit

のどれかが選ばれ、 php vendor/phpunit/phpunit/phpunit のような実行コマンドを生成するようです。

各関数の詳細: phpunitArguments($options) と paratestArguments($options)

Pestのことは一旦気にしません。(Pest) PHPUnitに絞って見に行きましょう。

余談ですが、 Laravel作者のTaylor氏がこんなアンケートを取っていましたね。 (コメントではPest派とPHPUnit派が入り乱れています)

https://x.com/taylorotwell/status/1673783220176052226?s=20

Laravel 11ではPestがデフォルトになるとか。 (個人的には賛成です。PestはPHPUnitのラッパーなので、PHPUnit風の書き方はできるし、デフォルトの書き方はJestとかからの人がとっつきやすいし。ただちょっとまだLint/Format周りが育ちきってない感はあります)

閑話休題。コードを見ましょう。

/**
 * Get the array of arguments for running PHPUnit.
 *
 * @param  array  $options
 * @return array
 */
protected function phpunitArguments($options)
{
    $options = array_merge(['--printer=NunoMaduro\\Collision\\Adapters\\Phpunit\\Printer'], $options);

    $options = array_values(array_filter($options, function ($option) {
        return ! Str::startsWith($option, '--env=')
            && $option != '-q'
            && $option != '--quiet'
            && $option != '--coverage'
            && ! Str::startsWith($option, '--min');
    }));

    if (! file_exists($file = base_path('phpunit.xml'))) {
        $file = base_path('phpunit.xml.dist');
    }

    return array_merge($this->commonArguments(), ["--configuration=$file"], $options);
}

/**
 * Get the array of arguments for running Paratest.
 *
 * @param  array  $options
 * @return array
 */
protected function paratestArguments($options)
{
    $options = array_values(array_filter($options, function ($option) {
        return ! Str::startsWith($option, '--env=')
            && $option != '--coverage'
            && $option != '-q'
            && $option != '--quiet'
            && ! Str::startsWith($option, '--min')
            && ! Str::startsWith($option, '-p')
            && ! Str::startsWith($option, '--parallel')
            && ! Str::startsWith($option, '--recreate-databases')
            && ! Str::startsWith($option, '--drop-databases');
    }));

    if (! file_exists($file = base_path('phpunit.xml'))) {
        $file = base_path('phpunit.xml.dist');
    }

    return array_merge($this->commonArguments(), [
        "--configuration=$file",
        "--runner=\Illuminate\Testing\ParallelRunner",
    ], $options);
}

/**
 * Gets the common arguments of PHPUnit and Pest.
 *
 * @return array
 */
protected function commonArguments()
{
    $arguments = [];

    if ($this->option('coverage')) {
        $arguments[] = '--coverage-php';
        $arguments[] = Coverage::getPath();
    }

    return $arguments;
}

基本的には不要なオプションを削除しつつ、コンフィグファイルを追加して新しいオプションを作成しています。 違いとしては

  • phpunitArguments: PHPUnitのprinterオプションに NunoMaduro\\Collision\\Adapters\\Phpunit\\Printer を指定しています。
    • PHPUnitは Printerインタフェースを持ったクラスを渡してあげることで、出力を制御しているようです。 冒頭で「出力形式が変わってるな〜」は、ここが影響していそうです。
  • paratestArguments: paratestのrunnerオプションに \Illuminate\Testing\ParallelRunner を指定しています。
    • 今回は深入りしませんが、 paratestが何をやっていて、 ParallelRunnerが何をやっているのか、今度確認してみようと思います。

ですね。

各関数の詳細: phpunitEnvironmentVariables() と paratestEnvironmentVariables()

独自の環境変数を設定するようです。これといって特筆すべき点はないですが、並列テストの環境変数などをオプションに応じて設定しているのもここでした。

/**
 * Get the array of environment variables for running PHPUnit.
 *
 * @return array
 */
protected function phpunitEnvironmentVariables()
{
    return [];
}

/**
 * Get the array of environment variables for running Paratest.
 *
 * @return array
 */
protected function paratestEnvironmentVariables()
{
    return [
        'LARAVEL_PARALLEL_TESTING' => 1,
        'LARAVEL_PARALLEL_TESTING_RECREATE_DATABASES' => $this->option('recreate-databases'),
        'LARAVEL_PARALLEL_TESTING_DROP_DATABASES' => $this->option('drop-databases'),
    ];
}

各関数の詳細: Process

最後にProcess部分ですが、 これまでの関数などを展開し、 PHPUnitで非parallelの場合は以下のようになります。
(Process自体の説明は割愛します。コマンドを実行しているだけですね。)

$process = (new Process(
    // コマンド (絶対パス相対パスの部分は正確ではない。実際にどんなコマンドが渡されるかはdd()とかを仕込んで見てみましょう)
    ['php', 'vendor/phpunit/phpunit/phpunit', '--configuration=phpunit.xml', '--printer=NunoMaduro\\Collision\\Adapters\\Phpunit\\Printer'],
    // ワーキングディレクトリ
    null,
    // 環境変数
    [],
))->setTimeout(null);

Processの第一引数を普通に書くと

php vendor/phpunit/phpunit/phpunit --configuration=phpunit.xml --printer=NunoMaduro\\Collition\\Adapters\\Phpunit\\Printer

となります。
実際のコアなコマンドとしては、これが実行されるということです。
異なるのは、先ほども紹介しましたが、 NunoMaduro\\Collision\\Adapters\\Phpunit\\Printer がPrinterとして設定されていることですね。

まとめ

想像通りといえば想像通りの内容でしたが、見知らぬコマンドが動いているわけではない(PHPUnitが元気に動いている)ことがはっきり確認できて安心ですね。
今回は内部まで入りませんでしたが、Printerを差し替えられる部分は、インタフェースの実例として勉強などに使えそうです。 (と、思ったが..?PHPUnit10でこのへんが様変わりしている...?)

ソーシャルデータバンク テックブログ

Discussion