RSpecとCapybaraでJavaScript/Ajaxをテストする

2012/10/01

RSpecはRubyのためのビヘイビア駆動開発(BDD)フレームワークで、Capybaraはブラウザの動きをシミュレートするRubyライブラリで、どちらもRubyGemsパッケージとして配布されています。Railsアプリケーションのテストを書く場合の定番の組み合わせといっていいでしょう。

最近(2013年8月)、RSpec/Capybara入門という新連載を始めました。この記事でRSpec/Capybaraに興味を持った方は、こちらもどうぞ。

「手順通りやったけどうまく行かなかった!」という方は、hermes@oiax.jp までメールでお問い合わせください。また、「微妙に説明通りではなかった」という経験をした方も同アドレスまで情報をお寄せいただけると助かります。

この文章の内容は、随時更新しています。最終更新日: 2012/11/02

たとえば、ユーザーがあるRailsアプリケーションのトップページにアクセスして、a#touchmeをクリックしたら、次のページのp#messageに「Hello!」というテキストが含まれることをテストしたい場合、次のようなSpecファイルを書きます。

# coding: utf-8

require 'spec_helper'

describe "Topページ" do
  before { visit root_path }
  
  context "a#touchmeをクリックすると" do
    before { find("a#touchme").click }

    specify do
      within('p#message') do
        expect(page).to have_content("Hello!")
      end
    end
  end
end

expect(page).to have_content("Hello!")page.should have_content("Hello!")と書くこともできます。後者が伝統的な書き方で、前者はRSpec 2.11で導入された新しい書き方です。RSpecにしばしばコミットしているmyronmarstonのブログ記事 RSpec's New Expectation Syntax (2012/06/15)によれば、今後は前者の書き方が推奨され、将来的には明示的に有効にしない限り、shouldは使えなくなるそうです。

さて、このRailsアプリケーションがJavaScript/Ajaxを利用していて、「span#touchmeをクリックしたら、p#messageの中に動的に「Hello!」というテキストが現れる」という仕様であった場合は、どうテストすればよいでしょうか。単にa#touchmespan#touchmeに変えるだけではだめです。CapybaraはJavaScriptを理解しないので、テストは失敗します。

ここで登場するのがcapybara-webkitというドライバです。WebKitはオープンソースのHTMLレンダリングエンジンで、Google ChromeやSafariがこれを使っています。このドライバを使えばJavaScriptのテストが可能になります。

capybara-webkitの特徴の一つは "headless" であることです。ここでいう "head" は、「デュアルヘッド」という用語と同様にディスプレイを指します。つまりcapybara-webkitはディスプレイを操作しない、ということです。有名なSeleiumは実際にブラウザを起動して、APIを通じて操作します。CapybaraはデフォルトでSeleniumのドライバを内包しているのですぐに使えますが、テストするたびにブラウザが現れて画面がどんどん切り替わります。初めのうちは面白いし、失敗の原因が分かりやすいけれども、現実の開発で使っていると次第に煩わしくなります。

capybara-webkitをインストールするには、例によってGemfile

  gem "capybara-webkit"

と書いて、bundle installを実行します。ただし、システムにQtのライブラリがインストールされていないと動きません。Mac OS X (Mountain Lion/Lion)の場合は、brew install qtでインストールします。Ubuntuの場合なら、libqt4-devパッケージをapt-getで入れておく必要があります。

もう一つ準備作業が必要です。spec/spec_helper.rb の末尾に次の1行を追加してください。

Capybara.javascript_driver = :webkit

CapybaraはデフォルトでWebKitを使いません。describecontextメソッドにjs: trueオプションを付けると、その範囲のエグザンプル(RSpec用語では「テストケース」をこう言います)だけWebKitによるテストを行います。書き換え後のSpecファイルは次のようになります。

# coding: utf-8

require 'spec_helper'

describe "Topページ" do
  before { visit root_path }
  
  context "span#touchmeをクリックすると", js: true do
    before { find("span#touchme").click }

    specify do
      within('#message') do
        expect(page).to have_content("Hello!")
      end
    end
  end
end

これでテストが通るようになります。

さて、単純なテストはこれでいいのですが、データベースが絡むと少々ややこしい問題に遭遇します。実は、WebKitを使用する場合、Capybaraは別スレッドでRailsアプリケーションを起動します。そのためRSpec側とRailsアプリケーション側は別々のデータベース接続を持つことになります。

通常、RSpecは各エグザンプルの終わりにデータベースの状態を素早く元に戻すため、エグザンプルをトランザクションの中で実行します。エグザンプルが終わったらロールバックするんですね。トランザクションの中で行われたデータベース操作の結果は、コミットされるまで他のデータベースクライアントには見えません。RSpecの中でデータベースにレコードを追加しても、それがRailsアプリケーションには分からないのです。

この問題への伝統的な対処法は、JavaScriptを使ったRailsアプリケーションをCapybaraでテストする際には、spec/spec_helper.rb

  config.use_transactional_fixtures = true

falseに変え、config.after(:each) ブロックの中でデータベースを空にするというものです。そのための専用のRubyGemsパッケージdatabase_cleanerも存在しています。確かにこれでテストは通るようになりますが、トランザクションのロールバックを利用するよりもかなり遅いのが難点でした。

しかし、昨年(2011年)の12月にRailsコミッタの一人José Valimがブログ記事 Three tips to improve the performance of your test suite の中で素敵なハックを紹介したことで状況が変わりました。

この記事を参考に私が試してみたところ、以下の手順を踏めば、config.use_transactional_fixtures = true のままでJavaScriptのテストができます。

まず、spec/supportディレクトリに、次のような内容を持つ新規ファイルshared_connection.rbを作成します。

class ActiveRecord::Base
  mattr_accessor :shared_connection
  @@shared_connection = nil
 
  def self.connection
    @@shared_connection || retrieve_connection
  end
end

そして、spec/spec_helper.rb の末尾に次の1行を追加します。

ActiveRecord::Base.shared_connection = ActiveRecord::Base.connection

database_cleanerREADMEによれば、このハックを使うと「非決定論的な(non-deterministic)失敗を引き起こすことが報告されている」そうです。具体的にどういう報告があったのか調べられませんでしたが、この点に留意の上、この手法を採用するかどうか判断してください。

[追記] StackOverflow に投稿された回答によれば、mysql2 を使用している場合に Mysql2::Error This connection is still waiting for a result というエラーが出て止まることがあるそうです。その回答は、RubyGemsパッケージconnection_poolGemfileに加え、self.connectionメソッドの中身を@@shared_connection || ConnectionPool::Wrapper.new(:size => 1) { retrieve_connection }で置き換えれば問題は解決すると言っています。ただし、私はこの現象をまだ確認していません。(2012/10/01)

重要 [追記] 私が携わっているRailsアプリケーション開発でこのハックを使ってみたところ、RSpecが途中でハングアウトする現象に遭遇しました。データベースにはPostgreSQLを使っています。PostgreSQLのログには SAVEPOINT can only be used in transaction blocks というエラーメッセージが現れ、あるいは何のエラーメッセージも出さずに、それ以上テストの実行が進まなくなります。問題は未解決です。試しに、データベースをMySQLに切り替えてみたところテストの実行は最後まで進んだので、PostgreSQL特有の問題かもしれません。(2012/10/06)

[追記] 私は上記のハックを2週間ほどMySQLベースのRails開発で使ってみましたが、大きな問題は起きませんでした。ただし、Ajaxを使ってページを書き換えた直後に、RSpec側でデータベースアクセスを行うと Mysql2::Error This connection is still waiting for a result というエラーが出ることがあります。Ajaxコールが完了するまで待ってからデータベースアクセスをするように書き換えれば問題は解消します。具体的には、Ajaxコールの直後に wait_until { page.evaluate_script('$.active') == 0 } という行を追加してください。Mike Gehardのブログ記事を参考にしました。(2012/10/24)

このハックはSporkにも対応しています。Sporkはテスト対象のアプリケーションを読み込んでおいて、テストケース(エグザンプル)ごとにforkしてくれます。その結果、テストがより堅牢になり、たいていは時間も短縮されます。

Sporkの使い方は本稿のテーマではありませんが、簡単に説明します。例によってGemfile

  gem "spork"

と書いて、bundle installを実行します。続いて、spork rspec --bootstrap を実行すると、spec/spec_helper.rb が書き換えられます。

require 'rubygems'
require 'spork'
#uncomment the following line to use spork with the debugger
#require 'spork/ext/ruby-debug'

Spork.prefork do
  # Loading more in this block will cause your tests to run faster. However,
  # if you change any configuration or code from libraries loaded here, you'll
  # need to restart spork for it take effect.

end

Spork.each_run do
  # This code will be run each time you run your specs.

end

# --- Instructions ---
(以下省略)

簡単に言えば、SporkはSpork.preforkブロックに書いてあるコードを1回だけ実行し、Spork.each_runブロックに書いてあるコードをエグザンプルを実行する直前に毎回実行します。

基本的には、# --- Instructions ---よりも下にあるコード(もともとのspec_helper.rbの中身)を全てSpork.preforkブロックに移動すればOKです。

ただし、José Valimのハックで追加した

ActiveRecord::Base.shared_connection = ActiveRecord::Base.connection

は、Spork.each_runブロックに移す必要があります。

rspecを実行するのとは別のターミナルを開いて、そこでsporkコマンドを実行するとテストサーバが起動します。そして--drbオプションを付けてrspecコマンドを実行すると、Spork経由でテストが実行されます。

常にSporkを使うなら.rspecファイルに--drbを追加しておくといいでしょう。

Sporkによる時間短縮の効果はけっこう大きいです。ちょっとしたサンプルを作って試してみたところ、全体で約6秒かかっていたテストが約3秒になりました。

[更新] Mac OS XでQtをインストール手順を追加しました。(2012/10/01)

[更新] mysql2 で発生する問題についてのStackOverflowからの引用を追加しました。(2012/10/01)

[更新] PostgreSQLで発生する問題についての囲み記事を追加しました。(2012/10/06)

[更新] Ajaxコールとの関連で発生する問題についての囲み記事を追加しました。(2012/10/24)

[更新] --boostrap--bootstrap に訂正。(2012/11/02)