mirror of https://github.com/rails/rails
Add ActionCable::Channel::TestCase
ActionCable::Channel::TestCase provides an ability to unit-test channel classes. There are several reasons to write unit/functional cable tests: - Access control (who has access to the channel? who can perform action and with which argument? - Frontend-less applications have no system tests at all–and we still need a way to test channels logic. See also #27191
This commit is contained in:
parent
7f870a5ba2
commit
8541394e71
|
@ -11,6 +11,7 @@ module ActionCable
|
|||
autoload :Naming
|
||||
autoload :PeriodicTimers
|
||||
autoload :Streams
|
||||
autoload :TestCase
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,275 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "active_support"
|
||||
require "active_support/test_case"
|
||||
require "active_support/core_ext/hash/indifferent_access"
|
||||
require "json"
|
||||
|
||||
module ActionCable
|
||||
module Channel
|
||||
class NonInferrableChannelError < ::StandardError
|
||||
def initialize(name)
|
||||
super "Unable to determine the channel to test from #{name}. " +
|
||||
"You'll need to specify it using `tests YourChannel` in your " +
|
||||
"test case definition."
|
||||
end
|
||||
end
|
||||
|
||||
# Stub `stream_from` to track streams for the channel.
|
||||
# Add public aliases for `subscription_confirmation_sent?` and
|
||||
# `subscription_rejected?`.
|
||||
module ChannelStub
|
||||
def confirmed?
|
||||
subscription_confirmation_sent?
|
||||
end
|
||||
|
||||
def rejected?
|
||||
subscription_rejected?
|
||||
end
|
||||
|
||||
def stream_from(broadcasting, *)
|
||||
streams << broadcasting
|
||||
end
|
||||
|
||||
def stop_all_streams
|
||||
@_streams = []
|
||||
end
|
||||
|
||||
def streams
|
||||
@_streams ||= []
|
||||
end
|
||||
|
||||
# Make periodic timers no-op
|
||||
def start_periodic_timers; end
|
||||
alias stop_periodic_timers start_periodic_timers
|
||||
end
|
||||
|
||||
class ConnectionStub
|
||||
attr_reader :transmissions, :identifiers, :subscriptions, :logger
|
||||
|
||||
def initialize(identifiers = {})
|
||||
@transmissions = []
|
||||
|
||||
identifiers.each do |identifier, val|
|
||||
define_singleton_method(identifier) { val }
|
||||
end
|
||||
|
||||
@subscriptions = ActionCable::Connection::Subscriptions.new(self)
|
||||
@identifiers = identifiers.keys
|
||||
@logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new)
|
||||
end
|
||||
|
||||
def transmit(cable_message)
|
||||
transmissions << cable_message.with_indifferent_access
|
||||
end
|
||||
end
|
||||
|
||||
# Superclass for Action Cable channel functional tests.
|
||||
#
|
||||
# == Basic example
|
||||
#
|
||||
# Functional tests are written as follows:
|
||||
# 1. First, one uses the +subscribe+ method to simulate subscription creation.
|
||||
# 2. Then, one asserts whether the current state is as expected. "State" can be anything:
|
||||
# transmitted messages, subscribed streams, etc.
|
||||
#
|
||||
# For example:
|
||||
#
|
||||
# class ChatChannelTest < ActionCable::Channel::TestCase
|
||||
# def test_subscribed_with_room_number
|
||||
# # Simulate a subscription creation
|
||||
# subscribe room_number: 1
|
||||
#
|
||||
# # Asserts that the subscription was successfully created
|
||||
# assert subscription.confirmed?
|
||||
#
|
||||
# # Asserts that the channel subscribes connection to a stream
|
||||
# assert_equal "chat_1", streams.last
|
||||
# end
|
||||
#
|
||||
# def test_does_not_subscribe_without_room_number
|
||||
# subscribe
|
||||
#
|
||||
# # Asserts that the subscription was rejected
|
||||
# assert subscription.rejected?
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# You can also perform actions:
|
||||
# def test_perform_speak
|
||||
# subscribe room_number: 1
|
||||
#
|
||||
# perform :speak, message: "Hello, Rails!"
|
||||
#
|
||||
# assert_equal "Hello, Rails!", transmissions.last["text"]
|
||||
# end
|
||||
#
|
||||
# == Special methods
|
||||
#
|
||||
# ActionCable::Channel::TestCase will also automatically provide the following instance
|
||||
# methods for use in the tests:
|
||||
#
|
||||
# <b>connection</b>::
|
||||
# An ActionCable::Channel::ConnectionStub, representing the current HTTP connection.
|
||||
# <b>subscription</b>::
|
||||
# An instance of the current channel, created when you call `subscribe`.
|
||||
# <b>transmissions</b>::
|
||||
# A list of all messages that have been transmitted into the channel.
|
||||
# <b>streams</b>::
|
||||
# A list of all created streams subscriptions (as identifiers) for the subscription.
|
||||
#
|
||||
#
|
||||
# == Channel is automatically inferred
|
||||
#
|
||||
# ActionCable::Channel::TestCase will automatically infer the channel under test
|
||||
# from the test class name. If the channel cannot be inferred from the test
|
||||
# class name, you can explicitly set it with +tests+.
|
||||
#
|
||||
# class SpecialEdgeCaseChannelTest < ActionCable::Channel::TestCase
|
||||
# tests SpecialChannel
|
||||
# end
|
||||
#
|
||||
# == Specifying connection identifiers
|
||||
#
|
||||
# You need to set up your connection manually to privide values for the identifiers.
|
||||
# To do this just use:
|
||||
#
|
||||
# stub_connection(user: users[:john])
|
||||
#
|
||||
# == Testing broadcasting
|
||||
#
|
||||
# ActionCable::Channel::TestCase enhances ActionCable::TestHelper assertions (e.g.
|
||||
# +assert_broadcasts+) to handle broadcasting to models:
|
||||
#
|
||||
#
|
||||
# # in your channel
|
||||
# def speak(data)
|
||||
# broadcast_to room, text: data["message"]
|
||||
# end
|
||||
#
|
||||
# def test_speak
|
||||
# subscribe room_id: rooms[:chat].id
|
||||
#
|
||||
# assert_broadcasts_on(rooms[:chat], text: "Hello, Rails!") do
|
||||
# perform :speak, message: "Hello, Rails!"
|
||||
# end
|
||||
# end
|
||||
class TestCase < ActiveSupport::TestCase
|
||||
module Behavior
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include ActiveSupport::Testing::ConstantLookup
|
||||
include ActionCable::TestHelper
|
||||
|
||||
CHANNEL_IDENTIFIER = "test_stub"
|
||||
|
||||
included do
|
||||
class_attribute :_channel_class
|
||||
|
||||
attr_reader :connection, :subscription
|
||||
delegate :streams, to: :subscription
|
||||
|
||||
ActiveSupport.run_load_hooks(:action_cable_channel_test_case, self)
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
def tests(channel)
|
||||
case channel
|
||||
when String, Symbol
|
||||
self._channel_class = channel.to_s.camelize.constantize
|
||||
when Module
|
||||
self._channel_class = channel
|
||||
else
|
||||
raise NonInferrableChannelError.new(channel)
|
||||
end
|
||||
end
|
||||
|
||||
def channel_class
|
||||
if channel = self._channel_class
|
||||
channel
|
||||
else
|
||||
tests determine_default_channel(name)
|
||||
end
|
||||
end
|
||||
|
||||
def determine_default_channel(name)
|
||||
channel = determine_constant_from_test_name(name) do |constant|
|
||||
Class === constant && constant < ActionCable::Channel::Base
|
||||
end
|
||||
raise NonInferrableChannelError.new(name) if channel.nil?
|
||||
channel
|
||||
end
|
||||
end
|
||||
|
||||
# Setup test connection with the specified identifiers:
|
||||
#
|
||||
# class ApplicationCable < ActionCable::Connection::Base
|
||||
# identified_by :user, :token
|
||||
# end
|
||||
#
|
||||
# stub_connection(user: users[:john], token: 'my-secret-token')
|
||||
def stub_connection(identifiers = {})
|
||||
@connection = ConnectionStub.new(identifiers)
|
||||
end
|
||||
|
||||
# Subsribe to the channel under test. Optionally pass subscription parameters as a Hash.
|
||||
def subscribe(params = {})
|
||||
@connection ||= stub_connection
|
||||
# NOTE: Rails < 5.0.1 calls subscribe_to_channel during #initialize.
|
||||
# We have to stub before it
|
||||
@subscription = self.class.channel_class.allocate
|
||||
@subscription.singleton_class.include(ChannelStub)
|
||||
@subscription.send(:initialize, connection, CHANNEL_IDENTIFIER, params.with_indifferent_access)
|
||||
# Call subscribe_to_channel if it's public (Rails 5.0.1+)
|
||||
@subscription.subscribe_to_channel if ActionCable.gem_version >= Gem::Version.new("5.0.1")
|
||||
@subscription
|
||||
end
|
||||
|
||||
# Unsubscribe the subscription under test.
|
||||
def unsubscribe
|
||||
check_subscribed!
|
||||
subscription.unsubscribe_from_channel
|
||||
end
|
||||
|
||||
# Perform action on a channel.
|
||||
#
|
||||
# NOTE: Must be subscribed.
|
||||
def perform(action, data = {})
|
||||
check_subscribed!
|
||||
subscription.perform_action(data.stringify_keys.merge("action" => action.to_s))
|
||||
end
|
||||
|
||||
# Returns messages transmitted into channel
|
||||
def transmissions
|
||||
# Return only directly sent message (via #transmit)
|
||||
connection.transmissions.map { |data| data["message"] }.compact
|
||||
end
|
||||
|
||||
# Enhance TestHelper assertions to handle non-String
|
||||
# broadcastings
|
||||
def assert_broadcasts(stream_or_object, *args)
|
||||
super(broadcasting_for(stream_or_object), *args)
|
||||
end
|
||||
|
||||
def assert_broadcast_on(stream_or_object, *args)
|
||||
super(broadcasting_for(stream_or_object), *args)
|
||||
end
|
||||
|
||||
private
|
||||
def check_subscribed!
|
||||
raise "Must be subscribed!" if subscription.nil? || subscription.rejected?
|
||||
end
|
||||
|
||||
def broadcasting_for(stream_or_object)
|
||||
return stream_or_object if stream_or_object.is_a?(String)
|
||||
|
||||
self.class.channel_class.broadcasting_for(
|
||||
[self.class.channel_class.channel_name, stream_or_object]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
include Behavior
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,188 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class TestTestChannel < ActionCable::Channel::Base
|
||||
end
|
||||
|
||||
class NonInferrableExplicitClassChannelTest < ActionCable::Channel::TestCase
|
||||
tests TestTestChannel
|
||||
|
||||
def test_set_channel_class_manual
|
||||
assert_equal TestTestChannel, self.class.channel_class
|
||||
end
|
||||
end
|
||||
|
||||
class NonInferrableSymbolNameChannelTest < ActionCable::Channel::TestCase
|
||||
tests :test_test_channel
|
||||
|
||||
def test_set_channel_class_manual_using_symbol
|
||||
assert_equal TestTestChannel, self.class.channel_class
|
||||
end
|
||||
end
|
||||
|
||||
class NonInferrableStringNameChannelTest < ActionCable::Channel::TestCase
|
||||
tests "test_test_channel"
|
||||
|
||||
def test_set_channel_class_manual_using_string
|
||||
assert_equal TestTestChannel, self.class.channel_class
|
||||
end
|
||||
end
|
||||
|
||||
class SubscriptionsTestChannel < ActionCable::Channel::Base
|
||||
end
|
||||
|
||||
class SubscriptionsTestChannelTest < ActionCable::Channel::TestCase
|
||||
def setup
|
||||
stub_connection
|
||||
end
|
||||
|
||||
def test_no_subscribe
|
||||
assert_nil subscription
|
||||
end
|
||||
|
||||
def test_subscribe
|
||||
subscribe
|
||||
|
||||
assert subscription.confirmed?
|
||||
assert_not subscription.rejected?
|
||||
assert_equal 1, connection.transmissions.size
|
||||
assert_equal ActionCable::INTERNAL[:message_types][:confirmation],
|
||||
connection.transmissions.last["type"]
|
||||
end
|
||||
end
|
||||
|
||||
class StubConnectionTest < ActionCable::Channel::TestCase
|
||||
tests SubscriptionsTestChannel
|
||||
|
||||
def test_connection_identifiers
|
||||
stub_connection username: "John", admin: true
|
||||
|
||||
subscribe
|
||||
|
||||
assert_equal "John", subscription.username
|
||||
assert subscription.admin
|
||||
end
|
||||
end
|
||||
|
||||
class RejectionTestChannel < ActionCable::Channel::Base
|
||||
def subscribed
|
||||
reject
|
||||
end
|
||||
end
|
||||
|
||||
class RejectionTestChannelTest < ActionCable::Channel::TestCase
|
||||
def test_rejection
|
||||
subscribe
|
||||
|
||||
assert_not subscription.confirmed?
|
||||
assert subscription.rejected?
|
||||
assert_equal 1, connection.transmissions.size
|
||||
assert_equal ActionCable::INTERNAL[:message_types][:rejection],
|
||||
connection.transmissions.last["type"]
|
||||
end
|
||||
end
|
||||
|
||||
class StreamsTestChannel < ActionCable::Channel::Base
|
||||
def subscribed
|
||||
stream_from "test_#{params[:id] || 0}"
|
||||
end
|
||||
end
|
||||
|
||||
class StreamsTestChannelTest < ActionCable::Channel::TestCase
|
||||
def test_stream_without_params
|
||||
subscribe
|
||||
|
||||
assert_equal "test_0", streams.last
|
||||
end
|
||||
|
||||
def test_stream_with_params
|
||||
subscribe id: 42
|
||||
|
||||
assert_equal "test_42", streams.last
|
||||
end
|
||||
end
|
||||
|
||||
class PerformTestChannel < ActionCable::Channel::Base
|
||||
def echo(data)
|
||||
data.delete("action")
|
||||
transmit data
|
||||
end
|
||||
|
||||
def ping
|
||||
transmit type: "pong"
|
||||
end
|
||||
end
|
||||
|
||||
class PerformTestChannelTest < ActionCable::Channel::TestCase
|
||||
def setup
|
||||
stub_connection user_id: 2016
|
||||
subscribe id: 5
|
||||
end
|
||||
|
||||
def test_perform_with_params
|
||||
perform :echo, text: "You are man!"
|
||||
|
||||
assert_equal({ "text" => "You are man!" }, transmissions.last)
|
||||
end
|
||||
|
||||
def test_perform_and_transmit
|
||||
perform :ping
|
||||
|
||||
assert_equal "pong", transmissions.last["type"]
|
||||
end
|
||||
end
|
||||
|
||||
class PerformUnsubscribedTestChannelTest < ActionCable::Channel::TestCase
|
||||
tests PerformTestChannel
|
||||
|
||||
def test_perform_when_unsubscribed
|
||||
assert_raises do
|
||||
perform :echo
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class BroadcastsTestChannel < ActionCable::Channel::Base
|
||||
def broadcast(data)
|
||||
ActionCable.server.broadcast(
|
||||
"broadcast_#{params[:id]}",
|
||||
text: data["message"], user_id: user_id
|
||||
)
|
||||
end
|
||||
|
||||
def broadcast_to_user(data)
|
||||
user = User.new user_id
|
||||
|
||||
self.class.broadcast_to user, text: data["message"]
|
||||
end
|
||||
end
|
||||
|
||||
class BroadcastsTestChannelTest < ActionCable::Channel::TestCase
|
||||
def setup
|
||||
stub_connection user_id: 2017
|
||||
subscribe id: 5
|
||||
end
|
||||
|
||||
def test_broadcast_matchers_included
|
||||
assert_broadcast_on("broadcast_5", user_id: 2017, text: "SOS") do
|
||||
perform :broadcast, message: "SOS"
|
||||
end
|
||||
end
|
||||
|
||||
def test_broadcast_to_object
|
||||
user = User.new(2017)
|
||||
|
||||
assert_broadcasts(user, 1) do
|
||||
perform :broadcast_to_user, text: "SOS"
|
||||
end
|
||||
end
|
||||
|
||||
def test_broadcast_to_object_with_data
|
||||
user = User.new(2017)
|
||||
|
||||
assert_broadcast_on(user, text: "SOS") do
|
||||
perform :broadcast_to_user, message: "SOS"
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue