勉強日記

チラ裏

Laravelのテストを高速化した話 (Laravelアドベントカレンダー20191217)

速さこそ有能なのが、 文化の基本法則 (ストレイト・クーガー)


この記事について

Laravel Advent Calender 2019 17日目の記事です。

qiita.com

DBのテスト高速化: RefreshDatabaseトレイトのハック

対象読者

  • DBが絡むテスト(以下DBテスト)を書いている
  • DBテストでインメモリDB (例: sqlite) を利用せず、本番環境同様のRDBMS (例: mysql) を利用している
    • なるべく本番と揃えたいのが人情というものかと
  • RefreshDatabaseトレイトを利用している

TL;DR

  • php artisan migrate:freshは遅い
  • php artisan migrateを利用するようハックする

DBテストの開始には時間がかかりがち

DBテストは遅くなりがちです。

DBへの問い合わせを伴う処理自体が遅いのはもちろんのこと、テスト用テーブルの準備に時間がかかります

テーブル数がそこそこあるオープンなプロジェクトフォークし、何もしないテストを用意して計測してみました:

tests/SomeDBTest.php

<?php

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as IlluminateTestCase;
use Tests\Concerns\RefreshDatabaseLite;

class SomeDBTest extends IlluminateTestCase
{
    use RefreshDatabaseLite, CreatesApplication;

    /**
     * @test
     */
    public function something_is_something()
    {
        $this->assertTrue(true);
    }
}

適当にphp実行環境を用意し、テストを実行してみます。

拙作:

github.com

Laravel Horizon依存なので、さらにext-pcntlが必要です。

f:id:wand_ta:20191215181411g:plain

7秒ほどかかっているようです。

比較用に、DBが絡まない場合も:

<?php

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as IlluminateTestCase;
use Tests\Concerns\RefreshDatabaseLite;

class SomeTest extends IlluminateTestCase
{
    use CreatesApplication;

    /**
     * @test
     */
    public function something_is_something()
    {
        $this->assertTrue(true);
    }
}

f:id:wand_ta:20191215185121g:plain

1.5秒くらい。差し引き5秒くらいの差が出ています。

理由 -- migrate:fresh で毎回テーブルをdrop/createしている

先述のように、DBテストでは、テスト用テーブルの準備すなわちマイグレーションに結構時間がかかります。

f:id:wand_ta:20191215181648g:plain

テーブル数が増えてくると、マイグレーション待ちだけでトイレに行けるくらいになってきます。

そして、RefreshDatabaseトレイト使用時は、テストを実行するたびにmigrate:freshコマンドで全テーブルdrop後にマイグレーションが実行されます。

日々の開発で、特にTDDを実践して、しょっちゅうテストを回しているような場合、これでは辛くなります。

しかしDBテスト開始時にトランザクションを張り、終了時にロールバックしているなら、テーブルをdropして作り直す必要は滅多にありません。

そこで、migrate:freshの代わりにmigrateを使用するようにします。

migrateは、テーブルのdropを行いません。既にマイグレーション済のテーブルについては生成をスキップしてくれます。

f:id:wand_ta:20191215185211g:plain

migrate:freshではなくmigrateを使用するよう、RefreshDatabaseトレイトをハックする

RefreshDatabaseトレイトをハックし、 軽量版RefreshDatabaseLiteトレイトを作ります。

以下、Laravel v6の場合

tests/Concerns/RefreshDatabaseLite.php

<?php

declare(strict_types=1);

namespace Tests\Concerns;

use App\Console\Kernel;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\RefreshDatabaseState;

trait RefreshDatabaseLite
{
    use RefreshDatabase;

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

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

            RefreshDatabaseState::$migrated = true;
        }

        $this->beginDatabaseTransaction();
    }
}

RefreshDatabaseをuseしていたテストケースを修正し、RefreshDatabaseLiteをuseするようにします

  <?php
   
  namespace Tests;

- use Illuminate\Foundation\Testing\RefreshDatabase;
  use Illuminate\Foundation\Testing\TestCase as IlluminateTestCase;
+ use Tests\Concerns\RefreshDatabaseLite;
   
  class SomeDBTest extends IlluminateTestCase
  {
-     use RefreshDatabase, CreatesApplication;
+     use RefreshDatabaseLite, CreatesApplication;
   
      /**
       * @test
       */
      public function something_is_something()
      {
          $this->assertTrue(true);
      }
  }

f:id:wand_ta:20191215182210g:plain

速くなった!うれしい!

migrate:freshを実行するためのテストケースも用意する

マイグレーションファイルを追加していく分には、migrateだけで十分です。

しかし、マイグレーションファイルに変更がある場合、migrate:freshが必要になってきます。

具体的には、

  • 開発初期段階で、ALTERのmigrationを追加するのではなく、CREATEのmigrationを編集する
  • 過去のバージョンのソースコードをチェックアウトする

といったケースです。

こういう場合のために、テスト用DBに対してmigrate:freshを実行するコマンド代わりのテストケースを作成します。

tests/MigrateFresh.php

<?php

declare(strict_types=1);

use Illuminate\Foundation\Testing\TestCase;
use Tests\CreatesApplication;

class MigrateFresh extends TestCase
{
    use CreatesApplication;

    /**
     * @test
     */
    public function migrate_fresh()
    {
        $this->artisan('migrate:fresh');
        $this->assertTrue(true);
    }
}

テストケースクラス名からはあえてTestサフィックスを外してあります。

Testサフィックスが付いていると、phpunitを引数なしで実行した際に毎回実施されてしまい、元も子もなくなってしまいます。(phpunit.xmlの設定で変えることもできます)

migrate:freshが必要になったら

./vendor/bin/phpunit tests/MigrateFresh.php

というように明示的にテストケースを指定して実行します。

f:id:wand_ta:20191215191543g:plain

適宜、シェルスクリプトなりcomposerスクリプトなりにすると良いと思います。

DB以外のテスト高速化: Docker Desktop for Windows から docker on WSL2へ移行する

もはやLaravel関係ありませんが、Laravelも速くなるのでね

WSL2については、最近発売された書籍「みんなのPHP 現場で役立つ最新ノウハウ!」でも取り上げられているようです。

gihyo.jp

みんな買おう!(#PR)

対象読者

TL;DR

  • WSL2はいいぞ
    • PHPだけ」のテストなら数倍速くなることも
    • 他にもいろいろ嬉しい
  • ネットワーク周りなど辛みもある

比較

導入については省きます

ちょうどいいLaravelプロジェクトがなかったので、たまたま手元にあったRay.AOPで比較しました。

(最近Bear流行ってますね!)

Windows

f:id:wand_ta:20191215182413g:plain
Windows

ubuntu18.04 on WSL2

f:id:wand_ta:20191215182429g:plain
WSL2

ハヤスギでしょwww

WSL2はいいぞ

Windows 10 ProでなくてもDockerを使える(らしい)

Docker Desktop for WindowsHyper-V依存で、Hyper-VWindows 10 Proでないと動作しません。

一方、WSL2はWindows 10 Homeでも動くらしいです。(試したことない)

WSL2の上ではLinuxが動き、Linuxの上ではDockerが動きます。

Docker Composeを使える

無印版WSLでDocker Composeを動かそうとすると、ネットワーク周りのエラーが出ていました。

WSL2ではそれが解決しています。

はやい

WSL2のrootfs以下に引きこもっている分には、上のスクショのように爆速です。

ただし、Windowsファイルシステムのマウントポイント /mnt/ 以下にアクセスすると台無しになるので注意。

メリットを享受するには、エディタもWSL2上に置いて、Windows側のX11サーバーでGUIレンダリングする等、完全に引きこもる覚悟が必要です。

その他もろもろ

  • 本物のlinuxを使える
    • (マサカリ飛んできそう)
    • systemdの使用等に制限はあります
  • パーミッション破壊おじさんにならない
  • gitで*.shをcheckoutしたとき「CRLF改行奴〜ww」にならない
    • 1行目が#!/bin/sh^Mになってて、dockerコンテナが謎の死を遂げるやつ
    • .gitattributes書けって話なんですけどね

WSL2のつらみ

つらみがあるのも確かです。

  • ネットワーク周り
    • TLS over VPNがうまく繋がらなくてメソメソ泣いてます
    • /etc/resolv.conf が常に調子悪い
      • 8.8.8.8とか追加しないとインターネットに出ていけない日がある
      • 大丈夫な日もある。謎
    • ローカルループバックでハマりがち
      • 127.0.0.1だと繋がらなくて::1だと繋がったりする
  • WSL2上にエディタを置くと、Windowsのデスクトップ上のファイルをエディタにドラッグ&ドロップできない
    • だってWSL2にないファイルですから

人口が増えて頭のいい人が解決してくれると嬉しい…