PHPUnitテストを高速化した話

laravel-test-speedyデータベース

Laravelを使ったアプリケーションでテストコード(PHPUnit)を書くことはよくあることだと思います。

その中でもデータベースを利用したテストを実行したいことも多いはずです。

またCircleCIなどのCI/CDツールを利用してテストを自動化するといったことも増えているのではないでしょうか。

デプロイまでの時間を少しでも短くしたいという思いからテストの実行時間を短くするために取り組んだことをまとめたいと思います。

結果だけ先にお伝えしておくと実行時間を約1/2に短縮できました。

前提

  • データベーステストの実行
  • シーディングを使用したテスト
  • Laravel 8.*

テストが遅い原因

まずテストが遅くなる原因を考えてみます。

テストにあまり詳しくない状態で書き進めたということもあり、ドキュメントに記載されている書き方を元に最初は実装していました。

RefreshDatabaseを使用

Laravelのデータベーステストではよく見かけることがあるRefreshDatabaseトレイトが存在します。

これは各テストが前のテストデータに影響されないようにテスト実施後にデータベースをリセットしてくれる役割があります。

このRefreshDatabaseが何をやっているのかを見ると遅くなる原因がわかります。(laravel8.*)

    /**
     * Refresh a conventional test database.
     *
     * @return void
     */
    protected function refreshTestDatabase()
    {
        if (! RefreshDatabaseState::$migrated) {
            $this->artisan('migrate:fresh', $this->migrateFreshUsing());

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

            RefreshDatabaseState::$migrated = true;
        }

        $this->beginDatabaseTransaction();
    }

テスト毎にmigrate:freshをしていることがわかります。

マイグレーションファイルが数件ほどであればそれほど問題ないかと思いますが、数十件、数百件レベルになってくると1回実行するだけでもかなりの時間がかかってしまいます。

また、初期データを登録するためにsetUp関数にシーディングを記述しているとさらなる時間がかかります。

初回だけmigrate:freshを実行する

RefreshDatabaseはテスト毎にデータベースが作り直されるのでテストケースの最初にだけ実行する方法を考えました。

setUpメソッドを使ってテストケースの初回のみmigrate:freshとシーディングを実行します。

private static bool $isSetup = false;

public function setUp(): void
{
    parent::setUp();
    if(self::$isSetup === false) {
        Artisan::call('migrate:fresh');
        $this->createDate();
        self::$isSetup = true;
    }
}

こうすることでテスト毎にデータベースが作り直されることはなくなり、多少の時間の短縮は実現できました。

ただ、デメリットとして1つ目のテストの実行結果が2つ目のテストにも影響するため、その点の考慮や順番を考える必要はあります。

例えば、1つ目のテストでid = 1のユーザー名を更新した場合、2つ目のテストでid = 1のユーザー名を取得すると更新されたユーザー名となります。

色々調べている中でDatabaseTransactionsというトレイトがRefreshDatabaseよりもはやいということを知りました。

しかし、autoIncrementなidを持つテーブルに対しては自動採番の数字は戻らずうまく動かなかったため採用しませんでした。

解決策

データベースを作り直すのではなくデータを全削除する

migrate:freshをするのではなくテーブルを全部truncateする方法を取りました。

仮にmigrate:freshにかかる時間が1分だとすると、×テストケース数になるため、テストケースが増える度に1分増えていくことになります。

そのため、migrate:freshはテスト実行前に一度だけ実行してテストデータを作り直したい場合はテーブルを消すのではなくデータだけ消すという方法を選択しました。

TestCase.phpに下記メソッドを追加しました。

    protected function truncateDatabase(): void
    {
        // テーブル名を全取得
        $tableNames = DB::getDoctrineSchemaManager()->listTableNames();
        // 外部キー制約を無効化
        Schema::disableForeignKeyConstraints();
        foreach ($tableNames as $name) {
            // マイグレーションテーブルのみ削除しない
            if ($name === 'migrations') {
                continue;
            }
            // 各テーブル毎にtruncateでデータ削除
            DB::table($name)->truncate();
        }
        // 外部キー制約を有効化
        Schema::enableForeignKeyConstraints();
    }

このメソッドを今までmigrate:freshしていたところにすべて置き換えて実行したところテストの実行時間を約半分短縮できました。

  • 改善前

Time: 05:28.437, Memory: 249.00 MB

OK (314 tests, 1832 assertions)

  • 改善後

Time: 02:23.249, Memory: 243.00 MB

OK (314 tests, 1832 assertions)

いかにマイグレーションが遅かったかがわかりました。

テスト環境のみシーダーのデータ数を減らす

シーディングも時間がかかる要因の一つだったため、微力な対策ではありますがテスト環境のみ作成するデータを最低限にすることで多少の時間短縮には繋がりました。

// (例)
$max = app()->runningUnitTests() ? 3 : 10;

ハマった問題点

ただtruncateするだけだとMySQLでは外部キー制約のエラーが出るため実行できませんでした。

そのため、Schema::disableForeignKeyConstraints();で外部キー制約を一旦無効化し、データ削除後に再度有効化することで解決しました。

最後に

今回はテーブルをtruncateするという方法でテストの実行時間を短縮する記事を書きました。

しかしこれが正解かはわかっていないので良くない点やこうしたほうが良いなどがあればコメントいただけますと幸いです。

コメント

タイトルとURLをコピーしました