CircleCIでMinitestを並列にして実行時間を1/3に短縮した話

はじめに

イタンジ株式会社の小林です。ノマドクラウドという不動産仲介向けのSaaSを開発しています。

ノマドクラウドではバックエンド開発にはRuby on Railsを採用し、CIツールとしてCircleCIを利用しています。これまでCIの高速化について真剣に取り組んだことはあまりなかったため、CIでのテストの実行に最大10分~15分程度要していました。ちょっとしたlintの修正や文言修正のコミットにも15分程度かかっており、「コード上にコメント残しておきたいけど面倒だな」「こちらの文言の方が良い気もするけど15分かかるの面倒だな」など心理的負荷をあげている状態でした。

修正前のテスト実行時間

この記事では、Minitestを並列実行することでCircleCIの実行時間をどのように短縮したかについて共有します。

やったこと

CircleCIでテスト実行を高速化する際に、消費クレジットを気にしなくてもいい場合、実行環境のリソースクラスをより良いものにしてしまえば特に何もせずとも一定程度は高速化できますが、通常、業務で使用する場合は消費クレジットは極力抑えたいです。また、当たり前ですが 遅いテストを削除することで高速化するといったことはせず、coverageは現状をキープしたまま高速化したいと考えました。

実行前のcoverage。テストに修正を加えないのでこれは変わらないはず。

実行前のcoverage

そのことを前提に踏まえると、CIでテストを高速化したい場合、以下の2つの方法が取れそうだと考えました。

  • railsのテスト自体を見直し、実行に時間のかかっているテストを修正する
  • CircleCIの実行環境を並列化する

今回は、railsのテストに変更を加えるよりも、CircleCI側を並列にしてしまった方が早いと考え、CircleCIの実行環境を並列化する方針としました。テストを並列化するというのは、例えば全体のテストケースが400個あった場合、4並列にすると、各実行環境でのテスト数は100ずつになります。これを行えば、単純計算で4倍の早さ(ここでの例えでは100個のテストを実行する時間)でテストが実行し終えるだろうと推測できます。

CircleCIでのテスト並列化

並列数分の実行環境を用意する

CircleCIでは並列化したいjobにparallelismというキーを渡すと値に指定した数の実行環境を用意することができます(参考)。今回は4つの実行環境を用意します。

4つとした理由としては、消費クレジットを抑えつつCIを高速化したく、そのバランスが良かったからです。 これまでのテスト実行時間を元に消費クレジットを計算してみます。

消費クレジットの計算方法は使っている実行環境とそのリソースクラスによって決まります。(参考) ノマドクラウドで使っているのはDockerのMediumなので、10クレジット/分 × 実行時間で計算できます。

計算式と結果は以下です。 後述しますが、並列した結果1台あたりのテスト実行時間は4分程度になったので、それを元に計算してみます。

直列 並列
13分 × 10クレジット = 130クレジット 4分 × 10クレジット × 4台 = 160クレジット

直列で実行した時よりも1実行あたり30クレジットの増加となりましたが、CIの待ち時間を大幅に短縮できることで開発生産性が上がることを考えると問題ないと判断しました。

設定ファイルの例

web_app_minitest:
  parallelism: 4 # ここに記載した数の実行環境が用意される
  steps:
    - checkout
    - run:
        name: "Test"
        command: |
          bundle exec rails test -f

これで4つ分の実行環境が用意されるようになりました。しかしこのままでは4つの実行環境で全てのテストが実行されてしまいます。そこで、テストを分割します。

テストの分割

CircleCI CLIでは前回のCI実行時のテスト結果のデータをもとに、テストを分割してくれるcircleci tests split --split-by=timingsというコマンドがあります(参考)。こちらを用いてテストを分割します。

テスト結果の出力について

circleci tests split --split-by=timingsに利用するテストの実行結果はJUnit形式でアップロードする必要があります。

JUnit形式のテストデータは何も設定していない状態だとこの形式で出力されません。そこで、minitest-reportersというgemを使うことによって出力できるようにしました。

以下はテストの設定ファイル(test_helper.rb)の例です。CI環境の場合に分割した実行環境でのテスト結果をJUnit形式で出力するための設定を記載しています。

if ENV["CI"]
  # CI環境だけテスト結果をJUnit形式でtmp/test-results配下に出力
  Minitest::Reporters.use! Minitest::Reporters::JUnitReporter.new('tmp/test-results')
else
  Minitest::Reporters.use! [Minitest::Reporters::DefaultReporter.new(:color => true)]
end

これにより、テスト実行するときにtmp/test-results配下に結果が出力されるので、store_test_resultstmp/test-resultsをアップロードすることでCircleCI側でうまい具合に次回のCI実行時に利用して分割してくれます。

テスト分割コマンド、テスト結果のアップロードを含む設定ファイルの例

web_app_minitest:
  parallelism: 4
  steps:
    - checkout
    - run:
        name: "Parallel Test"
        command: |
          # circleci tests globで全体のテストファイルをとってくる
          # circleci tests splitでとってきたテストファイルと前回のテスト実行結果からcircleciがテストを分割し、対象のファイルを返す
          TESTFILES="$(circleci tests glob "test/**/*_test.rb" | circleci tests split --split-by=timings)"
          bundle exec rails test -f ${TESTFILES}
      # ここでテスト結果をアップロードする。
      # テスト実行時にJUnit形式のテスト結果が`tmp/test-results`配下に出力されるように設定しておく
      - store_test_results:
          path: tmp/test-results

テスト分割した結果

CircleCI側のテスト分割によって多少実行時間は前後しますが、これまでの直列でテストを実行する場合と、並列化した場合の一番遅かった実行環境の実行時間を比較してみます。

直列 並列
13分24秒 4分9秒

前回のテスト実行結果の利用によるテストを4分割したことにより、単純に4倍速とはなりませんでしたが、1/3程度まで時間を短縮することができました。

テストを分割した後の実行時間

テストについての修正は行なっていないので、並列後のcoverageも実行前と変わらず。

coverageの変化はなし

おわりに

今回の取り組みにより、CircleCIでのテスト実行時間を大幅に短縮することができました。テストの並列実行によって開発サイクルが速くなり、コードをコミットすることに対する心理的負担が下がったことで、開発生産性・体験が向上したと思います。CircleCIでのテストがCI全体の実行時間のボトルネックとなっている場合、比較的手軽にできて効果もあるのでやって良かったと感じています。

CircleCIでMinitestを並列実行する方法を模索している方にとって、この記事が何らかの参考になれば幸いです。