Testing GraphQL Backend in Product Hunt
People don't write tests because it takes too much time to write them. They lose a lot of mental energy on answering trivial questions like "Do we test this in our application?", "Should I test this?", "How do I test this?", "How do I set up my test", and so on.
For your team to write tests, your team members need two things: good conventions on how and what to test, and good tooling.
I spend a lot of time making the common use-cases easy to test. In Product Hunt, one such example is our Ruby GraphQL testing helpers.
We test three categories of GraphQL API code.
- Helpers
- Mutation classes
- Resolver classes
Helpers
Those are helper module and classes like RecordLoader
/ RequestInfo
/ Context
/ RateLimiter
and others. We don't have many of those. They are regular Ruby objects and are tested as such.
Mutations
Mutations in GraphQL are the operations that change your data. It is very, very important for those to be fully tested.
For each mutation, we test:
- Authentication and authorization rules
- Failure cases - validation, errors
- Happy paths
Mutations inherit from Mutations::BaseMutation class. I have written a blog post on the topic - here
Here is an example mutation:
module Mutations
class QuestionCreate < BaseMutation
argument :title, String, required: false
authorize :create, Question
returns Types::QuestionType
def perform(attributes)
question = Question.new(user: current_user)
question.update(attributes)
question
end
end
end
The way to test this, without extra tooling is:
describe Mutations::QuestionCreate do
let(:user) { create :user }
it 'creates a question' do
context = Graph::Context.new(
query: OpenStruct.new(schema: StacksSchema),
values: context.merge(current_user: user),
object: nil,
)
result = described_class.new(object: nil, context: context, field: nil).resolve(
title: 'What email client do you use?',
)
expect(result[:errors]).to eq []
expect(result[:node]).to have_attributes(
id: be_present,
user: user,
title: 'What email client do you use?',
)
end
end
Yikes! ? Having to write something like this for every mutation will put off even the most enthusiastic developers.
The graphql-ruby classes aren't designed to be instantiated and run by you. The gem itself does this. Thus running them requires a lot of orchestration. That's why we have custom helpers to test mutations.
We have a custom helpers to test mutations.
Here is the updated example:
describe Mutations::QuestionCreate do
let(:user) { create :user }
it 'requires an user' do
# executes the mutation with one-liner
result = execute_mutation(
title: 'test',
)
# verify that mutation returned this error
expect_access_denied_error(result)
end
it 'creates a question' do
# the one-liner, allow us to specify current user
result = execute_mutation(
current_user: user,
title: 'What email client do you use?',
)
# "expect_node" verify that:
# 1. there are no errors
# 2. "node" is not null
expect_node(result) do |node|
expect(node).to have_attributes(
id: be_present,
user: user,
title: 'What email client do you use?',
)
end
end
it 'requires a title' do
result = execute_mutation(
current_user: user,
title: '',
)
# "expect_error" verify that:
# 1. "node" is nil
# 2. there is an error for given attribute and message
expect_error result, :title, :blank
end
end
This is a lot better. It clearly communicates what the business logic of this mutation is.
Here, you can find the gist of those helper methods.
Resolvers
Resolvers inherit from GraphQL::Schema::Resolver. They help us structure the code and make it easier to test. The alternative is to write the logic in the type classes. We tend to avoid this because classes get bloated and are harder to test.
The following is an example of a resolver that tells us if the logged-in user has liked a given object:
class Resolvers::IsLiked < Resolvers::Base
type Boolean, null: false
def resolve
current_user = context.current_user
return false if current_user.blank?
Graph::IsLoaderPolymorphic.for(current_user.likes).load(object)
end
end
Note: Graph::IsLoaderPolymorphic
is batching helper. I have written about batching in GraphQL - here and here.
How will we test this?
We have two helpers for testing resolvers: execute_resolver
and execute_batch_resolver
.
describe Resolvers::IsLiked do
let(:user) { create :user }
let(:answer) { create :answer }
def expect_call(object:, user:)
expect(execute_batch_resolver(current_user: user, object: object))
end
it 'returns false when there is no user' do
expect_call(object: answer, user: nil).to eq false
end
it 'returns false when the user has not liked the answer' do
expect_call(object: answer, user: user).to eq false
end
it 'returns true when the user has liked the answer' do
create: like, subject: answer, profile: user.profile
expect_call(object: answer, user: user).to eq true
end
end
Here, you can find the gist of those helper methods.
Conclusion
Test helpers can make testing more accessible. Our GraphQL helpers are just one example of this. People should think about the domain they are testing, not about test boilerplate.
If you have any questions or comments, you can ping me on Twitter.