Most of software engineers write tests. Some people use Ruby. Some of them use GraphQL. While testing queries and mutations has little differences compared to controller specs (at the end of the day—they both use HTTP requests), subscriptions are a bit more tricky. In this short post I’ll show you how I test my GraphQL backends.

I will use RSpec, but you can easily port these approaches to any other testing framework you prefer.

Queries and types

There are two approaches of testing data fetch: you can check either requests or types. In case of requests, you can have a single spec file that fetches a lot of data. If you decide to go with types—you should have a separate spec per type.

I personally prefer the second approach: it’s more verbose (and you end up with more files) but also gives you more granular control over the spec and makes it harder to miss something.

You can even set up Rubocop to enforce every type have a corresponding spec file

What is the responsibility of the GraphQL type? Technically speaking, it’s a serializer: you give it some data, it transforms the data and returns the result. You can even extract the transformation to the separate PORO class and test it separately.

Here is an example of the spec I usually create:

describe Types::PostType do
  # you can configure RSpec to add this to all specs
  # inside spec/graphql folder
  include GraphqlHelpers

  let(:query) do
    <<~GRAPHQL
      query Post($postId: ID!) {
        post(postId: $postId) {
          id
          title
          content
          author {
            id
          }
        }
      }
    GRAPHQL
  end

  let(:post) { create :post }
  let(:variables) { { postId: post.id } }

  specify do
    perform_request

    expect(gql_errors).to eq(nil)

    resolved_object = gql_data.dig("post")

    # Ideally you should also make sure that you fetch all fields that are
    # resolved by this type
    expect(resolved_object).to match(
      "id" => post.id.to_s,
      "title" => post.title,
      "content" => post.content,
      "author" => {
        "id" => post.author.id.to_s
        # note that we do not check contents of the author field, because
        # it's more straightforward to test Author separately
      }
    )
  end
end

There are three things that I care about:

  • HTTP request is performed;
  • there are no errors (or there are expected errors, in rare cases);
  • there is some data returned and contents make sense.

Do not confuse top–level errors with data–level errors—check out my huge post on that topic

If you do have some complex logic inside the type—you can also add some checks to this spec.

Now let’s inspect the GraphqlHelpers module I used:

module GraphqlHelpers
  extend ActiveSupport::Concern

  included do
    let(:variables) { { input: input } }
    let(:input) { {} }
  end

  def perform_request
    # you will probably handle auth here
    post "/graphql", # replace with your endpoint
      params: JSON.dump(request_params),
      xhr: true
  end

  def json_response_body
    JSON.parse(response.body)
  end

  def gql_data
    json_response_body["data"]
  end

  def gql_errors
    json_response_body["errors"]
  end

  private

  def request_params
    { query: query }.tap do |params|
      params[:variables] = variables if defined?(variables)
    end
  end
end

This module encapsulates the boilerplate logic of performing HTTP request and parsing the response, which should be common for all your type specs.

Mutations

Imagine that we have all types covered with specs, and now we want to test mutations. What is mutation in GraphQL? It’s the same thing as query, but we also expect some changes in the state.

As a result, we only care about three facts:

  • data was updated;
  • mutation returns some data (but we don’t need to test the whole type!);
  • no errors were returned.

For example:

describe Mutations::CreatePost do
  # you can configure RSpec to add this to all specs
  # inside spec/graphql folder
  include GraphqlHelpers

  let(:query) do
    <<~GRAPHQL
      mutation CreatePost($title: String!, $content: String!) {
        createPost(title: $title, content: $content) {
          id
        }
      }
    GRAPHQL
  end

  let(:title) { "First post" }
  let(:content) { "Bla bla bla" }
  let(:variables) { { title:, content: } }

  specify do
    expect { perform_request }.to change(Post, :count).by(1)

    Post.last.tap do |created_post|
      expect(created_post).to have_attributes(title:, content:, author: current_user)

      expect(gql_errors).to eq(nil)

      resolved_object = gql_data.dig("post")

      expect(resolved_object).to match(
        "id" => created_post.id.to_s
      )
    end
  end
end

Subscriptions

Subscriptions are a bit more tricky: the problem is that they can return data two times: first time when client is subscribed (via def subscribe) and second time when subscription is triggered. The first case is simple: you just need to perform the query and check the response, like we did for types.

However, how can we test the payload that comes when subscription is triggered?

Let’s start with the spec file:

describe Api::Types::DailyGoal::EarnTicketEventType do
  include GraphqlSubscriptionHelpers

  let(:query) { <<~GQL }
    subscription {
      postCreated {
        post {
          id
        }
      }
    }
  GQL

  let(:post) { create :post }
  let(:current_user_id) { 42 }
  let(:context) { { channel: mock_channel, current_user_id: current_user_id } }

  specify do
    subscribe(query, context:)
    trigger_subscription(:post_created, payload: { post: }, scope: current_user_id)

    event = mock_channel.mock_broadcasted_messages.first.dig("data", "post")
    expect(event).to match("id" => post.id.to_s)
  end
end

We expect the event that was sent to the user to be inside the message queue of the mock_channel. In order to get it we need to subscribe to the subscription and emit the event.

All the magic sits in the GraphqlSubscriptionHelpers:

module GraphqlSubscriptionHelpers
  extend ActiveSupport::Concern

  included do
    let(:mock_channel) { MockSubscriptionCable.fetch_mock_channel }

    # we want queue to be empty when individual example is run
    before { MockSubscriptionCable.clear_mocks }
  end

  def subscribe(query, context:)
    MockSubscriptionCable::GraphqlSchema.execute(query, context:)
  end

  def trigger_subscription(subscription, payload:, scope:)
    MockSubscriptionCable::GraphqlSchema.subscriptions.trigger(:post_created, {}, , scope:)
  end
end

We have an accessor to get the mocked channel, and we have two methods—subscribe and trigger_subscription that imitate the real subscription flow.

Now we need to implement that MockSubscriptionCable module. Turns out, that graphql-ruby gem has the code we need right in the repo—they need to test subscription as well. Now we can build our own version inspired by their implementation of the mock subscription queue:

class MockSubscriptionCable
  class MockChannel
    attr_reader :mock_broadcasted_messages

    def initialize
      @mock_broadcasted_messages = []
    end

    def stream_from(stream_name, coder: nil, &block)
      block ||= ->(msg) { @mock_broadcasted_messages << msg[:result] }
      MockSubscriptionCable.mock_stream_for(stream_name).add_mock_channel(self, block)
    end
  end

  class MockStream
    def initialize
      @mock_channels = {}
    end

    def add_mock_channel(channel, handler)
      @mock_channels[channel] = handler
    end

    def mock_broadcast(message)
      @mock_channels.each_value { |handler| handler&.call(message) }
    end
  end

  class << self
    def clear_mocks
      @mock_streams = {}
    end

    def server
      self
    end

    def broadcast(stream_name, message)
      stream = @mock_streams[stream_name]
      stream&.mock_broadcast(message)
    end

    def mock_stream_for(stream_name)
      @mock_streams[stream_name] ||= MockStream.new
    end

    def fetch_mock_channel
      MockChannel.new
    end

    def mock_stream_names
      @mock_streams.keys
    end
  end

  # you can inherit from your app's schema or just build a new one
  class GraphqlSchema < ::GraphqlSchema
    use GraphQL::Subscriptions::ActionCableSubscriptions,
      action_cable: MockSubscriptionCable,
      action_cable_coder: JSON
  end
end

In this post we learned how to test various responses from your Ruby backend powered by GraphQL Ruby.