Testing GraphQL APIs can be challenging due to their flexible nature. While clients can request data in countless ways, we need a testing strategy that remains robust regardless of how the API is consumed. And one that can scale with the growth of your app. The main pillar are proper, exhaustive type specs so I want to focus on that together with a couple best-practices that you can establish.

First, we want our tests to be integration tests that go through all the involved layers (GQL types, database, authorization policies, etc, as much as possible).

But we don’t want those tests to be query oriented. The frontend (or generally any clients) can change the queries at any point, they might add new queries, the queries might different between clients (web, ios, android). So we don’t focus on the queries at all. We test one type at a time. But we do it extensively.

What does extensively mean? We need to guarantee that all fields have been queried. Not only that, we are going to create multiple records and verify that fetching many of them does not cause N+1 queries. You need at least two records to detect N+1 problem.

To speed-up the tests we use let_it_be. This especially matters when the number of specs keeps increasing.

RSpec.describe GraphqlApi::Types::Article do
  subject(:result) { execute_query(query:, context:, variables:) }

  let(:context) { {current_user:} }
  let(:current_user) { build_stubbed(:user, permissions: %w(view_articles)) }
  let(:variables) { {ids: articles.map { |a| relay_node_id(a) }} }
  let(:query) { <<~GRAPHQL }
    query($ids: [ID!]!) {
      nodes(ids: $ids) {
        ... on Article {
          id
          title
          author { id }
          status
        }
      }
    }
  GRAPHQL

  let_it_be(:articles) { [article0, article1] }
  let_it_be(:article0) { create(:article, :published) }
  let_it_be(:article1) { create(:article, :draft) }

  specify 'all the fields are resolved' do
    expect { result }.to be_n1_efficient
    expect(result['errors']).to be_nil
    nodes = result.dig('data', 'nodes')
    expect(nodes.first).to contain_all_gql_fields_from(described_class)
    
    expect(nodes).to match(
      [
        {
          'id' => relay_node_id(article0, 'Article'),
          'title' => article0.title,
          'author' => {'id' => relay_node_id(article0.author_id, 'User')},
          'status' => 'PUBLISHED',
        }, {
          'id' => relay_node_id(article1, 'Article'),
          'title' => article1.title,
          'author' => {'id' => relay_node_id(article1.author_id, 'User')},
          'status' => 'DRAFT',
        }
      ]
    )
  end
end

We test through the Node interface because it is the simplest and most direct way of accessing or GQL type. When the tested type does not implement the interface, we fetch it through a parent object, sometimes that’s necessary.

When querying fields (author) returning other complex types, such as User in this example, we only fetch the id. The whole User type will be tested using the same guidelines, in a separate file so we don’t need to verify more than just that the correct author is returned.

The be_n1_efficient is a custom Rspec matcher which we implemented based on the n_one gem. When the type is not fully N+1 optimized we can say be_n1_efficient(known_offences: 1) to ensure that future changes (i.e. adding new attributes) don’t make the situation any worse. And if you fix the problem, but forget to update the spec, it will complain as well and force you update the number to new value.

The contain_all_gql_fields_from is also our custom Rspec matcher which checks if given hash contains all the fields from the tested type. It supports ignoring some of them, which is useful in case those fields are tested explicitly in separate rspec example.

Writing the GQL specs this way from day one will give you pretty strong confidence that your GQL layer is pretty solid and Just Works™. To scale these practices in your company, you can introduce a custom rubocop rule which verifies that those matchers have been used in your file. That way you establish a rule and enforce it. I believe this is actually a fantastic usage of Rubocop (as opposed to enforcing more silly rules like your preferred string quotes, I like " btw).

Key Takeaways

  • Focus on testing types instead of specific queries, but do test through a query.
  • Ensure complete field coverage for each type
  • Test for N+1 query efficiency
  • Automate best practices enforcement

Want more? Ruby Under Pressure Newsletter