How I Test GraphQL in Rails With RSpec

I'm hooked on GraphQL and really enjoy using graphql-ruby when I'm working on new rails apps. Here's a look into how I go about testing my schema, mutations, and queries. You can find some of these recommendations already documented in the Testing Overview on the libraries website

The content here assumes you are familiar with GraphQL and have learned about creating and using queries and mutations in graphql-ruby

Dumping the Schema

When you're building your API, your tooling and testing will thank you if dump your GraphQL schema when you make changes in app/graphql. I use graphql-code-generator to generate types and helpers for my frontend code and it requires access to the schema. The library provides a way to do this yourself by looping in some rake tasks in your Rakefile

require 'graphql/rake_task'
require_relative 'config/application'
Rails.application.load_tasks
GraphQL::RakeTask.new(
schema_name: 'AppSchema', # this needs to be your generated schema class name
)

Now you can use rails graphql:schema:dump to generate a graphql and json representation of your schema, schema.graphql and schema.json in the root of you rails project by default

Testing Your Schema

Testing your schema has the benefit of making sure your tooling is on the same page with the rest of your team, and can easily be caught by CI. I like to throw this in spec/graphql/app_schema_spec.rb

require 'rails_helper'
RSpec.describe AppSchema do
it 'matches the dumped schema (rails graphql:schema:dump)' do
aggregate_failures do
expect(described_class.to_definition).to eq(File.read(Rails.root.join('schema.graphql')).rstrip)
expect(described_class.to_json).to eq(File.read(Rails.root.join('schema.json')).rstrip)
end
end
end

This will ensure that the current dumped schemas match the on-demand generated schema

RSpec Setup

When setting up RSpec I create rspec/support/graphql.rb to plug in helpers and matchers

module GraphQLHelpers
def controller
@controller ||= GraphqlController.new.tap do |obj|
obj.set_request! ActionDispatch::Request.new({})
end
end
def execute_graphql(query, **kwargs)
AppSchema.execute(
query,
{ context: { controller: controller } }.merge(kwargs),
)
end
end
RSpec.configure do |c|
c.include GraphQLHelpers, type: :graphql
end

Because I pass { controller: self } as context in GraphqlController, I'm doing similar by replicating an instance of the controller and passing that as context in execute_graphql. This'll be helpful for mocking data.

Testing Queries and Mutations

Lets say we have defined a login mutation in MutationType

module Mutations
class Login < Base
null false
field :success, Boolean, null: false
argument :email, String, required: true
argument :password, String, required: true
def resolve(email:, password:)
user = User.find_by(email: email)
user = user&.authenticate(password)
context[:controller].session[:user_id] = user.id if user.present?
{ success: user.present? }
end
end
end

Our mutation takes an email and password, tries to find the user by email, and tries to authenticate them with their password. If it succeeds in finding and authenticating the user, we return success: true

Now we'll test to make sure that it is possible to login with valid credentials, and fail with invalid credentials

require 'rails_helper'
RSpec.describe Mutations::Login, type: :graphql do
let(:mutation) do
<<~GQL
mutation($input: LoginInput!) {
login(input: $input) {
success
}
}
GQL
end
it 'is successful' do
user = create(:user)
result = execute_graphql(
mutation,
variables: { input: { email: user.email, password: 'password' } },
)
aggregate_failures do
expect(controller.current_user).to eq user
expect(result['data']['login']['success']).to eq true
end
end
it 'fails' do
result = execute_graphql(
mutation,
variables: { input: { email: 'whatever', password: 'whatever' } },
)
expect(result['data']['login']['success']).to eq false
end
end

A Different Approach

It's worth knowing that the alternative to testing our login mutation would be to refactor the resolvers body to call a LoginService that takes the arguments from resolve and returns the response object or implements the response fields. Then we can test the service rather than the mutation

class LoginService
attr_reader :email, :password, :controller
def initialize(email, password, controller)
@email = email
@password = password
@controller = controller
end
def success
user = User.find_by(email: email)
user = user&.authenticate(password)
controller.session[:user_id] = user.id if user.present?
user.present?
end
end
module Mutations
class Login < Base
null false
field :success, Boolean, null: false
argument :email, String, required: true
argument :password, String, required: true
def resolve(email:, password:)
LoginService.new(email, password, context[:controller])
end
end
end

I hope you're now able to get started in adding coverage for your GraphQL API!