Feb 04, 2021

GraphQL-powered API Integration Tests

When you build a service focused around a GraphQL endpoint, you might think of testing strategies that can make use of it. After all, your resolvers will contain large parts of the actual business logic, otherwise, exposing an API wouldn't be useful, would it?

Once you reach the point where all other components should work as expected, and you added sufficient tests in those parts, you will get to the point where the only untested surface is your schema and the resolvers behind it. That's a great place to be in, because with a few steps, you can test that, too.

But before we talk about the tests, let's think of common approaches to designing GraphQL APIs and services around them.

🔎 Context is Key

When resolving a GraphQL query, after parsing and validating, we walk an in-memory structure called abstract syntax tree (AST), which represents the request, and resolve all fields along the way. Take the following example query

query example {
  posts {
    id
    title
    author {
      id
      name
    }
  }
}

This is a simple query that retrieves all posts including IDs, titles, and their respective authors with some metadata.

The business logic that executes the GraphQL request, often the GraphQL library used underneath the server implementation, so usually graphql-js, will step into every layer and resolve all fields defined in your query.

To include state in your request, you use a data structure referred to as the context. The context is defined prior to executing the request and is passed to every resolver. It could contain data such as the user running the request, structures to access your data layer, and other useful session-based utilities.

We don't want to focus on how the request is resolved in this post, I merely wanted to explain that we can use the context to pass session-specific data around, chances are, that you're already doing this anyway.

🎯 Focus on what you really want to test

If everything else is tested already, we want to make sure the resolving logic that ties together all those parts works as expected, too. Our users invoke this business logic by sending GraphQL queries, mutations, and subscriptions.

While we could simply launch a server and fire requests against it, that way, we'd also test the server implementation and even the network stack. We want fast tests, we don't want to fight against latency, the networking layer, or anything other than our code. So we need to test on another layer.

Conveniently enough, though, you don't have to spin up a full server implementation to execute GraphQL requests. Instead, you can build your schema into something executable and run your queries against it. Under the hood, Apollo Server does just this (with some additional features, of course).

This doesn't mean that it will give you full coverage, as you skip the server itself, but you can write fewer tests to cover that, instead of having every single test depend on the network, the server, and millions of other variables.

✅ Testing GraphQL Queries and Mutations

So now that we have our schema, we can finally run our tests against it. The minimum content of each test should be a query that is executed against the schema. If you simply want to test successful requests, you don't need anything else.

If you want to cover side-effects, you can add assertion logic before and after each query is run.

You also don't have to fiddle with any request-based logic such as authenticating users. That should be tested on its own anyway. So that's where our context comes in: If you built your server to store the current user in the context, just pass the user you want to run the tests as into the context your tests use.

This way, you may test request with or without an authenticated and authorized user, without covering anything past that. And because this approach is quite lean compared to other implementations, your tests will run in milliseconds, faster than your regular requests would, allowing you to run them whenever you want.

The common downside of slow-running integration tests was easily dodged by focusing on just the resolving logic and shaving off every else.

In the end, you have tests that are quick and easy to write, and blazingly-fast to run. You've got all your resolvers covered, tested implementations for auth, data layer access, and other logic separately as unit or integration tests, so you can focus on the area connecting it all.

If you use custom scalar types for data validation, don't worry, these can be unit-tested easily as well.