
概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: System of a test: Proper browser testing in Ruby on Rails — Martian Chronicles, Evil Martians’ team blog
- 原文公開日: 2020/07/14
- 著者: Vladimir Dementyev
- サイト: Evil Martians — ニューヨークやロシアを中心に拠点を構えるRuby on Rails開発会社です。良質のブログ記事を多数公開し、多くのgemのスポンサーでもあります。
日本語タイトルは内容に即したものにしました。画像は原文からの引用です。
2020年のRailsでブラウザテストを「正しく」行う方法(翻訳)
Rubyコミュニティは熱心にテストを実施しています。Rubyにはおびただしいテスト用ライブラリがあり、テストをお題にしたブログ記事も何百件あるかわからないほどですし、その名もThe Ruby Testing podcastというRubyのテスト専門のポッドキャストまであるほどです。凄まじいことに、最多ダウンロードgemトップ3を独占しているのはいずれもRSpecテスティングフレームワークのコンポーネントです。
TestProf: Ruby/Railsの遅いテストを診断するgem(翻訳)
TestProf II: Factory therapy for your Ruby tests
Rubyでテストがこれほど盛んな理由のひとつはRailsの存在にあると私は信じています。Railsフレームワークはテストをできる限り楽しく書けるようにしてくれます。ほとんどの場合、網羅的なRailsテスティングガイドを参照すれば事足ります(少なくとも最初のうちは)。しかし物事に例外はつきものです。私たちの場合「システムテスト」がそれでした。
Railsアプリケーションでシステムテストを書いたりメンテしたりするのは、正直「楽しい」からほど遠い作業です。この問題に対処するために用いた私のアプローチは、今を去ること2013年に初めてCucumberドリブンのテストツイートを導入してからというもの、随分と進化を遂げました。そして2020年となった今、ついにこれまでノウハウの粋を蓄積した現在の私のセットアップを皆さまと共有できるまでになりました。本記事では以下のトピックを扱います。
「システムテスト」とは何か
Rails界隈におけるシステムテスト(system test)は、主にエンドツーエンドテストの自動化を指すのに用いられます。この名称がRailsで「採用される」前は「フィーチャーテスト」だの「ブラウザテスト」だの、しまいには「受け入れテスト」(一般の受け入れテストとは理念からして違います)などといった不統一な用語が飛び交っていたものです。
Martin Fowlerのテスティングピラミッド(テスティングダイヤモンドでもいいのですが)を思い出してみると、システムテストはその頂点に位置します。システムテストはプログラム全体をブラックボックスとして扱い、エンドユーザーの操作や期待事項をエミュレートするのが普通です。Webアプリケーションではそうしたテストをブラウザで実行する(あるいはRack Testなどのエミュレーションが)必要が生じる理由がこれです。
システムテストの典型的なアーキテクチャをちょっと見てみましょう。

言うまでもありませんが、テストに用いるツールをつなぎ合わせるためにはテストの依存関係にRuby gemをいくつも足さなければなりません。依存関係が膨れ上がれば、その分問題も増えます。たとえば必須のアドオンとして長年活躍してきたDatabase Cleaner gemがそうです。データベースのステートを自動ロールバックしたくても、スレッドごとに独自のデータベースコネクションが使われるので、トランザクションを使うわけにはいきません。そのため、かつては各テーブルでTRUNCATE ...
やDELETE FROM ...
を使うしかありませんでしたが、これをやるとかなり速度が落ちてしまいます。私たちEvil Martiansは、全スレッドで共有されるコネクションを用いることでこの問題を解決しました(TestProf拡張)。やがてそれに近い機能をすぐ使える形でRails 5.1がリリースされました(#28083)。
そういうわけで、システムテストを追加すると開発環境やCI環境のメンテナンスコストが上昇するようになり、テストが失敗する可能性のある不安定な箇所が紛れ込むようになったのです。複雑怪奇なセットアップのせいで、今や不安定なテストはエンドツーエンドのテストで最もよく見かける問題となっています。そしてテストの不安定性のほとんどが、ブラウザとのやりとりで発生します。
Rails 5.1で導入されたシステムテストのおかげでシンプルなブラウザテストを維持できるようにはなったものの、スムーズに動かすためには未だに設定のいくつかを頑張る必要があります。
- Webドライバの指定が必要(RailsではSeleniumの利用が前提とされている)
- コンテナ化された環境(つまり開発用Docker環境を使う場合)ではシステムテストを独自に設定する必要がある
- 柔軟に設定できない(スクリーンショットの保存パスなど)
2020年にふさわしい、システムテストを楽しくやれる方法をコードで詳しく見てみることにしましょう。
Cupriteを用いたモダンなシステムテスト
Railsは、システムテストをSeleniumで実行することを前提にしています。SeleniumはWebブラウザ自動化方面で勝ち残ってきた実績のあるソフトウェアで、あらゆるブラウザに対応するユニバーサルなAPIや最も現実的なエクスペリエンスを提供することを目的としています。ユーザーによるブラウザ操作のエミュレーションでは、切れば血の出る本物の人間に勝るものはありません。
Seleniumのパワーを手に入れるには代償が必要です。ブラウザごとに固有のドライバをインストールしないといけませんし、現実のユーザー操作によるオーバーヘッドは、規模が大きくなるに連れて深刻になります(Seleniumのテストは相当遅いのが普通です)。
Seleniumが作られたのはだいぶ前の話であり、当時のブラウザには自動化の仕組みがまったく提供されていませんでした。この状況が変わったのは、何年か前にChrome用のCDPプロトコルが提供されてからです。CDPを使うことでブラウザセッションを直接操作できるようになり、途中に抽象レイヤやツールを挟む必要もありません。
それ以来CDPを活用したプロジェクトが山ほど登場しましたが、中でも最もよく知られているものといえば、Node.js用ブラウザ自動化ライブラリのPuppeteerでしょう。ではRuby方面ではどうでしょう?実はFerrumというRuby向けのCDPライブラリがあるのです。Ferrumは比較的歴史が浅いのですが、Puppeteerエクスペリエンスとの互換性も保たれています。そして私たちにとって重要な点は、Cupriteという付随プロジェクトも同梱されていることです。CupriteはピュアRuby製Capybaraドライバで、CDPを用いています。
訳注: cuprite(赤銅鉱)やferrum(鉄)は、Seleniumと同じく元素名や鉱物名つながりの命名と思われます。
私がCupriteを積極的に使い始めたのは2020年初頭からに過ぎません(実は昨年もやってみたのですが、当時はDocker環境でいくつか問題にぶち当たりました)が、Cupriteに賭けてみたことはまったく後悔していません。何しろシステムテストのセットアップが信じられないほどシンプルになりましたし(必要なのは愛しのChromeだけですよ!)、実行も目覚ましく高速です。実際、SeleniumからCupriteに移行してからいくつかテストが失敗したことがあったのですが、テストが落ちた理由は、単に従来のテストでは非同期関連のexpectationが正しく書かれてなかったというものでした。以前はSeleniumがめちゃめちゃ遅かったせいでテストがパスしていたのです。
いよいよ、私がCupriteで使っているピカピカな最新システムテスト用設定をご覧に入れることにしましょう。
設定例(解説付き)
この例は、このところ私が手掛けているRuby on RailsのオープンソースプロジェクトであるAnyCable Rails Demoのものを使いました。このプロジェクトの目的は、つい先頃リリースされたAnyCable 1.0をRailsアプリで使う方法をデモすることですが、何しろ最新のシステムテストカバレッジがありますので、そのまま本記事でも使えました。
同プロジェクトではRSpecと、RSpecのシステムテスト用ラッパーを用いていますが、使われているアイデアのほとんどはそのままMinitestにも通用します。
まずはローカルPCでも十分動く、最小限のテスト例から。このコードはAnyCable Rails Demoのdemo/dockerlessブランチにあります。
最初はとりあえずGemfileを見てみましょう。
group :test do
gem 'capybara'
gem 'selenium-webdriver'
gem 'cuprite'
end
「あれ?selenium-webdriver
要るの?」「Seleniumって要らないんじゃなかったけ?」とお思いの方へ。Railsでは、皆さんの使うドライバとは独立にこのgemが必須扱いされていることがわかりました。私がRailsに投げた#39179の修正が首尾よくマージされていれば、Rails 6.1ではこのgemを削除できるようになります(訳注: マージ済みです)。
なお、私はシステムテストの設定ファイルをspec/system/support/フォルダの下に、以下のように複数ファイルに分割して配置し、専用のsystem_helper.rbで読み込んで使っています。
spec/
system/
support/
better_rails_system_tests.rb
capybara_setup.rb
cuprite_setup.rb
precompile_assets.rb
...
system_helper.rb
それぞれの設定ファイルについて目的を見ていきましょう。
system_helper.rb
system_helper.rbには、システムテストで用いるRSpecの一般的な設定の一部を含むこともありますが、通常は以下のようにシンプルです。
# RSpec Railsの一般的な設定を読み込む
require "rails_helper.rb"
# 設定ファイルとヘルパーを読み込む
Dir[File.join(__dir__, "system/support/**/*.rb")].sort.each { |file| require file }
続いて、自分のシステムspecファイルにrequire "system_helper"
を追加して、この設定を有効にします。
私たちの場合、システムテストで使うヘルパーファイルとsupport/フォルダは他のものとは別にしてあります。これは、単体テストを1つ実行したいだけの場合に設定を増やしすぎないためです。
capybara_setup.rb
このファイルにはCapybaraフレームワーク用の設定を置きます。
# spec/system/support/capybara_setup.rb
# 特にSeleniumを使う場合、開発者はこのmax wait timeを増やす傾向がよく見られる
# Cupriteの場合そうした設定は不要
# ここではCapybaraのデフォルト値を明示的に指定している
Capybara.default_max_wait_time = 2
# `has_text?`や同種のマッチャーでホワイトスペースを正規化する
# (つまり改行や末尾スペースなどは無視される)
# こうすることで、UIにささいな変更が入っても影響されにくくなる
Capybara.default_normalize_ws = true
# システムテストで生成されるファイル(スクショやダウンロードファイルなど)の置き場所
# このパスを外部から設定可能にしておくとCIなどで便利かもしれない
Capybara.save_path = ENV.fetch("CAPYBARA_ARTIFACTS", "./tmp/capybara")
このファイルには、Capybaraに当てる便利パッチも含まれています。パッチの利用目的については後ほど解説します。
# spec/system/support/capybara_setup.rb
Capybara.singleton_class.prepend(Module.new do
attr_accessor :last_used_session
def using_session(name, &block)
self.last_used_session = name
super
ensure
self.last_used_session = nil
end
end)
Capybara.using_session
を使うと、別のブラウザセッションを制御できるようになります。それによって、1つのテストシナリオで複数の独立セッションを制御できます。これはリアルタイム機能のテストで特に有用です(WebSocketで何かする場合など)。
上のパッチは、最後に使われたセッション名をトラッキングします。後でこの情報を元に、マルチセッションのテストで失敗時のスクリーンショットを撮ります。
cuprite_setup.rb
このファイルはCupriteの設定を担当します。
# spec/system/support/cuprite_setup.rb
# 最初にCuprite〜Capybara統合を読み込む
require "capybara/cuprite"
# 続いてドライバを登録し、後で使えるようにする
# ここでは#driven_byメソッドを利用する
Capybara.register_driver(:cuprite) do |app|
Capybara::Cuprite::Driver.new(
app,
**{
window_size: [1200, 800],
# Docker化環境向けの追加設定については、本記事の対応するセクションを参照のこと
browser_options: {},
# Chrome起動時のwait timeを増やす(CIビルドを安定させるのに必要)
process_timeout: 10,
# デバッグ機能を有効にする
inspector: true,
# HEADLESS環境変数をfalsyな値に設定することで
# Chromeを「ヘッドフルモード」で実行できるようにする
headless: !ENV["HEADLESS"].in?(%w[n 0 no false])
}
)
end
# Capybaraで:cupriteドライバをデフォルトで使うよう設定する
Capybara.default_driver = Capybara.javascript_driver = :cuprite
Cupriteでよく使われるAPIメソッドのショートカットもいくつか定義しています。
module CupriteHelpers
# 実行を停止する#pauseメソッドはテストのどこにでも置ける
# ヘッドフルモードのテスト中にWebページの表示内容をチェックしたい場合に便利
def pause
page.driver.pause
end
# Chromeインスペクタを開いて実行を一時停止する#debugメソッドは
# テストのどこにでも置ける
def debug(*args)
page.driver.debug(*args)
end
end
RSpec.configure do |config|
config.include CupriteHelpers, type: :system
end
#debug
ヘルパーの動作については以下のデモをどうぞ。
better_rails_system_tests.rb
このファイルには、Railsシステムテストの内部に当てるパッチがいくつかと、一般的な設定が若干含まれています(詳しくはコードの説明をどうぞ)。
# spec/system/support/better_rails_system_tests.rb
module BetterRailsSystemTests
# スクショやその他の生成物の保存場所に`Capybara.save_path`を指定する
# (Railsのスクショのパスは設定不可能)
# https://github.com/rails/rails/blob/49baf092439fc74fc3377b12e3334c3dd9d0752f/actionpack/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb#L79
def absolute_image_path
Rails.root.join("#{Capybara.save_path}/screenshots/#{image_name}.png")
end
# 失敗したときのスクショをマルチセッションのセットアップと互換にする
# 上で導入したCapybara.last_used_sessionはここで使う
def take_screenshot
return super unless Capybara.last_used_session
Capybara.using_session(Capybara.last_used_session) { super }
end
end
RSpec.configure do |config|
config.include BetterRailsSystemTests, type: :system
# メイラー内のURLに正しいサーバーホストが含まれるようにする
# メールに含まれるリンクのテストで必要(capybara-emaiなど)
config.around(:each, type: :system) do |ex|
was_host = Rails.application.default_url_options[:host]
Rails.application.default_url_options[:host] = Capybara.server_host
ex.run
Rails.application.default_url_options[:host] = was_host
end
# このフックが他のものより先に実行されるようにする
config.prepend_before(:each, type: :system) do
# 常にJSドライバを使うようにする
driven_by Capybara.javascript_driver
end
end
precompile_assets.rb
このファイルは、システムテストの実行前にアセットのプリコンパイルを担当します(このファイルは長いので、最も興味深い部分のみを貼っておきます)。
RSpec.configure do |config|
# システムspecを除外する場合はアセットプリコンパイルをスキップする
# (以下のコマンドでシステムテスト以外を実行する場合など)
#
# rspec --tag ~type:system
#
# なお、ここではアセットプリコンパイルは不要
next if config.filter.opposite.rules[:type] == "system" || config.exclude_pattern.match?(%r{spec/system})
config.before(:suite) do
# テストでwebpack-dev-serverも使う
# フロントエンドのコード修正をシステムテストで確認したい場合に便利
if Webpacker.dev_server.running?
$stdout.puts "\n⚙ Webpack dev server is running! Skip assets compilation.\n"
next
else
$stdout.puts "\n🐢 Precompiling assets.\n"
# webpacker:compile Rakeタスクを実行する
# ...
end
end
end
Railsがアセットを自動でプリコンパイルしてくれるのであれば、手動でアセットをプリコンパイルする理由は何でしょうか?ここで問題なのは、Railsのアセットプリコンパイルが遅延実行されることです(つまりアセットに初めてリクエストをかけるとき)。最初のテストexampleでこれが発生すると実行が非常に遅くなり、タイムアウト例外がランダムに発生することすらあります。
もうひとつご注目いただきたい点は、webpack-dev-serverをシステムテストで利用できるという事実です。これはフロントエンドコードのリファクタリングで実にありがたい機能です!テストをpause
で一時停止してブラウザで開き、フロントエンドのコードを編集すればホットリロードされるのですから。
dev_server
設定を追加し、RAILS_ENV=test ./bin/webpack-dev-server
でテストを実行する必要があります。
システムテストをDocker化する
それでは設定を次のレベルに進めて、Evil Martians流Docker開発環境と互換性を取れるようにしましょう。Docker化環境向けのテストのセットアップは、先のAnyCable Rails Demoリポジトリのデフォルトブランチにありますので、ご自由にチェックアウトいただけますが、本記事では同設定の興味深い点すべてをこの後取り上げます。
Docker版セットアップでの最大の違いは、ブラウザのインスタンスを別コンテナで実行する点です。Chromeは、ベースとなるRailsのDockerイメージに追加することもできますし、(おそらくですが)コンテナからホストマシンのブラウザを動かすことも可能でしょう(SeleniumとChromeDriverでできたように)。しかしこういうときには、docker-compose.ymlに専用のブラウザサービスを定義するのが正しい「Docker way」であると私は思っています。
現時点の私は、browserless.ioにあるChrome Dockerイメージを使っています。これには便利なデバッグビューアが付属しているので、ヘッドレスブラウザのセッションをデバッグできます(本記事末尾に簡単な動画を用意しましたのでお楽しみに!)。
services:
# ...
chrome:
image: browserless/chrome:1.31-chrome-stable
ports:
- "3333:3333"
# アプリケーションのソースコードをマウントしてファイルアップロードをサポート
# (そうしないとファイルを見つけられない)
# 注: `#attach_file`では絶対パスを用いること
volumes:
- .:/app:cached
environment:
# なおRailsではデフォルトに3000を設定するのが普通
PORT: 3333
# コネクションタイムアウトを設定して、デバッグ中のタイムアウト例外を回避する
# https://docs.browserless.io/docs/docker.html#connection-timeout
CONNECTION_TIMEOUT: 600000
CHROME_URL: http://chrome:3333
を自分のRailsサービスの環境に追加し、以下のようにChromeをバックグラウンドで実行します。
docker-compose up -d chrome
次はCupriteを設定して、URLを与えられたときにCupriteからリモートブラウザを使えるようにします。
# cuprite_setup.rb
# URLをパースする
# 注意: 以下のいずれかを使う場合はREMOTE_CHROME_HOSTをWebmock/VCR許可リストに
# 追加すること
REMOTE_CHROME_URL = ENV["CHROME_URL"]
REMOTE_CHROME_HOST, REMOTE_CHROME_PORT =
if REMOTE_CHROME_URL
URI.parse(REMOTE_CHROME_URL).yield_self do |uri|
[uri.host, uri.port]
end
end
# リモートのChromeが実行中かどうかをチェック
remote_chrome =
begin
if REMOTE_CHROME_URL.nil?
false
else
Socket.tcp(REMOTE_CHROME_HOST, REMOTE_CHROME_PORT, connect_timeout: 1).close
true
end
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError
false
end
remote_options = remote_chrome ? { url: REMOTE_CHROME_URL } : {}
上の設定では、「CHROME_URL
が設定されていない場合」「ブラウザが応答しない場合」はユーザーがローカルインストール済みのChromeを使いたいはずだという前提になっています。
このようにして設定のローカルChromeとの後方互換性を保ちます(なお私たちは基本的にDockerを開発環境で使うことを強制しません: Docker嫌いマンの皆さまは独自のローカルセットアップを自力で頑張ってくださいまし)。
ドライバの登録部分は以下のような感じになります。
# spec/system/support/cuprite_setup.rb
Capybara.register_driver(:cuprite) do |app|
Capybara::Cuprite::Driver.new(
app,
**{
window_size: [1200, 800],
browser_options: remote_chrome ? { "no-sandbox" => nil } : {},
inspector: true
}.merge(remote_options)
)
end
もうひとつ、#debug
ヘルパーを更新して、ブラウザを開こうとする代わりにデバッグビューアのURLを出力するよう変更します。
module CupriteHelpers
# ...
def debug(binding = nil)
$stdout.puts "🔎 Open Chrome inspector at http://localhost:3333"
return binding.pry if binding
page.driver.pause
end
end
ブラウザは別の「マシン」で動作するのですから、テストサーバーに到達する方法を知っておく必要があります(テストサーバーはlocalhost
をリッスンしなくなります)。
そのためには、Capybaraサーバーホストに以下の設定が必要です。
# spec/system/support/capybara_setup.rb
# 外部世界からサーバーにアクセスできるようにする
Capybara.server_host = "0.0.0.0"
# 内部のDockerネットワークで解決可能なhostnameを使うこと
# 注意: 6.1より前のRailsはCapybara.appをオーバーライドするので
# 以下のように別の方法で保存する必要がある
CAPYBARA_APP_HOST = `hostname`.strip&.downcase || "0.0.0.0"
# Rails 6.1以降は以下の設定でやれるはず
# Capybara.app_host = "http://#{`hostname`.strip&.downcase || "0.0.0.0"}"
最後に、better_rails_system_tests.rbファイルに軽く調整を加えます。
まず、スクリーンショットの通知をVSCodeでクリッカブルにしましょう(Dockerの絶対パスがホストシステムのそれと異なるため)。
# spec/system/support/better_rails_system_tests.rb
module BetterRailsSystemTests
# ...
# スクリーンショットのメッセージに相対パスを使う
def image_path
absolute_image_path.relative_path_from(Rails.root).to_s
end
end
お次は、正しいサーバーホストがテストで使われるようにします(なおRails 6.1では#d415eb4で修正済み)。
# spec/system/support/better_rails_system_tests.rb
config.prepend_before(:each, type: :system) do
# Railsはデフォルトであらゆるテストのhostを`127.0.0.1`に設定する
# しかしこれはリモートブラウザでは無効
host! CAPYBARA_APP_HOST
# 常にJSドライバを使う
driven_by Capybara.javascript_driver
end
Dipならさらに便利に
訳注: Dipについては以下の記事もどうぞ。
Docker化された開発環境の管理にdipをお使いであれば(このツールを強くおすすめします: dipを使えば、Dockerの長ったらしいCLIコマンドを覚える苦労なしにコンテナのパワーを発揮できるようになります)、dip.ymlに以下のようにカスタムコマンドを追加し、docker-compose.ymlにもサービス定義を追加することで、chrome
サービスをいちいち手で起動しなくても済むようになります。
# docker-compose.yml
# Chromeを依存関係に追加する定義をシステムテスト用に分けておく
rspec_system:
<<: *backend
depends_on:
<<: *backend_depends_on
chrome:
condition: service_started
# dip.yml
rspec:
description: Run Rails unit tests
service: rails
environment:
RAILS_ENV: test
command: bundle exec rspec --exclude-pattern spec/system/**/*_spec.rb
subcommands:
system:
description: Run Rails system tests
service: rspec_system
command: bundle exec rspec --pattern spec/system/**/*_spec.rb
あとは以下のコマンドを実行するだけでシステムテストが走ります。
dip rspec system
以上でおしまいです!
最後に、Browserless.ioのDockerイメージをデバッグビューアでデバッグするときの様子を動画でご紹介いたします。
皆さんのエンジニアチームで(技術経験の多寡にかかわらず)確固たる開発ノウハウを確立したいとお考えの方は、どうぞお気軽にEvil Martiansのフォームにてお問い合わせください。企業のエンジニアリング文化の改善は私たちの大好物のひとつです!
原文Changelog
- 1.0.1 (2020-07-30): Docker化セットアップでchromeサービスにボリュームの設定を追加。