🍣

【RSpec】テストを高速化するために行ったこと | Offers Tech Blog

2023/01/26に公開

はじめに

こんにちは!
Offers を運営している株式会社 overflow の磯崎です。

弊社では、Ruby on Rails を使用しており、バックエンドのユニットテストには RSpec を使っています。

これを Github Actions で PR 単位で実行しており、バックエンドのユニットテスト含めたいくつかの各 job が成功ステータスを返して、全てパスすればマージ OK な運用をしています。

つまり、実装→リリースまでの間で、ユニットテストを通過する事は必須である且つ CI 上では最も時間のかかっていた job であるため、この実行時間を短縮できればリリース速度向上に寄与することが出来ます。

改善前の状況

実行環境

Github Actions

2 コア CPU (x86_64)
7 GB の RAM
14 GB の SSD 領域

gem parallel_tests を導入し、2 並列でのテスト実行

実行時間

平均 1 時間半
カバレッジは約 90%を維持している状態ですが、機能が増え、テストが増えるにつれて、実行時間もダラダラと伸び続けてしまったという状況でした。

遅いテストを抽出し、改善する

とにもかくにも、まず状況確認と計測。
時間がかかっているテストを抽出し、修正することによる改善幅が大きいものを抽出します。

遅いテストを抽出する

–profile オプション付きで RSpec を実行すると、遅いテストをリストアップできます。

Top 20 slowest examples (100.00 seconds, 10% of total time):
  xxxxxxx xxxxxxx
    20.00 seconds ./spec/models/xxxx_spec.rb:1
  xxxxxxx xxxxxxx
    10.00 seconds ./spec/models/xxxx_spec.rb:2

どこがボトルネックになっているかを探る

基本的には、上記で抽出したテストを 1 つずつ確認していき、どこに時間がかかっているかを特定します。

実際に改善していく際には、とにかくまず計測し、事実をベースに対応案を練っていく基本的な対応をしていきます。

以下にテストが遅くなる温床となりうるであろういくつかのポイントを羅列しました。

また、全体的により詳しく状況を知りたい場合は test-prof を導入して合わせて確認することで、幅広く情報を手に入れることができ、ボトルネックの特定に役立ちます。

無駄なデータ作成や更新、削除を行っていないか確認

DB の読み込みや書き込みが発生する箇所はボトルネックになりがちです。
弊社はここを見直すだけで、コスパよくテスト実行時間の削減をできました。

毎回大量のデータ作成を行っている箇所はないか?

弊社は FactoryBot でサンプルデータの生成をしています。

ループ内で create していないか

bulk insert に変更可能かを確認します。

ちなみに、FactoryBot が用意している create_list は件数分しっかりと insert 分が走ってしまうので、大量のデータ生成する必要ある場合は、build_list + bulk insert を行ったほうが速度改善につながります。

createではなく、buildに変更することはできないか?

DB に insert を行う必要はなく、生成したインスタンスを用いての検証が行える場合は、create ではなく build を使うことによって DB へのアクセスが行われずにインスタンスの生成ができるため、より高速です。

ちなみに、build 使用していたとしても、関連モデルが定義されている場合、そちらは普通に create されるので注意が必要です。

その場合は、build_stubbed を使用することによってアソシエーション先のデータ生成も行われず、インスタンスを生成できます。

サンプルデータ生成を行っている際、テストで使用しない関連テーブルを無条件で作っていたりしないか?

FactoryBot 使用の場合、trait で明示的にアソシエーションを作成するように変更していく、地味な作業をする必要があります。

before(:each)ではなく、before_allに変更できないか

なにもつけないと、before(:each) になるため、各 it の実行前に毎回呼び出されます。
この before の主な使い所と言えば、テストケースに対する事前データ作成などが主ではないでしょうか。
そのデータ作成は各テスト実行前に必ず毎回呼び出される必要があるものなのかをもう一度確認し、対応していきます。

例えばこのようなメール送信可否フラグを元に、メールが送信されるかどうかをテストするものがあったとします。

RSpec.describe User, type: :model do
  describe "#test_method" do
    let(:user){ create(:user) }

    before do
      user.update!(can_send_email: false)
    end

    it 'メールが送信されないこと' do
    end

    it 'ログが生成されないこと' do
    end
  end
end

このテストでは、it 実行前に毎回 user データを update する必要があるのかな?と疑問がわきますね。
このようなケースでは、beforebefore_all に変更するだけで、2 回実行されていた update 分を 1 回にできます。

ちなみに、before(:all) というのが FactoryBot で用意されていますが、これはトランザクション外での実行になりテスト実行後も clean しない限りはデータが残ってしまいます。
各テストケースを独立した状態で実行できなくなり、謎エラーにつながる懸念があります。

なので、ここでは使わず、test-prof が用意してくれている before_all を使います。
これを使うとトランザクション内で実行してくれるので、DB のロールバックなども行ってくれます。

let, let!, let_it_beの使い分け

let はそれが呼び出された時に実行されます。
let! は各テストブロック実行前に実行されます。before(:each) での実行です。
let_it_be という test-prof が用意してくれているものを使うと、let! と同じようにテストブロック実行前に実行されますが、before(:all) での実行となるため、一度の呼び出しで抑えることが出来ます。

なので、ここで速度上ボトルネックになる可能性が高いのは let! ですね。
これを letlet_it_be に置き換えられるかを検討します。
定数定義は雑にやってしまいがちですが、きちんと違いを把握した上で適切に使い分けることが大切です。

letを使用したケース

RSpec.describe User, type: :model do
  describe "#test_method" do
    let(:user){ create(:user) }

    it do
      expect(user)to eq true
    end

    it do
      expect(result)to eq false
    end
  end
end

user が呼び出されているのは 1 回のみなので、1 度だけ user の create が行われます。

let!を使用したケース

RSpec.describe User, type: :model do
  describe "#test_method" do
   let!(:user){ create(:user) }

    it do
      expect(user)to eq true
    end

    it do
      expect(result)to eq false
    end
  end
end

各テストブロック実行前に実行されるため、2 回 user が create されます。

let_it_beを使用したケース

RSpec.describe User, type: :model do
  describe "#test_method" do
   let_it_be(:user){ create(:user) }

    it do
      expect(user)to eq true
    end

    it do
      expect(result)to eq false
    end
  end
end

各テストブロック実行前に一度だけ実行されるため、1 回だけ user が create されます。

テストケースを一つにまとめる

例えば before 内でメソッドの実行結果を検証するテストなどを行っているパターンでは、メソッドの実行に時間がかかっていると it の分だけテスト実行に時間がかかってしまうので、1 つの it にまとめるといった作業をすることによって高速化できます。

ただし、ここはテストの可読性とのトレードオフになる可能性はあるので、どちらを取るかはケースバイケースです。

個人的には、テストケースを it 1 つにまとめたとしてもそこまで可読性は低くならず、落ちた箇所も行数をみれば明確なので、これをすることによって大幅に速度改善見込める場合はやってしまって良いものかと思います。

謎のSleepついてないか?

ないだろうと思いつつ、覗いたら隠れてたりするパターンがありますね。
外部通信をしている箇所など、これが隠れている事が多いので、目を凝らして確認し、テスト実行時は sleep 処理を行わないように変更を加えていくなどの対応が必要です。(slee)

これらを行った結果、テスト実行時間が約半分になりました

平均 1 時間半 → 平均 45 分
これらの DB アクセスに関連する部分を見直すだけでも、テスト実行時間を約半分にできました。

テストの実行環境を変える

遅いテストの改善が進んだ後は、お金の力を借り、実行環境をより良いものに変えていきます。

当たり前ですが、マシンスペックをあげて並列実行数を増やすだけでも大分改善がされると思います。
ここはお金との相談なので、一番コスパよく求めている実行環境がどこで手に入るかを調査し、実行環境を変えていくことになります。

弊社は引き続きテストの改善は進めておりますが、同時にこちらの対応も進行中です。

FLAKYなテストをなるべく減らす

時限爆弾式に発火するテストや、10 回に 1 回落ちるテストなど、ランダム落ちテストが紛れ込んでいるケースはかなり多いと思います。
これがあると、全然関係ないところでテストが落ち、実行→失敗→再実行→成功というルートをたどることになり、無駄な時間が発生してしまいます。
テストの実行時間が 1 時間とかあると、毎度やり直しするのはストレスが溜まっていきますね。

なので、これをチーム全体で協力しながら潰していく必要があります。
ライブラリを使って洗い出し、修正を書けていく方法もありますが、我々はシンプルに日々の実装の中で CI 通らずランダム落ちテスト見つけたら、そこでチケット化したりその場で対応したりなどして撲滅していくアプローチを取っています。

時間を扱うテストや、順序保証すべきではないところで順序保証をしているテストなどでこれがよく起こります。
例えば、expect(result)to eq(['aaa', 'bbb']) は、配列の順序も一致している必要があります。
しかし、この順序を保証する必要はなく中身が一致していることだけを確認したい場合は、expect(result)to match_array(['aaa', 'bbb'])eqmatch_array に変更すれば OK です。

改善も大事だが、維持していくことも大事

個人的にもパフォーマンス改善はすごく楽しい作業ですが、改善と同時に日々コードを書いている我々が気をつけて維持していくということもすごく大切です。

社内で気をつけるべきことのドキュメントを知見として展開したり、レビュー時に気を抜きがちなテストコードのレビューもしっかりと行うなどして、テストコード量が増えても、おそすぎるテストを生み出さない努力をしていく必要があります。

後からテストを書いていくフローですと、人によっては速くテストを通したいお気持ち優先になることもあるかと思います。

そのような場合、とにかくテストを通す事第一優先でコードを記述していき、万全を期してデータ作成をした結果、無駄なデータ操作が発生する可能性を秘めています。

当たり前ですが、人間を信じず、しっかりとその処理対象が動作している事を確認する目的で行っている行為ですので、100%の解像度でテスト含めしっかりと書いていくことが大切です。
テストケースが完璧に定義できない = 100%理解できないということになりますので、テスト対象のリファクタを行ったりするなどしてシンプルに保つことも大切です、

とはいえ、全て個人とチームの意識で済ませるのも大変なので、アンチパターンのチェックなどは CI に組み込んで機械に指摘いただくような物を取り入れる or 作っていくとキレイな世界が待っているかも知れません。

◯秒以上超えるテストでは CI 通さないなどのチェックを加えていっても良いですね。

遅いテストを生み出さず、引き続き改善をしていくことでみんなの笑顔を守っていきたいと思います。

関連記事

https://zenn.dev/offers/articles/20220526-rspec-tips-description
https://zenn.dev/offers/articles/20220421-rspec-merit-and-demerit-and-tips

Offers Tech Blog

Discussion