おもしろwebサービス開発日記

Ruby や Rails を中心に、web技術について書いています

Railsアプリケーションのテストが失敗したときにどうしたらいいのか

本エントリはiCARE Advent Calendar 2020の25日目です。

僕はiCARE社内で技術顧問としていろんなことをやっていますが、そのうちの一つとしてRailsアプリケーションのテスト改善があります。具体的には「たまに失敗するテスト」で難しいものがあったときに調査して解決をしています。この「たまに失敗するテスト」はiCAREに限らず、ほとんどの会社が苦しめられているのではないでしょうか。僕のお手伝いしている他の会社でも同様なので、複数社の社内ドキュメントツールに「こういうふうに調査するといいですよ」という文章を書いています。しかしこれらはどれも社内wikiどまりで、現時点で公開されている文章が存在していません。

そこで今回この場を借り「失敗したテストがあったときにどうしたらいいのか」の決定版を書いて、今後は「これ読んでおいてください」で済ませたいなと思っています。

前提

RailsアプリケーションのテストをRSpec、Capybara、Selenium、headless chromeを利用して書いている

まず、悩むのをやめよう

テストが失敗したとき、その原因になり得るものはたくさんあります。

  • テストコードが悪い
  • アプリケーションコードが悪い
  • テストの環境が悪い

テストコードやアプリケーションコードそのものが原因であれば比較的簡単に修正することができるはずです。しかし、それ以外に原因があったときに悩んでしまう人が多い気がします。しかし悩んでも解決することはまずありません。大事なのは必要な情報を収集することです。悩む前にありったけの情報をかき集めましょう。

  • binding.prybinding.irbbyebugなどのブレークポイントを活用し、該当するテストコードやアプリケーションコードが想定通り動いているかを確認する

  • E2Eのテストであれば、スクリーンショットをとりテスト失敗時の画面の状況を見る

  • テスト用のログ(例: log/test.log)を確認してサーバが想定通りリクエストを受け取り、レスポンスを返しているかを確認する
  • CIでたまに失敗するテストであれば、ローカルで再現するか試してみる
    • 例えば次のように書いて rspec --fail-fast ファイル名:行番号 とすると1000回テストを実行して最初に失敗したところで止まります。これが全部通るのであれば、順番依存などで失敗するテストの可能性が高いです。
1000.times do
  it do
    # たまに失敗するテスト
  end
end

テストがどのように動いているかを学ぼう

ログやスクリーンショットを見てもよくわからん、となるとき、そもそもテストがどう動いているかについてよく知らないのであたりがつけられない可能性があります。テストはあくまで本番を模して確認するためのもので、本番とは違ったテクニックで実現されています。それらのテクニックを把握してると、失敗の原因を推測できる可能性が高まります(もちろん本番環境がどのように動いているのか知るのも大事です)。

以下抑えておいてほしいテストの仕組みについて書いています。

テストの設定を知ろう

テストは基本的に次の3つのファイルで設定されているはずです。特に「開発環境だとちゃんと動いているように見えるんだけどテストだとなぜかうまくいかない」ようなケースではtest.rbとdevelopment.rbをみて、どのような差分があるかチェックしてみるとよさそうです。

  • config/environments/test.rb
  • spec/rails_helper.rb
  • spec/spec_helper.rb

例えばActive Jobは各環境で別々のアダプタを使っている事が多いです。本番時にはsidekiqを利用するけれど、開発時にはasyncアダプタ(スレッドを利用した非同期実行)を利用し、テスト時にはtestアダプタ(テスト用のキューにジョブの情報を入れるだけでジョブ自体は実行しない)を選択するような設定が一般的です。

このように、テストを実行するために本番や開発時とは違う形にしている箇所はいくつかあるはず。思わぬ箇所の設定が影響していることがあるので、設定のそれぞれの行がなにを表しているのか説明できるようになっているのが望ましいです(大変だけど)。

各種設定の内容はRailsガイドにまとまっています。読んだけどよくわからない、という場合はお近くの技術顧問に質問ください。

テスト中に行った変更は必ずもとに戻そう

複数のテストを実行するときは、それぞれ独立した環境でテストを実行したいところです。例えばあるテスト中にレコードをinsertしたら、テスト終了時に何らかの形でDBをロールバックしてから次のテストを実行しないとテストが不安定になってしまいます。

RailsはデフォルトでDBのトランザクションを利用してロールバックする仕組みを持っています。最近のRailsプロジェクトだとすべてその方式(トランザクションを利用してロールバックする)で統一できますが、昔のRailsプロジェクトだとfeature spec用にdatabase_cleanerdatabase_rewinderを利用してDBを毎回空にして対応しているケースもあります(詳細はコラム参照のこと)。

つまり、DBに関しては特に意識しなくても自動で元の状態に戻るようなっているはずです。またrspec-mocksによるモックの設定などもテスト終了時に自動でもとに戻してくれています。べんりですね。

しかし、ツール側で対応していないものは手動で元に戻さないといけません。例えば、csvファイルを出力するメソッドをテストする場合、作ったcsvファイルをテスト終了時に削除しないとファイルがずっと残ってしまいます。そのcsvファイルの有無で挙動が変わるようなアプリケーションコードがあったら、別のテストが失敗してしまうかもしれませんね。

このように、テスト中の変更をもとに戻さないと順番依存で失敗するテストが出来上がるので、意識して変更をもとに戻すようにしましょう。順番依存で失敗するテストは再現がなかなか難しいです。機械的に順番依存失敗テストを再現させる方法としてはrspec の --bisectオプションがあります(ローカルで大量のテストを--bisectするのは骨が折れるので、実際に適用できるケースは少なそうですが)。

参考: Rails tips: ランダムにコケるRSpecテストの修正に便利なbisect(翻訳)

[コラム] feature specと system specの違い

feature specでは、テストコード用のスレッドとテストサーバのスレッドが異なります。結果としてDBのコネクションが別々になり、テストコード用のスレッドでトランザクションを利用するとテストサーバ側のスレッドはそれを参照できません。つまりトランザクションによるロールバックの仕組みを利用できないことになります。なのでfeature specではそれぞれの変更を都度COMMITし、テスト終了時にTRUNCATE文やDELETE文でレコードを削除するのが通例でした。それを自動で行うためにdatabase_cleanerやdatabase_rewinderのgemが使われていました。Rails 5.1から利用できるsystem specではこの仕組をハックして各スレッド間で同一コネクションを使えるようにして、E2Eのテストでもトランザクションを利用できるようにしています。そのため、system specではdatabase_cleanerやdatabase_rewinderは必須ではなくなっています。

E2Eテストの仕組みを知ろう

E2Eのテストは実際にブラウザを動かしてテストするため他のテストよりも複雑で、デバッグも大変なことが多いです。まず、昨今の一般的なRailsのE2Eテストではheadless chromeを利用することが多いと思いますが、その際には次の3つのプロセスが起動します(アプリケーションサーバにpumaを使用していると仮定)。

  • capybara(selenium driver)とpuma
    • ※ capybara(selenium driver)とpumaは同一プロセスだが別スレッドで動いている
  • chrome driver
  • chrome

capybara がchrome driverのAPIを叩き、chrome driver が chrome のAPIを叩き、chromeはpumaにアクセスするという流れ。

それぞれが独立したプロセスで動いているので、テストを実行している最中に前のテスト関連のリクエストがpumaに届く、というケースがあります。ajaxで非同期にリクエストを投げる画面で起こりやすいです。これが原因でテストが時々落ちることがあります。

たとえばdeviseを利用しているプロジェクトでrequest specを実行するときに、deviseが提供するsign_inというヘルパメソッドを利用してログイン済み状態のリクエストを作ることがあります。このsign_inメソッドは、「次のリクエストをログイン済み状態として扱う」という挙動をします。これと先ほど説明した「テスト中に前のテストのリクエストが届く」を組み合わせるとたまに失敗するテストの一丁上がりです。前のテストのリクエストでsign_inメソッドによるログイン済み状態が解除され、request specが投げるリクエストが未ログイン状態になりテストが失敗します。

[コラム] chrome driverとchromeのバージョンを合わせる

chrome driverはバージョンごとにサポートしているchromeバージョンが違うので、ここが一致していないとE2Eのテストが全部失敗したりします。webdrivers gemを利用するとテスト実行時に自動でインストール済みのchromeに合ったバージョンのchrome driverをダウンロードしてくれるので積極的に使いましょう。

ただしwebdriversは並列テスト時に同時にchrome driverをダウンロードしようとして挙動がおかしくなることがあります。そのようなケースでは並列テストを実行する前に明示的にWebdrivers::Chromedriver.updateを実行してchrome driverをダウンロードすると回避できます。

capybaraの要素が現れる(or 消える)のを自動で待ってくれるのを知ろう

E2Eテストで問題になるのはajaxを利用している箇所とアニメーションを利用している箇所が多いのではないかと思います。

何かをクリックしたときにJavaScriptによって新しい要素が表示され、その新しい要素をクリックしたい場合は通常と変わらずclick_on '新しい要素'のように書きます。このときAPIを実行するなどでJavaScript中の処理が遅くclick_on実行時に新しい要素が表示されていないような場合、capybaraは一定時間待って再度要素を探す、ということを繰り返します。最大でCapybara.default_max_wait_time秒待ちます。デフォルトでは2秒ですが、5秒などにしているプロジェクトも多いのではないでしょうか。

これだけみるとJavaScript(ajax)を利用した画面でもなにも考えずにテストできるように思えますが、そんなことはありません。明示的に、何かが起きるのを待つためのコードを挿入しないとテストが失敗するケースがあります。

例えばモーダルダイアログがリンクの上に表示されているようなケースで「モーダルを閉じるボタンをクリックする→リンクをクリックする」という処理を書いたとします。このときはモーダルを閉じるときにすでにリンク自体は存在するので、先程書いた「要素が現れるのを待つ」は実行されません。なのでモーダルを閉じるのに時間がかかると、次のリンククリックに失敗します(上にモーダルが残っているのでクリックできない)。そのようなケースでは、リンクをクリックする前にモーダルが閉じられていることを明示的に確認し、もしモーダルが閉じられていなかったら待つようにするとうまくいくでしょう。例えば page.has_no_css?('.modal') のように書くとそれができます。

関連: capybaraの公式ドキュメント

capybara(selenium)によるクリックの仕組みを知ろう

capybaraのクリックの挙動も知っておく必要があります。capybaraは要素をクリックするときに

  1. 要素の座標を取得する
  2. 取得した座標をクリックする

という手順をとります。このとき1と2の間にタイムラグがあるため、そのときに

  • cssアニメーションの途中である
  • 画像ローディングの途中である

などの理由により要素が移動するとクリックに失敗します。

cssアニメーションをオフにする機能はcapybaraが用意してくれています(Capybara.disable_animation = true)。これはcssアニメーションをオフにするstyleタグをRackミドルウェアを利用してすべてのページに挿入することで実現しています。ただし、あくまでこのようなscriptやstylesheetがテスト中のHTML中に差し込まれるだけなので、ここでオフにしている以外の方法でアニメーションを実現していたら別の方法が必要です。

画像のローディングに関しては特に公式のサポートはないので、↓のようなヘルパメソッドを定義して必要なところで呼び出してあげるとよいでしょう。

  def wait_for_image_loading
    Timeout.timeout(Capybara.default_max_wait_time) do
      sleep 0.5 until evaluate_script(<<~JS)
        Array.prototype.every.call(
          document.querySelectorAll('img'),
          (e) => e.complete
        )
      JS
    end
  end

まとめ

ここまでテスト固有のTipsについて書いてきました。

しかしRailsアプリケーションのデバッグに必要な知識はこれにとどまりません。Rubyそのものに対する知識も必要ですし、Railsを始めとしたgemのソースコードを読み解かなければ解決できない問題に遭遇することもあるでしょう。他にもプロセス、スレッド、ソケットやJavaScriptなど、Railsアプリケーションが関わっている技術の知識も欲しいところです。

範囲が膨大で大変ですが、近道はないので少しずつ勉強してきましょう。僕もがんばります。