├── .gem_release.yml ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .rubocop.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── action-cable-testing.gemspec ├── cucumber.yml ├── features ├── .nav ├── Generators.md ├── Minitest.md ├── README.md ├── channel_specs │ └── channel_spec.feature ├── matchers │ ├── have_broadcasted_matcher.feature │ └── have_stream_from_matcher.feature ├── shared_contexts │ └── shared_contexts.feature ├── step_definitions │ └── additional_cli_steps.rb └── support │ └── env.rb ├── gemfiles ├── rails50.gemfile ├── rails5001.gemfile ├── rails51.gemfile ├── rails52.gemfile ├── rspec4rails5.gemfile └── rspec4rails6.gemfile ├── lib ├── action-cable-testing.rb ├── action_cable │ ├── subscription_adapter │ │ └── test.rb │ ├── testing.rb │ └── testing │ │ ├── channel │ │ └── test_case.rb │ │ ├── connection │ │ └── test_case.rb │ │ ├── rails_six.rb │ │ ├── rspec.rb │ │ ├── rspec │ │ └── features.rb │ │ ├── test_case.rb │ │ ├── test_helper.rb │ │ └── version.rb ├── generators │ ├── rspec │ │ └── channel │ │ │ ├── channel_generator.rb │ │ │ └── templates │ │ │ └── channel_spec.rb.erb │ └── test_unit │ │ └── channel │ │ ├── channel_generator.rb │ │ └── templates │ │ └── unit_test.rb.erb └── rspec │ └── rails │ ├── example │ └── channel_example_group.rb │ ├── matchers │ ├── action_cable.rb │ └── action_cable │ │ ├── have_broadcasted_to.rb │ │ └── have_streams.rb │ └── shared_contexts │ └── action_cable.rb ├── spec ├── dummy │ ├── .rspec │ ├── app │ │ ├── channels │ │ │ ├── application_cable │ │ │ │ ├── channel.rb │ │ │ │ └── connection.rb │ │ │ ├── chat_channel.rb │ │ │ ├── echo_channel.rb │ │ │ └── user_channel.rb │ │ └── models │ │ │ ├── broadcaster.rb │ │ │ └── user.rb │ ├── bin │ │ └── rails │ ├── config.ru │ ├── config │ │ ├── application.rb │ │ ├── boot.rb │ │ ├── cable.yml │ │ ├── environment.rb │ │ ├── puma.rb │ │ ├── routes.rb │ │ └── secrets.yml │ └── spec │ │ ├── rails_helper.rb │ │ └── spec_helper.rb ├── generators │ ├── rspec_spec.rb │ └── test_unit_spec.rb ├── rspec │ └── rails │ │ ├── channel_example_group_spec.rb │ │ └── matchers │ │ └── action_cable │ │ ├── have_broadcasted_to_spec.rb │ │ └── have_stream_spec.rb ├── spec_helper.rb └── support │ ├── deprecated_api.rb │ ├── helpers.rb │ └── shared_examples.rb └── test ├── channel └── test_case_test.rb ├── connection └── test_case_test.rb ├── stubs ├── global_id.rb ├── test_adapter.rb ├── test_server.rb └── user.rb ├── syntax_test.rb ├── test_helper.rb └── test_helper_test.rb /.gem_release.yml: -------------------------------------------------------------------------------- 1 | bump: 2 | file: lib/action_cable/testing/version.rb 3 | skip_ci: true 4 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | rake: 11 | runs-on: ubuntu-latest 12 | env: 13 | BUNDLE_JOBS: 4 14 | BUNDLE_RETRY: 3 15 | BUNDLE_FORCE_RUBY_PLATFORM: 1 16 | CI: true 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | ruby: ["2.7"] 21 | gemfile: ["gemfiles/rails52.gemfile"] 22 | bundler: ["2"] 23 | include: 24 | - ruby: "2.5" 25 | gemfile: "gemfiles/rails5001.gemfile" 26 | bundler: "1" 27 | - ruby: "2.6" 28 | gemfile: "gemfiles/rails51.gemfile" 29 | bundler: "2" 30 | - ruby: "2.6" 31 | gemfile: "gemfiles/rspec4rails5.gemfile" 32 | bundler: "2" 33 | - ruby: "2.7" 34 | gemfile: "gemfiles/rspec4rails6.gemfile" 35 | bundler: "2" 36 | steps: 37 | - uses: actions/checkout@v2 38 | - uses: actions/cache@v1 39 | with: 40 | path: /home/runner/bundle 41 | key: bundle-${{ matrix.ruby }}-${{ matrix.gemfile }}-${{ hashFiles('**/*.gemspec') }}-${{ hashFiles('**/Gemfile') }} 42 | restore-keys: | 43 | bundle-${{ matrix.ruby }}-${{ matrix.gemfile }}- 44 | - uses: ruby/setup-ruby@v1 45 | with: 46 | ruby-version: ${{ matrix.ruby }} 47 | bundler: ${{ matrix.bundler }} 48 | - name: Bundle install 49 | run: | 50 | bundle config path /home/runner/bundle 51 | bundle config --global gemfile ${{ matrix.gemfile }} 52 | bundle install 53 | bundle update 54 | - name: Run RSpec 55 | run: | 56 | bundle exec rake 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | .byebug_history 11 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.4 3 | # RuboCop has a bunch of cops enabled by default. This setting tells RuboCop 4 | # to ignore them, so only the ones explicitly set in this file are enabled. 5 | DisabledByDefault: true 6 | Exclude: 7 | - '**/templates/**/*' 8 | - '**/vendor/**/*' 9 | - 'gemfiles/**/*' 10 | - '**/*.gemfile' 11 | - 'actionpack/lib/action_dispatch/journey/parser.rb' 12 | - 'tmp/**/*' 13 | - 'spec/dummy/**/*' 14 | - 'spec/support/**/*' 15 | - 'spec/rspec/**/*' 16 | - 'features/**/*' 17 | 18 | # Prefer &&/|| over and/or. 19 | Style/AndOr: 20 | Enabled: true 21 | 22 | # Do not use braces for hash literals when they are the last argument of a 23 | # method call. 24 | Style/BracesAroundHashParameters: 25 | Enabled: true 26 | EnforcedStyle: context_dependent 27 | 28 | # Align `when` with `case`. 29 | Layout/CaseIndentation: 30 | Enabled: true 31 | 32 | # Align comments with method definitions. 33 | Layout/CommentIndentation: 34 | Enabled: true 35 | 36 | Layout/EmptyLineAfterMagicComment: 37 | Enabled: true 38 | 39 | # In a regular class definition, no empty lines around the body. 40 | Layout/EmptyLinesAroundClassBody: 41 | Enabled: true 42 | 43 | # In a regular method definition, no empty lines around the body. 44 | Layout/EmptyLinesAroundMethodBody: 45 | Enabled: true 46 | 47 | # In a regular module definition, no empty lines around the body. 48 | Layout/EmptyLinesAroundModuleBody: 49 | Enabled: true 50 | 51 | Layout/IndentFirstArgument: 52 | Enabled: true 53 | 54 | # Use Ruby >= 1.9 syntax for hashes. Prefer { a: :b } over { :a => :b }. 55 | Style/HashSyntax: 56 | Enabled: true 57 | 58 | # Method definitions after `private` or `protected` isolated calls need one 59 | # extra level of indentation. 60 | Layout/IndentationConsistency: 61 | Enabled: true 62 | EnforcedStyle: rails 63 | 64 | # Two spaces, no tabs (for indentation). 65 | Layout/IndentationWidth: 66 | Enabled: true 67 | 68 | Layout/SpaceAfterColon: 69 | Enabled: true 70 | 71 | Layout/SpaceAfterComma: 72 | Enabled: true 73 | 74 | Layout/SpaceAroundEqualsInParameterDefault: 75 | Enabled: true 76 | 77 | Layout/SpaceAroundKeyword: 78 | Enabled: true 79 | 80 | Layout/SpaceAroundOperators: 81 | Enabled: true 82 | 83 | Layout/SpaceBeforeFirstArg: 84 | Enabled: true 85 | 86 | # Defining a method with parameters needs parentheses. 87 | Style/MethodDefParentheses: 88 | Enabled: true 89 | 90 | Style/FrozenStringLiteralComment: 91 | Enabled: true 92 | EnforcedStyle: always 93 | Exclude: 94 | - 'actionview/test/**/*.builder' 95 | - 'actionview/test/**/*.ruby' 96 | - 'actionpack/test/**/*.builder' 97 | - 'actionpack/test/**/*.ruby' 98 | - 'activestorage/db/migrate/**/*.rb' 99 | 100 | # Use `foo {}` not `foo{}`. 101 | Layout/SpaceBeforeBlockBraces: 102 | EnforcedStyleForEmptyBraces: space 103 | Enabled: true 104 | 105 | # Use `foo { bar }` not `foo {bar}`. 106 | Layout/SpaceInsideBlockBraces: 107 | Enabled: true 108 | 109 | # Use `{ a: 1 }` not `{a:1}`. 110 | Layout/SpaceInsideHashLiteralBraces: 111 | Enabled: true 112 | 113 | Layout/SpaceInsideParens: 114 | Enabled: true 115 | 116 | # Check quotes usage according to lint rule below. 117 | Style/StringLiterals: 118 | Enabled: true 119 | EnforcedStyle: double_quotes 120 | 121 | # Detect hard tabs, no hard tabs. 122 | Layout/Tab: 123 | Enabled: true 124 | 125 | # Blank lines should not have any spaces. 126 | Layout/TrailingBlankLines: 127 | Enabled: true 128 | 129 | # No trailing whitespace. 130 | Layout/TrailingWhitespace: 131 | Enabled: true 132 | 133 | # Use quotes for string literals when they are enough. 134 | Style/UnneededPercentQ: 135 | Enabled: true 136 | 137 | # Align `end` with the matching keyword or starting expression except for 138 | # assignments, where it should be aligned with the LHS. 139 | Layout/EndAlignment: 140 | Enabled: true 141 | EnforcedStyleAlignWith: variable 142 | 143 | # Use my_method(my_arg) not my_method( my_arg ) or my_method my_arg. 144 | Lint/RequireParentheses: 145 | Enabled: true 146 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## 0.6.1 (2020-03-02) 4 | 5 | - Fix Ruby 2.7 warnings. ([@palkan][]) 6 | 7 | ## 0.6.0 (2019-08-19) 8 | 9 | - **Ruby 2.4+** is required. 10 | 11 | - Fix Rails 6 compatibility. ([@palkan][]) 12 | 13 | ## 0.5.0 (2019-02-24) 14 | 15 | - Make compatible with Rails 6. ([@palkan][]) 16 | 17 | That allows using RSpec part of the gem with Rails 6 18 | (since Action Cable testing will included only in the upcoming RSpec 4). 19 | 20 | - Backport Rails 6.0 API changes. ([@palkan][], [@sponomarev][]) 21 | 22 | Some APIs have been deprecated (to be removed in 1.0). 23 | 24 | ## 0.4.0 (2019-01-10) 25 | 26 | - Add stream assert methods and matchers. ([@sponomarev][]) 27 | 28 | See https://github.com/palkan/action-cable-testing/pull/42 29 | 30 | - Add session support for connection. ([@sponomarev][]) 31 | 32 | See https://github.com/palkan/action-cable-testing/pull/35 33 | 34 | ## 0.3.0 35 | 36 | - Add connection unit-testing utilities. ([@palkan][]) 37 | 38 | See https://github.com/palkan/action-cable-testing/pull/6 39 | 40 | ## 0.2.0 41 | 42 | - Update minitest's `assert_broadcast_on` and `assert_broadcasts` matchers to support a record as an argument. ([@thesmartnik][]) 43 | 44 | See https://github.com/palkan/action-cable-testing/issues/11 45 | 46 | - Update `have_broadcasted_to` matcher to support a record as an argument. ([@thesmartnik][]) 47 | 48 | See https://github.com/palkan/action-cable-testing/issues/9 49 | 50 | ## 0.1.2 (2017-11-14) 51 | 52 | - Add RSpec shared contexts to switch between adapters. ([@palkan][]) 53 | 54 | See https://github.com/palkan/action-cable-testing/issues/4. 55 | 56 | ## 0.1.1 57 | 58 | - Support Rails 5.0.0.1. ([@palkan][]) 59 | 60 | ## 0.1.0 61 | 62 | - Initial version. ([@palkan][]) 63 | 64 | [@palkan]: https://github.com/palkan 65 | [@thesmartnik]: https://github.com/thesmartnik 66 | [@sponomarev]: https://github.com/sponomarev 67 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in action-cable-testing.gemspec 6 | gemspec 7 | 8 | gem "rails", "~> 6.0" 9 | 10 | gem "pry-byebug" 11 | 12 | local_gemfile = "Gemfile.local" 13 | 14 | if File.exist?(local_gemfile) 15 | eval(File.read(local_gemfile)) # rubocop:disable Security/Eval 16 | end 17 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Vladimir Dementyev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/action-cable-testing.svg)](https://rubygems.org/gems/action-cable-testing) 2 | ![Build](https://github.com/palkan/action-cable-testing/workflows/Build/badge.svg) 3 | [![Build Status](https://travis-ci.org/palkan/action-cable-testing.svg?branch=master)](https://travis-ci.org/palkan/action-cable-testing)[![Yard Docs](http://img.shields.io/badge/yard-docs-blue.svg)](http://www.rubydoc.info/gems/action-cable-testing) 4 | 5 | # Action Cable Testing 6 | 7 | This gem provides missing testing utils for [Action Cable][]. 8 | 9 | **NOTE:** this gem [has](https://github.com/rails/rails/pull/33659) [been](https://github.com/rails/rails/pull/33969) [merged](https://github.com/rails/rails/pull/34845) into Rails 6.0 and [into RSpec 4](https://github.com/rspec/rspec-rails/pull/2113). 10 | 11 | If you're using Minitest – you don't need this gem anymore. 12 | 13 | If you're using RSpec < 4, you still can use this gem to write Action Cable specs even for Rails 6. 14 | 15 | ## Installation 16 | # For Rails < 6.0 ONLY: 17 | 18 | Add this line to your application's Gemfile: 19 | 20 | ```ruby 21 | gem 'action-cable-testing' 22 | ``` 23 | 24 | And then execute: 25 | 26 | $ bundle 27 | 28 | # For Usage with Rspec (any version of Rails, including 6+): 29 | add to `spec/rails_helper.rb` 30 | ``` 31 | RSpec.configure do |config| 32 | //more rspec configs... 33 | config.include ActionCable::TestHelper 34 | end 35 | ``` 36 | 37 | (note in older versions of Rails you will make Rspec config changes in `spec_helper.rb`) 38 | 39 | ## Usage 40 | 41 | ### Test Adapter and Broadcasting 42 | 43 | We add `ActionCable::SubscriptionAdapter::Test` (very similar Active Job and Action Mailer tests adapters) and `ActionCable::TestCase` with a couple of matchers to track broadcasting messages in our tests: 44 | 45 | ```ruby 46 | # Using ActionCable::TestCase 47 | class MyCableTest < ActionCable::TestCase 48 | def test_broadcasts 49 | # Check the number of messages broadcasted to the stream 50 | assert_broadcasts 'messages', 0 51 | ActionCable.server.broadcast 'messages', { text: 'hello' } 52 | assert_broadcasts 'messages', 1 53 | 54 | # Check the number of messages broadcasted to the stream within a block 55 | assert_broadcasts('messages', 1) do 56 | ActionCable.server.broadcast 'messages', { text: 'hello' } 57 | end 58 | 59 | # Check that no broadcasts has been made 60 | assert_no_broadcasts('messages') do 61 | ActionCable.server.broadcast 'another_stream', { text: 'hello' } 62 | end 63 | end 64 | end 65 | 66 | # Or including ActionCable::TestHelper 67 | class ExampleTest < ActionDispatch::IntegrationTest 68 | include ActionCable::TestHelper 69 | 70 | def test_broadcasts 71 | room = rooms(:office) 72 | 73 | assert_broadcast_on("messages:#{room.id}", text: 'Hello!') do 74 | post "/say/#{room.id}", xhr: true, params: { message: 'Hello!' } 75 | end 76 | end 77 | end 78 | ``` 79 | 80 | If you want to test the broadcasting made with `Channel.broadcast_to`, you should use 81 | `Channel.broadcasting_for`\* to generate an underlying stream name and **use Rails 6 compatibility refinement**: 82 | 83 | ```ruby 84 | # app/jobs/chat_relay_job.rb 85 | class ChatRelayJob < ApplicationJob 86 | def perform_later(room, message) 87 | ChatChannel.broadcast_to room, text: message 88 | end 89 | end 90 | 91 | 92 | # test/jobs/chat_relay_job_test.rb 93 | require "test_helper" 94 | 95 | # Activate Rails 6 compatible API (for `broadcasting_for`) 96 | using ActionCable::Testing::Rails6 97 | 98 | class ChatRelayJobTest < ActiveJob::TestCase 99 | include ActionCable::TestHelper 100 | 101 | test "broadcast message to room" do 102 | room = rooms(:all) 103 | 104 | assert_broadcast_on(ChatChannel.broadcasting_for(room), text: "Hi!") do 105 | ChatRelayJob.perform_now(room, "Hi!") 106 | end 107 | end 108 | end 109 | ``` 110 | 111 | \* **NOTE:** in Rails 6.0 you should use `.broadcasting_for`, but it's not backward compatible 112 | and we cannot use it in Rails 5.x. See https://github.com/rails/rails/pull/35021. 113 | Note also, that this feature hasn't been released in Rails 6.0.0.beta1, so you still need the refinement. 114 | 115 | ### Channels Testing 116 | 117 | Channels tests are written as follows: 118 | 1. First, one uses the `subscribe` method to simulate subscription creation. 119 | 2. Then, one asserts whether the current state is as expected. "State" can be anything: 120 | transmitted messages, subscribed streams, etc. 121 | 122 | For example: 123 | 124 | ```ruby 125 | class ChatChannelTest < ActionCable::Channel::TestCase 126 | def test_subscribed_with_room_number 127 | # Simulate a subscription creation 128 | subscribe room_number: 1 129 | 130 | # Asserts that the subscription was successfully created 131 | assert subscription.confirmed? 132 | 133 | # Asserts that the channel subscribes connection to a stream 134 | assert_has_stream "chat_1" 135 | 136 | # Asserts that the channel subscribes connection to a stream created with `stream_for` 137 | assert_has_stream_for Room.find(1) 138 | end 139 | 140 | def test_subscribed_without_room_number 141 | subscribe 142 | 143 | assert subscription.confirmed? 144 | # Asserts that no streams was started 145 | # (e.g., we want to subscribe later by performing an action) 146 | assert_no_streams 147 | end 148 | 149 | def test_does_not_subscribe_with_invalid_room_number 150 | subscribe room_number: -1 151 | 152 | # Asserts that the subscription was rejected 153 | assert subscription.rejected? 154 | end 155 | end 156 | ``` 157 | 158 | You can also perform actions: 159 | 160 | ```ruby 161 | def test_perform_speak 162 | subscribe room_number: 1 163 | 164 | perform :speak, message: "Hello, Rails!" 165 | 166 | # `transmissions` stores messages sent directly to the channel (i.e. with `transmit` method) 167 | assert_equal "Hello, Rails!", transmissions.last["text"] 168 | end 169 | ``` 170 | 171 | You can set up your connection identifiers: 172 | 173 | ```ruby 174 | class ChatChannelTest < ActionCable::Channel::TestCase 175 | include ActionCable::TestHelper 176 | 177 | def test_identifiers 178 | stub_connection(user: users[:john]) 179 | 180 | subscribe room_number: 1 181 | 182 | assert_broadcast_on("messages_1", text: "I'm here!", from: "John") do 183 | perform :speak, message: "I'm here!" 184 | end 185 | end 186 | end 187 | ``` 188 | When broadcasting to an object: 189 | 190 | ```ruby 191 | class ChatChannelTest < ActionCable::Channel::TestCase 192 | def setup 193 | @room = Room.find 1 194 | 195 | stub_connection(user: users[:john]) 196 | subscribe room_number: room.id 197 | end 198 | 199 | def test_broadcasting 200 | assert_broadcasts(@room, 1) do 201 | perform :speak, message: "I'm here!" 202 | end 203 | end 204 | 205 | # or 206 | 207 | def test_broadcasted_data 208 | assert_broadcast_on(@room, text: "I'm here!", from: "John") do 209 | perform :speak, message: "I'm here!" 210 | end 211 | end 212 | end 213 | ``` 214 | 215 | ### Connection Testing 216 | 217 | Connection unit tests are written as follows: 218 | 1. First, one uses the `connect` method to simulate connection. 219 | 2. Then, one asserts whether the current state is as expected (e.g. identifiers). 220 | 221 | For example: 222 | 223 | ```ruby 224 | module ApplicationCable 225 | class ConnectionTest < ActionCable::Connection::TestCase 226 | def test_connects_with_cookies 227 | cookies.signed[:user_id] = users[:john].id 228 | 229 | # Simulate a connection 230 | connect 231 | 232 | # Asserts that the connection identifier is correct 233 | assert_equal "John", connection.user.name 234 | end 235 | 236 | def test_does_not_connect_without_user 237 | assert_reject_connection do 238 | connect 239 | end 240 | end 241 | end 242 | end 243 | ``` 244 | 245 | You can also provide additional information about underlying HTTP request: 246 | 247 | ```ruby 248 | def test_connect_with_headers_and_query_string 249 | connect "/cable?user_id=1", headers: { "X-API-TOKEN" => 'secret-my' } 250 | 251 | assert_equal connection.user_id, "1" 252 | end 253 | 254 | def test_connect_with_session 255 | connect "/cable", session: { users[:john].id } 256 | 257 | assert_equal connection.user_id, "1" 258 | end 259 | ``` 260 | 261 | ### RSpec Usage 262 | 263 | First, you need to have [rspec-rails](https://github.com/rspec/rspec-rails) installed. 264 | 265 | Second, add this to your `"rails_helper.rb"` after requiring `environment.rb`: 266 | 267 | ```ruby 268 | require "action_cable/testing/rspec" 269 | ``` 270 | 271 | To use `have_broadcasted_to` / `broadcast_to` matchers anywhere in your specs, set your adapter to `test` in `cable.yml`: 272 | 273 | ```yml 274 | # config/cable.yml 275 | test: 276 | adapter: test 277 | ``` 278 | 279 | And then use these matchers, for example: 280 | 281 | 282 | ```ruby 283 | RSpec.describe CommentsController do 284 | describe "POST #create" do 285 | expect { post :create, comment: { text: 'Cool!' } }.to 286 | have_broadcasted_to("comments").with(text: 'Cool!') 287 | end 288 | end 289 | ``` 290 | 291 | Or when broadcasting to an object: 292 | 293 | ```ruby 294 | RSpec.describe CommentsController do 295 | describe "POST #create" do 296 | let(:the_post) { create :post } 297 | 298 | expect { post :create, comment: { text: 'Cool!', post_id: the_post.id } }.to 299 | have_broadcasted_to(the_post).from_channel(PostChannel).with(text: 'Cool!') 300 | end 301 | end 302 | ``` 303 | 304 | You can also unit-test your channels: 305 | 306 | 307 | ```ruby 308 | # spec/channels/chat_channel_spec.rb 309 | 310 | require "rails_helper" 311 | 312 | RSpec.describe ChatChannel, type: :channel do 313 | before do 314 | # initialize connection with identifiers 315 | stub_connection user_id: user.id 316 | end 317 | 318 | it "subscribes without streams when no room id" do 319 | subscribe 320 | 321 | expect(subscription).to be_confirmed 322 | expect(subscription).not_to have_streams 323 | end 324 | 325 | it "rejects when room id is invalid" do 326 | subscribe(room_id: -1) 327 | 328 | expect(subscription).to be_rejected 329 | end 330 | 331 | it "subscribes to a stream when room id is provided" do 332 | subscribe(room_id: 42) 333 | 334 | expect(subscription).to be_confirmed 335 | 336 | # check particular stream by name 337 | expect(subscription).to have_stream_from("chat_42") 338 | 339 | # or directly by model if you create streams with `stream_for` 340 | expect(subscription).to have_stream_for(Room.find(42)) 341 | end 342 | end 343 | ``` 344 | 345 | And, of course, connections: 346 | 347 | ```ruby 348 | require "rails_helper" 349 | 350 | RSpec.describe ApplicationCable::Connection, type: :channel do 351 | it "successfully connects" do 352 | connect "/cable", headers: { "X-USER-ID" => "325" } 353 | expect(connection.user_id).to eq "325" 354 | end 355 | 356 | it "rejects connection" do 357 | expect { connect "/cable" }.to have_rejected_connection 358 | end 359 | end 360 | ``` 361 | 362 | **NOTE:** for connections testing you must use `type: :channel` too. 363 | 364 | #### Shared contexts to switch between adapters 365 | 366 | **NOTE:** this feature is gem-only and hasn't been migrated to RSpec 4. You can still use the gem for that by adding `require "rspec/rails/shared_contexts/action_cable"` to your `rspec_helper.rb`. 367 | 368 | Sometimes you may want to use _real_ Action Cable adapter instead of the test one (for example, in Capybara-like tests). 369 | 370 | We provide shared contexts to do that: 371 | 372 | ```ruby 373 | # Use async adapter for this example group only 374 | RSpec.describe "cable case", action_cable: :async do 375 | # ... 376 | 377 | context "inline cable", action_cable: :inline do 378 | # ... 379 | end 380 | 381 | # or test adapter 382 | context "test cable", action_cable: :test do 383 | # ... 384 | end 385 | 386 | # you can also include contexts by names 387 | context "by name" do 388 | include "action_cable:async" 389 | # ... 390 | end 391 | end 392 | ``` 393 | 394 | We also provide an integration for _feature_ specs (having `type: :feature`). Just add `require "action_cable/testing/rspec/features"`: 395 | 396 | ```ruby 397 | # rails_helper.rb 398 | require "action_cable/testing/rspec" 399 | require "action_cable/testing/rspec/features" 400 | 401 | # spec/features/my_feature_spec.rb 402 | feature "Cables!" do 403 | # here we have "action_cable:async" context included automatically! 404 | end 405 | ``` 406 | 407 | For more RSpec documentation see https://relishapp.com/palkan/action-cable-testing/docs. 408 | 409 | ### Generators 410 | 411 | This gem also provides Rails generators: 412 | 413 | ```sh 414 | # Generate a channel test case for ChatChannel 415 | rails generate test_unit:channel chat 416 | 417 | # or for RSpec 418 | rails generate rspec:channel chat 419 | ``` 420 | 421 | ## Development 422 | 423 | After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rake` to run the tests. 424 | 425 | ## Contributing 426 | 427 | Bug reports and pull requests are welcome on GitHub at https://github.com/palkan/action-cable-testing. 428 | 429 | ## License 430 | 431 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 432 | 433 | [Action Cable]: http://guides.rubyonrails.org/action_cable_overview.html 434 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | require "rubocop/rake_task" 6 | require "rake/testtask" 7 | require "cucumber/rake/task" 8 | 9 | Rake::TestTask.new do |t| 10 | t.test_files = FileList["test/**/*_test.rb"] 11 | end 12 | 13 | RuboCop::RakeTask.new 14 | RSpec::Core::RakeTask.new(:spec) 15 | Cucumber::Rake::Task.new(:cucumber) 16 | 17 | task default: [:rubocop, :spec, :test, :cucumber] 18 | -------------------------------------------------------------------------------- /action-cable-testing.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("../lib", __FILE__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | 6 | require "action_cable/testing/version" 7 | 8 | Gem::Specification.new do |spec| 9 | spec.name = "action-cable-testing" 10 | spec.version = ActionCable::Testing::VERSION 11 | 12 | spec.authors = ["Vladimir Dementyev"] 13 | spec.email = ["dementiev.vm@gmail.com"] 14 | 15 | spec.summary = "Testing utils for Action Cable" 16 | spec.description = "Testing utils for Action Cable" 17 | spec.homepage = "http://github.com/palkan/action-cable-testing" 18 | spec.license = "MIT" 19 | 20 | spec.files = `git ls-files`.split($/).select { |p| p.match(%r{^lib/}) } + 21 | %w(README.md CHANGELOG.md LICENSE.txt) 22 | 23 | spec.require_paths = ["lib"] 24 | 25 | spec.required_ruby_version = ">= 2.4.0" 26 | 27 | spec.add_dependency "actioncable", ">= 5.0" 28 | 29 | spec.add_development_dependency "bundler", ">= 1.10" 30 | spec.add_development_dependency "cucumber", "~> 3.1.1" 31 | spec.add_development_dependency "rake", "~> 10.0" 32 | spec.add_development_dependency "rspec-rails", "~> 3.5" 33 | spec.add_development_dependency "aruba", "~> 0.14.6" 34 | spec.add_development_dependency "minitest", "~> 5.9" 35 | spec.add_development_dependency "ammeter", "~> 1.1" 36 | spec.add_development_dependency "rubocop", "~> 0.68.0" 37 | end 38 | -------------------------------------------------------------------------------- /cucumber.yml: -------------------------------------------------------------------------------- 1 | default: --require features --format progress --tags "not @wip" 2 | -------------------------------------------------------------------------------- /features/.nav: -------------------------------------------------------------------------------- 1 | - README.md (Getting Started) 2 | - Minitest.md (Minitest Usage) 3 | - Generators.md 4 | - channel_specs: 5 | - channel_spec.feature 6 | 7 | -------------------------------------------------------------------------------- /features/Generators.md: -------------------------------------------------------------------------------- 1 | This gem provides RSpec generators for channels specs. For example: 2 | 3 | rails generate rspec:channel chat 4 | 5 | will create a new spec file in `spec/channels/chat_channel_spec.rb`. 6 | -------------------------------------------------------------------------------- /features/Minitest.md: -------------------------------------------------------------------------------- 1 | [Full Documentation](http://www.rubydoc.info/gems/action-cable-testing) 2 | 3 | ### Test Adapter and Broadcasting 4 | 5 | We add `ActionCable::SubscriptionAdapter::Test` (very similar Active Job and Action Mailer tests adapters) and `ActionCable::TestCase` with a couple of matchers to track broadcasting messages in our tests: 6 | 7 | ```ruby 8 | # Using ActionCable::TestCase 9 | class MyCableTest < ActionCable::TestCase 10 | def test_broadcasts 11 | # Check the number of messages broadcasted to the stream 12 | assert_broadcasts 'messages', 0 13 | ActionCable.server.broadcast 'messages', { text: 'hello' } 14 | assert_broadcasts 'messages', 1 15 | 16 | # Check the number of messages broadcasted to the stream within a block 17 | assert_broadcasts('messages', 1) do 18 | ActionCable.server.broadcast 'messages', { text: 'hello' } 19 | end 20 | 21 | # Check that no broadcasts has been made 22 | assert_no_broadcasts('messages') do 23 | ActionCable.server.broadcast 'another_stream', { text: 'hello' } 24 | end 25 | end 26 | end 27 | 28 | # Or including ActionCable::TestHelper 29 | class ExampleTest < ActionDispatch::IntegrationTest 30 | include ActionCable::TestHelper 31 | 32 | def test_broadcasts 33 | room = rooms(:office) 34 | 35 | assert_broadcast_on("messages:#{room.id}", text: 'Hello!') do 36 | post "/say/#{room.id}", xhr: true, params: { message: 'Hello!' } 37 | end 38 | end 39 | end 40 | ``` 41 | 42 | If you want to test the broadcasting made with `Channel.broadcast_to`, you should use 43 | `Channel.broadcasting_for`\* to generate an underlying stream name and **use Rails 6 compatibility refinement**: 44 | 45 | ```ruby 46 | # app/jobs/chat_relay_job.rb 47 | class ChatRelayJob < ApplicationJob 48 | def perform_later(room, message) 49 | ChatChannel.broadcast_to room, text: message 50 | end 51 | end 52 | 53 | 54 | # test/jobs/chat_relay_job_test.rb 55 | require "test_helper" 56 | 57 | # Activate Rails 6 compatible API (for `broadcasting_for`) 58 | using ActionCable::Testing::Rails6 59 | 60 | class ChatRelayJobTest < ActiveJob::TestCase 61 | include ActionCable::TestHelper 62 | 63 | test "broadcast message to room" do 64 | room = rooms(:all) 65 | 66 | assert_broadcast_on(ChatChannel.broadcasting_for(room), text: "Hi!") do 67 | ChatRelayJob.perform_now(room, "Hi!") 68 | end 69 | end 70 | end 71 | ``` 72 | 73 | \* **NOTE:** in Rails 6.0 you should use `.broadcasting_for`, but it's not backward compatible 74 | and we cannot use it in Rails 5.x. See https://github.com/rails/rails/pull/35021. 75 | 76 | 77 | ### Channels Testing 78 | 79 | Channels tests are written as follows: 80 | 1. First, one uses the `subscribe` method to simulate subscription creation. 81 | 2. Then, one asserts whether the current state is as expected. "State" can be anything: 82 | transmitted messages, subscribed streams, etc. 83 | 84 | For example: 85 | 86 | ```ruby 87 | class ChatChannelTest < ActionCable::Channel::TestCase 88 | def test_subscribed_with_room_number 89 | # Simulate a subscription creation 90 | subscribe room_number: 1 91 | 92 | # Asserts that the subscription was successfully created 93 | assert subscription.confirmed? 94 | 95 | # Asserts that the channel subscribes connection to a stream 96 | assert_has_stream "chat_1" 97 | 98 | # Asserts that the channel subscribes connection to a specific 99 | # stream created for a model 100 | assert_has_stream_for Room.find(1) 101 | end 102 | 103 | def test_does_not_stream_with_incorrect_room_number 104 | subscribe room_number: -1 105 | 106 | # Asserts that not streams was started 107 | assert_no_streams 108 | end 109 | 110 | def test_does_not_subscribe_without_room_number 111 | subscribe 112 | 113 | # Asserts that the subscription was rejected 114 | assert subscription.rejected? 115 | end 116 | end 117 | ``` 118 | 119 | You can also perform actions: 120 | 121 | ```ruby 122 | def test_perform_speak 123 | subscribe room_number: 1 124 | 125 | perform :speak, message: "Hello, Rails!" 126 | 127 | # `transmissions` stores messages sent directly to the channel (i.e. with `transmit` method) 128 | assert_equal "Hello, Rails!", transmissions.last["text"] 129 | end 130 | ``` 131 | 132 | You can set up your connection identifiers: 133 | 134 | ```ruby 135 | class ChatChannelTest < ActionCable::Channel::TestCase 136 | include ActionCable::TestHelper 137 | 138 | def test_identifiers 139 | stub_connection(user: users[:john]) 140 | 141 | subscribe room_number: 1 142 | 143 | assert_broadcast_on("messages_1", text: "I'm here!", from: "John") do 144 | perform :speak, message: "I'm here!" 145 | end 146 | end 147 | end 148 | ``` 149 | When broadcasting to an object: 150 | 151 | ```ruby 152 | class ChatChannelTest < ActionCable::Channel::TestCase 153 | include ActionCable::TestHelper 154 | 155 | def setup 156 | @room = Room.find 1 157 | 158 | stub_connection(user: users[:john]) 159 | subscribe room_number: room.id 160 | end 161 | 162 | def test_broadcating 163 | assert_broadcasts(@room, 1) do 164 | perform :speak, message: "I'm here!" 165 | end 166 | end 167 | 168 | # or 169 | 170 | def test_broadcasted_data 171 | assert_broadcast_on(@room, text: "I'm here!", from: "John") do 172 | perform :speak, message: "I'm here!" 173 | end 174 | end 175 | end 176 | ``` 177 | 178 | ### Generators 179 | 180 | This gem also provides Rails generators: 181 | 182 | ```sh 183 | # Generate a channel test case for ChatChannel 184 | rails generate test_unit:channel chat 185 | ``` 186 | -------------------------------------------------------------------------------- /features/README.md: -------------------------------------------------------------------------------- 1 | This gem provides missing testing utils for [Action Cable][]. 2 | 3 | **NOTE:** this gem is just a combination of two PRs to Rails itself ([#23211](https://github.com/rails/rails/pull/23211) and [#27191](https://github.com/rails/rails/pull/27191)) and (hopefully) will be merged into Rails eventually. 4 | 5 | **NOTE 2:** this is the documentation for (mostly) RSpec part of the gem. For Minitest usage see the repo's [Readme](https://github.com/palkan/action-cable-testing) or [Minitest Usage](minitest) chapter. 6 | 7 | ## Installation 8 | 9 | Add this line to your application's Gemfile: 10 | 11 | ```ruby 12 | group :test, :development do 13 | gem 'action-cable-testing' 14 | end 15 | ``` 16 | 17 | And then execute: 18 | 19 | $ bundle 20 | 21 | ## Basic Usage 22 | 23 | First, you need to have [rspec-rails](https://github.com/rspec/rspec-rails) installed. 24 | 25 | Second, add this to your `"rails_helper.rb"` after requiring `environment.rb`: 26 | 27 | ```ruby 28 | require "action_cable/testing/rspec" 29 | ``` 30 | 31 | To use `have_broadcasted_to` / `broadcast_to` matchers anywhere in your specs, set your adapter to `test` in `cable.yml`: 32 | 33 | ```yml 34 | # config/cable.yml 35 | test: 36 | adapter: test 37 | ``` 38 | 39 | And then use these matchers, for example: 40 | 41 | ```ruby 42 | RSpec.describe CommentsController do 43 | describe "POST #create" do 44 | expect { post :create, comment: { text: 'Cool!' } }.to 45 | have_broadcasted_to("comments").with(text: 'Cool!') 46 | end 47 | end 48 | ``` 49 | 50 | You can also unit-test your channels: 51 | 52 | 53 | ```ruby 54 | # spec/channels/chat_channel_spec.rb 55 | 56 | require "rails_helper" 57 | 58 | RSpec.describe ChatChannel, type: :channel do 59 | before do 60 | # initialize connection with identifiers 61 | stub_connection user_id: user.id 62 | end 63 | 64 | it "rejects when no room id" do 65 | subscribe 66 | expect(subscription).to be_rejected 67 | end 68 | 69 | it "subscribes to a stream when room id is provided" do 70 | subscribe(room_id: 42) 71 | 72 | expect(subscription).to be_confirmed 73 | expect(streams).to include("chat_42") 74 | end 75 | end 76 | ``` 77 | 78 | ## Issues 79 | 80 | Bug reports and pull requests are welcome on GitHub at https://github.com/palkan/action-cable-testing. 81 | 82 | [Action Cable]: http://guides.rubyonrails.org/action_cable_overview.html 83 | -------------------------------------------------------------------------------- /features/channel_specs/channel_spec.feature: -------------------------------------------------------------------------------- 1 | Feature: channel spec 2 | 3 | Channel specs are marked by `:type => :channel` or if you have set 4 | `config.infer_spec_type_from_file_location!` by placing them in `spec/channels`. 5 | 6 | A channel spec is a thin wrapper for an ActionCable::Channel::TestCase, and includes all 7 | of the behavior and assertions that it provides, in addition to RSpec's own 8 | behavior and expectations. 9 | 10 | It also includes helpers from ActionCable::Connection::TestCase to make it possible to 11 | test connection behavior. 12 | 13 | Background: 14 | Given action cable is available 15 | 16 | Scenario: simple passing example 17 | Given a file named "spec/channels/echo_channel_spec.rb" with: 18 | """ruby 19 | require "rails_helper" 20 | 21 | RSpec.describe EchoChannel, :type => :channel do 22 | it "successfully subscribes" do 23 | subscribe 24 | expect(subscription).to be_confirmed 25 | end 26 | end 27 | """ 28 | When I run `rspec spec/channels/echo_channel_spec.rb` 29 | Then the example should pass 30 | 31 | Scenario: verifying that subscription is rejected 32 | Given a file named "spec/channels/chat_channel_spec.rb" with: 33 | """ruby 34 | require "rails_helper" 35 | 36 | RSpec.describe ChatChannel, :type => :channel do 37 | it "rejects subscription" do 38 | stub_connection user_id: nil 39 | subscribe 40 | expect(subscription).to be_rejected 41 | end 42 | end 43 | """ 44 | When I run `rspec spec/channels/chat_channel_spec.rb` 45 | Then the example should pass 46 | 47 | Scenario: specifying connection identifiers 48 | Given a file named "spec/channels/chat_channel_spec.rb" with: 49 | """ruby 50 | require "rails_helper" 51 | 52 | RSpec.describe ChatChannel, :type => :channel do 53 | it "successfully subscribes" do 54 | stub_connection user_id: 42 55 | subscribe 56 | expect(subscription).to be_confirmed 57 | expect(subscription.user_id).to eq 42 58 | end 59 | end 60 | """ 61 | When I run `rspec spec/channels/chat_channel_spec.rb` 62 | Then the example should pass 63 | 64 | 65 | 66 | Scenario: performing actions and checking transmissions 67 | Given a file named "spec/channels/echo_channel_spec.rb" with: 68 | """ruby 69 | require "rails_helper" 70 | 71 | RSpec.describe EchoChannel, :type => :channel do 72 | it "successfully subscribes" do 73 | subscribe 74 | 75 | perform :echo, foo: 'bar' 76 | expect(transmissions.last).to eq('foo' => 'bar') 77 | end 78 | end 79 | """ 80 | When I run `rspec spec/channels/echo_channel_spec.rb` 81 | Then the example should pass 82 | 83 | Scenario: successful connection with url params 84 | Given a file named "spec/channels/connection_spec.rb" with: 85 | """ruby 86 | require "rails_helper" 87 | 88 | RSpec.describe ApplicationCable::Connection, :type => :channel do 89 | it "successfully connects" do 90 | connect "/cable?user_id=323" 91 | expect(connection.user_id).to eq "323" 92 | end 93 | end 94 | """ 95 | When I run `rspec spec/channels/connection_spec.rb` 96 | Then the example should pass 97 | 98 | Scenario: successful connection with cookies 99 | Given a file named "spec/channels/connection_spec.rb" with: 100 | """ruby 101 | require "rails_helper" 102 | 103 | RSpec.describe ApplicationCable::Connection, :type => :channel do 104 | it "successfully connects" do 105 | cookies.signed[:user_id] = "324" 106 | 107 | connect "/cable" 108 | expect(connection.user_id).to eq "324" 109 | end 110 | end 111 | """ 112 | When I run `rspec spec/channels/connection_spec.rb` 113 | Then the example should pass 114 | 115 | Scenario: successful connection with session 116 | Given a file named "spec/channels/connection_spec.rb" with: 117 | """ruby 118 | require "rails_helper" 119 | 120 | RSpec.describe ApplicationCable::Connection, :type => :channel do 121 | it "successfully connects" do 122 | connect "/cable", session: { user_id: "324" } 123 | expect(connection.user_id).to eq "324" 124 | end 125 | end 126 | """ 127 | When I run `rspec spec/channels/connection_spec.rb` 128 | Then the example should pass 129 | 130 | Scenario: successful connection with headers 131 | Given a file named "spec/channels/connection_spec.rb" with: 132 | """ruby 133 | require "rails_helper" 134 | 135 | RSpec.describe ApplicationCable::Connection, :type => :channel do 136 | it "successfully connects" do 137 | connect "/cable", headers: { "X-USER-ID" => "325" } 138 | expect(connection.user_id).to eq "325" 139 | end 140 | end 141 | """ 142 | When I run `rspec spec/channels/connection_spec.rb` 143 | Then the example should pass 144 | 145 | Scenario: rejected connection 146 | Given a file named "spec/channels/connection_spec.rb" with: 147 | """ruby 148 | require "rails_helper" 149 | 150 | RSpec.describe ApplicationCable::Connection, :type => :channel do 151 | it "rejects connection" do 152 | expect { connect "/cable" }.to have_rejected_connection 153 | end 154 | end 155 | """ 156 | When I run `rspec spec/channels/connection_spec.rb` 157 | Then the example should pass 158 | 159 | Scenario: disconnect connection 160 | Given a file named "spec/channels/connection_spec.rb" with: 161 | """ruby 162 | require "rails_helper" 163 | 164 | RSpec.describe ApplicationCable::Connection, :type => :channel do 165 | it "disconnects" do 166 | connect "/cable?user_id=42" 167 | expect { disconnect }.to output(/User 42 disconnected/).to_stdout 168 | end 169 | end 170 | """ 171 | When I run `rspec spec/channels/connection_spec.rb` 172 | Then the example should pass 173 | -------------------------------------------------------------------------------- /features/matchers/have_broadcasted_matcher.feature: -------------------------------------------------------------------------------- 1 | Feature: have_broadcasted matcher 2 | 3 | The `have_broadcasted_to` (also aliased as `broadcast_to`) matcher is used to check if a message has been broadcasted to a given stream. 4 | 5 | Background: 6 | Given action cable is available 7 | 8 | Scenario: Checking stream name 9 | Given a file named "spec/models/broadcaster_spec.rb" with: 10 | """ruby 11 | require "rails_helper" 12 | 13 | RSpec.describe Broadcaster do 14 | it "matches with stream name" do 15 | expect { 16 | ActionCable.server.broadcast( 17 | "notifications", text: 'Hello!' 18 | ) 19 | }.to have_broadcasted_to("notifications") 20 | end 21 | end 22 | """ 23 | When I run `rspec spec/models/broadcaster_spec.rb` 24 | Then the examples should all pass 25 | 26 | Scenario: Checking passed message to stream 27 | Given a file named "spec/models/broadcaster_spec.rb" with: 28 | """ruby 29 | require "rails_helper" 30 | 31 | RSpec.describe Broadcaster do 32 | it "matches with message" do 33 | expect { 34 | ActionCable.server.broadcast( 35 | "notifications", text: 'Hello!' 36 | ) 37 | }.to have_broadcasted_to("notifications").with(text: 'Hello!') 38 | end 39 | end 40 | """ 41 | When I run `rspec spec/models/broadcaster_spec.rb` 42 | Then the examples should all pass 43 | 44 | 45 | Scenario: Checking that message passed to stream matches 46 | Given a file named "spec/models/broadcaster_spec.rb" with: 47 | """ruby 48 | require "rails_helper" 49 | 50 | RSpec.describe Broadcaster do 51 | it "matches with message" do 52 | expect { 53 | ActionCable.server.broadcast( 54 | "notifications", text: 'Hello!', user_id: 12 55 | ) 56 | }.to have_broadcasted_to("notifications").with(a_hash_including(text: 'Hello!')) 57 | end 58 | end 59 | """ 60 | When I run `rspec spec/models/broadcaster_spec.rb` 61 | Then the examples should all pass 62 | 63 | Scenario: Checking passed message with block 64 | Given a file named "spec/models/broadcaster_spec.rb" with: 65 | """ruby 66 | require "rails_helper" 67 | 68 | RSpec.describe Broadcaster do 69 | it "matches with message" do 70 | expect { 71 | ActionCable.server.broadcast( 72 | "notifications", text: 'Hello!', user_id: 12 73 | ) 74 | }.to have_broadcasted_to("notifications").with { |data| 75 | expect(data['user_id']).to eq 12 76 | } 77 | end 78 | end 79 | """ 80 | When I run `rspec spec/models/broadcaster_spec.rb` 81 | Then the examples should all pass 82 | 83 | Scenario: Using alias method 84 | Given a file named "spec/models/broadcaster_spec.rb" with: 85 | """ruby 86 | require "rails_helper" 87 | 88 | RSpec.describe Broadcaster do 89 | it "matches with stream name" do 90 | expect { 91 | ActionCable.server.broadcast( 92 | "notifications", text: 'Hello!' 93 | ) 94 | }.to broadcast_to("notifications") 95 | end 96 | end 97 | """ 98 | When I run `rspec spec/models/broadcaster_spec.rb` 99 | Then the examples should all pass 100 | 101 | Scenario: Checking broadcast to a record 102 | Given a file named "spec/channels/chat_channel_spec.rb" with: 103 | """ruby 104 | require "rails_helper" 105 | 106 | RSpec.describe ChatChannel, :type => :channel do 107 | it "successfully subscribes" do 108 | user = User.new(42) 109 | 110 | expect { 111 | ChatChannel.broadcast_to(user, text: 'Hi') 112 | }.to have_broadcasted_to(user) 113 | end 114 | end 115 | """ 116 | And a file named "app/models/user.rb" with: 117 | """ruby 118 | class User < Struct.new(:name) 119 | def to_global_id 120 | name 121 | end 122 | end 123 | """ 124 | When I run `rspec spec/channels/chat_channel_spec.rb` 125 | Then the example should pass 126 | 127 | Scenario: Checking broadcast to a record in non-channel spec 128 | Given a file named "spec/models/broadcaster_spec.rb" with: 129 | """ruby 130 | require "rails_helper" 131 | 132 | # Activate Rails 6 compatible API (for `broadcasting_for`) 133 | using ActionCable::Testing::Rails6 134 | 135 | RSpec.describe Broadcaster do 136 | it "matches with stream name" do 137 | user = User.new(42) 138 | skip unless ActionCable::Testing::Rails6::SUPPORTED 139 | expect { 140 | ChatChannel.broadcast_to(user, text: 'Hi') 141 | }.to broadcast_to(ChatChannel.broadcasting_for(user)) 142 | end 143 | end 144 | """ 145 | And a file named "app/models/user.rb" with: 146 | """ruby 147 | class User < Struct.new(:name) 148 | def to_global_id 149 | name 150 | end 151 | end 152 | """ 153 | When I run `rspec spec/models/broadcaster_spec.rb` 154 | Then the example should pass 155 | -------------------------------------------------------------------------------- /features/matchers/have_stream_from_matcher.feature: -------------------------------------------------------------------------------- 1 | Feature: have_stream_from matcher 2 | 3 | The `have_stream_from` matcher is used to check if a channel has been subscribed to a given stream specified as a String. 4 | If you use `stream_for` in you channel to subscribe to a model, use `have_stream_for` matcher instead. 5 | 6 | The `have_no_streams` matcher is used to check if a channe hasn't been subscribed to any stream. 7 | 8 | It is available only in channel specs. 9 | 10 | Background: 11 | Given action cable is available 12 | 13 | Scenario: subscribing with params and checking streams 14 | Given a file named "spec/channels/chat_channel_spec.rb" with: 15 | """ruby 16 | require "rails_helper" 17 | 18 | RSpec.describe ChatChannel, :type => :channel do 19 | it "successfully subscribes" do 20 | stub_connection user_id: 42 21 | subscribe(room_id: 1) 22 | 23 | expect(subscription).to be_confirmed 24 | expect(subscription).to have_stream_from("chat_1") 25 | end 26 | end 27 | """ 28 | When I run `rspec spec/channels/chat_channel_spec.rb` 29 | Then the example should pass 30 | 31 | Scenario: subscribing and checking streams for models 32 | Given a file named "spec/channels/user_channel_spec.rb" with: 33 | """ruby 34 | require "rails_helper" 35 | RSpec.describe UserChannel, :type => :channel do 36 | it "successfully subscribes" do 37 | subscribe(id: 42) 38 | expect(subscription).to be_confirmed 39 | expect(subscription).to have_stream_for(User.new(42)) 40 | end 41 | end 42 | """ 43 | When I run `rspec spec/channels/user_channel_spec.rb` 44 | Then the example should pass 45 | 46 | Scenario: stopping all streams 47 | Given a file named "spec/channels/chat_channel_spec.rb" with: 48 | """ruby 49 | require "rails_helper" 50 | 51 | RSpec.describe ChatChannel, :type => :channel do 52 | it "successfully subscribes" do 53 | stub_connection user_id: 42 54 | subscribe(room_id: 1) 55 | 56 | expect(subscription).to have_stream_from("chat_1") 57 | 58 | perform :leave 59 | expect(subscription).not_to have_streams 60 | end 61 | end 62 | """ 63 | When I run `rspec spec/channels/chat_channel_spec.rb` 64 | Then the example should pass 65 | -------------------------------------------------------------------------------- /features/shared_contexts/shared_contexts.feature: -------------------------------------------------------------------------------- 1 | Feature: action cable shared contexts 2 | 3 | Sometimes you may want to use _real_ Action Cable adapter instead of the test one (for example, 4 | in Capybara-like tests). 5 | 6 | We provide shared contexts to do that. 7 | 8 | Background: 9 | Given action cable is available 10 | 11 | Scenario: using tags to set async adapter 12 | Given a file named "spec/models/broadcaster_spec.rb" with: 13 | """ruby 14 | require "rails_helper" 15 | 16 | RSpec.describe Broadcaster, action_cable: :async do 17 | it "uses async adapter" do 18 | expect(ActionCable.server.pubsub).to be_a(ActionCable::SubscriptionAdapter::Async) 19 | end 20 | end 21 | """ 22 | When I run `rspec spec/models/broadcaster_spec.rb` 23 | Then the example should pass 24 | 25 | Scenario: using shared context to set async adapter 26 | Given a file named "spec/models/broadcaster_spec.rb" with: 27 | """ruby 28 | require "rails_helper" 29 | 30 | RSpec.describe Broadcaster do 31 | context "with async cable" do 32 | include_context "action_cable:async" 33 | 34 | it "uses async adapter" do 35 | expect(ActionCable.server.pubsub).to be_a(ActionCable::SubscriptionAdapter::Async) 36 | end 37 | end 38 | end 39 | """ 40 | When I run `rspec spec/models/broadcaster_spec.rb` 41 | Then the example should pass 42 | 43 | Scenario: using tags to set inline adapter 44 | Given a file named "spec/models/broadcaster_spec.rb" with: 45 | """ruby 46 | require "rails_helper" 47 | 48 | RSpec.describe Broadcaster, action_cable: :inline do 49 | it "uses inline adapter" do 50 | expect(ActionCable.server.pubsub).to be_a(ActionCable::SubscriptionAdapter::Inline) 51 | end 52 | end 53 | """ 54 | When I run `rspec spec/models/broadcaster_spec.rb` 55 | Then the example should pass 56 | 57 | Scenario: require features specs setup 58 | Given a file named "spec/models/broadcaster_spec.rb" with: 59 | """ruby 60 | require "rails_helper" 61 | require "action_cable/testing/rspec/features" 62 | 63 | RSpec.describe Broadcaster, type: :feature do 64 | it "uses async adapter for feature specs" do 65 | expect(ActionCable.server.pubsub).to be_a(ActionCable::SubscriptionAdapter::Async) 66 | end 67 | end 68 | """ 69 | When I run `rspec spec/models/broadcaster_spec.rb` 70 | Then the example should pass 71 | -------------------------------------------------------------------------------- /features/step_definitions/additional_cli_steps.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require "action_controller/railtie" 3 | require "action_view/railtie" 4 | require "action_cable" 5 | rescue LoadError # rubocop:disable Lint/HandleExceptions 6 | end 7 | 8 | require "rails/version" 9 | require "action_cable/testing/rspec" 10 | 11 | Then /^the example(s)? should( all)? pass$/ do |_, _| 12 | step %q{the output should contain "0 failures"} 13 | step %q{the exit status should be 0} 14 | end 15 | 16 | Then /^the example(s)? should( all)? fail/ do |_, _| 17 | step %q{the output should not contain "0 failures"} 18 | step %q{the exit status should not be 0} 19 | end 20 | 21 | Given /action cable is available/ do 22 | if !RSpec::Rails::FeatureCheck.has_action_cable_testing? 23 | pending "Action Cable is not available" 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /features/support/env.rb: -------------------------------------------------------------------------------- 1 | require "aruba/cucumber" 2 | require "fileutils" 3 | 4 | Before do 5 | @aruba_timeout_seconds = 30 6 | end 7 | 8 | Before do 9 | example_app_dir = "spec/dummy" 10 | aruba_dir = "tmp/aruba" 11 | 12 | # Remove the previous aruba workspace. 13 | FileUtils.rm_rf(aruba_dir) if File.exist?(aruba_dir) 14 | FileUtils.cp_r(example_app_dir, aruba_dir) 15 | end 16 | -------------------------------------------------------------------------------- /gemfiles/rails50.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem "rails", "~> 5.0.0" 4 | 5 | gemspec path: '..' 6 | -------------------------------------------------------------------------------- /gemfiles/rails5001.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem "rails", "5.0.0.1" 4 | 5 | gemspec path: '..' 6 | -------------------------------------------------------------------------------- /gemfiles/rails51.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem "rails", "~> 5.1.0" 4 | 5 | gemspec path: '..' 6 | -------------------------------------------------------------------------------- /gemfiles/rails52.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem "rails", "~> 5.2.0" 4 | 5 | gemspec path: '..' 6 | -------------------------------------------------------------------------------- /gemfiles/rspec4rails5.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem "rails", "~> 5.2" 4 | gem "rspec-rails", "4.0.0.beta4" 5 | 6 | gemspec path: '..' 7 | -------------------------------------------------------------------------------- /gemfiles/rspec4rails6.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem "rails", "~> 6.0" 4 | gem "rspec-rails", "4.0.0.beta4" 5 | 6 | gemspec path: '..' 7 | -------------------------------------------------------------------------------- /lib/action-cable-testing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "action_cable/testing" 4 | -------------------------------------------------------------------------------- /lib/action_cable/subscription_adapter/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "action_cable/subscription_adapter/async" 4 | 5 | module ActionCable 6 | module SubscriptionAdapter 7 | # == Test adapter for Action Cable 8 | # 9 | # The test adapter should be used only in testing. Along with 10 | # ActionCable::TestHelper it makes a great tool to test your Rails application. 11 | # 12 | # To use the test adapter set adapter value to +test+ in your +cable.yml+. 13 | # 14 | # NOTE: Test adapter extends the ActionCable::SubscriptionsAdapter::Async adapter, 15 | # so it could be used in system tests too. 16 | class Test < Async 17 | def broadcast(channel, payload) 18 | broadcasts(channel) << payload 19 | super 20 | end 21 | 22 | def broadcasts(channel) 23 | channels_data[channel] ||= [] 24 | end 25 | 26 | def clear_messages(channel) 27 | channels_data[channel] = [] 28 | end 29 | 30 | def clear 31 | @channels_data = nil 32 | end 33 | 34 | private 35 | def channels_data 36 | @channels_data ||= {} 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/action_cable/testing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "action_cable/testing/version" 4 | 5 | require "action_cable" 6 | require "action_cable/testing/rails_six" 7 | 8 | # These has been merged into Rails 6 9 | unless ActionCable::VERSION::MAJOR >= 6 10 | require "action_cable/testing/test_helper" 11 | require "action_cable/testing/test_case" 12 | 13 | require "action_cable/testing/channel/test_case" 14 | 15 | require "action_cable/testing/connection/test_case" 16 | 17 | # We cannot move subsription adapter under 'testing/' path, 18 | # 'cause Action Cable uses this path when resolving an 19 | # adapter from its name (in the config.yml) 20 | require "action_cable/subscription_adapter/test" 21 | end 22 | -------------------------------------------------------------------------------- /lib/action_cable/testing/channel/test_case.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support" 4 | require "active_support/test_case" 5 | require "active_support/core_ext/hash/indifferent_access" 6 | require "json" 7 | 8 | require "action_cable/testing/rails_six" 9 | using ActionCable::Testing::Rails6 10 | 11 | module ActionCable 12 | module Channel 13 | class NonInferrableChannelError < ::StandardError 14 | def initialize(name) 15 | super "Unable to determine the channel to test from #{name}. " + 16 | "You'll need to specify it using `tests YourChannel` in your " + 17 | "test case definition." 18 | end 19 | end 20 | 21 | # Stub `stream_from` to track streams for the channel. 22 | # Add public aliases for `subscription_confirmation_sent?` and 23 | # `subscription_rejected?`. 24 | module ChannelStub 25 | def confirmed? 26 | subscription_confirmation_sent? 27 | end 28 | 29 | def rejected? 30 | subscription_rejected? 31 | end 32 | 33 | def stream_from(broadcasting, *) 34 | streams << broadcasting 35 | end 36 | 37 | def stop_all_streams 38 | @_streams = [] 39 | end 40 | 41 | def streams 42 | @_streams ||= [] 43 | end 44 | 45 | # Make periodic timers no-op 46 | def start_periodic_timers; end 47 | alias stop_periodic_timers start_periodic_timers 48 | end 49 | 50 | class ConnectionStub 51 | attr_reader :transmissions, :identifiers, :subscriptions, :logger 52 | 53 | def initialize(identifiers = {}) 54 | @transmissions = [] 55 | 56 | identifiers.each do |identifier, val| 57 | define_singleton_method(identifier) { val } 58 | end 59 | 60 | @subscriptions = ActionCable::Connection::Subscriptions.new(self) 61 | @identifiers = identifiers.keys 62 | @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) 63 | end 64 | 65 | def transmit(cable_message) 66 | transmissions << cable_message.with_indifferent_access 67 | end 68 | end 69 | 70 | # Superclass for Action Cable channel functional tests. 71 | # 72 | # == Basic example 73 | # 74 | # Functional tests are written as follows: 75 | # 1. First, one uses the +subscribe+ method to simulate subscription creation. 76 | # 2. Then, one asserts whether the current state is as expected. "State" can be anything: 77 | # transmitted messages, subscribed streams, etc. 78 | # 79 | # For example: 80 | # 81 | # class ChatChannelTest < ActionCable::Channel::TestCase 82 | # def test_subscribed_with_room_number 83 | # # Simulate a subscription creation 84 | # subscribe room_number: 1 85 | # 86 | # # Asserts that the subscription was successfully created 87 | # assert subscription.confirmed? 88 | # 89 | # # Asserts that the channel subscribes connection to a stream 90 | # assert_has_stream "chat_1" 91 | # 92 | # # Asserts that the channel subscribes connection to a specific 93 | # # stream created for a model 94 | # assert_has_stream_for Room.find(1) 95 | # end 96 | # 97 | # def test_does_not_stream_with_incorrect_room_number 98 | # subscribe room_number: -1 99 | # 100 | # # Asserts that not streams was started 101 | # assert_no_streams 102 | # end 103 | # 104 | # def test_does_not_subscribe_without_room_number 105 | # subscribe 106 | # 107 | # # Asserts that the subscription was rejected 108 | # assert subscription.rejected? 109 | # end 110 | # end 111 | # 112 | # You can also perform actions: 113 | # def test_perform_speak 114 | # subscribe room_number: 1 115 | # 116 | # perform :speak, message: "Hello, Rails!" 117 | # 118 | # assert_equal "Hello, Rails!", transmissions.last["text"] 119 | # end 120 | # 121 | # == Special methods 122 | # 123 | # ActionCable::Channel::TestCase will also automatically provide the following instance 124 | # methods for use in the tests: 125 | # 126 | # connection:: 127 | # An ActionCable::Channel::ConnectionStub, representing the current HTTP connection. 128 | # subscription:: 129 | # An instance of the current channel, created when you call `subscribe`. 130 | # transmissions:: 131 | # A list of all messages that have been transmitted into the channel. 132 | # 133 | # 134 | # == Channel is automatically inferred 135 | # 136 | # ActionCable::Channel::TestCase will automatically infer the channel under test 137 | # from the test class name. If the channel cannot be inferred from the test 138 | # class name, you can explicitly set it with +tests+. 139 | # 140 | # class SpecialEdgeCaseChannelTest < ActionCable::Channel::TestCase 141 | # tests SpecialChannel 142 | # end 143 | # 144 | # == Specifying connection identifiers 145 | # 146 | # You need to set up your connection manually to privide values for the identifiers. 147 | # To do this just use: 148 | # 149 | # stub_connection(user: users[:john]) 150 | class TestCase < ActiveSupport::TestCase 151 | module Behavior 152 | extend ActiveSupport::Concern 153 | 154 | include ActiveSupport::Testing::ConstantLookup 155 | include ActionCable::TestHelper 156 | 157 | CHANNEL_IDENTIFIER = "test_stub" 158 | 159 | included do 160 | class_attribute :_channel_class 161 | 162 | attr_reader :connection, :subscription 163 | 164 | def streams 165 | ActiveSupport::Deprecation.warn "Use appropriate `assert_has_stream`, `assert_has_stream_for`, `assert_no_streams` " + 166 | "assertion methods for minitest or `have_stream`, `have_stream_for` and `have_stream_from` matchers " + 167 | "for RSpec. Direct access to `streams` is deprecated and is going to be removed in version 1.0" 168 | subscription.streams 169 | end 170 | 171 | ActiveSupport.run_load_hooks(:action_cable_channel_test_case, self) 172 | end 173 | 174 | module ClassMethods 175 | def tests(channel) 176 | case channel 177 | when String, Symbol 178 | self._channel_class = channel.to_s.camelize.constantize 179 | when Module 180 | self._channel_class = channel 181 | else 182 | raise NonInferrableChannelError.new(channel) 183 | end 184 | end 185 | 186 | def channel_class 187 | if channel = self._channel_class 188 | channel 189 | else 190 | tests determine_default_channel(name) 191 | end 192 | end 193 | 194 | def determine_default_channel(name) 195 | channel = determine_constant_from_test_name(name) do |constant| 196 | Class === constant && constant < ActionCable::Channel::Base 197 | end 198 | raise NonInferrableChannelError.new(name) if channel.nil? 199 | channel 200 | end 201 | end 202 | 203 | # Setup test connection with the specified identifiers: 204 | # 205 | # class ApplicationCable < ActionCable::Connection::Base 206 | # identified_by :user, :token 207 | # end 208 | # 209 | # stub_connection(user: users[:john], token: 'my-secret-token') 210 | def stub_connection(identifiers = {}) 211 | @connection = ConnectionStub.new(identifiers) 212 | end 213 | 214 | # Subsribe to the channel under test. Optionally pass subscription parameters as a Hash. 215 | def subscribe(params = {}) 216 | @connection ||= stub_connection 217 | # NOTE: Rails < 5.0.1 calls subscribe_to_channel during #initialize. 218 | # We have to stub before it 219 | @subscription = self.class.channel_class.allocate 220 | @subscription.singleton_class.include(ChannelStub) 221 | @subscription.send(:initialize, connection, CHANNEL_IDENTIFIER, params.with_indifferent_access) 222 | # Call subscribe_to_channel if it's public (Rails 5.0.1+) 223 | @subscription.subscribe_to_channel if ActionCable.gem_version >= Gem::Version.new("5.0.1") 224 | @subscription 225 | end 226 | 227 | # Unsubscribe the subscription under test. 228 | def unsubscribe 229 | check_subscribed! 230 | subscription.unsubscribe_from_channel 231 | end 232 | 233 | # Perform action on a channel. 234 | # 235 | # NOTE: Must be subscribed. 236 | def perform(action, data = {}) 237 | check_subscribed! 238 | subscription.perform_action(data.stringify_keys.merge("action" => action.to_s)) 239 | end 240 | 241 | # Returns messages transmitted into channel 242 | def transmissions 243 | # Return only directly sent message (via #transmit) 244 | connection.transmissions.map { |data| data["message"] }.compact 245 | end 246 | 247 | # Enhance TestHelper assertions to handle non-String 248 | # broadcastings 249 | def assert_broadcasts(stream_or_object, *args) 250 | super(broadcasting_for(stream_or_object), *args) 251 | end 252 | 253 | def assert_broadcast_on(stream_or_object, *args) 254 | super(broadcasting_for(stream_or_object), *args) 255 | end 256 | 257 | # Asserts that no streams have been started. 258 | # 259 | # def test_assert_no_started_stream 260 | # subscribe 261 | # assert_no_streams 262 | # end 263 | # 264 | def assert_no_streams 265 | assert subscription.streams.empty?, "No streams started was expected, but #{subscription.streams.count} found" 266 | end 267 | 268 | # Asserts that the specified stream has been started. 269 | # 270 | # def test_assert_started_stream 271 | # subscribe 272 | # assert_has_stream 'messages' 273 | # end 274 | # 275 | def assert_has_stream(stream) 276 | assert subscription.streams.include?(stream), "Stream #{stream} has not been started" 277 | end 278 | 279 | # Asserts that the specified stream for a model has started. 280 | # 281 | # def test_assert_started_stream_for 282 | # subscribe id: 42 283 | # assert_has_stream_for User.find(42) 284 | # end 285 | # 286 | def assert_has_stream_for(object) 287 | assert_has_stream(broadcasting_for(object)) 288 | end 289 | 290 | private 291 | def check_subscribed! 292 | raise "Must be subscribed!" if subscription.nil? || subscription.rejected? 293 | end 294 | 295 | def broadcasting_for(stream_or_object) 296 | return stream_or_object if stream_or_object.is_a?(String) 297 | 298 | self.class.channel_class.broadcasting_for(stream_or_object) 299 | end 300 | end 301 | 302 | include Behavior 303 | end 304 | end 305 | end 306 | -------------------------------------------------------------------------------- /lib/action_cable/testing/connection/test_case.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support" 4 | require "active_support/test_case" 5 | require "active_support/core_ext/hash/indifferent_access" 6 | require "action_dispatch" 7 | require "action_dispatch/http/headers" 8 | require "action_dispatch/testing/test_request" 9 | 10 | module ActionCable 11 | module Connection 12 | class NonInferrableConnectionError < ::StandardError 13 | def initialize(name) 14 | super "Unable to determine the connection to test from #{name}. " + 15 | "You'll need to specify it using `tests YourConnection` in your " + 16 | "test case definition." 17 | end 18 | end 19 | 20 | module Assertions 21 | # Asserts that the connection is rejected (via +reject_unauthorized_connection+). 22 | # 23 | # # Asserts that connection without user_id fails 24 | # assert_reject_connection { connect cookies: { user_id: '' } } 25 | def assert_reject_connection(&block) 26 | assert_raises(Authorization::UnauthorizedError, "Expected to reject connection but no rejection was made", &block) 27 | end 28 | end 29 | 30 | # We don't want to use the whole "encryption stack" for connection 31 | # unit-tests, but we want to make sure that users test against the correct types 32 | # of cookies (i.e. signed or encrypted or plain) 33 | class TestCookieJar < ActiveSupport::HashWithIndifferentAccess 34 | def signed 35 | self[:signed] ||= {}.with_indifferent_access 36 | end 37 | 38 | def encrypted 39 | self[:encrypted] ||= {}.with_indifferent_access 40 | end 41 | end 42 | 43 | class TestRequest < ActionDispatch::TestRequest 44 | attr_accessor :session, :cookie_jar 45 | end 46 | 47 | module TestConnection 48 | attr_reader :logger, :request 49 | 50 | def initialize(request) 51 | inner_logger = ActiveSupport::Logger.new(StringIO.new) 52 | tagged_logging = ActiveSupport::TaggedLogging.new(inner_logger) 53 | @logger = ActionCable::Connection::TaggedLoggerProxy.new(tagged_logging, tags: []) 54 | @request = request 55 | @env = request.env 56 | end 57 | end 58 | 59 | # Unit test Action Cable connections. 60 | # 61 | # Useful to check whether a connection's +identified_by+ gets assigned properly 62 | # and that any improper connection requests are rejected. 63 | # 64 | # == Basic example 65 | # 66 | # Unit tests are written as follows: 67 | # 68 | # 1. Simulate a connection attempt by calling +connect+. 69 | # 2. Assert state, e.g. identifiers, has been assigned. 70 | # 71 | # 72 | # class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase 73 | # def test_connects_with_proper_cookie 74 | # # Simulate the connection request with a cookie. 75 | # cookies["user_id"] = users(:john).id 76 | # 77 | # connect 78 | # 79 | # # Assert the connection identifier matches the fixture. 80 | # assert_equal users(:john).id, connection.user.id 81 | # end 82 | # 83 | # def test_rejects_connection_without_proper_cookie 84 | # assert_reject_connection { connect } 85 | # end 86 | # end 87 | # 88 | # +connect+ accepts additional information the HTTP request with the 89 | # +params+, +headers+, +session+ and Rack +env+ options. 90 | # 91 | # def test_connect_with_headers_and_query_string 92 | # connect params: { user_id: 1 }, headers: { "X-API-TOKEN" => "secret-my" } 93 | # 94 | # assert_equal "1", connection.user.id 95 | # assert_equal "secret-my", connection.token 96 | # end 97 | # 98 | # def test_connect_with_params 99 | # connect params: { user_id: 1 } 100 | # 101 | # assert_equal "1", connection.user.id 102 | # end 103 | # 104 | # You can also setup the correct cookies before the connection request: 105 | # 106 | # def test_connect_with_cookies 107 | # # Plain cookies: 108 | # cookies["user_id"] = 1 109 | # 110 | # # Or signed/encrypted: 111 | # # cookies.signed["user_id"] = 1 112 | # # cookies.encrypted["user_id"] = 1 113 | # 114 | # connect 115 | # 116 | # assert_equal "1", connection.user_id 117 | # end 118 | # 119 | # == Connection is automatically inferred 120 | # 121 | # ActionCable::Connection::TestCase will automatically infer the connection under test 122 | # from the test class name. If the channel cannot be inferred from the test 123 | # class name, you can explicitly set it with +tests+. 124 | # 125 | # class ConnectionTest < ActionCable::Connection::TestCase 126 | # tests ApplicationCable::Connection 127 | # end 128 | # 129 | class TestCase < ActiveSupport::TestCase 130 | module Behavior 131 | extend ActiveSupport::Concern 132 | 133 | DEFAULT_PATH = "/cable" 134 | 135 | include ActiveSupport::Testing::ConstantLookup 136 | include Assertions 137 | 138 | included do 139 | class_attribute :_connection_class 140 | 141 | attr_reader :connection 142 | 143 | ActiveSupport.run_load_hooks(:action_cable_connection_test_case, self) 144 | end 145 | 146 | module ClassMethods 147 | def tests(connection) 148 | case connection 149 | when String, Symbol 150 | self._connection_class = connection.to_s.camelize.constantize 151 | when Module 152 | self._connection_class = connection 153 | else 154 | raise NonInferrableConnectionError.new(connection) 155 | end 156 | end 157 | 158 | def connection_class 159 | if connection = self._connection_class 160 | connection 161 | else 162 | tests determine_default_connection(name) 163 | end 164 | end 165 | 166 | def determine_default_connection(name) 167 | connection = determine_constant_from_test_name(name) do |constant| 168 | Class === constant && constant < ActionCable::Connection::Base 169 | end 170 | raise NonInferrableConnectionError.new(name) if connection.nil? 171 | connection 172 | end 173 | end 174 | 175 | # Performs connection attempt to exert #connect on the connection under test. 176 | # 177 | # Accepts request path as the first argument and the following request options: 178 | # 179 | # - params – url parameters (Hash) 180 | # - headers – request headers (Hash) 181 | # - session – session data (Hash) 182 | # - env – additional Rack env configuration (Hash) 183 | def connect(path = ActionCable.server.config.mount_path, cookies: nil, **request_params) 184 | path ||= DEFAULT_PATH 185 | 186 | unless cookies.nil? 187 | ActiveSupport::Deprecation.warn( 188 | "Use `cookies[:param] = value` (or `cookies.signed[:param] = value`). " \ 189 | "Passing `cookies` as the `connect` option is deprecated and is going to be removed in version 1.0" 190 | ) 191 | cookies.each { |k, v| self.cookies[k] = v } 192 | end 193 | 194 | connection = self.class.connection_class.allocate 195 | connection.singleton_class.include(TestConnection) 196 | connection.send(:initialize, build_test_request(path, **request_params)) 197 | connection.connect if connection.respond_to?(:connect) 198 | 199 | # Only set instance variable if connected successfully 200 | @connection = connection 201 | end 202 | 203 | # Exert #disconnect on the connection under test. 204 | def disconnect 205 | raise "Must be connected!" if connection.nil? 206 | 207 | connection.disconnect if connection.respond_to?(:disconnect) 208 | @connection = nil 209 | end 210 | 211 | def cookies 212 | @cookie_jar ||= TestCookieJar.new 213 | end 214 | 215 | private 216 | def build_test_request(path, params: nil, headers: {}, session: {}, env: {}) 217 | wrapped_headers = ActionDispatch::Http::Headers.from_hash(headers) 218 | 219 | uri = URI.parse(path) 220 | 221 | query_string = params.nil? ? uri.query : params.to_query 222 | 223 | request_env = { 224 | "QUERY_STRING" => query_string, 225 | "PATH_INFO" => uri.path 226 | }.merge(env) 227 | 228 | if wrapped_headers.present? 229 | ActionDispatch::Http::Headers.from_hash(request_env).merge!(wrapped_headers) 230 | end 231 | 232 | TestRequest.create(request_env).tap do |request| 233 | request.session = session.with_indifferent_access 234 | request.cookie_jar = cookies 235 | end 236 | end 237 | end 238 | 239 | include Behavior 240 | end 241 | end 242 | end 243 | -------------------------------------------------------------------------------- /lib/action_cable/testing/rails_six.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActionCable 4 | module Testing 5 | # Enables Rails 6 compatible API via Refinements. 6 | # 7 | # Use this to write Rails 6 compatible tests (not every Rails 6 API 8 | # could be backported). 9 | # 10 | # Usage: 11 | # # my_test.rb 12 | # require "test_helper" 13 | # 14 | # using ActionCable::Testing::Syntax 15 | module Rails6 16 | begin 17 | # Has been added only after 6.0.0.beta1 18 | unless ActionCable::Channel::Base.respond_to?(:serialize_broadcasting) 19 | refine ActionCable::Channel::Broadcasting::ClassMethods do 20 | def broadcasting_for(model) 21 | super([channel_name, model]) 22 | end 23 | end 24 | end 25 | 26 | SUPPORTED = true 27 | rescue TypeError 28 | warn "Your Ruby version doesn't suppport Module refinements. " \ 29 | "Rails 6 compatibility refinement could not be applied" 30 | 31 | SUPPORTED = false 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/action_cable/testing/rspec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "action-cable-testing" 4 | require "rspec/rails" 5 | 6 | if RSpec::Rails::FeatureCheck.respond_to?(:has_action_cable_testing?) 7 | warn <<~MSG 8 | You're using RSpec with Action Cable support. 9 | 10 | You can remove `require "action_cable/testing/rspec"` from your RSpec setup. 11 | 12 | NOTE: if you use Action Cable shared contexts ("action_cable:async", "action_cable:inline", etc.) 13 | you still need to use the gem and add `require "rspec/rails/shared_contexts/action_cable"`. 14 | MSG 15 | else 16 | require "rspec/rails/example/channel_example_group" 17 | require "rspec/rails/matchers/action_cable" 18 | 19 | module RSpec # :nodoc: 20 | module Rails 21 | module FeatureCheck 22 | module_function 23 | def has_action_cable_testing? 24 | defined?(::ActionCable) 25 | end 26 | end 27 | 28 | self::DIRECTORY_MAPPINGS[:channel] = %w[spec channels] 29 | end 30 | end 31 | 32 | RSpec.configure do |config| 33 | if defined?(ActionCable) 34 | config.include RSpec::Rails::ChannelExampleGroup, type: :channel 35 | end 36 | end 37 | end 38 | 39 | require "rspec/rails/shared_contexts/action_cable" 40 | -------------------------------------------------------------------------------- /lib/action_cable/testing/rspec/features.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Use async adapter for features specs by deafault 4 | RSpec.configure do |config| 5 | config.include_context "action_cable:async", type: [:feature, :system] 6 | end 7 | -------------------------------------------------------------------------------- /lib/action_cable/testing/test_case.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/test_case" 4 | 5 | module ActionCable 6 | class TestCase < ActiveSupport::TestCase 7 | include ActionCable::TestHelper 8 | 9 | ActiveSupport.run_load_hooks(:action_cable_test_case, self) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/action_cable/testing/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "action_cable/testing/rails_six" 4 | using ActionCable::Testing::Rails6 5 | 6 | module ActionCable 7 | # Provides helper methods for testing Action Cable broadcasting 8 | module TestHelper 9 | CHANNEL_NOT_FOUND = ArgumentError.new("Broadcasting channel can't be infered. Please, specify it with `:channel`") 10 | 11 | def before_setup # :nodoc: 12 | server = ActionCable.server 13 | test_adapter = ActionCable::SubscriptionAdapter::Test.new(server) 14 | 15 | @old_pubsub_adapter = server.pubsub 16 | 17 | server.instance_variable_set(:@pubsub, test_adapter) 18 | super 19 | end 20 | 21 | def after_teardown # :nodoc: 22 | super 23 | ActionCable.server.instance_variable_set(:@pubsub, @old_pubsub_adapter) 24 | end 25 | 26 | # Asserts that the number of broadcasted messages to the channel matches the given number. 27 | # 28 | # def test_broadcasts 29 | # assert_broadcasts 'messages', 0 30 | # ActionCable.server.broadcast 'messages', { text: 'hello' } 31 | # assert_broadcasts 'messages', 1 32 | # ActionCable.server.broadcast 'messages', { text: 'world' } 33 | # assert_broadcasts 'messages', 2 34 | # end 35 | # 36 | # If a block is passed, that block should cause the specified number of 37 | # messages to be broadcasted. 38 | # 39 | # def test_broadcasts_again 40 | # assert_broadcasts('messages', 1) do 41 | # ActionCable.server.broadcast 'messages', { text: 'hello' } 42 | # end 43 | # 44 | # assert_broadcasts('messages', 2) do 45 | # ActionCable.server.broadcast 'messages', { text: 'hi' } 46 | # ActionCable.server.broadcast 'messages', { text: 'how are you?' } 47 | # end 48 | # end 49 | # 50 | def assert_broadcasts(target, number, channel: nil) 51 | warn_deprecated_channel! unless channel.nil? 52 | 53 | stream = stream(target, channel) 54 | 55 | if block_given? 56 | original_count = broadcasts_size(stream) 57 | yield 58 | new_count = broadcasts_size(stream) 59 | assert_equal number, new_count - original_count, "#{number} broadcasts to #{stream} expected, but #{new_count - original_count} were sent" 60 | else 61 | actual_count = broadcasts_size(stream) 62 | assert_equal number, actual_count, "#{number} broadcasts to #{stream} expected, but #{actual_count} were sent" 63 | end 64 | end 65 | 66 | # Asserts that no messages have been sent to the channel. 67 | # 68 | # def test_no_broadcasts 69 | # assert_no_broadcasts 'messages' 70 | # ActionCable.server.broadcast 'messages', { text: 'hi' } 71 | # assert_broadcasts 'messages', 1 72 | # end 73 | # 74 | # If a block is passed, that block should not cause any message to be sent. 75 | # 76 | # def test_broadcasts_again 77 | # assert_no_broadcasts 'messages' do 78 | # # No job messages should be sent from this block 79 | # end 80 | # end 81 | # 82 | # Note: This assertion is simply a shortcut for: 83 | # 84 | # assert_broadcasts 'messages', 0, &block 85 | # 86 | def assert_no_broadcasts(target, &block) 87 | assert_broadcasts target, 0, &block 88 | end 89 | 90 | # Asserts that the specified message has been sent to the channel. 91 | # 92 | # def test_assert_transmited_message 93 | # ActionCable.server.broadcast 'messages', text: 'hello' 94 | # assert_broadcast_on('messages', text: 'hello') 95 | # end 96 | # 97 | # If a block is passed, that block should cause a message with the specified data to be sent. 98 | # 99 | # def test_assert_broadcast_on_again 100 | # assert_broadcast_on('messages', text: 'hello') do 101 | # ActionCable.server.broadcast 'messages', text: 'hello' 102 | # end 103 | # end 104 | # 105 | def assert_broadcast_on(target, data, channel: nil) 106 | warn_deprecated_channel! unless channel.nil? 107 | 108 | # Encode to JSON and back–we want to use this value to compare 109 | # with decoded JSON. 110 | # Comparing JSON strings doesn't work due to the order if the keys. 111 | serialized_msg = 112 | ActiveSupport::JSON.decode(ActiveSupport::JSON.encode(data)) 113 | stream = stream(target, channel) 114 | 115 | new_messages = broadcasts(stream) 116 | if block_given? 117 | old_messages = new_messages 118 | clear_messages(stream) 119 | 120 | yield 121 | new_messages = broadcasts(stream) 122 | clear_messages(stream) 123 | 124 | # Restore all sent messages 125 | (old_messages + new_messages).each { |m| pubsub_adapter.broadcast(stream, m) } 126 | end 127 | 128 | message = new_messages.find { |msg| ActiveSupport::JSON.decode(msg) == serialized_msg } 129 | 130 | assert message, "No messages sent with #{data} to #{stream}" 131 | end 132 | 133 | def pubsub_adapter # :nodoc: 134 | ActionCable.server.pubsub 135 | end 136 | 137 | delegate :broadcasts, :clear_messages, to: :pubsub_adapter 138 | 139 | private 140 | def broadcasts_size(channel) # :nodoc: 141 | broadcasts(channel).size 142 | end 143 | 144 | def stream(target, channel = nil) 145 | return target if target.is_a?(String) 146 | 147 | channel ||= @subscription 148 | return target unless channel && channel.respond_to?(:channel_name) 149 | 150 | channel.broadcasting_for(target) 151 | end 152 | 153 | def warn_deprecated_channel! 154 | ActiveSupport::Deprecation.warn( 155 | "Passing channel class is deprecated and will be removed in version 1.0. " \ 156 | "Use `Channel.broadcasting_for(object) to build a stream name instead and " \ 157 | "add `using ActionCable::Testing::Rails6` to your test file." 158 | ) 159 | end 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /lib/action_cable/testing/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActionCable 4 | module Testing 5 | VERSION = "0.6.1" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/generators/rspec/channel/channel_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "generators/rspec" 4 | 5 | module Rspec 6 | module Generators 7 | # @private 8 | class ChannelGenerator < Base 9 | source_root File.expand_path("../templates", __FILE__) 10 | 11 | def create_channel_spec 12 | template "channel_spec.rb.erb", File.join("spec/channels", class_path, "#{file_name}_channel_spec.rb") 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/generators/rspec/channel/templates/channel_spec.rb.erb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | <% module_namespacing do -%> 4 | RSpec.describe <%= class_name %>Channel, <%= type_metatag(:channel) %> do 5 | pending "add some examples to (or delete) #{__FILE__}" 6 | end 7 | <% end -%> 8 | -------------------------------------------------------------------------------- /lib/generators/test_unit/channel/channel_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators/test_unit" 4 | 5 | module TestUnit # :nodoc: 6 | module Generators # :nodoc: 7 | class ChannelGenerator < Base # :nodoc: 8 | source_root File.expand_path("../templates", __FILE__) 9 | 10 | check_class_collision suffix: "ChannelTest" 11 | 12 | def create_test_file 13 | template "unit_test.rb.erb", File.join("test/channels", class_path, "#{file_name}_channel_test.rb") 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/generators/test_unit/channel/templates/unit_test.rb.erb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | <% module_namespacing do -%> 4 | class <%= class_name %>ChannelTest < ActionCable::Channel::TestCase 5 | # test "the truth" do 6 | # assert true 7 | # end 8 | end 9 | <% end -%> 10 | -------------------------------------------------------------------------------- /lib/rspec/rails/example/channel_example_group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rspec/rails/matchers/action_cable/have_streams" 4 | 5 | module RSpec 6 | module Rails 7 | # @api public 8 | # Container module for channel spec functionality. It is only available if 9 | # ActionCable has been loaded before it. 10 | module ChannelExampleGroup 11 | # This blank module is only necessary for YARD processing. It doesn't 12 | # handle the conditional `defined?` check below very well. 13 | end 14 | end 15 | end 16 | 17 | if defined?(ActionCable) 18 | module RSpec 19 | module Rails 20 | # Container module for channel spec functionality. 21 | module ChannelExampleGroup 22 | extend ActiveSupport::Concern 23 | include RSpec::Rails::RailsExampleGroup 24 | include ActionCable::Connection::TestCase::Behavior 25 | include ActionCable::Channel::TestCase::Behavior 26 | 27 | # Class-level DSL for channel specs. 28 | module ClassMethods 29 | # @private 30 | def channel_class 31 | described_class 32 | end 33 | 34 | # @private 35 | def connection_class 36 | raise "Described class is not a Connection class" unless 37 | described_class <= ::ActionCable::Connection::Base 38 | described_class 39 | end 40 | end 41 | 42 | def have_rejected_connection 43 | raise_error(::ActionCable::Connection::Authorization::UnauthorizedError) 44 | end 45 | 46 | def have_streams 47 | check_subscribed! 48 | 49 | RSpec::Rails::Matchers::ActionCable::HaveStream.new 50 | end 51 | 52 | def have_stream_from(stream) 53 | check_subscribed! 54 | 55 | RSpec::Rails::Matchers::ActionCable::HaveStream.new(stream) 56 | end 57 | 58 | def have_stream_for(object) 59 | check_subscribed! 60 | 61 | RSpec::Rails::Matchers::ActionCable::HaveStream.new(broadcasting_for(object)) 62 | end 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/rspec/rails/matchers/action_cable.rb: -------------------------------------------------------------------------------- 1 | # rubocop: disable Style/FrozenStringLiteralComment 2 | 3 | require "rspec/rails/matchers/action_cable/have_broadcasted_to" 4 | 5 | module RSpec 6 | module Rails 7 | module Matchers 8 | # Namespace for various implementations of ActionCable features 9 | # 10 | # @api private 11 | module ActionCable 12 | end 13 | 14 | # @api public 15 | # Passes if a message has been sent to a stream/object inside a block. 16 | # May chain `at_least`, `at_most` or `exactly` to specify a number of times. 17 | # To specify channel from which message has been broadcasted to object use `from_channel`. 18 | # 19 | # 20 | # @example 21 | # expect { 22 | # ActionCable.server.broadcast "messages", text: 'Hi!' 23 | # }.to have_broadcasted_to("messages") 24 | # 25 | # expect { 26 | # SomeChannel.broadcast_to(user) 27 | # }.to have_broadcasted_to(user).from_channel(SomeChannel) 28 | # 29 | # # Using alias 30 | # expect { 31 | # ActionCable.server.broadcast "messages", text: 'Hi!' 32 | # }.to broadcast_to("messages") 33 | # 34 | # expect { 35 | # ActionCable.server.broadcast "messages", text: 'Hi!' 36 | # ActionCable.server.broadcast "all", text: 'Hi!' 37 | # }.to have_broadcasted_to("messages").exactly(:once) 38 | # 39 | # expect { 40 | # 3.times { ActionCable.server.broadcast "messages", text: 'Hi!' } 41 | # }.to have_broadcasted_to("messages").at_least(2).times 42 | # 43 | # expect { 44 | # ActionCable.server.broadcast "messages", text: 'Hi!' 45 | # }.to have_broadcasted_to("messages").at_most(:twice) 46 | # 47 | # expect { 48 | # ActionCable.server.broadcast "messages", text: 'Hi!' 49 | # }.to have_broadcasted_to("messages").with(text: 'Hi!') 50 | 51 | def have_broadcasted_to(target = nil) 52 | check_action_cable_adapter 53 | 54 | ActionCable::HaveBroadcastedTo.new(target, channel: described_class) 55 | end 56 | alias_method :broadcast_to, :have_broadcasted_to 57 | 58 | private 59 | 60 | # @private 61 | def check_action_cable_adapter 62 | return if ::ActionCable::SubscriptionAdapter::Test === ::ActionCable.server.pubsub 63 | raise StandardError, "To use ActionCable matchers set `adapter: test` in your cable.yml" 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/rspec/rails/matchers/action_cable/have_broadcasted_to.rb: -------------------------------------------------------------------------------- 1 | # rubocop: disable Style/FrozenStringLiteralComment 2 | 3 | require "action_cable/testing/rails_six" 4 | using ActionCable::Testing::Rails6 5 | 6 | module RSpec 7 | module Rails 8 | module Matchers 9 | module ActionCable 10 | # rubocop: disable Metrics/ClassLength 11 | # @private 12 | class HaveBroadcastedTo < RSpec::Matchers::BuiltIn::BaseMatcher 13 | def initialize(target, channel:) 14 | @target = target 15 | @channel = channel 16 | @block = Proc.new {} 17 | set_expected_number(:exactly, 1) 18 | end 19 | 20 | def with(data = nil, &block) 21 | @data = data 22 | @data = @data.with_indifferent_access if @data.is_a?(Hash) 23 | @block = block if block_given? 24 | self 25 | end 26 | 27 | def exactly(count) 28 | set_expected_number(:exactly, count) 29 | self 30 | end 31 | 32 | def at_least(count) 33 | set_expected_number(:at_least, count) 34 | self 35 | end 36 | 37 | def at_most(count) 38 | set_expected_number(:at_most, count) 39 | self 40 | end 41 | 42 | def times 43 | self 44 | end 45 | 46 | def once 47 | exactly(:once) 48 | end 49 | 50 | def twice 51 | exactly(:twice) 52 | end 53 | 54 | def thrice 55 | exactly(:thrice) 56 | end 57 | 58 | def failure_message 59 | "expected to broadcast #{base_message}".tap do |msg| 60 | if @unmatching_msgs.any? 61 | msg << "\nBroadcasted messages to #{stream}:" 62 | @unmatching_msgs.each do |data| 63 | msg << "\n #{data}" 64 | end 65 | end 66 | end 67 | end 68 | 69 | def failure_message_when_negated 70 | "expected not to broadcast #{base_message}" 71 | end 72 | 73 | def message_expectation_modifier 74 | case @expectation_type 75 | when :exactly then "exactly" 76 | when :at_most then "at most" 77 | when :at_least then "at least" 78 | end 79 | end 80 | 81 | def supports_block_expectations? 82 | true 83 | end 84 | 85 | def matches?(proc) 86 | raise ArgumentError, "have_broadcasted_to and broadcast_to only support block expectations" unless Proc === proc 87 | 88 | original_sent_messages_count = pubsub_adapter.broadcasts(stream).size 89 | proc.call 90 | in_block_messages = pubsub_adapter.broadcasts(stream).drop(original_sent_messages_count) 91 | 92 | check(in_block_messages) 93 | end 94 | 95 | def from_channel(channel) 96 | @channel = channel 97 | self 98 | end 99 | 100 | private 101 | 102 | def stream 103 | @stream ||= if @target.is_a?(String) 104 | @target 105 | else 106 | check_channel_presence 107 | @channel.broadcasting_for(@target) 108 | end 109 | end 110 | 111 | def check(messages) 112 | @matching_msgs, @unmatching_msgs = messages.partition do |msg| 113 | decoded = ActiveSupport::JSON.decode(msg) 114 | decoded = decoded.with_indifferent_access if decoded.is_a?(Hash) 115 | 116 | if @data.nil? || @data === decoded 117 | @block.call(decoded) 118 | true 119 | else 120 | false 121 | end 122 | end 123 | 124 | @matching_msgs_count = @matching_msgs.size 125 | 126 | case @expectation_type 127 | when :exactly then @expected_number == @matching_msgs_count 128 | when :at_most then @expected_number >= @matching_msgs_count 129 | when :at_least then @expected_number <= @matching_msgs_count 130 | end 131 | end 132 | 133 | def set_expected_number(relativity, count) 134 | @expectation_type = relativity 135 | @expected_number = 136 | case count 137 | when :once then 1 138 | when :twice then 2 139 | when :thrice then 3 140 | else Integer(count) 141 | end 142 | end 143 | 144 | def base_message 145 | "#{message_expectation_modifier} #{@expected_number} messages to #{stream}".tap do |msg| 146 | msg << " with #{data_description(@data)}" unless @data.nil? 147 | msg << ", but broadcast #{@matching_msgs_count}" 148 | end 149 | end 150 | 151 | def data_description(data) 152 | if data.is_a?(RSpec::Matchers::Composable) 153 | data.description 154 | else 155 | data 156 | end 157 | end 158 | 159 | def pubsub_adapter 160 | ::ActionCable.server.pubsub 161 | end 162 | 163 | def check_channel_presence 164 | return if @channel.present? && @channel.respond_to?(:channel_name) 165 | 166 | error_msg = "Broadcasting channel can't be infered. Please, specify it with `from_channel`" 167 | raise ArgumentError, error_msg 168 | end 169 | end 170 | end 171 | end 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /lib/rspec/rails/matchers/action_cable/have_streams.rb: -------------------------------------------------------------------------------- 1 | # rubocop: disable Style/FrozenStringLiteralComment 2 | 3 | module RSpec 4 | module Rails 5 | module Matchers 6 | module ActionCable 7 | # @api private 8 | # Provides the implementation for `have_stream`, `have_stream_for`, and `have_stream_from`. 9 | # Not intended to be instantiated directly. 10 | class HaveStream < RSpec::Matchers::BuiltIn::BaseMatcher 11 | # @api private 12 | # @return [String] 13 | def failure_message 14 | "expected to have #{base_message}" 15 | end 16 | 17 | # @api private 18 | # @return [String] 19 | def failure_message_when_negated 20 | "expected not to have #{base_message}" 21 | end 22 | 23 | # @api private 24 | # @return [Boolean] 25 | def matches?(subscription) 26 | raise(ArgumentError, "have_streams is used for negated expectations only") if no_expected? 27 | 28 | match(subscription) 29 | end 30 | 31 | # @api private 32 | # @return [Boolean] 33 | def does_not_match?(subscription) 34 | !match(subscription) 35 | end 36 | 37 | private 38 | 39 | def match(subscription) 40 | case subscription 41 | when ::ActionCable::Channel::Base 42 | @actual = subscription.streams 43 | no_expected? ? actual.any? : actual.any? { |i| expected === i } 44 | else 45 | raise ArgumentError, "have_stream, have_stream_from and have_stream_from support expectations on subscription only" 46 | end 47 | end 48 | 49 | def base_message 50 | no_expected? ? "any stream started" : "stream #{expected_formatted} started, but have #{actual_formatted}" 51 | end 52 | 53 | def no_expected? 54 | !defined?(@expected) 55 | end 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/rspec/rails/shared_contexts/action_cable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Generate contexts to use specific Action Cable adapter: 4 | # - "action_cable:async" (action_cable: :async) 5 | # - "action_cable:inline" (action_cable: :inline) 6 | # - "action_cable:test" (action_cable: :test) 7 | %w[async inline test].each do |adapter| 8 | RSpec.shared_context "action_cable:#{adapter}" do 9 | require "action_cable/subscription_adapter/#{adapter}" 10 | 11 | adapter_class = ActionCable::SubscriptionAdapter.const_get(adapter.capitalize) 12 | 13 | before do 14 | next if ActionCable.server.pubsub.is_a?(adapter_class) 15 | 16 | @__was_pubsub_adapter__ = ActionCable.server.pubsub 17 | 18 | adapter = adapter_class.new(ActionCable.server) 19 | ActionCable.server.instance_variable_set(:@pubsub, adapter) 20 | end 21 | 22 | after do 23 | next unless instance_variable_defined?(:@__was_pubsub_adapter__) 24 | ActionCable.server.instance_variable_set(:@pubsub, @__was_pubsub_adapter__) 25 | end 26 | end 27 | 28 | RSpec.configure do |config| 29 | config.include_context "action_cable:#{adapter}", action_cable: adapter.to_sym 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/dummy/.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /spec/dummy/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | identified_by :user_id 4 | 5 | def connect 6 | self.user_id = verify_user 7 | end 8 | 9 | def disconnect 10 | $stdout.puts "User #{user_id} disconnected" 11 | end 12 | 13 | private 14 | 15 | def verify_user 16 | user_id = request.params[:user_id] || 17 | request.headers["x-user-id"] || 18 | cookies.signed[:user_id] || 19 | request.session[:user_id] 20 | reject_unauthorized_connection unless user_id.present? 21 | user_id 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/dummy/app/channels/chat_channel.rb: -------------------------------------------------------------------------------- 1 | class ChatChannel < ApplicationCable::Channel 2 | periodically every: 5.seconds do 3 | transmit action: :now, time: Time.now 4 | end 5 | 6 | def subscribed 7 | reject unless user_id.present? 8 | 9 | @room_id = params[:room_id] 10 | 11 | stream_from "chat_#{@room_id}" if @room_id.present? 12 | end 13 | 14 | def speak(data) 15 | ActionCable.server.broadcast( 16 | "chat_#{@room_id}", text: data['message'], user_id: user_id 17 | ) 18 | end 19 | 20 | def leave 21 | @room_id = nil 22 | stop_all_streams 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/dummy/app/channels/echo_channel.rb: -------------------------------------------------------------------------------- 1 | class EchoChannel < ApplicationCable::Channel 2 | def subscribed 3 | end 4 | 5 | def echo(data) 6 | data.delete("action") 7 | transmit data 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/dummy/app/channels/user_channel.rb: -------------------------------------------------------------------------------- 1 | class UserChannel < ApplicationCable::Channel 2 | def subscribed 3 | user = User.new(params[:id]) 4 | stream_for user 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy/app/models/broadcaster.rb: -------------------------------------------------------------------------------- 1 | class Broadcaster; end 2 | -------------------------------------------------------------------------------- /spec/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class User 4 | include GlobalID::Identification 5 | 6 | attr_reader :id 7 | 8 | def initialize(id) 9 | @id = id 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | APP_PATH = File.expand_path('../../config/application', __FILE__) 4 | require_relative '../config/boot' 5 | require 'rails/commands' 6 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative 'config/environment' 4 | 5 | run Rails.application 6 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | require "action_controller/railtie" 3 | require "action_cable/engine" 4 | require "global_id" 5 | 6 | GlobalID.app = "dummy" 7 | 8 | module Dummy 9 | class Application < Rails::Application 10 | # Settings in config/environments/* take precedence over those specified here. 11 | # Application configuration should go into files in config/initializers 12 | # -- all .rb files in that directory are automatically loaded. 13 | 14 | # Only loads a smaller set of middleware suitable for API only apps. 15 | # Middleware like session, flash, cookies can be added back manually. 16 | # Skip views, helpers and assets when generating a new resource. 17 | config.api_only = true 18 | config.logger = Logger.new(STDOUT) 19 | config.log_level = :fatal 20 | config.eager_load = false 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__) 3 | 4 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 5 | $LOAD_PATH.unshift File.expand_path('../../../lib', __dir__) 6 | -------------------------------------------------------------------------------- /spec/dummy/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: redis://localhost:6379/1 10 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative 'application' 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum, this matches the default thread size of Active Record. 6 | # 7 | threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }.to_i 8 | threads threads_count, threads_count 9 | 10 | # Specifies the `port` that Puma will listen on to receive requests, default is 3000. 11 | # 12 | port ENV.fetch("PORT") { 3000 } 13 | 14 | # Specifies the `environment` that Puma will run in. 15 | # 16 | environment ENV.fetch("RAILS_ENV") { "development" } 17 | 18 | # Specifies the number of `workers` to boot in clustered mode. 19 | # Workers are forked webserver processes. If using threads and workers together 20 | # the concurrency of the application would be max `threads` * `workers`. 21 | # Workers do not work on JRuby or Windows (both of which do not support 22 | # processes). 23 | # 24 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 25 | 26 | # Use the `preload_app!` method when specifying a `workers` number. 27 | # This directive tells Puma to first boot the application and load code 28 | # before forking the application. This takes advantage of Copy On Write 29 | # process behavior so workers use less memory. If you use this option 30 | # you need to make sure to reconnect any threads in the `on_worker_boot` 31 | # block. 32 | # 33 | # preload_app! 34 | 35 | # The code in the `on_worker_boot` will be called if you are using 36 | # clustered mode by specifying a number of `workers`. After each worker 37 | # process is booted this block will be run, if you are using `preload_app!` 38 | # option you will want to use this block to reconnect to any threads 39 | # or connections that may have been created at application boot, Ruby 40 | # cannot share connections between processes. 41 | # 42 | # on_worker_boot do 43 | # ActiveRecord::Base.establish_connection if defined?(ActiveRecord) 44 | # end 45 | 46 | # Allow puma to be restarted by `rails restart` command. 47 | plugin :tmp_restart 48 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rails secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | development: 14 | secret_key_base: 7e2f9ba6bdde6c08b1be9c8389ea715acd61506712f44b28ee2570267249dd3ecf84d4fada6516a869b834a90d876bff61cce637a7645e815afb1c1501262e4e 15 | 16 | test: 17 | secret_key_base: e304849faefdc1a5e10b31a030e97eadb723a60593e6f53e8c99ed8bf6e31af8c80e4a0fe4814fafc5a8029669e07b323f3a89fbca7e9c9b5aa6eb3ca51e84e1 18 | 19 | # Do not keep production secrets in the repository, 20 | # instead read values from the environment. 21 | production: 22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 23 | -------------------------------------------------------------------------------- /spec/dummy/spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # This file is copied to spec/ when you run 'rails generate rspec:install' 2 | require 'spec_helper' 3 | ENV['RAILS_ENV'] ||= 'test' 4 | require File.expand_path('../../config/environment', __FILE__) 5 | # Prevent database truncation if the environment is production 6 | abort("The Rails environment is running in production mode!") if Rails.env.production? 7 | require "action_cable/testing/rspec" 8 | 9 | # Add additional requires below this line. Rails is not loaded until this point! 10 | 11 | # Requires supporting ruby files with custom matchers and macros, etc, in 12 | # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are 13 | # run as spec files by default. This means that files in spec/support that end 14 | # in _spec.rb will both be required and run as specs, causing the specs to be 15 | # run twice. It is recommended that you do not name files matching this glob to 16 | # end with _spec.rb. You can configure this pattern with the --pattern 17 | # option on the command line or in ~/.rspec, .rspec or `.rspec-local`. 18 | # 19 | # The following line is provided for convenience purposes. It has the downside 20 | # of increasing the boot-up time by auto-requiring all files in the support 21 | # directory. Alternatively, in the individual `*_spec.rb` files, manually 22 | # require only the support files necessary. 23 | # 24 | # Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } 25 | 26 | RSpec.configure do |config| 27 | # RSpec Rails can automatically mix in different behaviours to your tests 28 | # based on their file location, for example enabling you to call `get` and 29 | # `post` in specs under `spec/controllers`. 30 | # 31 | # You can disable this behaviour by removing the line below, and instead 32 | # explicitly tag your specs with their type, e.g.: 33 | # 34 | # RSpec.describe UsersController, :type => :controller do 35 | # # ... 36 | # end 37 | # 38 | # The different available types are documented in the features, such as in 39 | # https://relishapp.com/rspec/rspec-rails/docs 40 | config.infer_spec_type_from_file_location! 41 | 42 | # Filter lines from Rails gems in backtraces. 43 | config.filter_rails_from_backtrace! 44 | # arbitrary gems may also be filtered via: 45 | # config.filter_gems_from_backtrace("gem name") 46 | end 47 | -------------------------------------------------------------------------------- /spec/dummy/spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rails generate rspec:install` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it. 14 | # 15 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 16 | RSpec.configure do |config| 17 | # rspec-expectations config goes here. You can use an alternate 18 | # assertion/expectation library such as wrong or the stdlib/minitest 19 | # assertions if you prefer. 20 | config.expect_with :rspec do |expectations| 21 | # This option will default to `true` in RSpec 4. It makes the `description` 22 | # and `failure_message` of custom matchers include text for helper methods 23 | # defined using `chain`, e.g.: 24 | # be_bigger_than(2).and_smaller_than(4).description 25 | # # => "be bigger than 2 and smaller than 4" 26 | # ...rather than: 27 | # # => "be bigger than 2" 28 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 29 | end 30 | 31 | # rspec-mocks config goes here. You can use an alternate test double 32 | # library (such as bogus or mocha) by changing the `mock_with` option here. 33 | config.mock_with :rspec do |mocks| 34 | # Prevents you from mocking or stubbing a method that does not exist on 35 | # a real object. This is generally recommended, and will default to 36 | # `true` in RSpec 4. 37 | mocks.verify_partial_doubles = true 38 | end 39 | 40 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 41 | # have no way to turn it off -- the option exists only for backwards 42 | # compatibility in RSpec 3). It causes shared context metadata to be 43 | # inherited by the metadata hash of host groups and examples, rather than 44 | # triggering implicit auto-inclusion in groups with matching metadata. 45 | config.shared_context_metadata_behavior = :apply_to_host_groups 46 | 47 | # The settings below are suggested to provide a good initial experience 48 | # with RSpec, but feel free to customize to your heart's content. 49 | =begin 50 | # This allows you to limit a spec run to individual examples or groups 51 | # you care about by tagging them with `:focus` metadata. When nothing 52 | # is tagged with `:focus`, all examples get run. RSpec also provides 53 | # aliases for `it`, `describe`, and `context` that include `:focus` 54 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 55 | config.filter_run_when_matching :focus 56 | 57 | # Allows RSpec to persist some state between runs in order to support 58 | # the `--only-failures` and `--next-failure` CLI options. We recommend 59 | # you configure your source control system to ignore this file. 60 | config.example_status_persistence_file_path = "spec/examples.txt" 61 | 62 | # Limits the available syntax to the non-monkey patched syntax that is 63 | # recommended. For more details, see: 64 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 65 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 66 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 67 | config.disable_monkey_patching! 68 | 69 | # Many RSpec users commonly either run the entire suite or an individual 70 | # file, and it's useful to allow more verbose output when running an 71 | # individual spec file. 72 | if config.files_to_run.one? 73 | # Use the documentation formatter for detailed output, 74 | # unless a formatter has already been configured 75 | # (e.g. via a command-line flag). 76 | config.default_formatter = "doc" 77 | end 78 | 79 | # Print the 10 slowest examples and example groups at the 80 | # end of the spec run, to help surface which specs are running 81 | # particularly slow. 82 | config.profile_examples = 10 83 | 84 | # Run specs in random order to surface order dependencies. If you find an 85 | # order dependency and want to debug it, you can fix the order by providing 86 | # the seed, which is printed after each run. 87 | # --seed 1234 88 | config.order = :random 89 | 90 | # Seed global randomization in this process using the `--seed` CLI option. 91 | # Setting this allows you to use `--seed` to deterministically reproduce 92 | # test failures related to randomization by passing the same `--seed` value 93 | # as the one that triggered the failure. 94 | Kernel.srand config.seed 95 | =end 96 | end 97 | -------------------------------------------------------------------------------- /spec/generators/rspec_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "generators/rspec/channel/channel_generator" 5 | 6 | describe Rspec::Generators::ChannelGenerator, type: :generator do 7 | destination File.expand_path("../../../tmp", __FILE__) 8 | 9 | let(:args) { ["chat"] } 10 | 11 | before do 12 | prepare_destination 13 | run_generator(args) 14 | end 15 | 16 | subject { file("spec/channels/chat_channel_spec.rb") } 17 | 18 | it "creates script", :aggregate_failures do 19 | is_expected.to exist 20 | is_expected.to contain("require 'rails_helper'") 21 | is_expected.to contain("RSpec.describe ChatChannel, type: :channel") 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/generators/test_unit_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "generators/test_unit/channel/channel_generator" 5 | 6 | describe TestUnit::Generators::ChannelGenerator, type: :generator do 7 | destination File.expand_path("../../../tmp", __FILE__) 8 | 9 | let(:args) { ["chat"] } 10 | 11 | before do 12 | prepare_destination 13 | run_generator(args) 14 | end 15 | 16 | subject { file("test/channels/chat_channel_test.rb") } 17 | 18 | it "creates script", :aggregate_failures do 19 | is_expected.to exist 20 | is_expected.to contain("class ChatChannelTest < ActionCable::Channel::TestCase") 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/rspec/rails/channel_example_group_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module RSpec::Rails 4 | describe ChannelExampleGroup do 5 | if defined?(ActionCable) 6 | it_behaves_like "an rspec-rails example group mixin", :channel, 7 | './spec/channels/', '.\\spec\\channels\\' 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/rspec/rails/matchers/action_cable/have_broadcasted_to_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "rspec/rails/feature_check" 3 | 4 | if RSpec::Rails::FeatureCheck.has_action_cable_testing? 5 | require "rspec/rails/matchers/action_cable" 6 | end 7 | 8 | RSpec.describe "have_broadcasted_to matchers", :skip => !RSpec::Rails::FeatureCheck.has_action_cable_testing? do 9 | let(:channel) do 10 | Class.new(ActionCable::Channel::Base) do 11 | def self.channel_name 12 | "broadcast" 13 | end 14 | end 15 | end 16 | 17 | def broadcast(stream, msg) 18 | ActionCable.server.broadcast stream, msg 19 | end 20 | 21 | before do 22 | server = ActionCable.server 23 | test_adapter = ActionCable::SubscriptionAdapter::Test.new(server) 24 | server.instance_variable_set(:@pubsub, test_adapter) 25 | end 26 | 27 | describe "have_broadcasted_to" do 28 | it "raises ArgumentError when no Proc passed to expect" do 29 | expect { 30 | expect(true).to have_broadcasted_to('stream') 31 | }.to raise_error(ArgumentError) 32 | end 33 | 34 | it "passes with default messages count (exactly one)" do 35 | expect { 36 | broadcast('stream', 'hello') 37 | }.to have_broadcasted_to('stream') 38 | end 39 | 40 | it "passes when using alias" do 41 | expect { 42 | broadcast('stream', 'hello') 43 | }.to broadcast_to('stream') 44 | end 45 | 46 | it "counts only messages sent in block" do 47 | broadcast('stream', 'one') 48 | expect { 49 | broadcast('stream', 'two') 50 | }.to have_broadcasted_to('stream').exactly(1) 51 | end 52 | 53 | it "passes when negated" do 54 | expect { }.not_to have_broadcasted_to('stream') 55 | end 56 | 57 | it "fails when message is not sent" do 58 | expect { 59 | expect { }.to have_broadcasted_to('stream') 60 | }.to raise_error(/expected to broadcast exactly 1 messages to stream, but broadcast 0/) 61 | end 62 | 63 | it "fails when too many messages broadcast" do 64 | expect { 65 | expect { 66 | broadcast('stream', 'one') 67 | broadcast('stream', 'two') 68 | }.to have_broadcasted_to('stream').exactly(1) 69 | }.to raise_error(/expected to broadcast exactly 1 messages to stream, but broadcast 2/) 70 | end 71 | 72 | it "reports correct number in fail error message" do 73 | broadcast('stream', 'one') 74 | expect { 75 | expect { }.to have_broadcasted_to('stream').exactly(1) 76 | }.to raise_error(/expected to broadcast exactly 1 messages to stream, but broadcast 0/) 77 | end 78 | 79 | it "fails when negated and message is sent" do 80 | expect { 81 | expect { broadcast('stream', 'one') }.not_to have_broadcasted_to('stream') 82 | }.to raise_error(/expected not to broadcast exactly 1 messages to stream, but broadcast 1/) 83 | end 84 | 85 | it "passes with multiple streams" do 86 | expect { 87 | broadcast('stream_a', 'A') 88 | broadcast('stream_b', 'B') 89 | broadcast('stream_c', 'C') 90 | }.to have_broadcasted_to('stream_a').and have_broadcasted_to('stream_b') 91 | end 92 | 93 | it "passes with :once count" do 94 | expect { 95 | broadcast('stream', 'one') 96 | }.to have_broadcasted_to('stream').exactly(:once) 97 | end 98 | 99 | it "passes with :twice count" do 100 | expect { 101 | broadcast('stream', 'one') 102 | broadcast('stream', 'two') 103 | }.to have_broadcasted_to('stream').exactly(:twice) 104 | end 105 | 106 | it "passes with :thrice count" do 107 | expect { 108 | broadcast('stream', 'one') 109 | broadcast('stream', 'two') 110 | broadcast('stream', 'three') 111 | }.to have_broadcasted_to('stream').exactly(:thrice) 112 | end 113 | 114 | it "passes with at_least count when sent messages are over limit" do 115 | expect { 116 | broadcast('stream', 'one') 117 | broadcast('stream', 'two') 118 | }.to have_broadcasted_to('stream').at_least(:once) 119 | end 120 | 121 | it "passes with at_most count when sent messages are under limit" do 122 | expect { 123 | broadcast('stream', 'hello') 124 | }.to have_broadcasted_to('stream').at_most(:once) 125 | end 126 | 127 | it "generates failure message with at least hint" do 128 | expect { 129 | expect { }.to have_broadcasted_to('stream').at_least(:once) 130 | }.to raise_error(/expected to broadcast at least 1 messages to stream, but broadcast 0/) 131 | end 132 | 133 | it "generates failure message with at most hint" do 134 | expect { 135 | expect { 136 | broadcast('stream', 'hello') 137 | broadcast('stream', 'hello') 138 | }.to have_broadcasted_to('stream').at_most(:once) 139 | }.to raise_error(/expected to broadcast at most 1 messages to stream, but broadcast 2/) 140 | end 141 | 142 | it "passes with provided data" do 143 | expect { 144 | broadcast('stream', id: 42, name: "David") 145 | }.to have_broadcasted_to('stream').with(id: 42, name: "David") 146 | end 147 | 148 | it "passes with provided data matchers" do 149 | expect { 150 | broadcast('stream', id: 42, name: "David", message_id: 123) 151 | }.to have_broadcasted_to('stream').with(a_hash_including(name: "David", id: 42)) 152 | end 153 | 154 | it "generates failure message when data not match" do 155 | expect { 156 | expect { 157 | broadcast('stream', id: 42, name: "David", message_id: 123) 158 | }.to have_broadcasted_to('stream').with(a_hash_including(name: "John", id: 42)) 159 | }.to raise_error(/expected to broadcast exactly 1 messages to stream with a hash including/) 160 | end 161 | 162 | it "throws descriptive error when no test adapter set" do 163 | require "action_cable/subscription_adapter/inline" 164 | ActionCable.server.instance_variable_set(:@pubsub, ActionCable::SubscriptionAdapter::Inline) 165 | expect { 166 | expect { broadcast('stream', 'hello') }.to have_broadcasted_to('stream') 167 | }.to raise_error("To use ActionCable matchers set `adapter: test` in your cable.yml") 168 | end 169 | 170 | it "fails with with block with incorrect data" do 171 | expect { 172 | expect { 173 | broadcast('stream', "asdf") 174 | }.to have_broadcasted_to('stream').with { |data| 175 | expect(data).to eq("zxcv") 176 | } 177 | }.to raise_error { |e| 178 | expect(e.message).to match(/expected: "zxcv"/) 179 | expect(e.message).to match(/got: "asdf"/) 180 | } 181 | end 182 | 183 | 184 | context "when object is passed as first argument" do 185 | let(:user) { User.new(42) } 186 | 187 | context "when channel is present" do 188 | it "passes" do 189 | expect { 190 | channel.broadcast_to(user, text: 'Hi') 191 | }.to have_broadcasted_to(user).from_channel(channel) 192 | end 193 | end 194 | 195 | context "when channel can't be infered" do 196 | it "raises exception" do 197 | expect { 198 | expect { 199 | channel.broadcast_to(user, text: 'Hi') 200 | }.to have_broadcasted_to(user) 201 | }.to raise_error(ArgumentError) 202 | end 203 | end 204 | end 205 | end 206 | end 207 | -------------------------------------------------------------------------------- /spec/rspec/rails/matchers/action_cable/have_stream_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "rspec/rails/feature_check" 3 | 4 | module FakeChannelHelper 5 | extend ActiveSupport::Concern 6 | 7 | module ClassMethods 8 | # @private 9 | def channel_class 10 | Class.new(ActionCable::Channel::Base) do 11 | def subscribed 12 | stream_from "chat_#{params[:id]}" if params[:id] 13 | stream_for User.new(params[:user]) if params[:user] 14 | end 15 | 16 | def self.channel_name 17 | "broadcast" 18 | end 19 | end 20 | end 21 | end 22 | end 23 | 24 | RSpec.describe "have_streams matchers" do 25 | include RSpec::Rails::ChannelExampleGroup 26 | include FakeChannelHelper 27 | 28 | before { stub_connection } 29 | 30 | describe "have_streams" do 31 | it "raises when no subscription started" do 32 | expect { 33 | expect(subscription).to have_streams 34 | }.to raise_error(/Must be subscribed!/) 35 | end 36 | 37 | it "does not allow usage" do 38 | subscribe 39 | 40 | expect { 41 | expect(subscription).to have_streams 42 | }.to raise_error(ArgumentError, /have_streams is used for negated expectations only/) 43 | end 44 | 45 | context "with negated form" do 46 | it "raises when no subscription started" do 47 | expect { 48 | expect(subscription).not_to have_streams 49 | }.to raise_error(/Must be subscribed!/) 50 | end 51 | 52 | it "raises ArgumentError when no subscription passed to expect" do 53 | subscribe id: 1 54 | 55 | expect { 56 | expect(true).not_to have_streams 57 | }.to raise_error(ArgumentError) 58 | end 59 | 60 | it "passes with negated form" do 61 | subscribe 62 | 63 | expect(subscription).not_to have_streams 64 | end 65 | 66 | it "fails with message" do 67 | subscribe id: 1 68 | 69 | expect { 70 | expect(subscription).not_to have_streams 71 | }.to raise_error(/expected not to have any stream started/) 72 | end 73 | end 74 | end 75 | 76 | describe "have_stream_from" do 77 | it "raises when no subscription started" do 78 | expect { 79 | expect(subscription).to have_stream_from("stream") 80 | }.to raise_error(/Must be subscribed!/) 81 | end 82 | 83 | it "raises ArgumentError when no subscription passed to expect" do 84 | subscribe id: 1 85 | 86 | expect { 87 | expect(true).to have_stream_from("stream") 88 | }.to raise_error(ArgumentError) 89 | end 90 | 91 | it "passes" do 92 | subscribe id: 1 93 | 94 | expect(subscription).to have_stream_from("chat_1") 95 | end 96 | 97 | it "fails with message" do 98 | subscribe id: 1 99 | 100 | expect { 101 | expect(subscription).to have_stream_from("chat_2") 102 | }.to raise_error(/expected to have stream "chat_2" started, but have \[\"chat_1\"\]/) 103 | end 104 | 105 | context "with negated form" do 106 | it "passes" do 107 | subscribe id: 1 108 | 109 | expect(subscription).not_to have_stream_from("chat_2") 110 | end 111 | 112 | it "fails with message" do 113 | subscribe id: 1 114 | 115 | expect { 116 | expect(subscription).not_to have_stream_from("chat_1") 117 | }.to raise_error(/expected not to have stream "chat_1" started, but have \[\"chat_1\"\]/) 118 | end 119 | end 120 | 121 | context "with composable matcher" do 122 | it "passes" do 123 | subscribe id: 1 124 | 125 | expect(subscription).to have_stream_from(a_string_starting_with("chat")) 126 | end 127 | 128 | it "fails with message" do 129 | subscribe id: 1 130 | 131 | expect { 132 | expect(subscription).to have_stream_from(a_string_starting_with("room")) 133 | }.to raise_error(/expected to have stream a string starting with "room" started, but have \[\"chat_1\"\]/) 134 | end 135 | end 136 | end 137 | 138 | describe "have_stream_for" do 139 | it "raises when no subscription started" do 140 | expect { 141 | expect(subscription).to have_stream_for(User.new(42)) 142 | }.to raise_error(/Must be subscribed!/) 143 | end 144 | 145 | it "raises ArgumentError when no subscription passed to expect" do 146 | subscribe user: 42 147 | 148 | expect { 149 | expect(true).to have_stream_for(User.new(42)) 150 | }.to raise_error(ArgumentError) 151 | end 152 | 153 | it "passes" do 154 | subscribe user: 42 155 | 156 | expect(subscription).to have_stream_for(User.new(42)) 157 | end 158 | 159 | it "fails with message" do 160 | subscribe user: 42 161 | 162 | expect { 163 | expect(subscription).to have_stream_for(User.new(31337)) 164 | }.to raise_error(/expected to have stream "broadcast:User#31337" started, but have \[\"broadcast:User#42\"\]/) 165 | end 166 | 167 | context "with negated form" do 168 | it "passes" do 169 | subscribe user: 42 170 | 171 | expect(subscription).not_to have_stream_for(User.new(31337)) 172 | end 173 | 174 | it "fails with message" do 175 | subscribe user: 42 176 | 177 | expect { 178 | expect(subscription).not_to have_stream_for(User.new(42)) 179 | }.to raise_error(/expected not to have stream "broadcast:User#42" started, but have \[\"broadcast:User#42\"\]/) 180 | end 181 | end 182 | end 183 | 184 | if DeprecatedApi.enabled? 185 | specify { expect(self).to respond_to(:streams) } 186 | 187 | it "passes" do 188 | subscribe id: 1 189 | 190 | ActiveSupport::Deprecation.silence do 191 | expect(streams).to include("chat_1") 192 | end 193 | end 194 | else 195 | specify { expect(self).not_to respond_to(:streams) } 196 | end 197 | end 198 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__) 4 | 5 | begin 6 | require "pry-byebug" 7 | rescue LoadError 8 | end 9 | 10 | require "action_controller/railtie" 11 | require "action_view/railtie" 12 | require "action_cable" 13 | 14 | require "action_cable/testing/rspec" 15 | 16 | require "ammeter/init" 17 | 18 | # Require all the stubs and models 19 | Dir[File.expand_path("../test/stubs/*.rb", __dir__)].each { |file| require file } 20 | 21 | # # Set test adapter and logger 22 | ActionCable.server.config.cable = { "adapter" => "test" } 23 | ActionCable.server.config.logger = 24 | ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) 25 | 26 | Dir[File.expand_path("support/**/*.rb", __dir__)].each { |f| require f } 27 | 28 | RSpec.configure do |config| 29 | config.mock_with :rspec do |mocks| 30 | mocks.verify_partial_doubles = true 31 | end 32 | 33 | config.example_status_persistence_file_path = "tmp/rspec_examples.txt" 34 | config.filter_run :focus 35 | config.run_all_when_everything_filtered = true 36 | 37 | config.order = :random 38 | Kernel.srand config.seed 39 | end 40 | -------------------------------------------------------------------------------- /spec/support/deprecated_api.rb: -------------------------------------------------------------------------------- 1 | DeprecatedApi = Module.new do 2 | def self.enabled? 3 | ActionCable::VERSION::MAJOR < 6 && ( 4 | Gem::Version.new(ActionCable::Testing::VERSION) < Gem::Version.new("1.0") || 5 | raise("Deprecated API should be removed") 6 | ) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/support/helpers.rb: -------------------------------------------------------------------------------- 1 | # Copied from rspec-rails 2 | 3 | module Helpers 4 | include RSpec::Rails::FeatureCheck 5 | 6 | def with_isolated_config 7 | original_config = RSpec.configuration 8 | RSpec.configuration = RSpec::Core::Configuration.new 9 | RSpec::Rails.initialize_configuration(RSpec.configuration) 10 | 11 | if defined?(ActionCable) 12 | RSpec.configure do |config| 13 | config.include RSpec::Rails::ChannelExampleGroup, type: :channel 14 | end 15 | end 16 | 17 | yield RSpec.configuration 18 | ensure 19 | RSpec.configuration = original_config 20 | end 21 | 22 | RSpec.configure { |c| c.include self } 23 | end 24 | -------------------------------------------------------------------------------- /spec/support/shared_examples.rb: -------------------------------------------------------------------------------- 1 | # Copied from rspec-rails 2 | 3 | require 'pathname' 4 | 5 | shared_examples_for "an rspec-rails example group mixin" do |type, *paths| 6 | let(:mixin) { described_class } 7 | 8 | def define_group_in(path, group_definition) 9 | path = Pathname(path) 10 | $_new_group = nil 11 | begin 12 | file = path + "whatever_spec.rb" 13 | 14 | Dir.mktmpdir("rspec-rails-app-root") do |dir| 15 | Dir.chdir(dir) do 16 | path.mkpath 17 | File.open(file, "w") do |f| 18 | f.write("$_new_group = #{group_definition}") 19 | end 20 | 21 | load file 22 | end 23 | end 24 | 25 | group = $_new_group 26 | return group 27 | ensure 28 | $_new_group = nil 29 | end 30 | end 31 | 32 | around(:example) do |ex| 33 | with_isolated_config(&ex) 34 | end 35 | 36 | it "adds does not add `:type` metadata on inclusion" do 37 | mixin = self.mixin 38 | group = RSpec.describe { include mixin } 39 | expect(group.metadata).not_to include(:type) 40 | end 41 | 42 | context 'when `infer_spec_type_from_file_location!` is configured' do 43 | before { RSpec.configuration.infer_spec_type_from_file_location! } 44 | 45 | paths.each do |path| 46 | context "for an example group defined in a file in the #{path} directory" do 47 | it "includes itself in the example group" do 48 | group = define_group_in path, "RSpec.describe" 49 | expect(group.included_modules).to include(mixin) 50 | end 51 | 52 | it "tags groups in that directory with `:type => #{type.inspect}`" do 53 | group = define_group_in path, "RSpec.describe" 54 | expect(group.metadata).to include(:type => type) 55 | end 56 | 57 | it "allows users to override the type" do 58 | group = define_group_in path, "RSpec.describe 'group', :type => :other" 59 | expect(group.metadata).to include(:type => :other) 60 | expect(group.included_modules).not_to include(mixin) 61 | end 62 | 63 | it "applies configured `before(:context)` hooks with `:type => #{type.inspect}` metadata" do 64 | block_run = false 65 | RSpec.configuration.before(:context, :type => type) { block_run = true } 66 | 67 | group = define_group_in path, "RSpec.describe('group') { it { } }" 68 | group.run(double.as_null_object) 69 | 70 | expect(block_run).to eq(true) 71 | end 72 | end 73 | end 74 | 75 | it "includes itself in example groups tagged with `:type => #{type.inspect}`" do 76 | group = define_group_in "spec/other", "RSpec.describe 'group', :type => #{type.inspect}" 77 | expect(group.included_modules).to include(mixin) 78 | end 79 | end 80 | 81 | context 'when `infer_spec_type_from_file_location!` is not configured' do 82 | it "includes itself in example groups tagged with `:type => #{type.inspect}`" do 83 | group = define_group_in "spec/other", "RSpec.describe 'group', :type => #{type.inspect}" 84 | expect(group.included_modules).to include(mixin) 85 | end 86 | 87 | paths.each do |path| 88 | context "for an example group defined in a file in the #{path} directory" do 89 | it "does not include itself in the example group" do 90 | group = define_group_in path, "RSpec.describe" 91 | expect(group.included_modules).not_to include(mixin) 92 | end 93 | 94 | it "does not tag groups in that directory with `:type => #{type.inspect}`" do 95 | group = define_group_in path, "RSpec.describe" 96 | expect(group.metadata).not_to include(:type) 97 | end 98 | end 99 | end 100 | end 101 | end -------------------------------------------------------------------------------- /test/channel/test_case_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | class TestTestChannel < ActionCable::Channel::Base 6 | end 7 | 8 | class CrazyNameChannelTest < ActionCable::Channel::TestCase 9 | tests TestTestChannel 10 | 11 | def test_set_channel_class_manual 12 | assert_equal TestTestChannel, self.class.channel_class 13 | end 14 | end 15 | 16 | class CrazySymbolNameChannelTest < ActionCable::Channel::TestCase 17 | tests :test_test_channel 18 | 19 | def test_set_channel_class_manual_using_symbol 20 | assert_equal TestTestChannel, self.class.channel_class 21 | end 22 | end 23 | 24 | class CrazyStringNameChannelTest < ActionCable::Channel::TestCase 25 | tests "test_test_channel" 26 | 27 | def test_set_channel_class_manual_using_string 28 | assert_equal TestTestChannel, self.class.channel_class 29 | end 30 | end 31 | 32 | class SubscriptionsTestChannel < ActionCable::Channel::Base 33 | end 34 | 35 | class SubscriptionsTestChannelTest < ActionCable::Channel::TestCase 36 | def setup 37 | stub_connection 38 | end 39 | 40 | def test_no_subscribe 41 | assert_nil subscription 42 | end 43 | 44 | def test_subscribe 45 | subscribe 46 | 47 | assert subscription.confirmed? 48 | assert_not subscription.rejected? 49 | assert_equal 1, connection.transmissions.size 50 | assert_equal ActionCable::INTERNAL[:message_types][:confirmation], 51 | connection.transmissions.last["type"] 52 | end 53 | end 54 | 55 | class StubConnectionTest < ActionCable::Channel::TestCase 56 | tests SubscriptionsTestChannel 57 | 58 | def test_connection_identifiers 59 | stub_connection username: "John", admin: true 60 | 61 | subscribe 62 | 63 | assert_equal "John", subscription.username 64 | assert subscription.admin 65 | end 66 | end 67 | 68 | class RejectionTestChannel < ActionCable::Channel::Base 69 | def subscribed 70 | reject 71 | end 72 | end 73 | 74 | class RejectionTestChannelTest < ActionCable::Channel::TestCase 75 | def test_rejection 76 | subscribe 77 | 78 | assert_not subscription.confirmed? 79 | assert subscription.rejected? 80 | assert_equal 1, connection.transmissions.size 81 | assert_equal ActionCable::INTERNAL[:message_types][:rejection], 82 | connection.transmissions.last["type"] 83 | end 84 | end 85 | 86 | class StreamsTestChannel < ActionCable::Channel::Base 87 | def subscribed 88 | stream_from "test_#{params[:id] || 0}" 89 | end 90 | end 91 | 92 | class StreamsTestChannelTest < ActionCable::Channel::TestCase 93 | def test_stream_without_params 94 | subscribe 95 | 96 | assert_has_stream "test_0" 97 | end 98 | 99 | def test_stream_with_params 100 | subscribe id: 42 101 | 102 | assert_has_stream "test_42" 103 | end 104 | 105 | if DeprecatedApi.enabled? 106 | def test_deprecated_streams 107 | subscribe id: 42 108 | 109 | ActiveSupport::Deprecation.silence do 110 | assert "test_42", streams.last 111 | end 112 | end 113 | else 114 | def test_deprecated_streams 115 | assert_not_respond_to self, :streams 116 | end 117 | end 118 | end 119 | 120 | class StreamsForTestChannel < ActionCable::Channel::Base 121 | def subscribed 122 | stream_for User.new(params[:id]) 123 | end 124 | end 125 | 126 | class StreamsForTestChannelTest < ActionCable::Channel::TestCase 127 | def test_stream_with_params 128 | subscribe id: 42 129 | 130 | assert_has_stream_for User.new(42) 131 | end 132 | end 133 | 134 | class NoStreamsTestChannel < ActionCable::Channel::Base 135 | def subscribed; end # no-op 136 | end 137 | 138 | class NoStreamsTestChannelTest < ActionCable::Channel::TestCase 139 | def test_stream_with_params 140 | subscribe 141 | 142 | assert_no_streams 143 | end 144 | end 145 | 146 | class PerformTestChannel < ActionCable::Channel::Base 147 | def echo(data) 148 | data.delete("action") 149 | transmit data 150 | end 151 | 152 | def ping 153 | transmit type: "pong" 154 | end 155 | end 156 | 157 | class PerformTestChannelTest < ActionCable::Channel::TestCase 158 | def setup 159 | stub_connection user_id: 2016 160 | subscribe id: 5 161 | end 162 | 163 | def test_perform_with_params 164 | perform :echo, text: "You are man!" 165 | 166 | assert_equal({ "text" => "You are man!" }, transmissions.last) 167 | end 168 | 169 | def test_perform_and_transmit 170 | perform :ping 171 | 172 | assert_equal "pong", transmissions.last["type"] 173 | end 174 | end 175 | 176 | class PerformUnsubscribedTestChannelTest < ActionCable::Channel::TestCase 177 | tests PerformTestChannel 178 | 179 | def test_perform_when_unsubscribed 180 | assert_raises do 181 | perform :echo 182 | end 183 | end 184 | end 185 | 186 | class BroadcastsTestChannel < ActionCable::Channel::Base 187 | def broadcast(data) 188 | ActionCable.server.broadcast( 189 | "broadcast_#{params[:id]}", 190 | text: data["message"], user_id: user_id 191 | ) 192 | end 193 | 194 | def broadcast_to_user(data) 195 | user = User.new user_id 196 | 197 | self.class.broadcast_to user, text: data["message"] 198 | end 199 | end 200 | 201 | class BroadcastsTestChannelTest < ActionCable::Channel::TestCase 202 | def setup 203 | stub_connection user_id: 2017 204 | subscribe id: 5 205 | end 206 | 207 | def test_broadcast_matchers_included 208 | assert_broadcast_on("broadcast_5", user_id: 2017, text: "SOS") do 209 | perform :broadcast, message: "SOS" 210 | end 211 | end 212 | 213 | def test_broadcast_to_object 214 | user = User.new(2017) 215 | 216 | assert_broadcasts(user, 1) do 217 | perform :broadcast_to_user, text: "SOS" 218 | end 219 | end 220 | 221 | def test_broadcast_to_object_with_data 222 | user = User.new(2017) 223 | 224 | assert_broadcast_on(user, text: "SOS") do 225 | perform :broadcast_to_user, message: "SOS" 226 | end 227 | end 228 | end 229 | -------------------------------------------------------------------------------- /test/connection/test_case_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | class SimpleConnection < ActionCable::Connection::Base 6 | identified_by :user_id 7 | 8 | class << self 9 | attr_accessor :disconnected_user_id 10 | end 11 | 12 | def connect 13 | self.user_id = request.params[:user_id] || cookies[:user_id] 14 | end 15 | 16 | def disconnect 17 | self.class.disconnected_user_id = user_id 18 | end 19 | end 20 | 21 | class ConnectionSimpleTest < ActionCable::Connection::TestCase 22 | tests SimpleConnection 23 | 24 | def test_connected 25 | connect 26 | 27 | assert_nil connection.user_id 28 | end 29 | 30 | def test_url_params 31 | connect "/cable?user_id=323" 32 | 33 | assert_equal "323", connection.user_id 34 | end 35 | 36 | def test_plain_cookie 37 | cookies[:user_id] = "456" 38 | 39 | connect 40 | 41 | assert_equal "456", connection.user_id 42 | end 43 | 44 | if DeprecatedApi.enabled? 45 | def test_deprecated_cookie 46 | ActiveSupport::Deprecation.silence do 47 | connect cookies: { user_id: "456" } 48 | end 49 | 50 | assert_equal "456", connection.user_id 51 | end 52 | end 53 | 54 | def test_disconnect 55 | cookies[:user_id] = "456" 56 | connect 57 | 58 | assert_equal "456", connection.user_id 59 | 60 | disconnect 61 | 62 | assert_equal "456", SimpleConnection.disconnected_user_id 63 | end 64 | end 65 | 66 | class Connection < ActionCable::Connection::Base 67 | identified_by :current_user_id 68 | identified_by :token 69 | 70 | class << self 71 | attr_accessor :disconnected_user_id 72 | end 73 | 74 | def connect 75 | self.current_user_id = verify_user 76 | self.token = request.headers["X-API-TOKEN"] 77 | logger.add_tags("ActionCable") 78 | end 79 | 80 | private 81 | 82 | def verify_user 83 | reject_unauthorized_connection unless cookies.signed[:user_id].present? 84 | cookies.signed[:user_id] 85 | end 86 | end 87 | 88 | class ConnectionTest < ActionCable::Connection::TestCase 89 | def test_connected_with_signed_cookies_and_headers 90 | cookies.signed[:user_id] = "1123" 91 | 92 | connect headers: { "X-API-TOKEN" => "abc" } 93 | 94 | assert_equal "abc", connection.token 95 | assert_equal "1123", connection.current_user_id 96 | end 97 | 98 | def test_connection_rejected 99 | assert_reject_connection { connect } 100 | end 101 | end 102 | 103 | class EncryptedCookiesConnection < ActionCable::Connection::Base 104 | identified_by :user_id 105 | 106 | def connect 107 | self.user_id = verify_user 108 | end 109 | 110 | private 111 | 112 | def verify_user 113 | reject_unauthorized_connection unless cookies.encrypted[:user_id].present? 114 | cookies.encrypted[:user_id] 115 | end 116 | end 117 | 118 | class EncryptedCookiesConnectionTest < ActionCable::Connection::TestCase 119 | tests EncryptedCookiesConnection 120 | 121 | def test_connected_with_encrypted_cookies 122 | cookies.encrypted[:user_id] = "789" 123 | connect 124 | assert_equal "789", connection.user_id 125 | end 126 | 127 | def test_connection_rejected 128 | assert_reject_connection { connect } 129 | end 130 | end 131 | 132 | class SessionConnection < ActionCable::Connection::Base 133 | identified_by :user_id 134 | 135 | def connect 136 | self.user_id = verify_user 137 | end 138 | 139 | private 140 | 141 | def verify_user 142 | reject_unauthorized_connection unless request.session[:user_id].present? 143 | request.session[:user_id] 144 | end 145 | end 146 | 147 | class SessionConnectionTest < ActionCable::Connection::TestCase 148 | tests SessionConnection 149 | 150 | def test_connected_with_encrypted_cookies 151 | connect session: { user_id: "789" } 152 | assert_equal "789", connection.user_id 153 | end 154 | 155 | def test_connection_rejected 156 | assert_reject_connection { connect } 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /test/stubs/global_id.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class GlobalID 4 | attr_reader :uri 5 | delegate :to_param, :to_s, to: :uri 6 | 7 | def initialize(gid, options = {}) 8 | @uri = gid 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/stubs/test_adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SuccessAdapter < ActionCable::SubscriptionAdapter::Base 4 | def broadcast(channel, payload) 5 | end 6 | 7 | def subscribe(channel, callback, success_callback = nil) 8 | end 9 | 10 | def unsubscribe(channel, callback) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/stubs/test_server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ostruct" 4 | 5 | class TestServer 6 | include ActionCable::Server::Connections 7 | include ActionCable::Server::Broadcasting 8 | 9 | attr_reader :logger, :config, :mutex 10 | 11 | def initialize(subscription_adapter: SuccessAdapter) 12 | @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) 13 | 14 | @config = OpenStruct.new(log_tags: [], subscription_adapter: subscription_adapter) 15 | 16 | @mutex = Monitor.new 17 | end 18 | 19 | def pubsub 20 | @pubsub ||= @config.subscription_adapter.new(self) 21 | end 22 | 23 | def event_loop 24 | @event_loop ||= ActionCable::Connection::StreamEventLoop.new.tap do |loop| 25 | loop.instance_variable_set(:@executor, Concurrent.global_io_executor) 26 | end 27 | end 28 | 29 | def worker_pool 30 | @worker_pool ||= ActionCable::Server::Worker.new(max_size: 5) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/stubs/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class User 4 | attr_reader :name 5 | 6 | def initialize(name) 7 | @name = name 8 | end 9 | 10 | def to_global_id 11 | GlobalID.new("User##{name}") 12 | end 13 | 14 | def to_gid_param 15 | to_global_id.to_param 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/syntax_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | using ActionCable::Testing::Rails6 6 | 7 | class BroadcastChannel < ActionCable::Channel::Base 8 | end 9 | 10 | class TransmissionsTest < ActionCable::TestCase 11 | def test_broadcasting_for_name 12 | skip unless ActionCable::Testing::Rails6::SUPPORTED 13 | 14 | user = User.new(42) 15 | 16 | assert_nothing_raised do 17 | assert_broadcasts BroadcastChannel.broadcasting_for(user), 1 do 18 | BroadcastChannel.broadcast_to user, text: "text" 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__) 4 | 5 | begin 6 | require "pry-byebug" 7 | rescue LoadError 8 | end 9 | 10 | require "action_cable" 11 | require "action-cable-testing" 12 | 13 | require "active_support/testing/autorun" 14 | 15 | # Require all the stubs and models 16 | Dir[File.expand_path("stubs/*.rb", __dir__)].each { |file| require file } 17 | 18 | require File.expand_path("../spec/support/deprecated_api", __dir__) 19 | 20 | # # Set test adapter and logger 21 | ActionCable.server.config.cable = { "adapter" => "test" } 22 | ActionCable.server.config.logger = 23 | ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) 24 | -------------------------------------------------------------------------------- /test/test_helper_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | class BroadcastChannel < ActionCable::Channel::Base 6 | end 7 | 8 | class TransmissionsTest < ActionCable::TestCase 9 | using ActionCable::Testing::Rails6 10 | 11 | def test_assert_broadcasts 12 | assert_nothing_raised do 13 | assert_broadcasts("test", 1) do 14 | ActionCable.server.broadcast "test", "message" 15 | end 16 | end 17 | end 18 | 19 | def test_assert_broadcasts_with_no_block 20 | assert_nothing_raised do 21 | ActionCable.server.broadcast "test", "message" 22 | assert_broadcasts "test", 1 23 | end 24 | 25 | assert_nothing_raised do 26 | ActionCable.server.broadcast "test", "message 2" 27 | ActionCable.server.broadcast "test", "message 3" 28 | assert_broadcasts "test", 3 29 | end 30 | end 31 | 32 | def test_assert_no_broadcasts_with_no_block 33 | assert_nothing_raised do 34 | assert_no_broadcasts "test" 35 | end 36 | end 37 | 38 | def test_assert_no_broadcasts 39 | assert_nothing_raised do 40 | assert_no_broadcasts("test") do 41 | ActionCable.server.broadcast "test2", "message" 42 | end 43 | end 44 | end 45 | 46 | def test_assert_broadcasts_message_too_few_sent 47 | ActionCable.server.broadcast "test", "hello" 48 | error = assert_raises Minitest::Assertion do 49 | assert_broadcasts("test", 2) do 50 | ActionCable.server.broadcast "test", "world" 51 | end 52 | end 53 | 54 | assert_match(/2 .* but 1/, error.message) 55 | end 56 | 57 | def test_assert_broadcasts_message_too_many_sent 58 | error = assert_raises Minitest::Assertion do 59 | assert_broadcasts("test", 1) do 60 | ActionCable.server.broadcast "test", "hello" 61 | ActionCable.server.broadcast "test", "world" 62 | end 63 | end 64 | 65 | assert_match(/1 .* but 2/, error.message) 66 | end 67 | 68 | def test_assert_broadcast_to_objects 69 | skip unless ActionCable::Testing::Rails6::SUPPORTED 70 | 71 | user = User.new(42) 72 | 73 | assert_nothing_raised do 74 | assert_broadcasts BroadcastChannel.broadcasting_for(user), 1 do 75 | BroadcastChannel.broadcast_to user, text: "text" 76 | end 77 | end 78 | end 79 | 80 | if DeprecatedApi.enabled? 81 | def test_deprecated_assert_broadcast_to_object_with_channel 82 | user = User.new(42) 83 | 84 | ActiveSupport::Deprecation.silence do 85 | assert_nothing_raised do 86 | assert_broadcasts user, 1, channel: BroadcastChannel do 87 | BroadcastChannel.broadcast_to user, text: "text" 88 | end 89 | end 90 | end 91 | end 92 | end 93 | 94 | def test_assert_broadcast_to_object_without_channel 95 | user = User.new(42) 96 | 97 | assert_raises Minitest::Assertion do 98 | assert_broadcasts user, 1 do 99 | BroadcastChannel.broadcast_to user, text: "text" 100 | end 101 | end 102 | end 103 | end 104 | 105 | class TransmitedDataTest < ActionCable::TestCase 106 | include ActionCable::TestHelper 107 | using ActionCable::Testing::Rails6 108 | 109 | def test_assert_broadcast_on 110 | assert_nothing_raised do 111 | assert_broadcast_on("test", "message") do 112 | ActionCable.server.broadcast "test", "message" 113 | end 114 | end 115 | end 116 | 117 | def test_assert_broadcast_on_with_hash 118 | assert_nothing_raised do 119 | assert_broadcast_on("test", text: "hello") do 120 | ActionCable.server.broadcast "test", text: "hello" 121 | end 122 | end 123 | end 124 | 125 | def test_assert_broadcast_on_with_no_block 126 | assert_nothing_raised do 127 | ActionCable.server.broadcast "test", "hello" 128 | assert_broadcast_on "test", "hello" 129 | end 130 | 131 | assert_nothing_raised do 132 | ActionCable.server.broadcast "test", "world" 133 | assert_broadcast_on "test", "world" 134 | end 135 | end 136 | 137 | def test_assert_broadcast_on_message 138 | ActionCable.server.broadcast "test", "hello" 139 | error = assert_raises Minitest::Assertion do 140 | assert_broadcast_on("test", "world") 141 | end 142 | 143 | assert_match(/No messages sent/, error.message) 144 | end 145 | 146 | def test_assert_broadcast_on_object 147 | skip unless ActionCable::Testing::Rails6::SUPPORTED 148 | 149 | user = User.new(42) 150 | 151 | assert_nothing_raised do 152 | assert_broadcast_on BroadcastChannel.broadcasting_for(user), text: "text" do 153 | BroadcastChannel.broadcast_to user, text: "text" 154 | end 155 | end 156 | end 157 | 158 | if DeprecatedApi.enabled? 159 | def test_deprecated_assert_broadcast_om_object_with_channel 160 | user = User.new(42) 161 | 162 | ActiveSupport::Deprecation.silence do 163 | assert_nothing_raised do 164 | assert_broadcast_on user, { text: "text" }, { channel: BroadcastChannel } do 165 | BroadcastChannel.broadcast_to user, text: "text" 166 | end 167 | end 168 | end 169 | end 170 | end 171 | 172 | def test_assert_broadcast_on_object_without_channel 173 | user = User.new(42) 174 | 175 | assert_raises Minitest::Assertion do 176 | assert_broadcast_on user, text: "text" do 177 | BroadcastChannel.broadcast_to user, text: "text" 178 | end 179 | end 180 | end 181 | end 182 | --------------------------------------------------------------------------------