Skip to content

Commit e7fe534

Browse files
committed
Add GraphQL::Testing::MockActionCable
1 parent 5c0a2d5 commit e7fe534

5 files changed

Lines changed: 132 additions & 78 deletions

File tree

guides/subscriptions/action_cable_implementation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ index: 4
1010

1111
[ActionCable](https://guides.rubyonrails.org/action_cable_overview.html) is a great platform for delivering GraphQL subscriptions on Rails 5+. It handles message passing (via `broadcast`) and transport (via `transmit` over a websocket).
1212

13-
To get started, see examples in the API docs: {{ "GraphQL::Subscriptions::ActionCableSubscriptions" | api_doc }}.
13+
To get started, see examples in the API docs: {{ "GraphQL::Subscriptions::ActionCableSubscriptions" | api_doc }}. GraphQL-Ruby also includes a mock ActionCable implementation for testing: {{ "GraphQL::Testing::MockActionCable" | api_doc }}.
1414

1515
See client usage for:
1616

lib/graphql/subscriptions/action_cable_subscriptions.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ class Subscriptions
8181
# end
8282
# end
8383
#
84+
# @see GraphQL::Testing::MockActionCable for test helpers
8485
class ActionCableSubscriptions < GraphQL::Subscriptions
8586
SUBSCRIPTION_PREFIX = "graphql-subscription:"
8687
EVENT_PREFIX = "graphql-event:"

lib/graphql/testing.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
# frozen_string_literal: true
22
require "graphql/testing/helpers"
3+
require "graphql/testing/mock_action_cable"
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# frozen_string_literal: true
2+
module GraphQL
3+
module Testing
4+
# A stub implementation of ActionCable.
5+
# Any methods to support the mock backend have `mock` in the name.
6+
#
7+
# @example Configuring your schema to use MockActionCable in the test environment
8+
# require "graphql/testing/mock_action_cable"
9+
#
10+
# class MySchema < GraphQL::Schema
11+
# # Use MockActionCable in test:
12+
# use GraphQL::Subscriptions::ActionCableSubscriptions,
13+
# action_cable: Rails.env.test? ? GraphQL::Testing::MockActionCable : ActionCable
14+
# end
15+
#
16+
# @example Clearing old data before each test
17+
# setup do
18+
# GraphQL::Testing::MockActionCable.clear_mocks
19+
# end
20+
#
21+
# @example Using MockActionCable in a test case
22+
# # Create a channel to use in the test, pass it to GraphQL
23+
# mock_channel = GraphQL::Testing::MockActionCable.get_mock_channel
24+
# ActionCableTestSchema.execute("subscription { newsFlash { text } }", context: { channel: mock_channel })
25+
#
26+
# # Trigger a subscription update
27+
# ActionCableTestSchema.subscriptions.trigger(:news_flash, {}, {text: "After yesterday's rain, someone stopped on Rio Road to help a box turtle across five lanes of traffic"})
28+
#
29+
# # Check messages on the channel
30+
# expected_msg = {
31+
# result: {
32+
# "data" => {
33+
# "newsFlash" => {
34+
# "text" => "After yesterday's rain, someone stopped on Rio Road to help a box turtle across five lanes of traffic"
35+
# }
36+
# }
37+
# },
38+
# more: true,
39+
# }
40+
# assert_equal [expected_msg], mock_channel.mock_broadcasted_messages
41+
#
42+
class MockActionCable
43+
class MockChannel
44+
def initialize
45+
@mock_broadcasted_messages = []
46+
end
47+
48+
# @return [Array<Hash>] Payloads "sent" to this channel by GraphQL-Ruby
49+
attr_reader :mock_broadcasted_messages
50+
51+
# Called by ActionCableSubscriptions. Implements a Rails API.
52+
def stream_from(stream_name, coder: nil, &block)
53+
# Rails uses `coder`, we don't
54+
block ||= ->(msg) { @mock_broadcasted_messages << msg }
55+
MockActionCable.mock_stream_for(stream_name).add_mock_channel(self, block)
56+
end
57+
end
58+
59+
# Used by mock code
60+
# @api private
61+
class MockStream
62+
def initialize
63+
@mock_channels = {}
64+
end
65+
66+
def add_mock_channel(channel, handler)
67+
@mock_channels[channel] = handler
68+
end
69+
70+
def mock_broadcast(message)
71+
@mock_channels.each do |channel, handler|
72+
handler && handler.call(message)
73+
end
74+
end
75+
end
76+
77+
class << self
78+
# Call this before each test run to make sure that MockActionCable's data is empty
79+
def clear_mocks
80+
@mock_streams = {}
81+
end
82+
83+
# Implements Rails API
84+
def server
85+
self
86+
end
87+
88+
# Implements Rails API
89+
def broadcast(stream_name, message)
90+
stream = @mock_streams[stream_name]
91+
stream && stream.mock_broadcast(message)
92+
end
93+
94+
# Used by mock code
95+
def mock_stream_for(stream_name)
96+
@mock_streams[stream_name] ||= MockStream.new
97+
end
98+
99+
# Use this as `context[:channel]` to simulate an ActionCable channel
100+
#
101+
# @return [GraphQL::Testing::MockActionCable::MockChannel]
102+
def get_mock_channel
103+
MockChannel.new
104+
end
105+
106+
# @return [Array<String>] Streams that currently have subscribers
107+
def mock_stream_names
108+
@mock_streams.keys
109+
end
110+
end
111+
end
112+
end
113+
end

spec/graphql/subscriptions/action_cable_subscriptions_spec.rb

Lines changed: 16 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,8 @@
11
# frozen_string_literal: true
22
require "spec_helper"
3+
require "graphql/testing/mock_action_cable"
34

4-
5-
describe GraphQL::Subscriptions::ActionCableSubscriptions do
6-
# A stub implementation of ActionCable.
7-
# Any methods to support the mock backend have `mock` in the name.
8-
class MockActionCable
9-
class MockChannel
10-
def initialize
11-
@mock_broadcasted_messages = []
12-
end
13-
14-
attr_reader :mock_broadcasted_messages
15-
16-
def stream_from(stream_name, coder: nil, &block)
17-
# Rails uses `coder`, we don't
18-
block ||= ->(msg) { @mock_broadcasted_messages << msg }
19-
MockActionCable.mock_stream_for(stream_name).add_mock_channel(self, block)
20-
end
21-
end
22-
23-
class MockStream
24-
def initialize
25-
@mock_channels = {}
26-
end
27-
28-
def add_mock_channel(channel, handler)
29-
@mock_channels[channel] = handler
30-
end
31-
32-
def mock_broadcast(message)
33-
@mock_channels.each do |channel, handler|
34-
handler && handler.call(message)
35-
end
36-
end
37-
end
38-
39-
class << self
40-
def clear_mocks
41-
@mock_streams = {}
42-
end
43-
44-
def server
45-
self
46-
end
47-
48-
def broadcast(stream_name, message)
49-
stream = @mock_streams[stream_name]
50-
stream && stream.mock_broadcast(message)
51-
end
52-
53-
def mock_stream_for(stream_name)
54-
@mock_streams[stream_name] ||= MockStream.new
55-
end
56-
57-
def get_mock_channel
58-
MockChannel.new
59-
end
60-
61-
def mock_stream_names
62-
@mock_streams.keys
63-
end
64-
end
65-
end
66-
5+
describe "GraphQL::Subscriptions::ActionCableSubscriptions" do
676
class ActionCableTestSchema < GraphQL::Schema
687
class Query < GraphQL::Schema::Object
698
field :int, Integer
@@ -106,7 +45,7 @@ class Subscription < GraphQL::Schema::Object
10645
query(Query)
10746
subscription(Subscription)
10847
use GraphQL::Subscriptions::ActionCableSubscriptions,
109-
action_cable: MockActionCable,
48+
action_cable: GraphQL::Testing::MockActionCable,
11049
action_cable_coder: JSON
11150
end
11251

@@ -115,20 +54,20 @@ class NamespacedActionCableTestSchema < GraphQL::Schema
11554
subscription(ActionCableTestSchema::Subscription)
11655
use GraphQL::Subscriptions::ActionCableSubscriptions,
11756
namespace: "other:",
118-
action_cable: MockActionCable,
57+
action_cable: GraphQL::Testing::MockActionCable,
11958
action_cable_coder: JSON
12059
end
12160

12261
before do
123-
MockActionCable.clear_mocks
62+
GraphQL::Testing::MockActionCable.clear_mocks
12463
end
12564

12665
def subscription_update(data)
12766
{ result: { "data" => data }, more: true }
12867
end
12968

13069
it "sends updates over the given `action_cable:`" do
131-
mock_channel = MockActionCable.get_mock_channel
70+
mock_channel = GraphQL::Testing::MockActionCable.get_mock_channel
13271
ActionCableTestSchema.execute("subscription { newsFlash { text } }", context: { channel: mock_channel })
13372
ActionCableTestSchema.subscriptions.trigger(:news_flash, {}, {text: "After yesterday's rain, someone stopped on Rio Road to help a box turtle across five lanes of traffic"})
13473
expected_msg = subscription_update({
@@ -140,7 +79,7 @@ def subscription_update(data)
14079
end
14180

14281
it "uses arguments to divide traffic" do
143-
mock_channel = MockActionCable.get_mock_channel
82+
mock_channel = GraphQL::Testing::MockActionCable.get_mock_channel
14483
ActionCableTestSchema.execute("subscription { newsFlash(maxPerHour: 3) { text } }", context: { channel: mock_channel })
14584
ActionCableTestSchema.subscriptions.trigger(:news_flash, {}, {text: "Sunrise enjoyed over a cup of coffee"})
14685
ActionCableTestSchema.subscriptions.trigger(:news_flash, {max_per_hour: 3}, {text: "Neighbor shares bumper crop of summer squash with widow next door"})
@@ -154,7 +93,7 @@ def subscription_update(data)
15493
end
15594

15695
it "handles custom argument correctly" do
157-
mock_channel = MockActionCable.get_mock_channel
96+
mock_channel = GraphQL::Testing::MockActionCable.get_mock_channel
15897
ActionCableTestSchema.execute("subscription { newsFlash(filter: { trending: true }) { text } }", context: { channel: mock_channel })
15998
ActionCableTestSchema.subscriptions.trigger(:news_flash, {filter: {trending: true}}, {text: "Neighbor shares bumper crop of summer squash with widow next door"})
16099
expected_msg = subscription_update({
@@ -166,7 +105,7 @@ def subscription_update(data)
166105
end
167106

168107
it "handles nested custom argument correctly" do
169-
mock_channel = MockActionCable.get_mock_channel
108+
mock_channel = GraphQL::Testing::MockActionCable.get_mock_channel
170109
ActionCableTestSchema.execute("subscription { newsFlash(keywords: [{ value: \"rain\", fuzzy: true }]) { text } }", context: { channel: mock_channel })
171110
ActionCableTestSchema.subscriptions.trigger(:news_flash, {keywords: [{value: "rain", fuzzy: true}]}, {text: "After yesterday's rain, someone stopped on Rio Road to help a box turtle across five lanes of traffic"})
172111
expected_msg = subscription_update({
@@ -178,11 +117,11 @@ def subscription_update(data)
178117
end
179118

180119
it "uses namespace to divide traffic" do
181-
mock_channel_1 = MockActionCable.get_mock_channel
120+
mock_channel_1 = GraphQL::Testing::MockActionCable.get_mock_channel
182121
ctx_1 = { channel: mock_channel_1 }
183122
ActionCableTestSchema.execute("subscription { newsFlash { text } }", context: ctx_1)
184123

185-
mock_channel_2 = MockActionCable.get_mock_channel
124+
mock_channel_2 = GraphQL::Testing::MockActionCable.get_mock_channel
186125
ctx_2 = { channel: mock_channel_2 }
187126
NamespacedActionCableTestSchema.execute("subscription { newsFlash { text } }", context: ctx_2)
188127

@@ -212,11 +151,11 @@ def subscription_update(data)
212151
"graphql-subscription:other:#{ctx_2[:subscription_id]}",
213152
"graphql-event:other::newsFlash:",
214153
]
215-
assert_equal expected_streams, MockActionCable.mock_stream_names
154+
assert_equal expected_streams, GraphQL::Testing::MockActionCable.mock_stream_names
216155
end
217156

218157
it "supports no_update" do
219-
mock_channel = MockActionCable.get_mock_channel
158+
mock_channel = GraphQL::Testing::MockActionCable.get_mock_channel
220159
ctx = { channel: mock_channel }
221160
ActionCableTestSchema.execute("subscription { evenCounter { count } }", context: ctx)
222161

@@ -330,17 +269,17 @@ def self.dump(obj)
330269
end
331270

332271
use GraphQL::Subscriptions::ActionCableSubscriptions,
333-
action_cable: MockActionCable,
272+
action_cable: GraphQL::Testing::MockActionCable,
334273
action_cable_coder: JSON,
335274
serializer: Serialize
336275
end
337276

338277
it "works with multi-tenant architecture" do
339-
mock_channel_1 = MockActionCable.get_mock_channel
278+
mock_channel_1 = GraphQL::Testing::MockActionCable.get_mock_channel
340279
ctx_1 = { channel: mock_channel_1, tenant: "tenant-1" }
341280
MultiTenantSchema.execute("subscription { pointScored { score player { name } } }", context: ctx_1)
342281

343-
mock_channel_2 = MockActionCable.get_mock_channel
282+
mock_channel_2 = GraphQL::Testing::MockActionCable.get_mock_channel
344283
ctx_2 = { channel: mock_channel_2, tenant: "tenant-2" }
345284
MultiTenantSchema.execute("subscription { pointScored { score player { name } } }", context: ctx_2)
346285
# This will use the `.find` in `def update`:

0 commit comments

Comments
 (0)