How to test this?
There seems to be a painful lack of documentation on how to test graphql-anycable. Due to #12 it's not possible to use default ActionCable testing and graphql-ruby itself is also quite slim on instructions but at least it provides something (though that something does not work with graphql-anycable due to internal differences like expecting anycable_socket to be there).
It would be amazing if there was a documented way on how to test the basic flows:
- Initial subscription payload
- Update subscription payload
- Triggering subscriptions in general and ensuring they're sent to all relevant clients
I've been trying to come up with something for our internal needs (as we require full test coverage for all our new features) but it's been proving a bit difficult (lots of deep diving into graphql-anycable internals and trying to understand how things work without previous knowledge takes time that I honestly no longer can afford) so any assistance or pointers would be more than welcome.
Here is the shared context from one of the projects using AnyCable GraphQL:
shared_context 'graphql:subscription' do
let(:anycable) { AnyCable.broadcast_adapter }
let(:fingerprint) { SecureRandom.uuid }
let(:channel) do
socket = double('Socket', istate: AnyCable::Socket::State.new({}))
connection = double('Connection', anycable_socket: socket)
double('Channel', id: 'legacy_id', params: { 'channelId' => 'legacy_id' }, stream_from: nil, connection:)
end
let(:context) {}
before do
allow(anycable).to receive(:broadcast)
allow_any_instance_of(GraphQL::Subscriptions::Event).to receive(:fingerprint).and_return(fingerprint)
end
def check_gql_subscription(subscription_name, expected_result, args: {}, object: {},
scope: nil)
ApplicationSchema.subscriptions.trigger(subscription_name, args, object, scope:, context:)
expected_result = {
result: {
data: expected_result
},
more: true # always true for subscriptions
}.to_json
expect(anycable).to have_received(:broadcast).with("graphql-test-subscriptions:#{fingerprint}", expected_result)
end
end
And then use it like this:
describe Subscriptions::ItemAdded do
let(:query) do
<<~GRAPHQL
subscription ItemAddedSubscription {
itemAdded {
item {
...
}
}
}
GRAPHQL
end
let(:item) { create(:item) }
describe '#subscribe' do
it 'returns nothing' do
expect(data).to be_nil
end
end
describe '#update' do
let(:object) { item }
it 'returns requested data' do
expected_result = {
itemAdded: {
item: {
# ...
}
}
}
check_gql_subscription(:item_added, expected_result, object:)
end
end
end
@palkan I see this is quite similar to what we've built (literally today 😄), though our solution is more complex than that (but then the DSL in the specs ends up being simpler)
module GraphqlRubySubscriptionsHelper
module Extend
def stub_subscriptions(schema)
before do
schema.subscriptions.singleton_class.attr_accessor :_events
schema.subscriptions._events = []
allow(schema.subscriptions).to receive(:broadcast) do |subscription_id, payload|
payload_object = begin
JSON.parse(payload)['result']
rescue JSON::ParserError
payload
end
schema.subscriptions._events << [subscription_id, payload_object]
end
end
after do
schema.subscriptions._events = []
GraphQL::AnyCable.with_redis(&:flushall)
end
end
end
def stub_graphql_channel(channel, params: {}, env: {})
# We're trying to reuse as much of ActionCable testing capabilities as possible so we're using the subscribe
# method from ActionCable::Channel::TestCase::Behavior here. Unfortunately it relies on a global as it was created
# to test specific channel in one test so it would not work well for integration testing with multiple schemas.
previous_channel_class = self.class._channel_class
self.class._channel_class = channel
channel = subscribe(params)
self.class._channel_class = previous_channel_class
# This is required for AnyCable to work properly
anycable_socket = AnyCable::Socket.new(env: AnyCable::Env.new(env))
channel.connection.singleton_class.define_method(:anycable_socket) { anycable_socket }
channel
end
end
module GraphqlSubscriptionMatchers
class TriggerSubscriptionTo
include ::RSpec::Matchers::Composable
def supports_block_expectations?
true
end
def initialize(schema, subscription_id)
@schema = schema
@subscription_id = subscription_id
end
def matches?(actual)
if @schema.subscriptions._events.nil?
schema_klass_name = @schema.is_a?(Class) ? @schema.name : "schema_class"
raise "You need to use stub_subscriptions(#{schema_klass_name}) first"
end
schema.subscriptions._events = []
actual.call
if @expected_payload.nil?
@schema.subscriptions._events.any? { |event| event[0] == @subscription_id }
else
@schema.subscriptions._events.any? { |event| event[0] == @subscription_id && event[1] == @expected_payload }
end
end
def with(expected_payload)
@expected_payload = expected_payload
self
end
# Most of the code below is just to make the developer experience nicer when working with the specs
# These are just for pretty-printing the error message and are not strictly required for the matcher to work
def failure_message
if @expected_payload
formatted_payload = pp(@expected_payload)
subscription_id_event = @schema.subscriptions._events.reverse.find { |event| event[0] == @subscription_id }
subscription_id_events_count = @schema.subscriptions._events.count { |event| event[0] == @subscription_id }
if subscription_id_event
formatted_event = pp(subscription_id_event[1])
"expected that subscription to #{@subscription_id} would be triggered with\n\n #{formatted_payload}\n\n" \
"but it was triggered with\n\n #{formatted_event}\n\n" + (subscription_id_events_count > 1 ? " (and #{subscription_id_events_count - 1} other events)" : "")
else
"expected that subscription to #{@subscription_id} would be triggered with \n#{formatted_payload}\n, but no events were triggered"
end
else
"expected that subscription to #{@subscription_id} would be triggered"
end
end
def failure_message_when_negated
if @expected_payload
formatted_payload = pp(@expected_payload)
"expected that no subscription events to #{@subscription_id} would be triggered with \n#{formatted_payload}\n"
else
"expected that no subscription events to #{@subscription_id} would be triggered"
end
end
end
def trigger_subscription_to(schema, subscription_id)
TriggerSubscriptionTo.new(schema, subscription_id)
end
end
And then our specs look like this:
channel = stub_graphql_channel(Gql::AdminChannel)
result = admin_gql_query(query:, variables: { conversationId: conversation.id }, context: { current_admin: admin, channel: })
expect(result.dig("data", "homeAdvisorChatMessageReceived")).to eq({
"conversationId" => conversation.id.to_s,
"message" => nil,
})
subscription_id = channel.streams.last
expect(subscription_id).to be_present
expect {
Gql::Admin::Schema.subscriptions.trigger 'homeAdvisorChatMessageReceived', {}, message
}.to trigger_subscription_to(Gql::Admin::Schema, subscription_id)
.with({
"data" => {
"homeAdvisorChatMessageReceived" => {
"conversationId" => conversation.id.to_s,
"message" => { "id" => message.id.to_s },
},
},
})
I'm wondering if this isn't something tha could become a part of graphql-anycable directly as a minitest/rspec matcher so others wouldn't have to reinvent the same solution in their own code 🤔