Testing Apollo Components using react-testing-library

Testing Apollo Components using react-testing-library
Zoltan Tasi

Apollo Client is a beast. It does a lot of heavy lifting for graphql react apps to manage data coming from a server including cache management, loading and error states, retries, and more. Plus, it's highly configurable what with the providers and clients and fetchPolicies oh my!

It's great how much Apollo abstracts away for us but it also makes testing difficult as Apollo can be a black box; a wizard unwilling to reveal their secrets.

react-testing-library also does some heavy lifting for us. It does a great job simplifying our tests and at the same time improving our confidence in those tests by encouraging tests that resemble how our software is used.

How do we take the behemoth known as Apollo and wrangle it to do our bidding with react-testing-library? Well, with a little bit of set up we can enable some powerful testing patterns while also following the guiding principles of react-testing-library. Let's begin!

How to set up react-testing-library in an Apollo react app

We will start start with a fresh create-react-app (for example, using codesandbox via react.new). But the setup steps are the same for any React project.

We will need to install the following dependencies to get our test setup going:

# yarn
yarn add @testing-library/react @apollo/client graphql @graphql-tools/mock @graphql-tools/schema

# npm
npm install --save @apollo/client graphql @graphql-tools/mock @graphql-tools/schema @testing-library/react

Before we go any further, let's create a component that consumes a graphql query that we will then write tests for:

import { gql, useQuery } from '@apollo/client'

const GET_DOG_QUERY = gql`
  query getDog($name: String) {
    dog(name: $name) {
      id
      name
      breed
    }
  }
`;

function Dog({ name }) {
  const { loading, error, data } = useQuery(GET_DOG_QUERY, {
    variables: { name }
  });

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error!</p>;

  return (
    <p>
      {data.dog.name} is a {data.dog.breed}
    </p>
  );
}

Cool! Now we have a basic component that displays queried data as well as handling loading and error states. So how do we test this Apollo component?

The Apollo docs include examples that use a MockedProvider. However, that  approach doesn't fit well with the react-testing-library principles. It also doesn't make for a streamlined testing experience.

Here's why:

  1. Mocking too much. Apollo is highly configurable. MockProvider doesn't leverage your app's actual ApolloProvider meaning any customizations you've made to your Apollo client can't be applied in your testing environment.
  2. Boilerplate. MockProvider requires explicitly defining query variables, the graphql document used in the component, and a complete json response for all fields queried. This produces a lot of noise in your test and obscures what is actually under test. It's also just plain hard to get exactly right.
  3. False Negatives. MockProvider will fail our tests if our mocks are missing any part of the query. This means if we start requesting new data, our tests will fail even though our component behavior is unchanged.
  4. Not schema-driven. MockProvider knows nothing about your graphql schema. Meaning we can't leverage it to generate correctly shaped responses that have the correct data type.

Instead of using MockProvider we will take a different approach to setting up our tests to solve the above issues and keep testing principles in tact.

Let's start with not being able to leverage our application's actual ApolloProvider. At the root of your application you have something that looks like this:

import ReactDOM from "react-dom";
import { ApolloProvider, ApolloClient } from "@apollo/client";
import Dog from "./dog";

const client = new ApolloClient({
  cache: new InMemoryCache()
});

const App = () => (
  <ApolloProvider client={client}>
    <Dog />
  </ApolloProvider>
);

ReactDOM.render(<App />, document.getElementById("root"));

Your app is wrapped by ApolloProvider which takes in client which has all your configuration options applied. Our Dog component earlier then is able to use this client when it uses the useQuery hook.

We want to be able to take this exact ApolloProvider and use it in our tests for the Dog component.

Let's write out our first test and see how we can get ApolloProvider working. We can start with this:

import { ApolloClient, ApolloProvider, InMemoryCache } from "@apollo/client";
import { render } from "@testing-library/react";
import { Dog } from "./dog";

it("should render the dog breed", async () => {
  // client has the exact same configuration as our root app client
  const client = new ApolloClient({
    cache: new InMemoryCache()
  });

  render(
    <ApolloProvider client={client}>
      <Dog name="Fido" />
    </ApolloProvider>
  );
});

We could also extract client into a shared variable that's reused between our tests and our application. But for now we will keep it inline for simplicity.

Now that we have our ApolloProvider wired up, how do we get data into our component for testing without also requiring too much boilerplate?

In our real app we would make network requests to a backend. But making network requests in our unit tests is a recipe for a flakey disaster. Instead, we want to stub the network response with fake data we can look for in our test. To make this happen we will need a bit more set up.

Using a GraphQL schema to generate query responses

One of the awesome benefits of graphql is its strongly-typed schema. When you implement a graphql server, you automatically get a documented (and runtime enforced) schema to run your queries against. This means you can always guarantee the shape and data types of your responses.

What's even cooler is you can introspect this schema in order to analyze what the schema can do. Introspection in graphql is a type of graphql query that generates a json response describing all the schema fields and queries.

Introspection is perfect if you don't have direct access to the typeDefs for your graphql server (for example if your frontend is in a different codebase than your backend).

For this article, we will be working directly with the typeDefs. If you need to go the introspection route, @graphql/codegen-introspection is a great tool that can generate an introspection file for you.

The typeDefs definition for our Dog component's query would look like this:

const typeDefs = `
  type Dog {
    id: Int!
    name: String!
    breed: String!
  }

  
  type Query {
    dog(name: String!): Dog!
  }
`;

We can create a schema that our ApolloClient understands using @graphql-tools/schema and Apollo Client's SchemaLink:

import { makeExecutableSchema } from "@graphql-tools/schema";
import { SchemaLink } from '@apollo/client/link/schema';

const schema = makeExecutableSchema({ typeDefs });

const client = new ApolloClient({
  link: new SchemaLink({ schema }),
  cache: new InMemoryCache()
});

SchemaLink is a plugin for Apollo client that enables mocking data by leveraging the provided schema to generate data. This is more powerful than MockProvider which has zero information about your app's schema and requires you to include everything in your mock responses.

We are now generating full graphql responses without any additional input! But we want to be able to control what data is returned for our tests. So how do we do that?

Earlier we installed @graphql-tools/mock which includes an addMocksToSchema function. This function takes a resolvers option that takes an object of schema types as the keys, and the value of each is a callback function that returns your faked data:

import { addMocksToSchema } from "@graphql-tools/mock";

const mockSchema = addMocksToSchema({
  schema,
  resolvers: () => ({
    Dog: () => ({ id: 1, name: "Fido", breed: "Pug" })
  })
});

const client = new ApolloClient({
  link: new SchemaLink({ schema: mockSchema }),
  cache: new InMemoryCache()
});


The above mock will always return the object { id: 1, name: "Fido", breed: "Pug" } for any query that returns a Dog type (such as our dog query).

Another way we could write this would be:

const mockSchema = addMocksToSchema({
  schema,
  mocks: {
    Query: {
      dog: () => ({ id: 1, name: "Fido", breed: "Pug" })
    }
  }
});

This second example is a bit different than the first. This one stubs out the entire dog query type with the given response object. This can be nice as it maps more directly to the query we are looking to generate data for. But if you only care about stubbing a specific type like Dog, you might want the first example.

It's important to note that while the above examples provide a complete query response, we don't have to include all parts of the query. For example, if our return object was just { id: 1 }, our mocked schema would include generated values for anything we left out.

That doesn't mean we should rely on implicit generation for testing our app, but it lets us focus our return object on returning just the data which is relevant to our test which makes our tests more concise and focused.

Writing react-testing-library tests with generated query responses

We now have all the pieces we need to start writing tests using generated query responses with our mocked schema.

Let's put everything together and write some tests!

import {
  ApolloClient,
  ApolloProvider,
  InMemoryCache,
} from "@apollo/client";
import { SchemaLink } from "@apollo/client/link/schema";
import { addMocksToSchema } from "@graphql-tools/mock";
import { render, screen } from "@testing-library/react";
import { makeExecutableSchema } from "@graphql-tools/schema";

it("should render the dog breed", async () => {
  const mockSchema = addMocksToSchema({
    schema,
    mocks: {
      Dog: () => ({ id: 1, name: "Fido", breed: "Pug" })
    }
  });

  const client = new ApolloClient({
    link: new SchemaLink({ schema: mockSchema }),
    cache: new InMemoryCache()
  });

  render(
    <ApolloProvider client={client}>
      <Dog name="Fido" />
    </ApolloProvider>
  );

  // we need to `await` here because
  // Apollo will initially be in a loading state.
  // We want to wait until loading has finished
  // and our data has rendered
  await screen.findByText(/pug/i);
});

And with that, we have a complete example of how to generate query responses using your actual Apollo config and your actual graphql schema.

It look like a lot but this is everything we need to start generating type-aware responses based on an actual schema. Also, a lot of this setup is boilerplate that can be reused between tests.

Before go further with testing, let's clean up our existing test so we can reuse our setup between tests:

// render.js
import { ApolloClient, ApolloProvider, InMemoryCache } from "@apollo/client";
import { SchemaLink } from "@apollo/client/link/schema";
import { addMocksToSchema } from "@graphql-tools/mock";
import { render as rtlRender } from "@testing-library/react";

export function render(component, { mocks } = {}) {
  const mockSchema = addMocksToSchema({
    schema,
    resolvers: () => mocks
  });

  const client = new ApolloClient({
    link: new SchemaLink({ schema: mockSchema }),
    cache: new InMemoryCache()
  });

  return rtlRender(
    <ApolloProvider client={client}>{component}</ApolloProvider>
  );
}

// dog.test.js
import { screen } from "@testing-library/react";
import { render } from "./render";

it("should render the dog breed", async () => {
  render(<Dog name="Fido" />, {
    mocks: {
      Dog: () => ({ id: 1, name: "Fido", breed: "Pug" })
    }
  });

  await screen.findByText(/pug/i);
});

Now in dog.test.js we can focus just on the query response being stubbed out. All the setup code is abstracted into a custom render function that wraps react-testing-library's render function. We can re-use this custom render function in all our other tests too!

One more thing: by default, Apollo will return the same stubbed value every time for a given type. For example, String types will always return "Hello World". We can improve the default behavior of generated values like this:

const mockSchema = addMocksToSchema({
  schema,
  resolvers: () => ({ String: randomString(), ID: randomId(), ...mocks })
});

Where randomString and randomId are generated using a library like faker. You could also stub out hard-coded default values. This way, you can control how realistic your data looks across tests. Then, if a specific test wants to override that default behavior it can since ...mocks will replace any default object properties.

Now that we have this convenient render wrapper, let's look at a couple cool things we can do with it.

Testing graphql queries are called with the correct variables

Something I often want to check is that I'm calling my graphql queries and mutations with the right variables.

Our mocks object makes this really easy. Notice how each property in our mock object is a function? Well, what if we just turned those functions into jest spies?

it("should call the dog query with the right parameters", async () => {
  const dogQuerySpy = jest.fn();

  render(<Dog name="Fido" />, {
    mocks: {
      Query: {
        dog: dogQuerySpy
      }
    }
  });

  await waitFor(() => expect(dogQuerySpy).toHaveBeenCalled());

  expect(dogQuerySpy.mock.calls[0][1]).toEqual({ name: "Fido" });
});

Where before we passed something like () => ({ id: 1 }) to dog, we are now passing a jest.fn() spy.

Let's look at why this works:

All of our Apollo mock functions are called with two variables:

dog: (context, variables) => { ... }

context can be ignored for most cases, but the one we are interested in is variables. variables will be the variables object passed in useQuery (eg, { name: "Fido" } in this example).

We have the option here to return dummy data based on what variables are passed. Or, as we are doing in our test, we can ignore the return value and assert with expect that our spy was called with the variables we are expecting.

This same pattern also applies for mutations!

Testing mutations are called with the correct variables

First, we will extend our example typeDefs to include a mutation we can call from our component and then test:

type Mutation {
  createDog(name: String!): Dog!
}

Next, let's create a component that calls this new mutation:

import { gql, useMutation } from "@apollo/client";

export const CREATE_DOG_QUERY = gql`
  mutation createDog($name: String) {
    createDog(name: $name) {
      id
      name
      breed
    }
  }
`;

export const CreateDog = ({ name }) => {
  const [createDog, { loading, error }] = useMutation(CREATE_DOG_QUERY, {
    variables: { name }
  });
  
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error!</p>;

  return <button onClick={createDog}>create dog</button>;
};

Finally, let's test this component using everything we've put together so far!

it("should call the createDog mutation with the right variables", async () => {
  const createDogSpy = jest.fn();

  render(<CreateDog name="Buddy" />, {
    mocks: {
      Mutation: {
        createDog: createDogSpy
      }
    }
  });

  fireEvent.click(screen.getByText(/create dog/i));
  
  // The spy is not called synchronously so we need to wait until
  // it's called before we can assert what variables were passed
  await waitFor(() => expect(createDogSpy).toHaveBeenCalled());

  expect(createDogSpy.mock.calls[0][1]).toEqual({ name: "Buddy" });
});

Similar to our Query spy before, we create a jest spy and assign it to the createDog mutation. Since Apollo does not call our mocks synchronously, we have to waitFor the spy in our mocks to be called and then assert which variables were called.

Testing errors in Apollo components

We now know how to test components that use queries that are working as intended. But how do we test when something goes wrong? Once again, we can turn to our mock object:

it("should show an error message when the mutation fails", async () => {
  render(<CreateDog name="Buddy" />, {
    mocks: {
      Mutation: {
        createDog: () => {
          throw Error("invalid dog name");
        }
      }
    }
  });

  fireEvent.click(screen.getByText(/create dog/i));

  await screen.findByText("Error!");
});

This same pattern can be followed for Query types too:

it("should show an error message when the query fails", async () => {
  render(<Dog name="Fido" />, {
    mocks: {
      Query: {
        dog: () => {
          throw Error("invalid dog name");
        }
      }
    }
  });

  await screen.findByText("Error!");
});

See it all in action

Here is a codesandbox that includes everything we've done so far. I encourage you to fork it and play around with a couple examples of your own!

You can also find the codesandbox example here.

Conclusion

GraphQL enables some powerful patterns for testing when you're able to leverage the schema. While it requires a bit of extra legwork to get going, once you do you unlock a much more expressive form of testing.

The patterns I've shown here are just a starting point for you to continue exploring what's possible with this set up. I would love to see what patterns you come up with so hit me up on twitter and let me know what you build!

Additional Resources