厄介な Flaky Test を解消した話
はじめに
コードを変更せずに実行結果が変化するテストに出会ったことはありますか。
そのような不安定なテストのことを「Flaky Test」と呼びます。
今回は、弊社で今までに発覚したFlaky Testのパターンと、その解消方法を紹介します。
本題
目次
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!
end
︙
end
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では、積極的にエンジニアを採用しています。
少しでも興味がありましたら、以下の記事をご覧ください。
また、下記はSUPER STUDIOで年に一度開催されるKICKOFFイベントにて、社内表彰されたエンジニアの受賞インタビューです。SUPER STUDIOのエンジニア組織についてより理解を深められる内容となっておりますので、ぜひご一読ください。
Discussion