見出し画像

Railsプロジェクトで発生するflaky testの傾向と対策

はじめに

はじめまして。この一年ほど学び放題のDevExpチームでバックエンド開発のお手伝いをしてるmasa_iwasakiです。
今回の記事では、学び放題のバックエンドとして使われているRailsアプリケーションで実際に発生していたflaky testの事例を中心に、一般的なRailsアプリケーションで発生しがちなケースをまとめました。
個人的に、flaky testの発生パターンは割と定番化している印象を持っています。たとえば、以下の記事に記載されている内容と本記事の内容は共通するものが多いです。
Ruby: テストを不安定にする5つの残念な書き方(翻訳)|TechRacho by BPS株式会社
しかし、同じ原因で発生したflaky testであっても、コードベースが異なれば発生の仕方は変わりますし、なにより原因の調査にかかる手間は大きく異なります。本記事がflaky testに遭遇してトラブルシューティングする際のヒントになれば幸いです。
以下、原因調査及び修正・対策が簡単なものから紹介していきます。なお、学び放題のバックエンドではRSpecを利用しています。

配列の順序が変わることで失敗するテスト

配列に含まれる要素は確定しているけれど、並び順が変わる可能性があるテストがあるケースで、テストで想定していた並び順と異なる結果が返って来て失敗するパターンです。

対策1:そもそも要素の順序が変わらないようにする

まず、このパターンの原因としてよく見かけるのは、DBからデータを取得する際に order を明示的に指定していないケースです。この場合はアプリケーション側のコードで order を明示的に指定することで対応するのが適切でしょう。

対策2:array_including_matcherの利用

一方で、意図的にランダムな順番で要素を返す実装をテストしたいこともあるでしょう。このケースではspec側で結果をソートして並び順を固定するという方法もありますが、 array_including matcher を利用することもできます。特に要素にhashを含む場合は hash_including matcher と組み合わせることでわかりやすくspecを記述することができます。

Tips: CIサーバーでのスクリーンショット&HTML保存を忘れずに

このパターンによるテスト失敗はまだシンプルなほうですが、system spec(もしくは integration test) で発生した場合に失敗時のスクリーンショットとHTMLが保存されていないと発見が困難になりがちです。
スクリーンショットだけあれば十分に思えるかもしれませんが、flashに表示されるメッセージからバリデーションエラーがあったことを確認する必要があります。このとき、flashに表示されるメッセージが複数あったりするとバリデーションエラーが発生したというメッセージがスクリーンショット内で視覚的に確認できないというケースもあります。そういった場合、保存されているHTMLの中身を確認するということになります。
スクリーンショット及びHTMLの保存は、この後紹介するパターンでも調査の際に必要となるケースが多々ありますので、CI上でスクリーンショットとHTMLを保存できるように設定をしておきましょう。設定をした後は意図的にCI上でテストを失敗させて、必要なフォントがインストールされていて文字が正しく表示されること、記録される画面サイズが適切かどうかも確認しておきましょう。

時刻を固定していないことで失敗するテスト

RSpecはデフォルトでテスト実行時の時刻を固定しません。これにより、以下の要因でflaky testが発生することがあります。

  • テスト実行中に時間が進んで期待した時刻と異なる時刻が返ってくる

  • 休日・祝祭日を考慮するコードのテストを書いたときに想定していないパターンの日付で失敗

具体例

前者のパターンは特に実行時間の長いrequest specやsystem specで発生することが多いと感じますが、タイミング次第ですのでmodel specなどでも発生する可能性はあります。このパターンの問題は失敗したテストのエラーログから原因がわかりづらいケースがあることです。
後者のパターンは特に営業日計算関連で発生することが多いかもしれません。祝祭日が少ない月に開発していて問題がなかったけれど、年末やゴールデンウィーク、シルバーウィークなどが近づいてきたときに突然発生します。条件が揃うとずっと失敗し続けるため、厳密にはflaky testと呼ぶべきものではないかもしれません。

対策:時刻を固定する

どちらも解決方法はRSpec実行時に時刻を固定することです。Rails6以降であれば RSpec でも ActiveSupport::Testing::TimeHelpers の freeze_time を利用することで現在時刻をテスト開始時に固定できます。特定の日付に変更する場合は travel_to メソッドで指定ができます。
なお、後者のパターンに関しては以前関わっていたプロジェクトで、土日や祝祭日に実行すると失敗するというケースを見たことがあります。CIの設定で土日に実行するようにしてみると失敗するテストがあるかもしれません。

Tips: timecopが必要なケース

Rails6より前から開発が続いているプロジェクトでは、時刻を固定するために以前から timecop を利用しているところがあるかもしれません。
timecopはネストした呼び出しをサポートしていますが、多くのケースでは ActiveSupport::Testing::TimeHelpers で十分と思われます。

時刻を固定することで失敗するテスト

今度は反対に時刻を固定することで発生するエラーです。

具体例

時刻を固定することで影響を受けやすいのが ActiveRecordを継承したクラスの created_at と updated_at です。時刻を固定している場合、created_at と updated_at の値も固定されてしまいます。そのため、このカラムでソートしているようなケースではテストで想定している順番と異なる結果が返ってくる場合があります。

対策:ケースバイケース

修正方法は、created_atやupdated_atが実際のデータでも発生しうるかどうかで変わってきます。実環境でも発生する可能性が僅かながらでもあるのであればcreated_atとupdated_at以外にソート対象にするカラムを追加するという対策が良いでしょう。
もし実環境で発生しないのであれば、spec側で明示的に created_at を指定したり、上述の array_including matcher の利用を検討することになるでしょうか。

ランダムな値の重複でUNIQUE制約違反が発生し失敗するテスト

DBでUNIQUE制約が設定されているカラムに一意にならない値を設定して保存しようとすると、制約違反になって例外が発生します。当たり前ですね。
ですが、うっかりこの制約が課されているカラムにランダムな値を設定するようになっていると、偶然値が重複したときにのみ失敗するflaky testになります。実装開始時はUNIQUE制約が課されていなかったカラムにランダムな値を設定していたけど、後になってUNIQUE制約がついたときにそのまま放置されつづけていたりすると発生します。

対策:常に一意になる値を設定する

修正はシンプルで、一意な値が設定されるようにすれば完了です。FactoryBotでfactoryを生成している場合はsequence を利用することで一意な値を設定できます。アプリケーションのコードで動的に値を生成するような場合は、メソッドをスタブして指定した値を返すことで解決できます。

ランダム文字列がテスト対象の文字列と偶然マッチする

テストで使う任意の文字列、特に文章対してランダムな文字列を生成して割り当てるというのは良く行われていることではないでしょうか。

具体例

以下のコードでは ffaker を利用してFFaker::Lorem.sentence でランダムなアルファベット文字列を生成しています。

FactoryBot.define do
  factory :post do
    content { FFaker::Lorem.sentence }
  end
end

これだけなら問題はないのですが、system specで posts.content に対して abc という部分文字列を含むレコードを検索するテストを書くとどうなるでしょうか。

visit posts_path
fill_in 'Enter keywords', with: 'abc'
click_on 'Search'

# 'abc' を含むPostが存在しない前提のexpectationだが...
expect(page).not_to have_css('.post')

上記のテストは abc という文字列を含むレコードがないことを想定していますが、ランダムに生成された文字列に abc が含まれていると失敗します。当然ですね。

現実はもっと複雑

上記の例であればわかりやすいのですが、実際のflaky testはもっと多様です。たとえば、氏名に関わるものなどでは以下のようなケースが多く見られます。

  • system specで単位として使用している口(くち) などの文字列が生成された氏名に偶然マッチしてしまう

  • 複数のユーザーで生成した苗字や名前が偶然マッチしてしまう

このパターンに該当するエラーは原因追求が難航しがちです。特にsystem specでは失敗したことは分かっても、どういった文字列が生成されたかわからないケースもあります。先ほどの具体例では abc が含まれたレコードがあればスクリーンショットにそのレコードの情報が記載されますが、検索対象となったカラムが検索結果に表示されない場合などは簡単に特定はできないため、エスパー力を発揮したり怪しいところにログを仕込むなどして見つけるしかないこともあります。

対策:ランダム文字列に頼らない

まず不要なランダム文字列の利用を避けることが第一に上がります。ランダム文字列を利用することで想定していない入力値に対するテストができるという考え方もあるのかもしれませんが、flaky testとなってしまっては本末転倒です。特にsystem specでは上記のように原因調査が困難になる可能性が高いです。そもそも、system specは実行コストが高いため、ランダムな要素に頼るよりは正常系と異常系のデータを明示的に定めてテストするほうが効率的でしょう。
多様な入力値に対するチェックをしたいのであれば、E2Eテストではなくユニットテストでテストデータを用意して行うほうが良いでしょう。どうしてもランダムな入力値でテストしたいのであれば、テスト失敗時に生成されたランダムな値が分かるようにmatcherとexpectationを利用したり、ログを残すようにしましょう。

Capybaraのページ読み込みを待たないことで発生するエラー

system specでは click_on などを実行した後、すぐに画面が切り替わることを前提にexpectationを記載しているテストが失敗することがあります。

具体例

実例に近いサンプルコードで説明します。

expect(page.all('.plan-name').map(&:text)).to eq(['GLOBIS 学び放題 12ヶ月', 'GLOBIS 学び放題 6ヶ月'])

click_on '昇順でソート'

expect(page.all('.plan-name').map(&:text)).to eq(['GLOBIS 学び放題 6ヶ月', 'GLOBIS 学び放題 12ヶ月'])

このコードは 昇順でソート というボタンをクリックしたときに、要素がソートされて正しい順序で並び替えられることを想定しています。
このとき、画面遷移前後で .plan-name が存在するため、サーバーからのレスポンスが届く前に2つめのexpectationが実行されてしまうとテストが失敗します。このケースは一般的に開発環境のほうが高速にテストが動作するため、開発環境ではほとんど発生せず、CIではより頻度高く発生するということが多いです。

対策:遷移後の画面にだけある要素をチェックする

このパターンに対しては画面遷移前後で変化する要素をチェックすることが必要になります。いくつかの方法がありますが、まずはHTML中に含まれるテキスト・セレクタをチェックする方法から見ていきましょう

# 解決策1: 遷移後の画面にのみある要素・テキストをチェックする

expect(page.all('.plan-name').map(&:text)).to eq(['GLOBIS 学び放題 12ヶ月', 'GLOBIS 学び放題 6ヶ月'])

click_on '昇順でソート'

# テキストを待つ場合
expect(page).to have_content 'ソート:昇順'
# セレクタを待つ場合
expect(page).to have_css '.plans.sort_finished'

expect(page.all('.plan-name').map(&:text)).to eq(['GLOBIS 学び放題 6ヶ月', 'GLOBIS 学び放題 12ヶ月'])

この方法はJSで非同期的に画面が変更される場合にも利用出来ます。特にテキストに差分がない場合はJSで特定の要素のクラス名やdata属性の値を変更することでJSの完了を検出することができます。
URLが変更される場合は以下の方法があります。

# 解決策2: URLで判別

expect(page.all('.plan-name').map(&:text)).to eq(['GLOBIS 学び放題 12ヶ月', 'GLOBIS 学び放題 6ヶ月'])

click_on '昇順でソート'

# URLが変わる場合はこのアプローチが利用可能
expect(current_url).to eq sorted_plans_path

expect(page.all('.plan-name').map(&:text)).to eq(['GLOBIS 学び放題 6ヶ月', 'GLOBIS 学び放題 12ヶ月'])
# 解決策3:クエリパラメーターで判別

# RSpec example でのメソッド定義はRSpec全体に適用されるので代わりにlambdaを使っている
have_sort_query_parameter = -> do
  URI.decode_www_form_component(URI(current_url).query).include? 'order=asc'
end

expect(page.all('.plan-name').map(&:text)).to eq(['GLOBIS 学び放題 12ヶ月', 'GLOBIS 学び放題 6ヶ月'])

click_on '昇順でソート'

expect(have_sort_query_parameter.call).to be true
expect(page.all('.plan-name').map(&:text)).to eq(['GLOBIS 学び放題 6ヶ月', 'GLOBIS 学び放題 12ヶ月'])

クエリパラメーターをチェックするメソッドを繰り返し利用したい場合は、 spec/support 以下にヘルパーモジュールを定義してそちらに実装するほうが良いでしょう。

外部API呼び出しで発生するエラー

外部APIを実際に呼び出しているテストがある場合、ネットワークエラー等によりテストが失敗することがあります。flaky testかどうかに限らず、テスト実行時には外部APIへのアクセスを何らかの方法でスタブすることで、実際のAPIへアクセスしないようにする方法が定番です。
Railsを利用したアプリケーションのテストでネットワークアクセスをスタブするにはいくつかの方法があります。

対策1:gemが用意しているテスト用の機能を利用する

外部APIへのアクセスを行うgemを利用している場合、gem自体がスタブ機能を持っていることがあります。gemを利用している場合は先にドキュメントを調べると良いでしょう。
たとえば、geocoeder ではテスト用にレスポンスをスタブする機能があります。この機能を利用すると、デフォルト値では通常利用されない Etc/UTC を返すようにして意図せず利用した場合はエラーになりやすくしておいて、実際に利用箇所をテストするspecでは明示的にスタブしたりできます。

# rails_helper.rb
Geocoder::Lookup::Test.set_default_stub(
      [
        {
          # NOTE: ダミー値としてすぐ分かるように通常返って来ないタイムゾーンを設定
          'timezone' => 'Etc/UTC'
        }
      ]
    )

# 任意のspec
before do
  Geocoder::Lookup::Test.add_stub('192.168.0.1', [{ 'timezone' => 'Asia/Tokyo' }])
end

gemを利用していない、もしくはgemでスタブする方法が用意されていない場合、他の方法を検討する必要があります。

対策2:webmockの利用

webmock は対象となるHTTPクライアントに対して、指定したホストへのネットワークリクエストをスタブすることが出来ます。様々なHTTPクライアントに対応していることが特徴です。比較的シンプルなHTTPレスポンスをスタブしたい場合に適しているでしょう。

対策3:vcrの利用

vcr は実際に外部APIを操作した際のHTTPリクエストに対応するHTTPレスポンスをファイルに保存してくれるツールです。実際のAPIレスポンスを利用できるため、複雑なレスポンスが返ってくる外部APIに対して利用すると効果的です。

注意点

いずれの方法を利用する場合でも、外部APIの仕様が変わってしまうとスタブが返すレスポンスは実際のAPIアクセスと異なってしまいます。実際に変更があった場合には先に本番環境でエラーが発生することが多いと思われますが、古いスタブの設定がspecに残ったままになる可能性もあるため、定期的な確認をすると安全です。

日付におけるJST↔UTCの変換ミスで発生するエラー

おそらく、この記事を読んでいる方は扱っているシステムで扱っているタイムゾーンはJSTのみという方が多いのではないでしょうか。そのようなシステムでも何らかの要因でタイムゾーンをUTCで取り扱っている箇所があるかもしれません。JSTとUTCの変換をミスしてしまうと、日時から日付だけを取得する場合に日本時間の日付と異なる日付が返って来ます。
RailsでタイムゾーンをJSTだけ扱うように設定していれば発生することはほとんどないと思いますが、なんらかの歴史的経緯でUTCとJSTの間で相互変換しなければならないような構成の場合、変換を忘れると発生します。この場合、時刻を固定してないspecでは9時以前もしくは15時以降にのみ失敗するテストが発生します。

対策

修正方法はもちろん時刻の変換漏れを直すことになります。

まとめ

本記事では実際に発生したflaky testをベースにパターン別にまとめてみました。皆さんの開発現場で何かお役に立つ情報が提供できたら幸いです。

一緒に働く仲間を募集しています!

グロービスの開発組織では、一緒に働けるエンジニアを探しています!
まずは、カジュアル面談を通して、あなたに合う組織かどうか確かめてみませんか?
https://recruiting-tech-globis.wraptas.site/

この記事が気に入ったらサポートをしてみませんか?