🍁

厄介な Flaky Test を解消した話

2024/10/04に公開

はじめに

コードを変更せずに実行結果が変化するテストに出会ったことはありますか。
そのような不安定なテストのことを「Flaky Test」と呼びます。
今回は、弊社で今までに発覚したFlaky Testのパターンと、その解消方法を紹介します。

本題

目次

  1. 並び順を比較しないマッチャーに変更
  2. travel_to で時間固定
  3. Gem が提供しているテストクラスを呼び出す
  4. キャッシュに依存しない形のスタブを定義
  5. 外部 API を叩いている部分のスタブ化


1 並び順を比較しないマッチャーに変更


比較対象の順番が固定されていない場合、要素の順番も厳密にチェックしてしまうテストは Flaky Test の温床となります。

エラーログ

Failures:

  1) POST /admin/csv_export 「検索結果すべてを処理対象にする」にチェックを入れた場合 存在するすべての取引に対するジョブが作成されること
     Failure/Error:
       ︙
        expected: ([1, 2, 3])
             got: ([3, 1, 2])
     # ./spec/requests/admin/csv_export_spec.rb:27:in `block (3 levels) in <top (required)>'

Failed examples:

rspec ./spec/requests/admin/csv_export_spec.rb:20 # POST /admin/csv_export 「検索結果すべてを処理対象にする」にチェックを入れた場合 存在するすべての取引に対するジョブが作成されること

改善方法

配列内の要素順を考慮しない場合、以下のマッチャーを使用しましょう。

今回対応した場所は with の引数内のため、 array_including を使用して解消しました。

context '「検索結果すべてを処理対象にする」にチェックを入れた場合' do
  it '存在するすべての取引に対するジョブが作成されること' do
    captured_summary = create(:payments_summary, :captured)
    refunded_summary = create(:payments_summary, :refunded)
    dispute_closed_summary = create(:payments_summary, :dispute_closed)

    expect(BulkCsvReport).to receive(:run).with(
-     [captured_summary.id, refunded_summary.id, dispute_closed_summary.id]
+     array_including([captured_summary.id, refunded_summary.id, dispute_closed_summary.id])  
    ).once
    ︙
  end
end


2 travel_to で時間固定


現在日時でデータを作成し、現在日時と比較するテストでよく失敗が起こります。
データ作成後に比較する処理まで1秒でも経過してしまうと、以下のように期待値がずれてしまうからです。

エラーログ

Failures:

  1) GET /api/admin/products Success lighterオプションを有効で指定する場合 is expected to eq "2023/03/14 04:05:05"
      ︙
     
      expected: "2023/03/14 04:05:05"
           got: "2023/03/14 04:05:06"
     
      (compared using ==)
     # ./spec/requests/api/admin/products/index_spec.rb:214:in `block (4 levels) in <top (required)>'

Failed examples:

rspec ./spec/requests/api/admin/products/index_spec.rb:177 # GET /api/v2/admin/products Success lighterオプションを有効で指定する場合 is expected to eq "2023/03/14 04:05:05"

改善方法

travel_to を使用して時間を固定しましょう。

context '検索該当する3件取得する' do
  it {
-   products = create_list(:product, 3)
+   products = []
+   c_t = Time.current
+   3.times do
+     travel_to c_t do
+       products << create(:product)
+     end
+     c_t += 1.day
+   end
    params = { 'q[product_id_in]' => products.map(&:id).join(',') }
    get '/api/admin/products.json', params: params, headers: headers

    products.sort_by{ |x| [-x.created_at.to_i, x.id] }.each_with_index do |product, idx|
      expect(parsed_body['data'][idx]['attributes']['id']).to eq(product.id)
    end
  }
end


3 Gem が提供しているテストクラスを呼び出す


ログインしているユーザーの ID が想定とは違った値を返す事象に遭遇しました。
弊社では Devise Gem を使用していますが、ログイン時に別の Request を掴んでしまっているのではないかと考えました。

エラーログ

Failures:

  1) POST /api/admins/sign_in 正しいバラメータを指定した場合 is expected to eq 128
     Failure/Error: expect(parsed_body['id']).to eq(admin.id)
     
       expected: 128
            got: 1
     
       (compared using ==)
     # ./spec/requests/api/admins/sessions/create_spec.rb:15:in `block (3 levels) in <top (required)>'

Failed examples:

rspec ./spec/requests/api/admins/sessions/create_spec.rb:10 # POST /api/admins/sign_in 正しいバラメータを指定した場合 is expected to eq 128

間違えたリクエストを掴んでいる場所

    142: def warden
 => 143:   request.env['warden'] or raise MissingWarden
    144: end

改善方法

テスト用のログイン処理を使用することで解消しました。

  # spec/rails_helper.rb
  RSpec.configure do |config|
    ︙
    config.include Warden::Test::Helpers
    config.before do
      Warden.test_mode!
    end
    config.after do
      Warden.test_reset!
    endend


4 キャッシュに依存しない形のスタブを定義


弊社では、設定値管理に Gem の rails-settings-cached が使われています。
こちらは大元の Setting モデルを設定値管理モデルに継承して使用するのですが、モデル経由で直接値を更新する場合、 Rails.cache が呼ばれてキャッシュされた値を参照する仕組みとなっています。
そのため、テストで設定値管理テーブルの値を直接更新してもキャッシュされた古い値を参照してしまい、更新された値で書いているテストは Flaky Test となっていました。

エラーログ

Failures:

  1) EmailTemplate#body use_sms が true の場合 body_sms が返却されること
     Failure/Error: expect(email_template.body(customer.email, true)).to eq email_template.body_sms
     
       expected: "subject6_body_sms"
            got: ""
     
       (compared using ==)
     # ./spec/models/email_template_spec.rb:48:in `block (4 levels) in <top (required)>'

Failed examples:

rspec ./spec/models/email_template_spec.rb:47 # EmailTemplate#body use_sms が true の場合 body_sms が返却されること

改善方法

  • テストを実行する前後でキャッシュを削除する
  • スタブを作る

弊社では「スタブを作る」形で対応しました。

before

    let(:customer){ create(:customer) }

    context 'use_sms が true の場合' do
      it 'body_sms が返却されること' do
        EcForce::BaseInfo.first.update(use_sms_auth: true)
        Setting['twilio'].update(activate: '1')
        email_template.update(body_text: '', body_html: '')
        expect(email_template.body(customer.email, true)).to eq email_template.body_sms
      end
    end

after

    let(:customer){ create(:customer) }

    context 'use_sms が true の場合' do
+     let(:twilio_copy_with_activate) {
+       twilio = Setting['twilio'].dup
+       twilio[:activate] = '1'

+       twilio
+     }

+     before do
        BaseInfo.first.update(use_sms_auth: true)
+       allow(Setting).to receive(:[]).with('twilio').and_return(twilio_copy_with_activate)
+     end

      it 'body_sms が返却されること' do
        email_template.update(body_text: '', body_html: '')
        expect(email_template.body(customer.email, true)).to eq email_template.body_sms
      end
    end


5 外部 API を叩いている部分のスタブ化


こちらは、Slack に通知する処理で失敗する Flaky Test でした。
API へのリクエスト過多で SlackNotifyError が発生していたと思われます。

エラーログ

5) CustomizeSections#make_settings_from_schema 異常系 schemaタイプがselectorの場合 optionsもoptions_modelも存在しない場合 behaves like raise_DefaultValueError_with_message is expected to raise CustomizeSections::DefaultValueError
     Failure/Error:
       expect do
         instance.merge_settings(block)
       end.to raise_error(ThemeServices::CustomizeSections::DefaultValueError) do |error|
         expect(error.message).to eq error_text
       end
     
       expected CustomizeSections::DefaultValueError, got #<Slack::Notifier::APIError: The slack API returned an error: {"retry_after":1,"ok":false,"error":"ra...heck the "Handling Errors" section on https://api.slack.com/incoming-webhooks for more information
       > with backtrace:
         # /home/circleci/ec_force/lib/notify_slack.rb:24:in `run'
         # /home/circleci/ec_force/app/services/theme_services/customize_sections.rb:448:in `raise_convert_error'
         # /home/circleci/ec_force/app/services/theme_services/customize_sections.rb:420:in `convert_default_value'
         # /home/circleci/ec_force/app/services/theme_services/customize_sections.rb:322:in `block in default_settings_hash'
         # /home/circleci/ec_force/app/services/theme_services/customize_sections.rb:319:in `each'
         # /home/circleci/ec_force/app/services/theme_services/customize_sections.rb:319:in `each_with_object'
         # /home/circleci/ec_force/app/services/theme_services/customize_sections.rb:319:in `default_settings_hash'
         # /home/circleci/ec_force/app/services/theme_services/customize_sections.rb:169:in `block in merge_settings_data'
         # /home/circleci/ec_force/app/services/theme_services/customize_sections.rb:155:in `each'
         # /home/circleci/ec_force/app/services/theme_services/customize_sections.rb:155:in `merge_settings_data'
         # ./spec/services/theme_services/customize_sections_spec.rb:598:in `block (6 levels) in <top (required)>'
         # ./spec/services/theme_services/customize_sections_spec.rb:597:in `block (5 levels) in <top (required)>'
     Shared Example Group: :raise_DefaultValueError_with_message called from ./spec/services/theme_services/customize_sections_spec.rb:674
     # ./spec/services/theme_services/customize_sections_spec.rb:597:in `block (5 levels) in <top (required)>

改善方法

Slack API 部分をスタブにしました。

  shared_examples :raise_DefaultValueError do
+   let(:slack_notifier){ instance_double(Slack::Notifier) }
+   before do
+     allow(Slack::Notifier).to receive(:new).and_return(slack_notifier)
+     allow(slack_notifier).to receive(:ping)
+   end

    it {
      expect do
        instance.merge_settings(block)
      end.to raise_error(ThemeServices::DefaultValueError) do |error|
        expect(error.message).to eq error_text
      end
    }
  end

まとめ

Flaky Test は決まったパターンに落とし込めるものだけではなく、時にはアプリケーションや Gem の性質上発生する場合もあります。
不安定なテストを改善するには気力が必要ですが、安定したテストを実施することはシステム全体の品質に繋がるでしょう。
これらの情報が皆さまのお役に立てば光栄です。

SUPER STUDIOの採用について

SUPER STUDIOでは、積極的にエンジニアを採用しています。
少しでも興味がありましたら、以下の記事をご覧ください。
https://hrmos.co/pages/superstudio/jobs/0000400
https://hrmos.co/pages/superstudio/jobs/0000414
https://hrmos.co/pages/superstudio/jobs/0010025

また、下記はSUPER STUDIOで年に一度開催されるKICKOFFイベントにて、社内表彰されたエンジニアの受賞インタビューです。SUPER STUDIOのエンジニア組織についてより理解を深められる内容となっておりますので、ぜひご一読ください。
https://www.wantedly.com/companies/super-studio/post_articles/497997
https://www.wantedly.com/companies/super-studio/post_articles/487617

SUPER STUDIOテックブログ

Discussion