こんにちはみなさん
テストって書くだけなら大したことないんですよね。やることって、条件作って動作させて結果見るだけって感じで、プリントデバッグとかでいつもやってることを、機械化するだけですんで。
ですが、テストの数が増えてくると顕在化するのがテストのスピードです。ある意味成長するプロダクトにおいては宿命とも言えますが、基本的にテストが減ることはなく、累積されていくので必ずいつかはぶち当たる壁になっています。
そんなわけで、テストが遅くなってきたら並列でやりたいなっていうのと、そいつをLaravelでやろうという試みをしたので、その記録をここに書いていきます。
TL;DR
- Paratestっていうのがあるよ
- Laravelには、テストにおいてはじめの一回だけ動く系の処理があって、これをそのままにするとParatestを素直に導入できないよ
- RunnerとBootstrapを改造して対応できるよ
- テストの数が多いほど、Paratestの効果が期待できるみたい
Laravelで普通にテストを書く
Laravel はテストを重要視しており、それはマニュアルのメインコンテンツの一角を testing が占めているところを見ても明らかです。
https://laravel.com/docs/6.x/testing
まずはテストを書いてみましょう。
テスト内容は適当なユーザデータを作ってそれをModelで取得できるかという、まるで意味のないテストです。
<?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 で囲むようになります。コードは以下の部分です。
/**
* 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::$migrated
がtrue
になるので、以降はマイグレーションが走らないわけです。
こいつが各プロセスで独立して走るため、テスト中に別のプロセスによるマイグレーションにより、データベースがすっ飛んでいるために、テストに失敗しているんじゃないかって思いっきり推測し、それを検証してみます。
$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などのキャッシュを取るようになっています。
<?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.xml
に extensions
という項目がありますが、これが上述したBootstrap
を呼び出すところなので、とりあえず抜いておきます。
</filter>
<extensions>
<extension class="Tests\Bootstrap"/>
</extensions>
<php>
こいつの詳しい動きは参考にリンクを載せておくので、そっちを参照してください。
暇だったら記事を書くかもです。
RefreshDatabase -> DatabaseTransactions
悲しいですが、RefreshDatabase
の便利さはParatest
ではエラーの元凶です。初回のマイグレーションを入れないDatabaseTransactions
を代用するようにしましょう。
<?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
を使えるようにしましょう。
<?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.json
のscripts
に以下の設定を加えます。
"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)
いけますね。
データベースのマイグレーションを入れる
先にRefreshDatabase
をDatabaseTransactions
に置き換えてしまっていたので、データベースのマイグレーションが発生しません。こうなると、新しくマイグレーションが発生した際には、手動でマイグレーションを走らせ、テスト用のDBを調整してからテストをすることになります。
これは若干面倒なので、先のBootstrap
に一緒に突っ込んでしまいます。
$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
extensions
をphpunit.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