GraphQL Integration Testing Made Easy

6 Sept 2019

For a long time I've been struggling with how to find the right balance when testing. The goal is to move quickly but with confidence. Recently, I have started testing my GraphQL APIs in an integration testing-like way: everything is done using GraphQL queries and mutations.

Some background: unit tests tend to be time consuming to write and very implementation specific. End-to-end testing (eg using Cypress or Selenium to click around on a website) can be flaky, takes time to execute and is too far away from the code when developing the backend. I have some thoughts on UI/end-to-end testing in general, but that's for a later post...

Through this approach of integration testing using the Apollo Server directly, I've found a lagom level of testing. At the end it has probably saved me more time than it has taken; no need to fool around using Graphiql to test the API.

This approach means that we get the benefit of end-to-end testing: the API is a black box that should work. Also, we can (almost) get the granularity of unit tests; the code running the tests can interact directly with our models and DBs.

Parts:

  1. Jest runs our tests
  2. Creating a fake Apollo client
  3. Generating Typescript types for GraphQL
  4. Conclusion

Technologies used:

Let me show you how it works.

Jest runs our tests

import gql from 'graphql-tag';
// We will see how this is created later
import { createApolloTestServer } from '...';

describe('Task mutation', () => {

    // Create the test Apollo Server. More details on this later.
    const apollo = createApolloTestServer();
    // The actual mutation being run
    const mutation = gql`
        mutation CreateTaskMutation($name: String!) {
            tasks {
                create(name: $name) {
                    id
                    name
                    isCompleted
                }
            }
        }
    `;

    // Helper function to run the GraphQL mutation
    const req = (variables) => apollo.mutate<
        GQL.CreateTaskMutation,
        GQL.CreateTaskMutationVariables
    >({
        mutation,
        variables,
        // Here you can override your GraphQL context. This is
        // useful eg for user authentication. The user can be
        // created using GQL or by interacting directly with your
        // model. I usually create a TestUtils class which makes
        // it easy and clean to create all basic data models.
        context: { /* user: authedUser */ },
    });

    it('should return a task with the expected name', async () => {
        const expectedName = 'expected-name';
        const res = await req({ name: expectedName });
        expect(res.data.tasks.create).toBeTruthy();
        expect(res.data.tasks.create.name).toEqual(expectedName);
        expect(res.data.tasks.create.isCompleted).toBe(false);
    });

    // This is more similar to a unit test. The point here is
    // that it's easy to interact closely with the underlying
    // data if we want to. Ideally we shouldn't go this far
    // that often, as this is implementation specific and will
    // break if we change the underlying implementation.
    it('should create a task in MongoDB', async () => {
        const res = await req({ name: 'mongodb task' });
        // whatever way we have to interact with the DB models
        const task = await MongoDB.findById('Task', res.data.tasks.create.id);
        expect(task).toBeTruthy();
    });

});

Creating a fake Apollo client

To interact with the GraphQL API, we can either:

  1. Spin up Apollo Server using express and do proper HTTP requests to the API
  2. Interact directly with the Apollo Server to execute queries without the HTTP layer inbetween

What we want to test is the API logic, not necessarily all the HTTP steps. With 2, we can more flexibly decide what GraphQL context we want to use when executing a schema. This is useful eg for authentication (instead of having to deal with auth tokens through the HTTP layer). So this is how I create a fake Apollo client to interact:

import { ApolloServer } from 'apollo-server';
import { print, DocumentNode, GraphQLFormattedError } from 'graphql';
import { resolvers } from '@backend/graphql/resolvers';
import { typeDefs } from '@backend/graphql/typeDefs';

type StringOrAst = string | DocumentNode;

// The return type is GraphQLResponse from here:
// https://github.com/apollographql/apollo-server/blob/master/packages/apollo-server-core/src/requestPipelineAPI.ts
type GraphQLResponse<T = {}> = {
    data?: T;
    errors?: GraphQLFormattedError;
};

// Fancy Typescript types which forces us to specify the types of the queries we run.
interface TestApolloClient {
    query<TData = {}, TVariables = {}>(params: {
        query: StringOrAst;
        mutation?: undefined;
        variables?: TVariables;
        context?: Partial<GQLContext>;
    }): Promise<GraphQLResponse<TData>>;

    mutate<TData = {}, TVariables = {}>(params: {
        mutation: StringOrAst;
        query?: undefined;
        variables?: TVariables;
        context?: Partial<GQLContext>;
    }): Promise<GraphQLResponse<TData>>;
}

// Inspired by:
// https://github.com/apollographql/apollo-server/blob/master/packages/apollo-server-testing/src/createTestClient.ts
export function createApolloTestServer(): TestApolloClient {
    const test = async ({ query, mutation, variables, context }) => {
        // Create a new Apollo Server for each request
        const server = new ApolloServer({
            typeDefs,
            resolvers,
            context: {
                // ⚠️ Note: here you should construct your GraphQL
                // context! When calling the query/mutation, I
                // usually like to override some properties (eg the
                // authed user which I attach to the context). Here
                // is where you need to put all your default GraphQL
                // context data. Eg dataloaders etc.
                ...context,
            },
        });

        const executeOperation = server.executeOperation.bind(server);
        const operation = query || mutation;
        if (!operation || (!!query && !!mutation)) {
            throw new Error('Either query or mutation must be passed, but not both');
        }

        // Execute the actual operation
        const res = await executeOperation({
            variables,
            query: typeof operation === 'string'
                ? operation
                : print(operation),
        });

        // Throw an error with all the messages of the
        // errors to make them easy to match using Jest
        if (!!res.errors && !!res.errors.length) {
            const message = res.errors
                .map((error) => error.message)
                .join('\n');
            throw new Error(message);
        }

        return res;
    };

    return { query: test, mutate: test };
}

Generating Typescript types for GraphQL

To make it easy to discover breaking API changes and get the true benefit of Typescript when writing the tests, we generate GraphQL types for the queries and mutations used.

yarn add --dev apollo
apollo client:codegen --localSchemaFile /path/to/schema.grapqhl \
                      --target 'typescript'                     \
                      --includes './src/**/__tests__/*.ts'      \
                      --outputFlat                              \
                      /path/to/gql.d.ts

Conclusion

This approach to backend testing has worked pretty well for me. I've found myself actually TDDing even for MVPs since this allows me to be faster than manually testing while developing. For the business logic side of apps, this is a great start which should cover the majority of the API's use cases.