CapybaraでActionCableの機能テストをする。

サーバーで全機能を動かしてのテストになるので、単体テストを行うときとはちがって、テストケースを書く際に特殊なことはありません。

ただし、いくつかの足をひっかけるポイントがありました。

何回かに1度遭遇するようなタイミングで起こる足払いもあり、確認しきれていない可能性もあります。(一応40.timesとかで同じテストケースを回して確認しました)

バージョン

マルチセッションでテストするときのvisitは最初から

WebSocketを利用した機能のテストということで、同時接続のテストをしたいと思います。Capybaraは以下のメソッドで新しいセッションを作成できますが、その際visithttp://からはじまる完全なものが必要になります。

def mount_path
  @mount_path ||= begin
    mount = Rails.application.config.action_cable.mount_path
    test_host + mount
  end
end

def test_host
  host = Capybara.current_session.server.host
  port = Capybara.current_session.server.port
  "http://#{host}:#{port}"
end
session = Capybara::Session.new(:poltergeist)
session.visit(mount_path)

マルチセッションのテスト後にサーバーがとまらず、テストが終わらない

毎回起こるわけではありませんが、接続が生きつづけ、たとえばJavaScriptのconsole.logからログが延々と流されてくる状況です。

これはテストケース終了時に明示的にreset_session!することで回避できます。

session = Capybara::Session.new(:poltergeist)
# テスト内容
session.reset_session!

止まっているように見えて止まっていない

新しいCapybara::Session.newではなく、page.open_new_windowで新しいsessionを得てWebSocketが生きたままでテストケースを終えると、config.use_transactional_fixturesうまく作用せず、データがロールバックされないまま次のテストケースが始まってしまうようです。

なんらかの、本筋以外のsessionが生じた場合は、明示的にreset_session!するとよいでしょう。

ActionCable内部の並列処理とデータベース更新が組み合わさった時エラー

これも毎回起こるわけではありません。

原因

config.use_transactional_fixtures = true

上の設定をした上で以下のハックを行うと発生します。

class ActiveRecord::Base
  mattr_accessor :shared_connection
  @@shared_connection = nil

  def self.connection
    @@shared_connection || retrieve_connection
  end
end

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

これはCapybaraのテストケースから、直接ActiveRecord::Baseを継承したモデルを参照するためのものです。一般的なものなので、使っている人も多いと思います。

なぜ

ActionCableの処理の多くは非同期に行われます。テストケースが終わってから実行される場合もあります。

config.use_transactional_fixturesを有効にしていると、テストケースが終わるたびに保持されたtransactionをもとにロールバックが起こり、あたらしいtransactionに移ります。

unsubscribedなど非同期に呼ばれるメソッド内でsave!などを実行していると、3回に1回ぐらい、次のテストケースがはじまるかはじまらないかの微妙なタイミングでsave!が呼ばれることがあります。この時に失敗して例外が発生します。

(おそらく、)すでにテストケースは終了しているので、本来ならば問題にならないはずが、上のハックではtransactionの持ち主の@@shared_connectionを全テストケースを通じて使いまわしますので、今現在走っているテストケースで発生したものとして扱われ、テストが落ちます。

ということで

There was an exception - ActiveRecord::StatementInvalid(PG::NoActiveSqlTransaction: ERROR:  ROLLBACK TO SAVEPOINTはトランザクションブロック内でのみ使用できます
: ROLLBACK TO SAVEPOINT active_record_1)

ActiveRecord::StatementInvalid:
       SQLite3::SQLException: no such savepoint: active_record_1: ROLLBACK TO SAVEPOINT active_record_1

といったエラーに遭遇することになります。

(ちなみにconfig.use_transactional_fixtures = trueで何が起こるかはActiveRecord::TestFixturesrun_in_transaction?が関わっているメソッドでわかります。RSpecの中を必死に探していて全然みつからなかった……)

対応

などが考えられました。

とりあえず直接モデルを参照しようとするのがそもそもの間違いかなということで、ハックをやめました。

WebSocketクライアントを用いたテストは

これは原因がよくわからなかったのですが、Capybara内部からWebSocket::Client::Simpleを用いてアクセスすると、とんでもない重さでした。別のテストサーバーを立てて、そこへ別の素のRSpecのテストケースからアクセスするのがいいかなと思います。

メッセージの準備など手間がかかるのであまりやりませんでしたので、ただの走り書きっぽくなっています。

非同期であることに注意

CapybarafindなどはそのDOM要素が出現するまでしばらく待つ機能があるので気になりませんが、素のRSpecで書く場合はなんらかの手段を用意しなければなりません。

received = []
ws.on :message do |data|
  received.push JSON.parse(data.data)
end

などとしておいて、receivedに変化が生じたり、望みの値が届くまで待つ処理が必要になります。

receivedが配列であるのは、ちょっとしたタイミングで次の値に更新されてしまうからです。テスト時にはArray#selectなどで溜まった値からピックアップ後、検査することになります。

送るメッセージ

def make_message(channel, data)
  {command: 'message', identifier: {channel: channel.to_s}.to_json, data: data.to_json}.to_json
end

def make_subscribe(channel)
  {command: 'subscribe', identifier: {channel: channel.to_s}.to_json}.to_json
end

def make_perform(channel, perform)
  {command: 'message', identifier: {channel: channel.to_s}.to_json, data: {action: perform}.to_json}.to_json
end

どのようなメッセージが届いているかは、ActionCable::Connection::Basereceiveをオーバーライドなどして覗き見するとよくわかります。

def receive(websocket_message)
  pp websocket_message
  super
end

とにかく非同期に足をひっかける

ここ1週間ほどActionCableにかかりっきりでしたが、不可解な動作やエラーはほぼ非同期処理が原因でした。

ActionCable::Connection::Base#send_asyncが関わる動作には注意したほうがよさそうです。(ほぼ全部ですが)