Testing GraphQL Backend in Product Hunt

rstankov

Radoslav Stankov

Posted on August 4, 2020

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.

  1. Helpers
  2. Mutation classes
  3. 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.

💖 💪 🙅 🚩
rstankov
Radoslav Stankov

Posted on August 4, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related