Rails 6 adds tools for Action Cable testing.


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.