CapybaraでActionCableの機能テストをする。
サーバーで全機能を動かしてのテストになるので、単体テストを行うときとはちがって、テストケースを書く際に特殊なことはありません。
ただし、いくつかの足をひっかけるポイントがありました。
何回かに1度遭遇するようなタイミングで起こる足払いもあり、確認しきれていない可能性もあります。(一応40.timesとかで同じテストケースを回して確認しました)
バージョン
- ActionCable (5.0.0.rc1)
- RSpec (3.1.0)
- Capybara (2.7.1)
- PhantomJS (2.1.1)
マルチセッションでテストするときのvisitは最初から
WebSocketを利用した機能のテストということで、同時接続のテストをしたいと思います。Capybaraは以下のメソッドで新しいセッションを作成できますが、その際visitはhttp://からはじまる完全なものが必要になります。
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::TestFixturesでrun_in_transaction?が関わっているメソッドでわかります。RSpecの中を必死に探していて全然みつからなかった……)
対応
unsubscribedなどがテスト終了後に呼び出されるようなテストケースを書かない
(接続が完結するテストケースを書く)use_transactional_fixturesを使わない- ハックを使わない
などが考えられました。
とりあえず直接モデルを参照しようとするのがそもそもの間違いかなということで、ハックをやめました。
WebSocketクライアントを用いたテストは
これは原因がよくわからなかったのですが、Capybara内部からWebSocket::Client::Simpleを用いてアクセスすると、とんでもない重さでした。別のテストサーバーを立てて、そこへ別の素のRSpecのテストケースからアクセスするのがいいかなと思います。
メッセージの準備など手間がかかるのであまりやりませんでしたので、ただの走り書きっぽくなっています。
非同期であることに注意
Capybaraのfindなどはその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::Baseのreceiveをオーバーライドなどして覗き見するとよくわかります。
def receive(websocket_message)
pp websocket_message
super
end
とにかく非同期に足をひっかける
ここ1週間ほどActionCableにかかりっきりでしたが、不可解な動作やエラーはほぼ非同期処理が原因でした。
ActionCable::Connection::Base#send_asyncが関わる動作には注意したほうがよさそうです。(ほぼ全部ですが)