graphql-anycable icon indicating copy to clipboard operation
graphql-anycable copied to clipboard

How to test this?

Open d4rky-pl opened this issue 9 months ago • 2 comments

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.

d4rky-pl avatar Mar 10 '25 07:03 d4rky-pl

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 avatar Mar 12 '25 18:03 palkan

@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 🤔

d4rky-pl avatar Mar 12 '25 20:03 d4rky-pl