食べチョク開発者ブログ

食べチョクエンジニアによるプロダクト開発ブログ

食べチョクの自動テスト実行速度を2倍以上速くした

皆さんこんにちは、エンジニアの久保金田一です。

今回は、食べチョクの自動テスト改善チームが取り組んでいるテスト実行速度の改善についてお話しいたします。

自動テスト改善チームとは何か?について知りたい方は、以下のエントリーをご覧ください。

食べチョクにおける自動テストの現状

食べチョクでは、GitHub Actions のワークフローを使って、push をトリガーに自動テストを実行しています。食べチョクのリポジトリには System Spec までを含んでいて、E2E は別リポジトリとなっています。

メンバーが増えて体制が整ってきたこともあり、この 1 年くらいに実装した機能についてはテストがしっかり書かれていることが多いです。一方で、それ以上前に作られた機能については、まだまだカバレッジが低い箇所もあり、みんなで手分けして少しずつテストを追加しています。

不足していたテストの追加だけではなく、新しい機能の開発でもテストは増えていきます。これらのテスト追加に伴い出てきた課題がテスト実行時間の増加です。徐々にテストの実行時間が伸び、結果として CI 全体の時間が伸びてしまい、開発者体験が悪くなりつつあると感じ始めていました。具体的には、push してから merge するまで時間がかかる、push 後しばらく別の作業を実施してからテストの失敗を検知して前の作業に戻るので非効率、などの意見がありました。

このままでは開発効率が悪くなる一方なので、自動テストの速度改善をしよう!ということになりました。

CI 上でのテスト実行時間の計測と観察

単にテストが遅いと言っても、どこに問題があるかを明確にしないとどういった改善が有効か判断することができません。まずはボトルネックを突き止めるために、CI 上でのテスト実行時間の計測と観察をしました。

テスト実行時間をランダムで 20 回分集計してみたところ、最短で 11 分 44 秒、最長で 16 分 3 秒、平均で 13 分 17 秒かかっていました。

rspec_execution_time_before
改善前のRSpec実行時間

この全体の実行時間を「テスト自体の実行時間」と「ファイルの読み込み時間」に分けて確認したところ、「ファイルの読み込み時間」が全体の 30% 前後を占めていることがわかりました。 例えば、全体の実行時間が約 14 分だった場合では、「テスト自体の実行時間」が約 9 分 36 秒、「ファイルの読み込み時間」が 4 分 30 秒といった具合です。

Finished in 9 minutes 36 seconds (files took 4 minutes 29.8 seconds to load)
4846 examples, 0 failures, 15 pending

次に「テスト自体の実行時間」をさらに分析するために、実行が遅いテスト TOP30 を抽出してみました。

$ bundle exec rspec --profile 30

その結果、1 秒以上かかる example が 6 つあり、最も遅いテストでは 8.4 秒かかっていました。この 6 つテストだけで約 25 秒かかっており、改善の余地がありそうでした。

ただし、6 つのうち 4 つは System Spec で、この 4 つについては時間がかかるのも仕方がない部分もあります。

また、他のテストの実行時間は 1 秒未満だったので、全体の実行時間からすると 1 つ 1 つのテストを少しずつ改善しても大幅な改善は見込めませんでした。

ここまでの分析から次のことがわかりました。

  • 改善には「テストの実行時間の改善」と「ファイルの読み込み時間の改善」の 2 つの軸がありそう
  • 1 つ 1 つのテストの改善は、最初に取り組むべき改善策ではなさそう

速くするための改善策

実行時間の計測と観察の結果から、「テストの実行時間の改善」と「ファイルの読み込み時間の改善」の 2 つの軸が見えてきました。

まず「テストの実行時間の改善」については、テストを並列実行するという案が出ました。他社事例などを調べた結果、食べチョクでも並列実行することで大幅な時間短縮が見込めそうだったため、チャレンジしてみることにしました。

次に「ファイルの読み込み時間の改善」についてです。これは意図せず解決に至ったのですが、Ruby 3.0 系へのバージョンアップが有効でした。Ruby 2.7.2 から 3.0.4 にバージョンアップした結果、ファイルの読み込み時間が約 4 分 30 秒から約 60 秒まで短縮されました。

以上のことから、テスト実行速度の改善策としては「テストの並列実行」に取り組むことになりました。

テストの並列実行

テストの並列化は、主に以下のような実装をしました。

  • フロントエンドのテスト(Jest)とバックエンドのテスト(RSpec)の実行の分離
  • RSpec の実行を 10 個に並列化

Jest と RSpec の実行の分離は、ジョブを分けて並列実行にしただけなので、ここでは RSpec の実行を 10 個に並列化した実装について解説していきます。 以下は、RSpec 実行のジョブと、並列化をするためのファイル分割スクリプトです。

  backend:
    runs-on: ubuntu-latest
    needs:
      - backend-prepare
    timeout-minutes: 10
    strategy:
      fail-fast: false
      matrix:
        NODE_INDEX: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
    env:
      NUMBER_OF_NODES: 10
    steps:
      ...buildステップ...
      - name: Exec RSpec
        run: |
          SPEC_FILES=$(./bin/rspec_splitter.rb --glob 'spec/**/*_spec.rb' --node-count "${NUMBER_OF_NODES}" --node-index "${MATRIX_NODE_INDEX}")
          bundle exec rspec --format progress --format RSpec::Github::Formatter --format RspecJunitFormatter --out "tmp/junit-rspec-${MATRIX_NODE_INDEX}.xml" ${SPEC_FILES}
        env:
          MATRIX_NODE_INDEX: ${{ matrix.NODE_INDEX }}
          ...その他のenv...
      - name: Rename coverage result
        ...カバレッジを送信するステップ...

rspec_splitter.rb

#!/usr/bin/env ruby
require "optparse"
options = ARGV.getopts("", *%w[glob: node-index: node-count:])
glob_pattern = options["glob"]
$node_count = options["node-count"].to_i
node_index = options["node-index"].to_i
SpecFile = Struct.new(:path, :index, keyword_init: true) do
  def group
    index % $node_count
  end
end
files = Dir[glob_pattern]
  .sort_by { |path| [File.size(path), path] }
  .reverse_each
  .with_index
  .map { |path, index| SpecFile.new(path: path, index: index) }
puts files.select { |f| f.group == node_index }.map(&:path)

まず、GitHub Actions 上で、ジョブの並列実行をするためには、Matrix Strategyを使用します。例えば、複数の OS や言語などのバージョンの組み合わせに対する Job を記述することが出来るとても便利な機能です。

ここでは、NODE_INDEX という変数にそれぞれ 0 から 9 までの値を保持しておきます。NODE_INDEX のみ定義しているので、ここで 10 個のジョブが生成されることになります。 fail-fastを false にしておくと、ジョブが fail しても他のジョブの処理が継続します。

次に、RSpec の分割実行です。テストファイルを分割し、各ジョブで実行するようにしました。テストファイルのサイズを取得し、並列にした時の実行時間が平準化するようにしています。

具体的には、テストファイルのサイズ比較で降順にした配列に対して、ファイルパスと配列のインデックスを持ったオブジェクトを作成します。配列のインデックス % node_count と NODE_INDEX を比較し、一致した NODE_INDEX のジョブ番号で実行するという実装にしています。

結果

テストの並列実行を実現した後に、再度テストの実行時間をランダムで 20 回分集計してみたところ、最短で 3 分 8 秒、最長で 7 分 46 秒、平均で 4 分 45 秒という結果になりました。 改善前の時点では、最短が 11 分 44 秒、最長が 16 分 3 秒、平均が 13 分 17 秒だったので、以前の半分以下の時間でテストを実行できるようになりました。

rspec_execution_time_after
改善後のRSpec実行時間

さらなる改善に向けて

テストの並列実行によりテストの実行速度は向上し、一旦は開発者体験も良くなりました。

しかしながら、プロダクトは日々成長し、機能もテストも追加されていきます。また、既存のテストに改善の余地があることも認識しています。

よって、テスト実行速度の改善についてはこれで終わりではなく、今後は以下のような地道な改善にも取り組んでいくつもりです。

  • 並列実行のチューニング
    • 今回並列実行は実現したが、並列数の調整や実行時間単位で分割するなどで効率を up できる可能性がある
  • テストの重複排除
    • どのレイヤーで何をテストするかなどを整理して、無駄に複数のレイヤーで同じテストをしないようにする
    • ロジックのテストを Request Spec のような高コストなレイヤーでやらない(必要な場合を除く)
  • 作成するテストデータを削減する
    • DB アクセスが関係ない部分のテストでは、FactoryBot で create せずに buildbuild_stubbedを使う
    • テストに関係ないデータがafter(:create)でたくさん作られがちなので、作らないようにする
    • 1 example につき 1 expect で書くとわかりやすいが、DB レコードが必要なテストは毎回レコード作成することになるので、RSpec の aggregate failures を活用して example を統合する

最後に

食べチョクでは仲間を募集しています。 ご興味がある方は是非、RECRUITからご応募ください。