Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: SeleniumをCupriteにアップグレードする(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

参考: WebDriver | Selenium

Rails: SeleniumをCupriteにアップグレードする(翻訳)

現在の会社に入社したときのRailsアプリケーションでは、システムテストのCapybaraドライバとしてSeleniumが使われていました。それまでSeleniumを使ったときの経験は今ひとつで、特に自動更新されるChromeとchromedriverを常に最新の状態に保たなければならない点が手間でした。このプロジェクトでは、おそらくSpring gemと組み合わせてシステムテストを実行するときに、OS上でオープンされるファイルディスクリプタの最大数制限に達することがしばしばありました。

titusfortner/webdrivers - GitHub

私たちはWebdrivers gemを使っていますが、VCRとWebMockではWebdriversのダウンロードURLを無視する必要もありました。しかし、私にとって主な問題は、システムテストが一般的に遅いように感じられることでした。

訳注

Rails 7.1からは、#48847でWebdriversに依存しなくなります。

参考: 週刊Railsウォッチ20230824: webdriversへの依存を解消

たまたま聞いたThe Bike Shed podcastのエピソード#355によると、Seleniumはかなりのオーバーヘッドを引き起こすとのことなので、私はCupriteを試してみる価値があると判断しました。

rubycdp/cuprite - GitHub

ご存知ない方向けに説明すると、CupriteはCapybaraドライバーであり、CDP(Chrome DevTools Protocol)を利用して直接Chromeとやり取りします。これに対してSeleniumは、chromedriver/geckodriverコマンドラインツールを経由してやり取りする点が対照的です。

ここで申し上げておかなければならない点は、Cupriteによるパフォーマンス向上がこれほど目覚ましいものだとは私も予想していなかったことです。私のM1 MacBook Airでは、個別のシステムテストの実行速度が約30〜50%高速化されました。また、全体的なシステムテストスイートも9分から6分に短縮され、30%の高速化が実現しました。この結果は、Capybaraドライバーを変更しただけで得られたのです。高速なテストを重要視している人にとって、これはかなり大きな勝利です🤘。

私たちのCapybaraの設定は、最終的に以下のようになりました。

require 'capybara/rspec'
require 'capybara/cuprite'

Capybara.default_max_wait_time = 5
Capybara.disable_animation = true

RSpec.configure do |config|
  config.before(:each, type: :system) do
    driven_by(:cuprite, screen_size: [1440, 810], options: {
      js_errors: true,
      headless: %w[0 false].exclude?(ENV["HEADLESS"]),
      slowmo: ENV["SLOWMO"]&.to_f,
      process_timeout: 15,
      timeout: 10,
      browser_options: ENV["DOCKER"] ? { "no-sandbox" => nil } : {}
    })
  end

  config.filter_gems_from_backtrace("capybara", "cuprite", "ferrum")
end

🔗 不安定なテストが失敗する問題

Cupriteに移行したおかげでテストは速くなりましたが、今度は新たに不安定なテストの失敗が数十件も発生するようになりました。詳しく調査した結果、どの失敗もJavaScriptのウェイト処理が不適切であることが原因とわかりました(なお私たちはHotwireを使っています)。Seleniumはオーバーヘッドが大きかったため競合状態が発生せず、こうした問題が単に表面化していなかったのです。

ほとんどの失敗は、リンクをクリックしてからテキストが表示されるまで待機していたにもかかわらず、そのテキストが既に直前のページに存在していたため、Capybaraが実際に新しいページに遷移するまで待機していなかったというものです。場合によっては、要素とやり取りする前に手動でスクロールする必要もありました(Seleniumでは自動的にスクロールされていたようです)。

click_on "Some link"
# 直前のページにこのテキストが「存在しない」ようにしておくこと
expect(page).to have_content("Some text")

🔗 CSSトランジションを無効にする

私たちはBootstrapを使っていますが、モーダルクリックのいくつかが失敗することに気付きました。これは、Bootstrapモーダルで使われるCSSトランジション(CSS transitions)が原因となって、遷移中のターゲットをCapybaraがクリックしようとすることがあるためです。実際のテストではアニメーションは不要なので、アニメーションを無効化する方法を探していました。そして偶然にも、この問題を解決する便利なCapybaraの設定オプションを見つけました。

# CSSトランジションやjQueryアニメーションを無効にする
Capybara.disable_animation = true

1つ問題になった点は、私たちのflashメッセージが3秒後に消えるように設定されていたのが、自動的に消えなくなってしまったことです。ほとんどの場合は問題ありませんでしたが、場合によってはアクセスする必要のあるコンテンツがflashメッセージで覆い隠されてしまうこともありました。この問題を解決するために、flashメッセージを取得してすぐにアラートを閉じるヘルパーメソッドを作成しました。

def flash_message
  message = find(".flash").text.split("\n").last
  find(".flash .close").click # アラートを閉じる
  message
end
# ...
click_on "Create Device"
expect(flash_message).to eq "Device was successfully created"

🔗 Turboプレビューを無効にする

以前のプロジェクトで、ある不安定なテストの原因がTurboプレビューであることがわかりました。そのため、以下をレイアウトの<head>に追加することでTurboプレビューを無効化しました。

<!-- 遷移中のキャッシュ済みTurboプレビューを無効にする -->
<meta name="turbo-cache-control" content="no-preview">

🔗 Stimulusエラーの問題

Stimulusライフサイクル内のコールバックやアクションでJavaScriptエラーが発生すると、Stimulusはそのエラーをキャッチしてログ出力します。これにより、あるStimulusコントローラーのエラーがJavaScriptの実行を停止して他のStimulusコントローラーの実行を妨げないようになります(詳しくはSam Stephensonによる解説(#236)を参照してください)。

この振る舞いはproduction環境では有用ですが、テストではJavaScriptのエラーが発生した際にアラートを表示したいと思います。
そこで、Cupriteのコンフィグでjs_errors: trueを設定することで、JavaScriptのエラーをRubyの例外に変換するようにしました。さらに、テスト内ではStimulusアプリケーションのエラーハンドラを以下のようにオーバーライドしてエラーが伝播するようにしました。

import { Application } from "@hotwired/stimulus"

const application = Application.start()

// 以下はWebpackerで有効
// (Viteでは`import.meta.env.MODE === "test"`などとする)
if (process.env.RAILS_ENV === "test") {
  // Stimulusコントローラ内部で発生したエラーを伝搬させる
  application.handleError = (error, message, detail) => {
    throw error
  }
}

🔗 アセットのプリコンパイル

最初のテストでは、CIでCupriteのタイムアウトエラーが発生しました。原因は、Webpackerが最初のリクエスト時にアセットをコンパイルしていたためでした。Cupriteの:process_timeoutの値を増やしてみましたが、効果はありませんでした。

Webpackerでアセットをその都度コンパイルする代わりに、アセットをプリコンパイルしておくことで解決しました。

$ bundle exec rake assets:precompile
$ bundle exec rspec spec/system

しかし、JavaScriptエラーが発生したときにJavaScriptのソースがminify(最小化)されていることに気付きました。これではエラーが発生した場所の特定が難しくなり、CIでもエラーメッセージ自体がまったく表示されませんでした。

これは、webpack:compilerakeタスクがデフォルトのNODE_ENVproductionに設定しているためです。最初NODE_ENV=testを設定してみましたが、それでも最小化はスキップされませんでした(これはJavaScriptテストで意図的に行われていることを後に知りました)。

最終的に、CIでNODE_ENV=developmentと設定することで動くようになりました。この設定はファイル保存時のコンパイルで使われます。

自分としては、テストでWebpackerがアセットを単一のファイルにマージしない方が好ましいと思いますが、Viteへの移行はこれに役立つだろうと考えています。

🔗 ヘッドレスモードの切り替え

CupriteはデフォルトでChromeを「ヘッドレス」モードで実行します。つまり、テストの実行中にブラウザを開きません。ただし、テストの失敗をデバッグする場合は、キャプチャしたスクリーンショットだけでは不十分で、ページ上で起こっていることを実際に見る必要が生じることもあります。

私たちのCuprite設定では、rspecコマンドにHEADLESS=0環境変数を渡してヘッドレスモードを無効化するようになっています。ブラウザの動作が速すぎて何も理解できない場合は、さらに SLOWMO=0.5などを設定すれば、クリックごとに0.5秒の待ち時間を追加することも可能です。

$ HEADLESS=0 SLOWMO=0.5 bin/rspec spec/system/something_spec.rb

🔗 Dockerの扱い

チームメンバーの中には、RailsアプリをDockerでローカル実行することを好む人もいます。CupriteをDockerで動かすために、私たちの場合はdocker-compose.ymlDOCKER=trueを設定し、この環境変数に基づいてChromeにno-sandboxオプションを渡すようにしています。

🔗 しめくくり

私は、パフォーマンス上のメリットだけでもCupriteの利用をきっと推奨するでしょう。唯一、ドラッグアンドドロップ機能だけはまだサポートされていません(初期サポートは#176でmasterにマージ済みですが)。

不安定なテストによる失敗は、私がシステムテストを書くときに常につきまといます。自分を支えてくれたのは、根本原因は必ず解明できるという信念でした。以前はSeleniumの複雑な内部要素が原因だと考えていましたが、Cupriteは魔法がはるかに少ないので、何かが起こっている原因を理解しやすくなります。

関連記事

Railsでブラウザテストを「正しく」行う方法(翻訳)

Railsの技: "プログレッシブエンハンスメント"でHotwire的思考を身につける(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。