👬

ユニットテスト並列実行の導入

2024/03/02に公開

⚡️: 他の記事に書いてなさそうだったこと

✅ この記事の目的

CircleCI×Codeceptionで運用されているユニットテストに並列実行を導入する方法とその注意点を説明すること

❌ この記事に書いてないこと

  • CircleCI×Codeception自体の導入方法
  • 並列実行に関連しないコード
  • タイミングデータ以外のテスト分割の設定方法

並列実行数とテスト分割の設定

CircleCIで並列実行を設定するには並列実行数とテスト分割の設定を行う必要がありますが,その前に並列実行導入前の設定ファイルを確認しておきます.

.circleci/config.yml
version: 2

jobs:
    build_and_testing:
    docker:
        - image: circleci/php:7.2-node-browsers
        # 省略
    working_directory: /var/www
    steps:
        # 省略
        - run:
            name: Run Codeception
            command: |
                php -d memory_limit=-1 vendor/bin/codecept run --ext DotReporter unit
            working_directory: ./src/site
src/site/codeception.yml
paths:
    tests: tests
    output: tests/_output
    data: tests/_data
    support: tests/_support
    envs: tests/_envs
actor_suffix: Tester
extensions:
    enabled:
        - Codeception\Extension\RunFailed
settings:
    memory_limit: 4096M

では,まずはじめに並列実行数の定義を追加します.

.circleci/config.yml
version: 2

jobs:
    build_and_testing:
+   parallelism: 3
    docker:
        - image: circleci/php:7.2-node-browsers
        # 省略

ここで指定した値によって,CircleCIがテスト実行時に用意するExecutorの数(この記事ではdockerを使っているので,コンテナの数のこと)が決まります.

ただ,このままでは各Executorで全てのテストが重複して実行されるだけです.
各Executorで異なるテストを実行させて,実行時間を短縮するには適切にテストスイートを分割してあげる必要があります.

テスト分割に関してもCircleCIが用意してくれている方法があるのでそちらを使用します.

.circleci/config.yml
version: 2

jobs:
    build_and_testing:
    parallelism: 3
    docker:
        - image: circleci/php:7.2-node-browsers
        # 省略
    working_directory: /var/www
    steps:
        # 省略
+       - run:
+           name: Split Tests
+           command: |
+               TESTFILES=$(circleci tests glob "tests/unit/**/*Test.php" | circleci tests split)
+               echo $TESTFILES | tr ' ' '\n' > tests/_data/splitted_tests
+           working_directory: ./src/site
        - run:
            name: Run Codeception
            command: |
                php -d memory_limit=-1 vendor/bin/codecept run --ext DotReporter unit
            working_directory: ./src/site

circleci tests globでマッチしたファイルパスをcircleci tests splitにパイプすることでよしなに分割してくれます.便利ですね.
そして,分割されたファイルパスのリストを適切に整形してtests/_data/splitted_testsに出力しています.

あとは,Codeceptionがtests/_data/splitted_testsを見に行くように設定してあげれば完了です.

src/site/codeception.yml
paths:
    tests: tests
    output: tests/_output
    data: tests/_data
    support: tests/_support
    envs: tests/_envs
# 省略
+ groups:
+    splitted_tests: tests/_data/splitted_tests
.circleci/config.yml
version: 2

jobs:
    # 省略
    steps:
        # 省略
        - run:
            name: Run Codeception
            command: |
-               php -d memory_limit=-1 vendor/bin/codecept run --ext DotReporter unit
+               php -d memory_limit=-1 vendor/bin/codecept run --ext DotReporter unit -g splitted_tests
            working_directory: ./src/site

ここまでの設定によって,指定した並列実行数分のExecutorが起動し,その分だけテスト分割が行われ,それぞれのテストスイートがそれぞれのExecutorで並列に実行されるようになります.

並列実行数と利用料の関係

つづいて,並列実行数をいくつにするのが良いかという話ですが,こちらは費用対効果で考えます.

CircleCIでは,使用したクレジットに基づいて請求額が決まります.以下のFAQによると,消費するクレジットは合計ビルド時間で決まるため,合計20分かかるテストを2つに並列実行してそれぞれ10分で終了すれば,合計ビルド時間は変わらず料金は同じです,というように書かれていますが,ここには注意が必要です.

https://circleci.com/docs/ja/faq/#what-are-credits

Executorの数を2倍にすれば実行時間は1/2になるというのであれば,上記の見積もりで問題ないですが,

実際に私が並列実行を導入した際は以下のようになりました.

並列実行数 1 Executorあたりの平均実行時間 (s) 合計ビルド時間(各Executorの実行時間の総和)(s)
1(並列実行導入前) 525 525
2~3 実行失敗(メモリオーバー発生) 実行失敗(メモリオーバー発生)
4 ⏬260 ⏫876
5 ⏬206 ⏫953
6 ⏬181 ⏫984

このような関係性になる場合は,闇雲に並列実行数を増やすのではなく,プロジェクトの開発に支障をきたさない実行時間になれば十分というところで止めておきましょう.

並列実行を導入した後に急激に利用料が増えたりしていないかのチェックも忘れずに

⚡️ タイミングデータによるテスト分割(CircleCI推奨)の設定

前節で書いたように,闇雲に並列実行数を増やすことはできないため,各Executorになるべく均等にテストを分割してあげる必要があります.
こちらについてもCircleCIがオプションを用意してくれているので,それを利用します.

  • 名前に基づいた分割
    • アルファベット順に分割される
    • デフォルトはこれ
  • ⭐ タイミングデータに基づいた分割
    • 最後にテストが成功したビルドのデータ(タイミングデータ)を分析して,可能な限り均等に分割される
      • ⚠ 実行ごとに実行順序が変化する可能性があるということ(これに起因して次の節で説明する気づきづらいエラーが発生することがあります.)
    • CircleCI推奨(これが一番均等に分割されるらしい)
    • --split-by=timingsというオプションを指定する必要がある
    • store_test_resultsステップを追加する必要がある
  • ファイルサイズに基づいた分割
    • ファイルサイズが均等になるように分割される
    • --split-by=filesizeというオプションを指定する必要がある

ということで,CircleCIが推奨しているタイミングデータによるテスト分割を設定します.

.circleci/config.yml
version: 2

jobs:
    # 省略
    steps:
        # 省略
        - run:
            name: Split Tests
            command: |
-               TESTFILES=$(circleci tests glob "tests/unit/**/*Test.php" | circleci tests split)
+               TESTFILES=$(circleci tests glob "tests/unit/**/*Test.php" | circleci tests split --split-by=timings)
                echo $TESTFILES | tr ' ' '\n' > tests/_data/splitted_tests
            working_directory: ./src/site
        - run:
            name: Run Codeception
            command: |
-               php -d memory_limit=-1 vendor/bin/codecept run --ext DotReporter unit -g splitted_tests
+               php -d memory_limit=-1 vendor/bin/codecept run --xml unit -g splitted_tests
            working_directory: ./src/site
+       - run:
+           name: Format Tests Report
+           # テスト分割時とレポート生成時のパスの不一致を修正
+           command: |
+               sed -i -e "s|/var/www/src/site/||g" ./src/site/tests/_output/unit
+       - store_test_results:
+           path: ./src/site/tests/_output/unit

このあたりで少々ハマりました.以下注意です.

⚡️ 実行順が変わるとエラーになるテストの注意とその対策

ここまでセットアップしたところ,並列実行前は成功していたけど,並列実行にしたら失敗するようになったテストが出現しました.

主に並列実行前の実行順序から変化して,それまで潜在化していた実装ミスが顕在化したものと考えられます.

そのため,並列実行というテーマからは少しそれますが,タイミングデータによるテスト分割では,実行ごとに実行順序が変わるので,実行順序が変わっても結果の変わらない堅牢なテストを書こう,という観点で読み進めて頂ければと思います.

一意制約のあるキーに固定値をセットする

❌ エラー内容

  • [ErrorException] Undefined index: hogehoge

🔍 原因

  • 一意制約のあるキーに固定値をセットしていたため、前のテストで同じ値を同じキーにセットしようとして、後にセットする方のデータ作成が失敗(一意制約エラー)したため,そのデータにアクセスしようとしたときに上記のエラーが発生したと思われる.

⭕️ 対策

  • 一意制約のあるキーは基本指定せず,他の場所でキーの値を使用したい場合も動的に指定するようにする.

モックのクローズ忘れ

❌ エラー内容

  • [Mockery\Exception\InvalidCountException] Method hoge() from Mockery_fuga should be called exactly 1 times but called 0 times.

🔍 原因

  • モックの実装に問題があったものの、モックのクローズ忘れによりモックの検証が行われず表面化していなかったものが、後続のテストのモッククローズ処理で検証されたため

⭕️ 対策

  • モックを使用したら,クローズ処理まで必ず行う
  • Codeceptionでは共通のHelperメソッドを定義してあげることで,全てのテストケースの終了後にモックのクローズ処理を行うように設定しておくこともできました.
    • モックのクローズ処理はモックが使われていないテストケースで実行すると.単に「検証するべきモックなし」と判断するだけで,エラーになったりすることもなく,オーバーヘッドがほとんどないことが確認できたのでこのアプローチを取りました.
tests/_support/Helper/Unit.php
<?php

namespace Helper;

use Mockery;

class Unit extends \Codeception\Module
{
    public function _after(\Codeception\TestInterface $test)
    {
        Mockery::close();
    }
}

保険的対策

タイミングデータによるテスト分割では,実行ごとに実行順序が変わるので,今あげたもの以外にも潜在的なエラーは存在しそうです.さらに,実行順序に起因するので,再現するのが難しく,その原因を特定するのも結構大変そうなので,このまま何も手を打たないと,エラーになるたびCIが機能しなくなってしまうなんてことになりかねません.ということで保険的対策を入れました.

.circleci/config.yml
version: 2

jobs:
    # 省略
    steps:
        # 省略
        - run:
            name: Split Tests
            command: |
                TESTFILES=$(circleci tests glob "tests/unit/**/*Test.php" | circleci tests split --split-by=timings)
                echo $TESTFILES | tr ' ' '\n' > tests/_data/splitted_tests
            working_directory: ./src/site
        - run:
            name: Run Codeception
+           # エラーが発生しても継続し、ビルドがシグナルによって終了した場合(メモリオーバーによるKillなど)のみ、ワークフロー全体を失敗させる
            command: |
+               set +e
                php -d memory_limit=-1 vendor/bin/codecept run --xml unit -g splitted_tests
+               EXIT_CODE=$?
+               if [ $EXIT_CODE -ge 128 ]; then
+               exit $EXIT_CODE
+               fi
            working_directory: ./src/site
        - run:
            name: Format Tests Report
            # テスト分割時とレポート生成時のパスの不一致を修正
            command: |
                sed -i -e "s|/var/www/src/site/||g" ./src/site/tests/_output/unit
        - store_test_results:
            path: ./src/site/tests/_output/unit
+       - run:
+           name: Retry Failed Tests
+           command: |
+               php -d memory_limit=-1 vendor/bin/codecept run unit -g failed
+           working_directory: ./src/site

これでRun Codeceptionステップで並列実行が原因でテストが失敗した場合でも,Retry Failed Testsで失敗したもののみ再実行されるので,失敗したテストへの対処をある程度余裕をもって行えます.

ℹ️ 参考

https://circleci.com/docs/ja/parallelism-faster-jobs/

https://circleci.com/docs/ja/faq/#what-are-credits

https://circleci.com/docs/ja/use-the-circleci-cli-to-split-tests/#split-tests

https://codeception.com/docs/Reporting#:~:text=Extension page.-,XML,-JUnit XML is

GitHubで編集を提案

Discussion