7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

laravelのUTを高速化する

Last updated at Posted at 2021-02-10

laravelを使ったというか、全般に使えると思うのですが、UTを高速化したお話です。
4倍速ですよ!!試したプロジェクトでは...。

遅い原因

原因は3点ありました。

1つ目: RefreshDatabase

言わずもなですが、こいつがやってくれるのは、

  • DBを初期化して、migrationを行う (1度だけ)
  • TestCase毎にトランザクションを開始して、TestCase終了時にrollbackしてくれる

の2つです。
テストケースが実行される前にDBを綺麗にしてくれるので、ゴミデータなどが残りません。便利です。

が、コードが以下のようになっています。

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

RefreshDatabaseを使うと、
php artisan migrate:fresh
されるので、毎回 全テーブルを消す → マイグレーションを実行する といったことが走ります。

まぁ、当たり前っちゃ当たり前。それを求めているので..。

ただ、置き換えれば早くなりそうなので、置き換えます。

DBを初期化して、migrationを行う (1度だけ)

動的にやるから遅いんでしょ。
そんなに頻繁にかわらないよー。と思ったので、事前にDBを作ることにしました。

$ php artisan migrate:fresh --env=testing
$ mysqldump -u $(DATABASE_USER) -p$(DATABASE_PASSWORD) --opt --no-tablespaces $(DATABASE_NAME) >|testing.db.sql

testing.db.sql というファイルが出来上がるので、テスト実行時に、

$ mysql -u $(DATABASE_USER)s -p$(DATABASE_PASSWORD) $(DATABASE_NAME) <testing.db.sql
$ vendor/phpunit/phpunit/phpunit -d memory_limit=4G

とすれば、ひとまずはDBのデータが綺麗なまま、UTが実行されます。
幸い、マスターデータは大きくないのでデータをインポートするのは、初期から構築するよりも時間はかかりません。

TestCase毎にトランザクションを開始して、TestCase終了時にrollbackしてくれる

これは、traitで代替できます。
RefreshDatabase を、DatabaseTransactions に置き換えるだけです。

エディタとかで全置換かけちゃいましょう。

2つ目: seedの実行

setup()とかで、$this->seed('seeder'); と、各テストケースでやっちゃいますよね。
まぁ、これも数が多くなると積み重なって時間がかかります。
事前に作るようにしましょう。

$ php artisan migrate:fresh --env=testing
$ php artisan db:seed --env=testing
$ mysqldump -u $(DATABASE_USER) -p$(DATABASE_PASSWORD) --opt --no-tablespaces $(DATABASE_NAME) >|testing.db.sql

はい。さっきのに、seederの実行を足しただけです。

3っつ目: シリアルだから

直列でテストが走るので、TestCaseが増えれば増えるほど、遅くなります。
並列化します!

並列化すれば良いのでshellとか書けばできそうですが、paratestを導入します。
今回のプロジェクトではphpunitのバージョンがきつく固定されていて、paratestのバージョンとphpunitのバージョンの関係で最新が入りませんでした。
paratestのリリースログからそれっぽいやつを見つけたら、バージョン 2系だと動きそうだったので、一旦入れます。

$ COMPOSER_MEMORY_LIMIT=-1  composer require --dev brianium/paratest:2

で、テストのコマンドを置き換えます。

APP_ENV=testing php vendor/bin/paratest --phpunit vendor/phpunit/phpunit/phpunit -p4 -c phpunit.xml

これだけです!

対応結果

対応前

phpunitの実行を言葉で言うと、ダダダダダ、ダ・・・、ダ・・・・・・、ダダダダダダ、ダ・・・・・ダ・・・・・ みたに流れたーと思うとすぐ詰まって遅いテストがありました。

...............................................................  63 / 271 ( 23%)
............................................................... 126 / 271 ( 46%)
............................................................... 189 / 271 ( 69%)
............................................................... 252 / 271 ( 92%)
...................                                             271 / 271 (100%)

Time: 8.69 minutes, Memory: 106.50 MB

対応後

phpunitの実行を言葉で言うと、ダダダダダダダダダダダダダダダダ・ダ・・・、ダダダダダダ みたに流れたーと思う時間が長くなりました。

.......................................F.......................  63 / 211 ( 29%)
................F.F............................................ 126 / 211 ( 59%)
..................E.E....E.........E........................... 189 / 239 ( 79%)
..........................................................F.... 252 / 259 ( 97%)
...................

Time: 58.79 seconds, Memory: 12.00 MB

9分くらいかかっていたのが、1分に。DBのインポートが10秒くらいで終わるので、それを加味しても1分ちょっとで終わるようになりました。
9倍速!!

が、がしかし大量のエラー(失敗)が。多分、途中で終わっているのもあるのでそのせいもあって早いんでしょう。
エラー原因は2つです。

エラー1:デッドロック

並列化したのでトランザクションが複数走ることになり、遅いテストに引きずられてロックのタイムアウトに引っかかってしまいました。
どっか良きところで、\DB::query('SET innodb_lock_wait_timeout=10000') とかすればひとまずは大丈夫!(だと思う)

エラー2:テストの書き方

以下のような関数があるとします。 (雰囲気。UserはModel)

function insertNew() { User::insert(...); }

なんかのデータをインサートしているやつ。
インサート自体はいいですが、これをテストすると、

$lastId = User::max('id');
insertNew();
$insertId = User::max('id');
$this->assertTrue($lastId + 1 == $insertId);

みたいに、新しいレコードが入ったことを確認するようなやつ。
もっと賢く実際はやっていますが、処理前のID + 1が挿入されたよね。って確認するようなテストです。
これ、並列化すると + 1 とは限らなくなり、エラーになります。
検索キーを変更すれば動くんでしょうが、テスト全体を見直して、ユニークなデータが入ってそれを取得するように置き換える必要があります。

対応はちょっと。。。。面倒なので、並列化を諦めました。

並列化をやめた場合

...............................................................  63 / 271 ( 23%)
............................................................... 126 / 271 ( 46%)
............................................................... 189 / 271 ( 69%)
............................................................... 252 / 271 ( 92%)
...................                                             271 / 271 (100%)


Time: 1.75 minutes, Memory: 90.50 MB

(フェアな計測ではないですが)並列化した時より遅いけど、早くなりました!!!
やったー!元々の4倍速!!

並列化した場合はさらに倍なのですが、対応するのが大変なのと、そもそも、他のTestCaseに依存しちゃうってどうなの?
と思うので、並列化ができる場面は限られているのかもしれません。

おまけ

いちいちコマンドをただくのは大変なので、Makefile作りましょう!
make test-db-initmake test だけで、できちゃう!
(自分用なので手抜きです)

test-db-dump:
	php artisan migrate:fresh --env=testing
	php artisan db:seed --env=testing
	mysqldump -u $(DATABASE_USER) -p$(DATABASE_PASSWORD) --opt --no-tablespaces $(DATABASE_NAME) >|testing.db.sql

test-db-init: test-db-dump test-db-import

test-db-import:
	mysql -u $(DATABASE_USER)s -p$(DATABASE_PASSWORD) $(DATABASE_NAME) <testing.db.sql

test:
	vendor/phpunit/phpunit/phpunit -d memory_limit=4G
7
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?