Unitテスト高速化の話


Symfony Advent Calendar 2017 4日目の記事。

アドベントカレンダーについ立候補してしまったので書きます

PHPUnit高速化の話

PHPUnitを使ったテストを実行する際、準備するデータ(LoadFixture)が多い場合、恐ろしく遅くなってしまいます。
下手すると1テスト1分とか超えちゃってる人もいるかもしれません。

テストは何度も繰り返し行われるものですので短縮できたほうが良いに決まっています!

今回の記事はどうやってLoadFixtureのコストを少なくしてテストを高速化するかを書いていきます

まずは高速化の結果から

対応前
4m 25.980 seconds (23 tests, 152 assertions)

対応後
36.630 seconds (23 tests, 152 assertions)

合計23個のテストを4分25秒から36秒までに短縮することができました。

やったことですが、LoadFixtureをテスト毎にロールバックして再利用することにより、一番コストの高い部分をカットすることができます

もう少し細かくみると下記のような実行結果になります

対応前 対応後

22個中一つ目のテストは変更前と変わらず初回処理やデータロードを行っているので、差はありません。

ですが、二つ目以降はLoadFixutre行なっていない分時間がが10分の1になりました。

素晴らしい

二回目以降はデータロードを行っていないことに注目

対応内容

ではどうのように対応したかを↓↓

class HogeControllerTest extends WebTestCase
{
    // 前回のLoadFixtureのパスを記録
   $latestLoadPaht = null;

   public function setUp()
   {
      // LoadFixture時間の掛かる原因
       $this->loadFixture('HogeBundle/Tests/DataFixtures');
       // ロールバック開始メソッド
       $this->beginRollbackPoint();
   }

   public function tearDown()
   {
      // ロールバック
      $this->rollbackFixturePoint();
    }

    /**
    / ロールバック範囲の開始位置
    */ 
   public function beginRollbackPoint()
   {
        // Client::request しても色々再起動されなくなる。EntityManagerをcloseされたら困るので必要
        $this->client->disableReboot();
     // トランザクション開始
        $this->container->get('doctrine')->getConnection()->beginTransaction();
        // もしかしたら要らない
        $this->container->get('doctrine')->getConnection()->setAutoCommit(false);
    }

    /**
    / ロールバック
    */ 
   public function rollbackFixturePoint()
   {
        $this->container->get('doctrine')->getConnection()->rollback();
    }

   /**
    / pathからLoadFixtureを読み込む。
   / 二回目も同じpathだった場合、再読込しない
    */ 
   public function loadFixture($path)
   {
      // LoadFixtureのパスが前回と同じならLoadFixtureを行わない
       if (self::$latestPath === $path) {
         return;
      }

      self::$latestPath = $path;
      // データロード 
      $this->doLoadFixture(self::$latestPath);
   }

   /**
    / LoadFixtureを読み込み処理
    */ 
    public function doLoadFixture($path)
    {
       // LoadFixture処理(割愛!
       // 参考 https://github.com/doctrine/data-fixtures
    }

    /**
    / 一回目のテスト
    */
    public function testHogeAction()
    {
        $em = $this->container->get('doctrine')->getManager();
        // 初期で10こデータがある
        $entities = $em->getRepository('HogeBundle:HogeEntity')->findAll();
        $this->assertEquals(10, entities);

        // 全部削除
        foreach($entities as $entity) {
            $em->remove($entity);
        }
        $em->flush();
    }

    /**
    / 二回目のテスト
    */
    public function testHogeAction2()
    {
     // 1つ目のテストでエンティティを削除したが、LoadFixtureの初期データの状態に戻っている
     $entities = $em->getRepository('HogeBundle:HogeEntity')->findAll();
        $this->assertEquals(10, entities);   
    }
}

大体こんな感じです。適当な部分があるので、適宜保管してください😖
setUpでトランザクションを開始し、tearDownでロールバックを行っています。

前提条件

  • テストのLoadFixtureが同じである
    • テスト毎にロードするデータが違った場合は、その都度再ロードしないと行けないので、使いまわせない
  • LoadFixtureのコストに頭を抱えている ←重要

対応できないパターン

処理内で

$this->container->get('doctrine')->close();

をコールしたり

こんな感じでflushの内部でclose呼ばれたり

try {
 $this->container->get('doctrine')->flush();
} catch (\Exception $e) {
 // flush でエラーがでるとEntityManagerがcloseされる
}

こうなってしまうと、rollbackできなくなり、テストできなくなってしまいます

disableRebootによる EntityManagerの問題

$this->client->disableReboot();
と読んでいるため、Client::RequestをしてもEntityManagerを再インスタンス化されないようにしています。
これだと1つ問題が発生し、テスト内のEntityManagerとController内のEntityManagerが共通しているため、思いのよらないところでEntityがflushされ更新されたり削除されてしまいます。ご注意を

EntityManager->clear();
Client::request('GET', $url)
EntityManager->clear();

簡単な対応だとRequestの前後にEntityManager::clearを呼べば多分問題ないです

きちっとした対応だと↓辺りを変更すれば行けそう?(未検証

vendor/symfony/symfony/src/Symfony/Bundle/FrameworkBundle/Client.php

 protected function doRequest($request)
    {
        // avoid shutting down the Kernel if no request has been performed yet
        // WebTestCase::createClient() boots the Kernel but do not handle a request
        if ($this->hasPerformedRequest && $this->reboot) {
            $this->kernel->shutdown();
        } else {
            $this->hasPerformedRequest = true;
        }

        if ($this->profiler) {
            $this->profiler = false;

            $this->kernel->boot();
            $this->kernel->getContainer()->get('profiler')->enable();
        }

        return parent::doRequest($request);
    }

vendor/symfony/symfony/src/Symfony/Bundle/FrameworkBundle/Client.php

 protected function doRequest($request)
    {
        // avoid shutting down the Kernel if no request has been performed yet
        // WebTestCase::createClient() boots the Kernel but do not handle a request
        if ($this->hasPerformedRequest && $this->reboot) {
            $this->kernel->shutdown();
        } else {
            $this->hasPerformedRequest = true;
        }

        if ($this->profiler) {
            $this->profiler = false;

            $this->kernel->boot();
            $this->kernel->getContainer()->get('profiler')->enable();
        }

        // この辺変更
        $this->getContainer()->get('doctrine')->getManager()->clear();
        $response = parent::doRequest($request);
        $this->getContainer()->get('doctrine')->getManager()->clear();
        return $response;
    }

まとめ

ちょっと安定に欠ける部分があるかもしれませんが、幾つか試した中ではロールバックを使ったデータの再利用が圧倒的に時間の短縮につながりました。

とりあえずこんな感じで終わります!ありがとうございました!

ぼちぼちアレに着手せねば! m(_ _)m