Capybaraを使って、Rails+JavaScriptの非同期な統合テストを書く

バランスの取れたテスト方法として、私たちは統合テストを頼りにしています。統合テストは、アウトサイドイン開発をサポートしており、リグレッションを見つけたり、RubyとJavaScriptそれぞれの単体テストのギャッブを埋めたりすることができます。

しかし、RubyとJavaScriptの統合テストでは、リスクが伴います。開発者は不定期に発生するテストの失敗に対して、度々、不満を漏らしています。これらのテストのデバッグでは、ログ見る限りはテストのデータベースに挿入された記録があるレコードが、ページ上に表示されないといったような問題が起こることがあるからです。そのため、開発者の中には、統合テストを全く行わないという人もいます。

なぜ、JavaScriptでの統合テストは難しいのでしょうか?

rack-test

まずは、背景からお話しましょう。

Railsアプリケーションでの統合テストは、HTMLのインターフェイスを通して行うユーザエクスペリエンスのシミュレーションによって機能します。Railsアプリケーションを読み込み、例えばCapybaraといった統合テストハーネスがサイト上でユーザのクリックをシミュレートするのです。

JavaScriptを考慮しない場合では、Capybaraはrack-testをデフォルトのドライバとして使用し、以下のようになります。

  • visitなどのCapybara DSLメソッドを呼び出してユーザアクションを作動する。
  • リクエストされたURLの読み込みをCapybaraが(capybara-webkitなどの)ドライバに指示する。
  • rack-testは、偽のRackリクエストを生成するためにそのURLを使って、Railsアプリケーションのインメモリのインスタンスに直接渡す。
  • Rackのレスポンスは、rack-testによって解析され、現在のページとして保存される。

click_linkといった他のアクションでも同様に機能します。rack-testは、本来のブラウザのようにするために、クッキーの保存、リダイレクトのフォロー、フォームの投稿を行います。このシミュレーションブラウザは、本来のブラウザと同様に様々なことが行えますが、大事な機能であるJavaScriptが含まれていません。

Selenium、capybara-webkit、Poltergeist

JavaScriptのドライバは、少し異なった動きをします。なぜなら、WebKitのようなブラウザツールでは、Rubyプロセスの中に読み込むことが難しく、これらのドライバは、HTMLレンダリングエンジンと相互作用することができる外部プロセスを起動するからです。そして、この外部プロセスはRailsアプリケーションのインメモリのインスタンスにアクセスすることができないため、実際のHTTPリクエストを作成しなくてはなりません。

CapybaraのJavaScriptドライバを使用した際に実行されるインタラクションは次のとおりです。

  • visitなどのCapybara DSLメソッドを呼び出してユーザアクションを作動する。
  • リクエストされたURLの読み込みをCapybaraが(capybara-webkitなどの)ドライバに指示する。
  • HTMLレンダリングエンジンを保留するため、ドライバは外部プロセスを始動する。
  • 実際のHTTPリクエストを実行するため、CapybaraはバックグラウンドスレッドのRailsアプリケーションの新しいインスタンスを起動する。
  • ドライバのブラウザプロセスはURLを使用し、Thinなどのアプリケーションサーバに渡す実際のHTTPリクエストを作成する。
  • ThinがHTTPリクエストをRackリクエストに翻訳します。Rackリクエストはバックグラウンドスレッドで動作するRailsアプリケーションに渡される。
  • さらに、ThinはRackレスポンスを実際のHTTPレスポンスに翻訳します。HTTPレスポンスはブラウザプロセスに受け取られる。

上記が機能するには、プロセスフォーキングやHTTPリクエスト、バックグラウンドスレッドなど、追加で多くの構造が必要となります。しかしながら、よくユーザがぶち当たる主な壁は、あのやっかいなバックグラウンドスレッドなのです。

バックグラウンドスレッドでリクエストが処理されているということは、アプリケーションがシミュレーションのインタラクションに応答する間もテストは実行されるということです。このれにより、無限の数の競合状態が生じ、ページ上にまだ表示されていない要素を実行中のテストが探すことになります。

Capybaraで解決

ほとんどのCapybaraのソースコードは、これらの非同期の問題を解決するためだけのものです。インタラクションまでにページが読み込まれたか分かるほど、Capybaraは賢いのです。代表的なインタラクションは次のとおりです。

テストスレッド アプリケーションスレッド
テストでvisitを起動する。 リクエストを待つ。
Capybaraがドライバにページを読み込むよう指示する。 リクエストを待つ。
ドライバがリクエストを実行する リクエストを待つ。
テストでclick_linkを起動する。 アプリケーションがリクエストを受信する。
Capybaraがページ上のリンクを探すが見つからない。 アプリケーションがレスポンスを送信する。
Capybaraが再び要素を探すが見つからない。 ドライバがレスポンスを受信する。
Capybaraが受信したレスポンスから要素を探し出す。 リクエストを待つ。

上からも分かるように、ページの読み込みが完了する前に、テストでリンク探しを始めたのにも関わらず、Capybaraは優雅にインタラクションを処理しています。

しかし、Capybaraがこれらの非同期の問題を処理してくれるにもかかわらず、成功したり失敗したりする一貫性のないテストにいとも簡単に陥ってしまうのはなぜなのでしょう。

競合状態の数を最小限に抑えて、Capybara APIを正しく使用するには、いくつかのコツがあります。

最初に適合するエレメントを見つける

悪い例

first(".active").click 

まだページに.active要素がない場合、firstnilを返し、クリックは失敗します。

良い例

# 完全に一致するものが欲しい場合
find(".active").click 

# ただ単に最初の要素がほしいだけの場合
find(".active", match: :first).click 

適合する全ての要素とインタラクションさせる

悪い例

all(".active").each(&:click) 

まだ適合する要素がない場合、空の配列を返してしまうので、要素を操作することができません。

良い例

find(".active", match: :first) all(".active").each(&:click) 

クリックを始める前に、Capybaraはまず適合するエレメントを待ちます。

注:適合する要素を1つずつ返していくより、良いテストはありますが、記事の内容から離れてしまうためここでは触れません。よく考えてからallを使うようにしてください。

JavaScriptと直接インタラクションさせる

悪い例

execute_script("$('.active').focus()")

JavaScriptの表現は、その動作が完了しないうちに評価されることがあり、間違った要素に影響を及ぼす、あるいはどの要素にも影響を及ぼさないかもしれません。

良い例

find(".active") execute_script("$('.active').focus()")

Capybaraは適合する要素がページに現れるまで待ち、それと相互に作用するJavaScriptのコマンドを送信します。

注:execute_scriptは、ドライバの制限あるいはその他の問題により他のCapybaraメソッドが使えない場合に限り、最終手段として用いられるべきです。

フィールドの値をチェックする

悪い例

expect(find_field("Username").value).to eq("Joe")

Capybaraは適合する値を待ち、その後すぐにその値を返します。もしも値がページの読み込み、あるいはAjaxのリクエストから変化すれば、手遅れになります。

良い例

expect(page).to have_field("Username", with: "Joe")

Capybaraは適合する値を待ち、その後はその値が適合するまで、最大で2秒待ちます。

要素の属性をチェックする

悪い例

expect(find(".user")["data-name"]).to eq("Joe")

Capybaraは適合する要素を待ち、その後すぐに要求された属性を返します。

良い例

expect(page).to have_css(".user[data-name='Joe']")

Capybaraは要素が現れるのを待ち、正しい属性を手に入れます。

適合するCSSを探す

悪い例

it "doesn't have an active class name" do 
    expect(has_active_class).to be_false
end

def has_active_class
    has_css?(".active")
end

Capybaraは、要素がまだページから削除されていなければすぐにtrueを返すので、テストは失敗に終わります。また、falseを返すまでにも2秒待ちますので、結果が出るまで時間がかかるテストだということになります。

良い例

it "doesn't have an active class name" do 
    expect(page).not_to have_active_class 
end 

def have_active_class 
    have_css(".active") 
end

Capybaraはテストが失敗になる前に、要素が消えるまで最大2秒間待ちます。要素が予期していたようにページにはないときは、即座にパスします。

まとめ

ページとのインタラクションを行う場合は、findのようなfinderメソッドの代わりに、可能なときはいつでもclick_onのようなactionメソッドを使いましょう。Capybaraはこれらのメソッドで何をしているか大抵は理解していますし、おかしなエッジケースをより賢く扱うことができます。

要素が予期していたとおりにページにあるということを証明する場合は、textのようなnodeメソッド の代わりに、可能な限りhave_cssのようなRSpec matcherを使いましょう。Capybaraはマッチャと共に、望んでいる結果を待つことができますが、ノードでメソッドを作動させる時にどのようなテキストを期待しているかは分かりません。