What is Action Cable?
Rails 5 added Action Cable, as a new framework that is used to implement Websockets in Rails. With this we can build realtime applications in Rails.
Testing Action Cable prior to Rails 6
Prior to Rails 6, there were no tools for testing Action Cable functionality provided out of the box. One can use action-cable-testing gem which provides test utils for Action Cable.
Rails 6 adds tools for Action Cable testing
In Rails 6, the action-cable-testing gem was merged into Rails, in addition to other additional utilities. so now we can test Action Cable functionalities at different levels.
Testing Action Cable
Testing connection
Consider the following example:
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_user
end
private
def find_user
User.find_by(id: cookies.signed[:current_user_id]) || reject_unauthorized_connection
end
end
end
Here its assumed that authentication is already handled somewhere else and
that signed cookie is set for current user.
To test this connection class,
we can use connect
method to simulate a client server connection and
then test the state of connection
is as expected.
The connection
object is available in the test.
class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase
def test_connection_success_when_cookie_is_set_correctly
user = users(:naren)
cookies.signed["current_user_id"] = user.id
# You can set plain or signed or encrypted cookies.
# cookies["current_user_id"] = user.id or
# cookies.encrypted["current_user_id"] = user.id
# Simulate the connection
connect
# Assert if the correct user is set
assert_equal user.id, connection.current_user.id
end
def test_connection_rejected_without_cookie_set
assert_reject_connection { connect }
end
end
The connect
method also accepts following parameters
params
, headers
, session
and Rack env
,
that can be used to specify more details of HTTP request.
module ApplicationCable
class Connection < ActionCable::Connection::Base
...
# Lets say the method of find_user in above connection class tries to find user by token
def find_user
User.find_by(auth_token: request.headers["x-api-token"]) || reject_unauthorized_connection
end
...
end
end
def test_connect_with_headers_and_query_string
connect params: { key1: "val1" }, headers: { "X-API-TOKEN" => "secret-token" }, session: {session_var: "value"}
assert_equal "secret-token", connection.user.auth_token
end
Testing channel
When we generate a channel using rails g channel ChannelName
,
rails will generate corresponding test file for the channel in test/channels
.
Lets say we have a CommentaryChannel
# app/channels/commentary_channel.rb
class CommentaryChannel < ApplicationCable::Channel
def subscribed
# `reject`s the subscription if proper params not present
reject unless params[:match_id]
# Create stream only if valid match id present
if match_exists?(params[:match_id])
stream_from "match_#{params[:match_id]}"
end
end
end
# Somewhere else in the code you can broadcast the message or comment using broadcast method
# ActionCable.server.broadcast("match_#{match_id}", {comment: "test comment"})
We can test this CommentaryChannel
as below
require "test_helper"
class CommentaryChannelTest < ActionCable::Channel::TestCase
test "subscribes and stream for a match" do
# Simulates the subscription to the channel
subscribe match_id: "1"
# The channel object is available as `subscription` identifier.
# We can check that subscription was successfully created.
assert subscription.confirmed?
# We can check that channel subscribed the connection to correct stream
assert_has_stream "match_1"
end
test "no stream for invalid match" do
subscribe match_id: "-1"
assert_no_streams
end
test "no subscription if match identifier not present" do
subscribe
assert subscription.rejected?
end
end
Testing broadcasts
Broadcasting as the name suggests is used to publish message, which is received by that channel subscribers. It can be called from anywhere in the Rails application, as follows:
CommentaryChannel.broadcast_to match_identifier, comment: "Hello and welcome everyone!!"
Rails adds custom assertions, assert_broadcast_on
which asserts specific message on channel stream,
assert_broadcasts
which asserts the number of messages sent to stream and assert_no_broadcasts
which asserts no messages sent to stream.
Consider the above example of CommentaryChannel. Lets say we have a job to publish commentary:
class PublishCommentaryJob < ApplicationJob
def perform(match_id, comment)
return if match_id < 1 # invalid match id
CommentaryChannel.broadcast_to "match_#{match_id}", comment: comment
end
end
We can test the above as follows:
require "test_helper"
class PublishCommentaryJobTest < ActionCable::Channel::TestCase
include ActiveJob::TestHelper
# `assert_broadcast_on` asserts exact message sent on a channel stream.
test "publishes commentary" do
perform_enqueued_jobs do
assert_broadcast_on(CommentaryChannel.broadcasting_for('match_1'), comment: "Hello and welcome everyone!!") do
PublishCommentaryJob.perform_later(1, "Hello and welcome everyone!!")
end
end
end
# `assert_broadcasts` asserts the number of messages sent to stream
test "asserts number of messages" do
perform_enqueued_jobs do
PublishCommentaryJob.perform_later(1, "Hello and welcome everyone!!")
assert_broadcasts CommentaryChannel.broadcasting_for('match_1'), 1
end
end
# `assert_no_broadcasts` asserts no messages sent to stream
test "no comment published if invalid match id" do
perform_enqueued_jobs do
PublishCommentaryJob.perform_later(-1, "Hello and welcome everyone!!")
assert_no_broadcasts CommentaryChannel.broadcasting_for('match_1')
end
end
end
More details about these newly introduced methods can be found at the ActionCable::Connection::TestCase documentation.