Skip to content

Commit 255a9b1

Browse files
authored
Merge pull request #5482 from rmosolgo/mock-action-cable
Add GraphQL::Testing::MockActionCable
2 parents 5c0a2d5 + c436e28 commit 255a9b1

5 files changed

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

spec/graphql/subscriptions/action_cable_subscriptions_spec.rb

Lines changed: 15 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,7 @@
11
# frozen_string_literal: true
22
require "spec_helper"
33

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-
4+
describe "GraphQL::Subscriptions::ActionCableSubscriptions" do
675
class ActionCableTestSchema < GraphQL::Schema
686
class Query < GraphQL::Schema::Object
697
field :int, Integer
@@ -106,7 +44,7 @@ class Subscription < GraphQL::Schema::Object
10644
query(Query)
10745
subscription(Subscription)
10846
use GraphQL::Subscriptions::ActionCableSubscriptions,
109-
action_cable: MockActionCable,
47+
action_cable: GraphQL::Testing::MockActionCable,
11048
action_cable_coder: JSON
11149
end
11250

@@ -115,20 +53,20 @@ class NamespacedActionCableTestSchema < GraphQL::Schema
11553
subscription(ActionCableTestSchema::Subscription)
11654
use GraphQL::Subscriptions::ActionCableSubscriptions,
11755
namespace: "other:",
118-
action_cable: MockActionCable,
56+
action_cable: GraphQL::Testing::MockActionCable,
11957
action_cable_coder: JSON
12058
end
12159

12260
before do
123-
MockActionCable.clear_mocks
61+
GraphQL::Testing::MockActionCable.clear_mocks
12462
end
12563

12664
def subscription_update(data)
12765
{ result: { "data" => data }, more: true }
12866
end
12967

13068
it "sends updates over the given `action_cable:`" do
131-
mock_channel = MockActionCable.get_mock_channel
69+
mock_channel = GraphQL::Testing::MockActionCable.get_mock_channel
13270
ActionCableTestSchema.execute("subscription { newsFlash { text } }", context: { channel: mock_channel })
13371
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"})
13472
expected_msg = subscription_update({
@@ -140,7 +78,7 @@ def subscription_update(data)
14078
end
14179

14280
it "uses arguments to divide traffic" do
143-
mock_channel = MockActionCable.get_mock_channel
81+
mock_channel = GraphQL::Testing::MockActionCable.get_mock_channel
14482
ActionCableTestSchema.execute("subscription { newsFlash(maxPerHour: 3) { text } }", context: { channel: mock_channel })
14583
ActionCableTestSchema.subscriptions.trigger(:news_flash, {}, {text: "Sunrise enjoyed over a cup of coffee"})
14684
ActionCableTestSchema.subscriptions.trigger(:news_flash, {max_per_hour: 3}, {text: "Neighbor shares bumper crop of summer squash with widow next door"})
@@ -154,7 +92,7 @@ def subscription_update(data)
15492
end
15593

15694
it "handles custom argument correctly" do
157-
mock_channel = MockActionCable.get_mock_channel
95+
mock_channel = GraphQL::Testing::MockActionCable.get_mock_channel
15896
ActionCableTestSchema.execute("subscription { newsFlash(filter: { trending: true }) { text } }", context: { channel: mock_channel })
15997
ActionCableTestSchema.subscriptions.trigger(:news_flash, {filter: {trending: true}}, {text: "Neighbor shares bumper crop of summer squash with widow next door"})
16098
expected_msg = subscription_update({
@@ -166,7 +104,7 @@ def subscription_update(data)
166104
end
167105

168106
it "handles nested custom argument correctly" do
169-
mock_channel = MockActionCable.get_mock_channel
107+
mock_channel = GraphQL::Testing::MockActionCable.get_mock_channel
170108
ActionCableTestSchema.execute("subscription { newsFlash(keywords: [{ value: \"rain\", fuzzy: true }]) { text } }", context: { channel: mock_channel })
171109
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"})
172110
expected_msg = subscription_update({
@@ -178,11 +116,11 @@ def subscription_update(data)
178116
end
179117

180118
it "uses namespace to divide traffic" do
181-
mock_channel_1 = MockActionCable.get_mock_channel
119+
mock_channel_1 = GraphQL::Testing::MockActionCable.get_mock_channel
182120
ctx_1 = { channel: mock_channel_1 }
183121
ActionCableTestSchema.execute("subscription { newsFlash { text } }", context: ctx_1)
184122

185-
mock_channel_2 = MockActionCable.get_mock_channel
123+
mock_channel_2 = GraphQL::Testing::MockActionCable.get_mock_channel
186124
ctx_2 = { channel: mock_channel_2 }
187125
NamespacedActionCableTestSchema.execute("subscription { newsFlash { text } }", context: ctx_2)
188126

@@ -212,11 +150,11 @@ def subscription_update(data)
212150
"graphql-subscription:other:#{ctx_2[:subscription_id]}",
213151
"graphql-event:other::newsFlash:",
214152
]
215-
assert_equal expected_streams, MockActionCable.mock_stream_names
153+
assert_equal expected_streams, GraphQL::Testing::MockActionCable.mock_stream_names
216154
end
217155

218156
it "supports no_update" do
219-
mock_channel = MockActionCable.get_mock_channel
157+
mock_channel = GraphQL::Testing::MockActionCable.get_mock_channel
220158
ctx = { channel: mock_channel }
221159
ActionCableTestSchema.execute("subscription { evenCounter { count } }", context: ctx)
222160

@@ -330,17 +268,17 @@ def self.dump(obj)
330268
end
331269

332270
use GraphQL::Subscriptions::ActionCableSubscriptions,
333-
action_cable: MockActionCable,
271+
action_cable: GraphQL::Testing::MockActionCable,
334272
action_cable_coder: JSON,
335273
serializer: Serialize
336274
end
337275

338276
it "works with multi-tenant architecture" do
339-
mock_channel_1 = MockActionCable.get_mock_channel
277+
mock_channel_1 = GraphQL::Testing::MockActionCable.get_mock_channel
340278
ctx_1 = { channel: mock_channel_1, tenant: "tenant-1" }
341279
MultiTenantSchema.execute("subscription { pointScored { score player { name } } }", context: ctx_1)
342280

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

0 commit comments

Comments
 (0)