⏱️

【CircleCI】テストを並列に実行してカバレッジも出したいときは

2024/07/17に公開

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

https://github.com/linux-test-project/lcov/issues/113#issuecomment-762335134

カバレッジをまとめたい

別々のコンテナで生成されたカバレッジレポートを一つにまとめ、並列実行されたテストに対してもコードベース全体でのカバレッジを計測できないか調査をしてみました。

すると別々のコンテナで生成されたレポートを結合したとき、上記のブロック内の順番が守られていれば、正しく全体のカバレッジを計測できることがわかりました。

さらにそれぞれのブロック同士は順不同ということもわかりました。つまり以下の2つは同じように解釈されるということです。

test1 → test2
TN: test 1
SF: ...
(省略)
BRF: ...
LH:  ...
end_of_record
TN: test 2
SF: ...
(省略)
BRF: ...
LH:  ...
end_of_record
test2 → test1
TN: test 2
SF: ...
(省略)
BRF: ...
LH:  ...
end_of_record
TN: test 1
SF: ...
(省略)
BRF: ...
LH:  ...
end_of_record

それぞれのコンテナで生成されたカバレッジレポートの内容を単純にマージしていけばソースコード全体のカバレッジが正しく計測できそうです。

実際のCIワークフロー

そうと分かればあとは簡単なスクリプトと少し工夫されたワークフローを作ればテストの並列実行とカバレッジ計測を同時に実現することができそうです。

config.yml(簡易)
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.infolcov1.infoのように生成する)

そして生成されたカバレッジレポートを次のジョブと共有するためにCircleCiのワークスペース機能を使っています。

collect_test_coverageジョブ

collect_test_coverageジョブではattach_workspaceで共有されたワークスペースでファイルを操作していきます。

cat ./workspace/coverage/lcov*.info > merged-coverage/lcov.info
この処理により全てのコンテナで生成されたカバレッジレポートをマージします。

workflow

この2つのジョブを使ったワークフローは以下のようになります。

config.yml
workflows:
  coverage_on_test:
    jobs:
      - test:
          parallelism: 4
      - collect_test_coverage:
          requires:
            - test-coverage

requiresを使ってジョブの実行順序を制御しています。

嬉しいポイント

上記のワークフローができると、テストは並列に実行し高速化を保ちながら、全体のカバレッジレポートもPRごとに生成することができます。

私の所属する会社ではソースコードの静的解析ツールとしてSonarQubeを利用しています。

上記のワークフローを利用することでPRごとに解析結果をコメントしてくれるようになります。
その都度カバレッジや重複したコードの比率を確認できて非常に助かりますしレビューの材料にもなります。
SonarQube解析結果

まとめ

この記事ではCircleCIを使って並列実行されたテストのカバレッジを計測してきました。
実現する過程で興味深かったのではlcovフォーマットが単純に結合が可能だったということです。

Jestでは他にもJSON形式でカバレッジを出力するものもあり、その場合だとこんなにもシンプルにいかないだろうと思っています。

「こんなことできないかなあ」と思って調べてみる過程で今回のワークフローを作成することができたのでとても身になったと思います。

Discussion