【CircleCI】テストを並列に実行してカバレッジも出したいときは
CIの待ち時間
エンジニアなら誰もが「CIの待ち時間の辛さ」について共感すると思います。
次のタスクに取りかかろうとしてもずっとローディングとログを見てしまうのは、あるあるなのではないでしょうか。
特に大規模プロジェクトでは、数分の待ち時間が10分を超え、時には30分を超えることもあります。再度修正してプッシュし直すたびにリセットされる待ち時間は、まるで終わりの見えないサイクルのようです。
この「待ち時間」へのフラストレーションは、エンジニアの共通の苦労話だと思います。
並列実行とテストカバレッジ
私の開発しているプロダクトでは、PRごとにテストを並列で実行しています。具体的な設定は省きますが、CircleCIでは parallelism に2以上の値を設定することで、ジョブを複数のコンテナで並列に実行できます。詳細はテスト分割と並列実行をご覧ください。
テストはPRごとにコードをプッシュするたびに実行されるようになっています。テストカバレッジの計測は定期ジョブで行っていますが、ふと「PRごとにテストカバレッジを計測できるとさらに良いのでは?」と考えました。
例えば、Node.jsで使われるテストツールの一つであるJestでは、テスト実行コマンドを
jest --collectCoverage
に変更するだけで簡単にカバレッジを計測できます。
しかし、並列化されたテストではこの方法でカバレッジを正しく計測することができません。並列実行される各テスト群は別々のコンテナで実行されるため、カバレッジもその単位で計測されてしまいます。
このように、並列実行とカバレッジ計測の両立は単純にはいかなさそうです。エンジニアなら誰しも、こうした細かな問題に出会ったことがあるはずです。
LCOVフォーマット
lcovとはgcovの拡張であり、カバレッジについての情報を提供してくれるツールです。Jestでは
coverageReporters: ['lcovonly']
と設定することでカバレッジの結果をlcov.info
に出力することができます。
lcov.infoの中身は以下の構成からなるブロックが、複数回繰り返されています。
TN: test name
SF: source file path
FN: line number,function name
FNF: number functions found
FNH: number hit
BRDA: branch data: line, block, (expressions,count)+
BRF: branches found
DA: line number, hit count
LF: lines found
LH: lines hit.
end_of_record
カバレッジをまとめたい
別々のコンテナで生成されたカバレッジレポートを一つにまとめ、並列実行されたテストに対してもコードベース全体でのカバレッジを計測できないか調査をしてみました。
すると別々のコンテナで生成されたレポートを結合したとき、上記のブロック内の順番が守られていれば、正しく全体のカバレッジを計測できることがわかりました。
さらにそれぞれのブロック同士は順不同ということもわかりました。つまり以下の2つは同じように解釈されるということです。
TN: test 1
SF: ...
(省略)
BRF: ...
LH: ...
end_of_record
TN: test 2
SF: ...
(省略)
BRF: ...
LH: ...
end_of_record
TN: test 2
SF: ...
(省略)
BRF: ...
LH: ...
end_of_record
TN: test 1
SF: ...
(省略)
BRF: ...
LH: ...
end_of_record
それぞれのコンテナで生成されたカバレッジレポートの内容を単純にマージしていけばソースコード全体のカバレッジが正しく計測できそうです。
実際のCIワークフロー
そうと分かればあとは簡単なスクリプトと少し工夫されたワークフローを作ればテストの並列実行とカバレッジ計測を同時に実現することができそうです。
jobs:
test:
docker:
- image: cimg/node:<< parameters.node-version >>
parameters:
node-version:
type: string
default: '20.15.1'
parallelism:
type: integer
default: 1
parallelism: << parameters.parallelism >>
steps:
- ...セットアップ
- run:
command: |
TEST_FILES=$(circleci tests glob "src/**/*.test.{tsx,ts}" | circleci tests split)
npm run test:ci $TEST_FILES
mv coverage/lcov.info coverage/lcov${CIRCLE_NODE_INDEX}.info
- run:
name: Create coverage directory
command: mkdir -p workspace/coverage && cp coverage/lcov${CIRCLE_NODE_INDEX}.info ./workspace/coverage/lcov${CIRCLE_NODE_INDEX}.info
- persist_to_workspace:
root: workspace
paths:
- coverage/
collect_test_coverage:
docker:
- image: ...セットアップ
steps:
- checkout
- attach_workspace:
at: ./workspace
- run:
name: Concatenate coverage reports
command: |
mkdir -p merged-coverage
cat ./workspace/coverage/lcov*.info > merged-coverage/lcov.info
- run:
name: Move coverage report to root
command: mv merged-coverage/lcov.info ./workspace/coverage/lcov.info
- run:
name: You can do anything with coverage report
command: |
cat ./workspace/coverage/lcov.info
test
ジョブ
testジョブではparallelism
をパラメータとして受け取りテストを分割して実行しています。
Jestでは何も設定しなければ/coverage
配下にlcov.info
を生成するのですが、ファイル名の衝突を防ぐためにCIRCLE_NODE_INDEX
でコンテナごとに異なる名前のカバレッジレポートを生成しています。
(lcov0.info
、lcov1.info
のように生成する)
そして生成されたカバレッジレポートを次のジョブと共有するためにCircleCiのワークスペース機能を使っています。
collect_test_coverage
ジョブ
collect_test_coverageジョブではattach_workspace
で共有されたワークスペースでファイルを操作していきます。
cat ./workspace/coverage/lcov*.info > merged-coverage/lcov.info
この処理により全てのコンテナで生成されたカバレッジレポートをマージします。
workflow
この2つのジョブを使ったワークフローは以下のようになります。
workflows:
coverage_on_test:
jobs:
- test:
parallelism: 4
- collect_test_coverage:
requires:
- test-coverage
requires
を使ってジョブの実行順序を制御しています。
嬉しいポイント
上記のワークフローができると、テストは並列に実行し高速化を保ちながら、全体のカバレッジレポートもPRごとに生成することができます。
私の所属する会社ではソースコードの静的解析ツールとしてSonarQubeを利用しています。
上記のワークフローを利用することでPRごとに解析結果をコメントしてくれるようになります。
その都度カバレッジや重複したコードの比率を確認できて非常に助かりますしレビューの材料にもなります。
まとめ
この記事ではCircleCIを使って並列実行されたテストのカバレッジを計測してきました。
実現する過程で興味深かったのではlcov
フォーマットが単純に結合が可能だったということです。
Jestでは他にもJSON形式でカバレッジを出力するものもあり、その場合だとこんなにもシンプルにいかないだろうと思っています。
「こんなことできないかなあ」と思って調べてみる過程で今回のワークフローを作成することができたのでとても身になったと思います。
Discussion