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!