ユニットテスト並列実行の導入
⚡️: 他の記事に書いてなさそうだったこと
✅ この記事の目的
CircleCI×Codeceptionで運用されているユニットテストに並列実行を導入する方法とその注意点を説明すること
❌ この記事に書いてないこと
- CircleCI×Codeception自体の導入方法
- 並列実行に関連しないコード
- タイミングデータ以外のテスト分割の設定方法
並列実行数とテスト分割の設定
CircleCIで並列実行を設定するには並列実行数とテスト分割の設定を行う必要がありますが,その前に並列実行導入前の設定ファイルを確認しておきます.
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
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
では,まずはじめに並列実行数の定義を追加します.
version: 2
jobs:
build_and_testing:
+ parallelism: 3
docker:
- image: circleci/php:7.2-node-browsers
# 省略
ここで指定した値によって,CircleCIがテスト実行時に用意するExecutorの数(この記事ではdockerを使っているので,コンテナの数のこと)が決まります.
ただ,このままでは各Executorで全てのテストが重複して実行されるだけです.
各Executorで異なるテストを実行させて,実行時間を短縮するには適切にテストスイートを分割してあげる必要があります.
テスト分割に関してもCircleCIが用意してくれている方法があるのでそちらを使用します.
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
を見に行くように設定してあげれば完了です.
paths:
tests: tests
output: tests/_output
data: tests/_data
support: tests/_support
envs: tests/_envs
# 省略
+ groups:
+ splitted_tests: tests/_data/splitted_tests
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分で終了すれば,合計ビルド時間は変わらず料金は同じです,というように書かれていますが,ここには注意が必要です.
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が推奨しているタイミングデータによるテスト分割を設定します.
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メソッドを定義してあげることで,全てのテストケースの終了後にモックのクローズ処理を行うように設定しておくこともできました.
- モックのクローズ処理はモックが使われていないテストケースで実行すると.単に「検証するべきモックなし」と判断するだけで,エラーになったりすることもなく,オーバーヘッドがほとんどないことが確認できたのでこのアプローチを取りました.
<?php
namespace Helper;
use Mockery;
class Unit extends \Codeception\Module
{
public function _after(\Codeception\TestInterface $test)
{
Mockery::close();
}
}
保険的対策
タイミングデータによるテスト分割では,実行ごとに実行順序が変わるので,今あげたもの以外にも潜在的なエラーは存在しそうです.さらに,実行順序に起因するので,再現するのが難しく,その原因を特定するのも結構大変そうなので,このまま何も手を打たないと,エラーになるたびCIが機能しなくなってしまうなんてことになりかねません.ということで保険的対策を入れました.
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
で失敗したもののみ再実行されるので,失敗したテストへの対処をある程度余裕をもって行えます.
ℹ️ 参考
Discussion