Edited at

Laravelで並列テストを導入するための道のり

こんにちはみなさん

テストって書くだけなら大したことないんですよね。やることって、条件作って動作させて結果見るだけって感じで、プリントデバッグとかでいつもやってることを、機械化するだけですんで。

ですが、テストの数が増えてくると顕在化するのがテストのスピードです。ある意味成長するプロダクトにおいては宿命とも言えますが、基本的にテストが減ることはなく、累積されていくので必ずいつかはぶち当たる壁になっています。

そんなわけで、テストが遅くなってきたら並列でやりたいなっていうのと、そいつをLaravelでやろうという試みをしたので、その記録をここに書いていきます。


TL;DR


  • Paratestっていうのがあるよ

  • Laravelには、テストにおいてはじめの一回だけ動く系の処理があって、これをそのままにするとParatestを素直に導入できないよ

  • RunnerとBootstrapを改造して対応できるよ

  • テストの数が多いほど、Paratestの効果が期待できるみたい


Laravelで普通にテストを書く

Laravel はテストを重要視しており、それはマニュアルのメインコンテンツの一角を testing が占めているところを見ても明らかです。

https://laravel.com/docs/6.x/testing

まずはテストを書いてみましょう。

テスト内容は適当なユーザデータを作ってそれをModelで取得できるかという、まるで意味のないテストです。


ParaTest.php

<?php

namespace Tests\Unit\User;

use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ParaTest extends TestCase
{

use RefreshDatabase;

/**
* A basic unit test example.
*
* @return void
*/

public function testExample()
{
$user = factory(User::class)->create();
sleep(1);
$this->assertEquals($user->name, User::find($user->id)->name);
}
}


これと同じテストを20個くらい用意します。

当然テストを走らせると、

# ./vendor/bin/phpunit

PHPUnit 8.4.0 by Sebastian Bergmann and contributors.

...................... 22 / 22 (100%)

Time: 52.56 seconds, Memory: 26.00 MB

OK (22 tests, 22 assertions)

このテストですが、DBへの接続が含まれたテストになります。

一つのテストケースで、2回データベースへのアクセスが含まれています。

また、trait であるRefreshDatabase をuseしているので、実際にはトランザクションのbegin および rollback が実行されていますので、各テストケースで一回を除き4回ほどデータベースへの接続が発生していると言えます。

この程度の数しかテストがないのであれば、並列化する必要もないのですが、これが5行6行と続くようになると、だんだん待ち時間が長くなります。


Paratestを導入する

PHPUnitを並列化するツールとして、Paratestというものがあります。これを導入することで、テストを並列化してみましょう。


インストール

現在のLaravelはPHPUnit 8.4 を使っているので、それに合わせたバージョンのParatestを入れます。

composer require --dev brianium/paratest:^3

あとは実行するだけです。



paratest

Running phpunit in 8 processes with /var/www/vendor/phpunit/phpunit/phpunit

Configuration read from /var/www/phpunit.xml

.E..E.E.EEE.EE..E.....

Time: 1.58 minutes, Memory: 6.00 MB

There were 9 errors:

1) Tests\Unit\User\Para10Test::testExample
Illuminate\Database\QueryException: SQLSTATE[42S02]: Base table or view not found: 1051 Unknown table 'homestead.failed_jobs,homestead.migrations,homestead.password_resets,homestead.users' (SQL: drop table `failed_jobs`,`migrations`,`password_resets`,`users`)
...

あれ?落ちてる

試しに並列数を1にすると

paratest -p 1

Running phpunit in 1 process with /var/www/vendor/phpunit/phpunit/phpunit

Configuration read from /var/www/phpunit.xml

......................

Time: 5.89 minutes, Memory: 6.00 MB

OK (22 tests, 22 assertions)

どうも並列化に問題がある模様。


RefreshDatabaseの問題

RefreshDatabaseはこれをuseしているすべてのテストケースの中で、一番はじめに実行されたものにおいて php artisan migrate:fresh を実行し、データベースをきれいにしてから、あとはその他のテストケースと同様に処理をtransaction で囲むようになります。コードは以下の部分です。


RefreshDatabase.php

    /**

* Refresh a conventional test database.
*
* @return void
*/

protected function refreshTestDatabase()
{
if (! RefreshDatabaseState::$migrated) {
$this->artisan('migrate:fresh', [
'--drop-views' => $this->shouldDropViews(),
'--drop-types' => $this->shouldDropTypes(),
]);

$this->app[Kernel::class]->setArtisan(null);

RefreshDatabaseState::$migrated = true;
}

$this->beginDatabaseTransaction();
}


一番初めにこれが動作したとき、RefreshDatabaseState::$migratedtrueになるので、以降はマイグレーションが走らないわけです。

こいつが各プロセスで独立して走るため、テスト中に別のプロセスによるマイグレーションにより、データベースがすっ飛んでいるために、テストに失敗しているんじゃないかって思いっきり推測し、それを検証してみます。


$this->app[Kernel::class]->setArtisan(null);
echo 'abc';

RefreshDatabaseState::$migrated = true;

こんな感じで、プリントデバッグを仕込んでみます。

[2019-10-13 04:14:45] testing.DEBUG: migrate  

[2019-10-13 04:14:51] testing.DEBUG: migrate
[2019-10-13 04:15:01] testing.DEBUG: migrate
...

普通に連発しています。

しかも、これ、ファイルの数だけ実行しています。

これをやらないようにすればちょっとは早くなるかもしれません。


Cacheの問題

Laravel6では、Bootstrapが用意されており、ここで、configなどのキャッシュを取るようになっています。


tests/Bootstrap.php

<?php

namespace Tests;

use Illuminate\Contracts\Console\Kernel;
use PHPUnit\Runner\AfterLastTestHook;
use PHPUnit\Runner\BeforeFirstTestHook;

class Bootstrap implements BeforeFirstTestHook, AfterLastTestHook
{
/*
|--------------------------------------------------------------------------
| Bootstrap The Test Environment
|--------------------------------------------------------------------------
|
| You may specify console commands that execute once before your test is
| run. You are free to add your own additional commands or logic into
| this file as needed in order to help your test suite run quicker.
|
*/

use CreatesApplication;

public function executeBeforeFirstTest(): void
{
$console = $this->createApplication()->make(Kernel::class);

$commands = [
'config:cache',
'event:cache',
];

foreach ($commands as $command) {
\Log::debug('bootstrap');
$console->call($command);
}
}

public function executeAfterLastTest(): void
{
array_map('unlink', glob('bootstrap/cache/*.phpunit.php'));
}
}


コンフィグをいちいち読み込んだり、イベントディスカバリを毎回は知らせるのはコストが高いので、テスト開始前にキャッシュしちゃおうという手段ですが、Paratestではこれがプロセスごとに毎回走るようになります。

なので、ファイル書き込みや消込がかぶったりすると、

Warning: Uncaught ErrorException: require(/var/www/bootstrap/cache/config.p  

hpunit.php): failed to open stream: No such file or directory in /var/www/v
endor/laravel/framework/src/Illuminate/Foundation/Console/ConfigCacheComman
d.php:67

こんなエラーが出たりしてテストが停止します。


Paratest を Laravel でできるようにする

Laravelで無理矢理にでもテストを並列化するためには、どうすればよいでしょうか。

Patatestの内部的には、各テストファイルごとにPHPUnitプロセスを立ち上げて、処理をするというやり方を取っていますので、 各テストごとに初期化処理が走り、各テスト終了ごとに終了処理が走る ようになています。そのため、テストが一つのプロセス内で完結することを前提としている場合、上述したようなエラーを引き起こします。


PHPUniコマンドを使わない

LaravelのテストがPHPUnitのライフサイクルに依存している状態では、Paratestの導入はちょいと難しいです。とりあえず、PHPUnitコマンドを使わないでテストすると決意しましょう。


extension を抜く

phpunit.xmlextensions という項目がありますが、これが上述したBootstrapを呼び出すところなので、とりあえず抜いておきます。


phpunit.xml

    </filter>

<extensions>
<extension class="Tests\Bootstrap"/>
</extensions>
<php>

こいつの詳しい動きは参考にリンクを載せておくので、そっちを参照してください。

暇だったら記事を書くかもです。


RefreshDatabase -> DatabaseTransactions

悲しいですが、RefreshDatabaseの便利さはParatestではエラーの元凶です。初回のマイグレーションを入れないDatabaseTransactionsを代用するようにしましょう。


Para0Test.php

<?php

namespace Tests\Unit\User;

use App\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;

class Para0Test extends TestCase
{

use DatabaseTransactions;

/**
* A basic unit test example.
*
* @return void
*/

public function testExample()
{
$user = factory(User::class)->create();
sleep(1);
$this->assertEquals($user->name, User::find($user->id)->name);
}
}


普通に一括変換で大丈夫です。

これでとりあえずは動くと思いますが、もとの想定していた動作を再現しているとは言えません。


Bootstrap を起動させる

先に述べたとおり、もともとのテストでは一番最初にBootstrapによるコンフィグのキャッシュが行われていました。こいつをテストの始めで動かすようにしちゃいましょう。

Paratestは独自のテストランナーを定義できるようになっているので、それを利用してBootstrapを使えるようにしましょう。


tests/MyRunner.php

<?php

namespace Tests;

use ParaTest\Runners\PHPUnit\WrapperRunner;

class MyRunner extends WrapperRunner
{

protected $bootstrap;

public function run()
{
$this->bootstrap = new Bootstrap($this->loadConfig());
$this->bootstrap->executeBeforeFirstTest();
parent::run();
}

protected function complete()
{
$this->bootstrap->executeAfterLastTest();
parent::complete();
}

private function loadConfig(): void
{
$xml = simplexml_load_file(__DIR__ . '/../phpunit.xml');
$ret = [];
foreach ($xml->php->server as $env) {
$_SERVER[(string)$env['name']] = (string)$env['value'];
}
}
}


ここでloadConfigは、phpunit.xmlの設定の反映が、実際のPHPUnitの起動時、つまり、Workerでテストをするときになっているため、MyRunnerで設定値を反映させるために、一旦phpunit.xmlを読み込ませています。

また、各テストごとにプロセス作り直すのがちょっとだるいなぁって思ったので、普通のRunnerではなく、WrapperRunnerという、ワーカープロセスを使い回すRunnerを拡張させて使います。

あとはテストを実行するだけですが、コマンドがちょっと面倒になるので、composer.jsonscriptsに以下の設定を加えます。


composer.json

"paratest": "paratest --runner \\\\Tests\\\\MyRunner"


これで、composer paratestで並列テストを実行できます。


# composer paratest
Do not run Composer as root/super user! See https://getcomposer.org/root for details
> paratest --runner \\Tests\\MyRunner

Running phpunit in 8 processes with /var/www/vendor/phpunit/phpunit/phpunit

Configuration read from /var/www/phpunit.xml

......................

Time: 34.21 seconds, Memory: 18.00 MB

OK (22 tests, 22 assertions)

いけますね。


データベースのマイグレーションを入れる

先にRefreshDatabaseDatabaseTransactionsに置き換えてしまっていたので、データベースのマイグレーションが発生しません。こうなると、新しくマイグレーションが発生した際には、手動でマイグレーションを走らせ、テスト用のDBを調整してからテストをすることになります。

これは若干面倒なので、先のBootstrapに一緒に突っ込んでしまいます。


tests/Bootstrap.php

        $commands = [

'config:cache',
'event:cache',
+ 'migrate:fresh',
];

これで、テスト開始前に、まずデータベースを調整し、然る後に各テストをTransaction内で実行するようになります。そもそもデータベースを使わない場合はコメントアウトでもしておけばいいのです。


実行速度を見てみる

さて、最後にちょっとだけ動かして、実行速度を比較してみましょう


普通のユニットテスト

Paratest用にRefreshDatabaseを置き換えたり、Bootstrapをやめたりしていますが、普通のユニットテストを走らせることはできます。

普通に上述したextensionsの部分をphpunit.xml上で復活させれば、Paratestと同じ用に動作します。

# time composer test

Do not run Composer as root/super user! See https://getcomposer.org/root for details
> phpunit
PHPUnit 8.4.0 by Sebastian Bergmann and contributors.

...................... 22 / 22 (100%)

Time: 51.18 seconds, Memory: 26.00 MB

OK (22 tests, 22 assertions)

real 0m58.107s
user 0m3.975s
sys 0m4.902s


Paratest

extensionsphpunit.xmlから抜いておきます。

# time composer paratest

Do not run Composer as root/super user! See https://getcomposer.org/root for details
> paratest --runner \\Tests\\MyRunner

Running phpunit in 8 processes with /var/www/vendor/phpunit/phpunit/phpunit

Configuration read from /var/www/phpunit.xml

......................

Time: 32.85 seconds, Memory: 18.00 MB

OK (22 tests, 22 assertions)

real 0m40.037s
user 0m9.166s
sys 0m17.028s

実行時間自体は2割強の改善となりますが、CPU時間が割とヘビーです。


テストの数を増やしたら

普通のユニットテスト

# time composer test

Do not run Composer as root/super user! See https://getcomposer.org/root for details
> phpunit
PHPUnit 8.4.0 by Sebastian Bergmann and contributors.

............................................................... 63 / 102 ( 61%)
....................................... 102 / 102 (100%)

Time: 2.58 minutes, Memory: 42.00 MB

OK (102 tests, 102 assertions)

real 2m42.626s
user 0m8.170s
sys 0m6.855s

paratest

w# time composer paratest

Do not run Composer as root/super user! See https://getcomposer.org/root for details
> paratest --runner \\Tests\\MyRunner

Running phpunit in 8 processes with /var/www/vendor/phpunit/phpunit/phpunit

Configuration read from /var/www/phpunit.xml

............................................................... 63 / 102 ( 61%)
.......................................

Time: 48.43 seconds, Memory: 20.00 MB

OK (102 tests, 102 assertions)

real 0m55.716s
user 0m13.409s
sys 0m19.213s

どうも、開始時に時間を食っているだけで、動き始めりゃ早いみたい。


まとめ

ということで、Laravel 6 にParatestを導入しました。

システムが成長するにつれてテストの数が多くなってきますし、そうなったときに並列でテストを実行する利点はなかなかかと思います。

まぁ、Laravelはバージョンによってテストのやり方も変わっていますので、今回ここに述べたのはあくまでLaravel 6 のケーススタディってことで、他のバージョンでの導入のときには参考にでもしていただければ幸いです。

今回はこんなところです。


参考

Paratest

Laravelのテストを高速化するやり方集(英語: Paratesについてあっさり。。。)

PHPUnitの拡張#フック


追記

本記事におけるBootstrapについては、バージョン 6.0.2のみに生じていたものであることが判明してしまいました。。。

https://github.com/laravel/laravel/pull/5107