GitHub Actions でテストを並列に実行して高速化する (parallelism)

6 min read読了の目安(約6000字

テスト A に 5 分、テスト B に 10 分、テスト C に 3 分かかるとします。
直列で実行すると全部で 5 + 10 + 3 で 18 分かかります。
これを、テスト A と C だけを実行する job と、テスト B だけを実行する job の 2 つにわけると、A + C の 8 分と B の 10 分になるため、全体では長い方の 10 分待つだけで済みます。

拙作のツール、split-test を使うことで、JUnit Format XML に記録された実行時間を元にしたグループ分割が簡単にできます。

https://github.com/mtsmfm/split-test
split-test --junit-xml-report-dir <xml があるディレクトリ> --node-index {{ matrix.node_index }} --node-total 2 --tests-glob 'spec/**/*_spec.rb'

のように実行すると、node_index 0 では

spec/a_spec.rb
spec/c_spec.rb

node_index 1 では

spec/b_spec.rb

のように、グループ分けされた結果を標準出力に返します。
この結果をテストコマンド (例: rspec) に渡すことで、分割実行しようというものです。

例:

bin/rspec --format progress --format RspecJunitFormatter --out report/rspec-${{ matrix.node_index }}.xml $(./split-test --junit-xml-report-dir report-tmp --node-index ${{ matrix.node_index }} --node-total 2 --tests-glob 'spec/**/*_spec.rb' --debug)

--debug オプションをつけることで、どのように分割しようとしているか、実行時間データの検出漏れがないかを確認することができます。これは標準エラーに出力されます。

例 (https://github.com/mtsmfm/split-test-example/runs/1667231492)

[2021-01-08T07:12:09Z WARN  split_test] Timing data not found: /home/runner/work/split-test-example/split-test-example/spec/1_spec.rb
...
[2021-01-08T07:12:09Z DEBUG split_test] node 0: recorded_total_time: 16.439781
[2021-01-08T07:12:09Z DEBUG split_test] /home/runner/work/split-test-example/split-test-example/spec/87_spec.rb
[2021-01-08T07:12:09Z DEBUG split_test] /home/runner/work/split-test-example/split-test-example/spec/62_spec.rb
[2021-01-08T07:12:09Z DEBUG split_test] /home/runner/work/split-test-example/split-test-example/spec/53_spec.rb
...
[2021-01-08T07:12:09Z DEBUG split_test] node 1: recorded_total_time: 16.440659
[2021-01-08T07:12:09Z DEBUG split_test] /home/runner/work/split-test-example/split-test-example/spec/97_spec.rb
[2021-01-08T07:12:09Z DEBUG split_test] /home/runner/work/split-test-example/split-test-example/spec/59_spec.rb
[2021-01-08T07:12:09Z DEBUG split_test] /home/runner/work/split-test-example/split-test-example/spec/50_spec.rb
...
[2021-01-08T07:12:09Z DEBUG split_test] node 2: recorded_total_time: 16.450345999999996
[2021-01-08T07:12:09Z DEBUG split_test] /home/runner/work/split-test-example/split-test-example/spec/80_spec.rb
[2021-01-08T07:12:09Z DEBUG split_test] /home/runner/work/split-test-example/split-test-example/spec/64_spec.rb
[2021-01-08T07:12:09Z DEBUG split_test] /home/runner/work/split-test-example/split-test-example/spec/56_spec.rb
...

GitHub Actions に直接依存しているわけではないため、実行結果を保存、展開するところさえ用意すればどの CI サービスでも利用可能です。

GitHub Actions における流れ

設定例は次の通りです。

on: push

jobs:
  # Download test-report and save as test-report-tmp to use the exactly same test report across parallel jobs.
  download-test-report:
    runs-on: ubuntu-latest
    steps:
      # Use dawidd6/action-download-artifact to download JUnit Format XML test report from another branch
      # https://github.com/actions/download-artifact/issues/3
      - uses: dawidd6/action-download-artifact@v2
        with:
          branch: main
          name: test-report
          workflow: ci.yml
          path: report
        # Use continue-on-error to run tests even if test-report is not uploaded
        continue-on-error: true
      - uses: actions/upload-artifact@v2
        with:
          name: test-report-tmp
          path: report

  test:
    needs: download-test-report
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node_index: [0, 1, 2]
    steps:
      - uses: actions/checkout@v2
      - uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
          ruby-version: 3.0.0
      - uses: actions/download-artifact@v2
        with:
          name: test-report-tmp
          path: report-tmp
        # Use continue-on-error to run tests even if test-report is not uploaded
        continue-on-error: true
      - run: |
          curl -L --out split-test https://github.com/mtsmfm/split-test/releases/download/v0.3.0/split-test-x86_64-unknown-linux-gnu
          chmod +x split-test
      - run: bin/rspec --format progress --format RspecJunitFormatter --out report/rspec-${{ matrix.node_index }}.xml $(./split-test --junit-xml-report-dir report-tmp --node-index ${{ matrix.node_index }} --node-total 3 --tests-glob 'spec/**/*_spec.rb' --debug)
      - uses: actions/upload-artifact@v2
        with:
          name: test-report
          path: report
          if-no-files-found: error
        # Upload test-report on main branch only to avoid conflicting test report
        if: github.ref == 'refs/heads/main'
  1. main ブランチ (デフォルトブランチ) 上でテストの実行結果を記録し、artifacts として保存する。
  2. main ブランチのテスト記録を復元する。このとき、公式アクションだと他のブランチから取得できないため、dawidd6/action-download-artifact@v2 を使用する。 https://github.com/actions/download-artifact/issues/3
  3. main ブランチのテスト記録は、matrix 設定された各 job が全く同時に動くとは限らず、タイミングによっては main 側の workflow が動いて更新される可能性があるため、一旦 workflow 用にスナップショットを tmp として保存する。
  4. tmp 側を matrix 設定された各 job で復元する。
  5. 記録された情報を元に split-test コマンドを使って分割する。
  6. main ブランチのときだけ実行記録を保存しなおす。

分割手法

内部では貪欲法で分割しています。
具体的には、ファイルごとに実行時間を足し合わせ、その実行時間が多い順に並べ、グループごと合計実行時間が最も少ないものに詰める、を繰り返しています。
厳密には最適な分割ではない可能性がありますが、多くの場合 queue の時間などで数秒~数十秒はずれてくるため、ここが最適でなくても誤差の範疇になるはずだと考えています。
実際に利用した際においてひどいケースを見つけたら issue で報告 お願いします。

余談: JUnit Format XML の方言たち

JUnit Format XML といいつつもいくつか方言が存在するようです。
そして、split-test にとって最も重要な、テストファイル名の情報は実は方言のようで、どこが公式な情報かはわかりませんが、ググって上の方にでてきた IBM Knowledge Center の定義にはありませんでした。

https://www.ibm.com/support/knowledgecenter/SSQ2R2_9.1.1/com.ibm.rsar.analysis.codereview.cobol.doc/topics/cac_useresults_junit.html

仕事で使うのがほぼ Ruby なので RSpec や Minitest の結果を食わせて試していましたが、この2つだけでも次のような違いがありました。

rspec_junit_formatter:

<testsuite>
  <testcase file="foo_spec.rb">
  <testcase>
</testsuite>

minitest-reporters:

<testsuites>
  <testsuite filepath="foo_spec.rb">
    <testcase>
    <testcase>
  </testsuite>
</testsuites>

Coverage 情報なども考慮した、扱いやすい大統一フォーマットが求められているのかもしれません。