Since 3.10.0

Schema-driven testing

Using createTestSchema and associated APIs


This article describes best practices for writing integration tests using testing utilities released as experimental in v3.10. These testing tools allow developers to execute queries against a schema configured with mock resolvers and default scalar values in order to test an entire Apollo Client application, including the link chain.

Guiding principles

Kent C. Dodds said it best:

The more your tests resemble the way your software is used, the more confidence they can give you.

When it comes to testing applications built with Apollo Client, this means validating the code path your users' requests will travel from the UI to the network layer and back.

Unit-style testing with MockedProvider can be useful for testing individual components—or even entire pages or React subtrees—in isolation by mocking the expected response data for individual operations. However, it's important to also test the integration of your components with the network layer. That's where schema-driven testing comes in.

This page is heavily inspired by the excellent Redux documentation; the same principles apply to Apollo Client.

createTestSchema and createSchemaFetch

Installation

First, ensure you have installed Apollo Client v3.10 or greater. Then, install the following peer dependencies:

Bash
1npm i @graphql-tools/merge @graphql-tools/schema @graphql-tools/utils undici --save-dev

Consider a React application that fetches a list of products from a GraphQL server:

Click to expand
TypeScript
products.tsx
1import { gql, TypedDocumentNode, useSuspenseQuery } from "@apollo/client";
2
3type ProductsQuery = {
4  products: Array<{
5    __typename: "Product";
6    id: string;
7    title: string;
8    mediaUrl: string;
9  }>;
10};
11
12const PRODUCTS_QUERY: TypedDocumentNode<ProductsQuery> = gql`
13  query ProductsQuery {
14    products {
15      id
16      title
17      mediaUrl
18    }
19  }
20`;
21
22export function Products() {
23  const { data } = useSuspenseQuery(PRODUCTS_QUERY);
24
25  return (
26    <div>
27      {data.products.map((product) => (
28        <p key={product.id}>
29          <a href={product.mediaUrl}>
30            {product.title} - {product.id}
31          </a>
32        </p>
33      ))}
34    </div>
35  );
36}

Now let's write some tests using a test schema created with the createTestSchema utility that can then be used to create a mock fetch implementation with createSchemaFetch.

Configuring your test environment

First, some Node.js globals will need to be polyfilled in order for JSDOM tests to run correctly. Create a file called e.g. jest.polyfills.js:

JavaScript
jest.polyfills.js
1/**
2 * @note The block below contains polyfills for Node.js globals
3 * required for Jest to function when running JSDOM tests.
4 * These have to be require's and have to be in this exact
5 * order, since "undici" depends on the "TextEncoder" global API.
6 */
7
8const { TextDecoder, TextEncoder } = require("node:util");
9const { ReadableStream } = require("node:stream/web");
10const { clearImmediate } = require("node:timers");
11const { performance } = require("node:perf_hooks");
12
13Object.defineProperties(globalThis, {
14  TextDecoder: { value: TextDecoder },
15  TextEncoder: { value: TextEncoder },
16  ReadableStream: { value: ReadableStream },
17  performance: { value: performance },
18  clearImmediate: { value: clearImmediate },
19});
20
21const { Blob, File } = require("node:buffer");
22const { fetch, Headers, FormData, Request, Response } = require("undici");
23
24Object.defineProperties(globalThis, {
25  fetch: { value: fetch, writable: true },
26  Response: { value: Response },
27  Blob: { value: Blob },
28  File: { value: File },
29  Headers: { value: Headers },
30  FormData: { value: FormData },
31  Request: { value: Request },
32});
33
34// Note: if your environment supports it, you can use the `using` keyword
35// but must polyfill Symbol.dispose here with Jest versions <= 29
36// where Symbol.dispose is not defined
37//
38// Jest bug: https://github.com/jestjs/jest/issues/14874
39// Fix is available in https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.3
40if (!Symbol.dispose) {
41  Object.defineProperty(Symbol, "dispose", {
42    value: Symbol("dispose"),
43  });
44}
45if (!Symbol.asyncDispose) {
46  Object.defineProperty(Symbol, "asyncDispose", {
47    value: Symbol("asyncDispose"),
48  });
49}

Now, in a jest.config.ts or jest.config.js file, add the following configuration:

TypeScript
jest.config.ts
1import type { Config } from "jest";
2
3const config: Config = {
4  globals: {
5    "globalThis.__DEV__": JSON.stringify(true),
6  },
7  testEnvironment: "jsdom",
8  setupFiles: ["./jest.polyfills.js"],
9  // You may also have an e.g. setupTests.ts file here
10  setupFilesAfterEnv: ["<rootDir>/setupTests.ts"],
11  // If you're using MSW, opt out of the browser export condition for MSW tests
12  // For more information, see: https://github.com/mswjs/msw/issues/1786#issuecomment-1782559851
13  testEnvironmentOptions: {
14    customExportConditions: [""],
15  },
16  // If you plan on importing .gql/.graphql files in your tests, transform them with @graphql-tools/jest-transform
17  transform: {
18    "\\.(gql|graphql)$": "@graphql-tools/jest-transform",
19  },
20};
21
22export default config;

In the example setupTests.ts file below, @testing-library/jest-dom is imported to allow the use of custom jest-dom matchers (see the @testing-library/jest-dom documentation for more information) and fragment warnings are disabled which can pollute the test output:

TypeScript
setupTests.ts
1import "@testing-library/jest-dom";
2import { gql } from "@apollo/client";
3
4gql.disableFragmentWarnings();

Testing with MSW

Now, let's write a test for the Products component using MSW.

MSW is a powerful tool for intercepting network traffic and mocking responses. Read more about its design and philosophy here.

MSW has the concept of handlers that allow network requests to be intercepted. Let's create a handler that will intercept all GraphQL operations:

TypeScript
src/__tests__/handlers.ts
1import { graphql, HttpResponse } from "msw";
2import { execute } from "graphql";
3import type { ExecutionResult } from "graphql";
4import type { ObjMap } from "graphql/jsutils/ObjMap";
5import { gql } from "@apollo/client";
6import { createTestSchema } from "@apollo/client/testing/experimental";
7import { makeExecutableSchema } from "@graphql-tools/schema";
8import graphqlSchema from "../../../schema.graphql";
9
10// First, create a static schema...
11const staticSchema = makeExecutableSchema({ typeDefs: graphqlSchema });
12
13// ...which is then passed as the first argument to `createTestSchema`
14// along with mock resolvers and default scalar values.
15export let testSchema = createTestSchema(staticSchema, {
16  resolvers: {
17    Query: {
18      products: () => [
19        {
20          id: "1",
21          title: "Blue Jays Hat",
22        },
23      ],
24    },
25  },
26  scalars: {
27    Int: () => 6,
28    Float: () => 22.1,
29    String: () => "string",
30  },
31});
32
33export const handlers = [
34  // Intercept all GraphQL operations and return a response generated by the
35  // test schema. Add additional handlers as needed.
36  graphql.operation<ExecutionResult<ObjMap<unknown>, ObjMap<unknown>>>(
37    async ({ query, variables, operationName }) => {
38      const document = gql(query);
39
40      const result = await execute({
41        document,
42        operationName,
43        schema: testSchema,
44        variableValues: variables,
45      });
46
47      return HttpResponse.json(result);
48    }
49  ),
50];

MSW can be used in the browser, in Node.js and in React Native. Since this example is using Jest and JSDOM to run tests in a Node.js environment, let's configure the server per the Node.js integration guide:

TypeScript
src/__tests__/server.ts
1import { setupServer } from "msw/node";
2import { handlers } from "./handlers";
3
4// This configures a request mocking server with the given request handlers.
5export const server = setupServer(...handlers);

Finally, let's do server set up and teardown in the setupTests.ts file created in the previous step:

TypeScript
setupTests.ts
1import "@testing-library/jest-dom";
2import { gql } from "@apollo/client";
3
4gql.disableFragmentWarnings();
5
6beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
7afterAll(() => server.close());
8afterEach(() => server.resetHandlers());

Finally, let's write some tests 🎉

TypeScript
src/__tests__/products.test.tsx
1import { Suspense } from "react";
2import { render as rtlRender, screen } from "@testing-library/react";
3import {
4  ApolloClient,
5  ApolloProvider,
6  NormalizedCacheObject,
7} from "@apollo/client";
8import { testSchema } from "./handlers";
9import { Products } from "../products";
10// This should be a function that returns a new ApolloClient instance
11// configured just like your production Apollo Client instance - see the FAQ.
12import { makeClient } from "../client";
13
14const render = (renderedClient: ApolloClient<NormalizedCacheObject>) =>
15  rtlRender(
16    <ApolloProvider client={renderedClient}>
17      <Suspense fallback="Loading...">
18        <Products />
19      </Suspense>
20    </ApolloProvider>
21  );
22
23describe("Products", () => {
24  test("renders", async () => {
25    render(makeClient());
26
27    await screen.findByText("Loading...");
28
29    // This is the data from our initial mock resolver in the test schema
30    // defined in the handlers file 🎉
31    expect(await screen.findByText(/blue jays hat/i)).toBeInTheDocument();
32  });
33
34  test("allows resolvers to be updated via .add", async () => {
35    // Calling .add on the test schema will update the resolvers
36    // with new data
37    testSchema.add({
38      resolvers: {
39        Query: {
40          products: () => {
41            return [
42              {
43                id: "2",
44                title: "Mets Hat",
45              },
46            ];
47          },
48        },
49      },
50    });
51
52    render(makeClient());
53
54    await screen.findByText("Loading...");
55
56    // Our component now renders the new data from the updated resolver
57    await screen.findByText(/mets hat/i);
58  });
59
60  test("handles test schema resetting via .reset", async () => {
61    // Calling .reset on the test schema will reset the resolvers
62    testSchema.reset();
63
64    render(makeClient());
65
66    await screen.findByText("Loading...");
67
68    // The component now renders the initial data configured on the test schema
69    await screen.findByText(/blue jays hat/i);
70  });
71});

Testing by mocking fetch with createSchemaFetch

First, import createSchemaFetch and createTestSchema from the new @apollo/client/testing entrypoint. Next, import a local copy of your graph's schema: jest should be configured to transform .gql or .graphql files using @graphql-tools/jest-transform (see the jest.config.ts example configuration above.)

Here's how an initial set up of the test file might look:

TypeScript
products.test.tsx
1import {
2  createSchemaFetch,
3  createTestSchema,
4} from "@apollo/client/testing/experimental";
5import { makeExecutableSchema } from "@graphql-tools/schema";
6import { render as rtlRender, screen } from "@testing-library/react";
7import graphqlSchema from "../../../schema.graphql";
8// This should be a function that returns a new ApolloClient instance
9// configured just like your production Apollo Client instance - see the FAQ.
10import { makeClient } from "../../client";
11import { ApolloProvider, NormalizedCacheObject } from "@apollo/client";
12import { Products } from "../../products";
13import { Suspense } from "react";
14
15// First, let's create an executable schema...
16const staticSchema = makeExecutableSchema({ typeDefs: graphqlSchema });
17
18// which is then passed as the first argument to `createTestSchema`.
19const schema = createTestSchema(staticSchema, {
20  // Next, let's define mock resolvers
21  resolvers: {
22    Query: {
23      products: () =>
24        Array.from({ length: 5 }, (_element, id) => ({
25          id,
26          mediaUrl: `https://example.com/image${id}.jpg`,
27        })),
28    },
29  },
30  // ...and default scalar values
31  scalars: {
32    Int: () => 6,
33    Float: () => 22.1,
34    String: () => "default string",
35  },
36});
37
38// This `render` helper function would typically be extracted and shared between
39// test files.
40const render = (renderedClient: ApolloClient<NormalizedCacheObject>) =>
41  rtlRender(
42    <ApolloProvider client={renderedClient}>
43      <Suspense fallback="Loading...">
44        <Products />
45      </Suspense>
46    </ApolloProvider>
47  );

Now let's write some tests 🎉

First, createSchemaFetch can be used to mock the global fetch implementation with one that resolves network requests with payloads generated from the test schema.

TypeScript
products.test.tsx
1describe("Products", () => {
2  it("renders", async () => {
3    using _fetch = createSchemaFetch(schema).mockGlobal();
4
5    render(makeClient());
6
7    await screen.findByText("Loading...");
8
9    // title is rendering the default string scalar
10    const findAllByText = await screen.findAllByText(/default string/);
11    expect(findAllByText).toHaveLength(5);
12
13    // the products resolver is returning 5 products
14    await screen.findByText(/0/);
15    await screen.findByText(/1/);
16    await screen.findByText(/2/);
17    await screen.findByText(/3/);
18    await screen.findByText(/4/);
19  });
20});

A note on using and explicit resource management

You may have noticed a new keyword in the first line of the test above: using.

using is part of a proposed new language feature which is currently at Stage 3 of the TC39 proposal process.

If you are using TypeScript 5.2 or greater, or using Babel's @babel/plugin-proposal-explicit-resource-management plugin, you can use the using keyword to automatically perform some cleanup when _fetch goes out of scope. In our case, this is when the test is complete; this means restoring the global fetch function to its original state automatically after each test.

If your environment does not support explicit resource management, you'll find that calling mockGlobal() returns a restore function that you can manually call at the end of each test:

TypeScript
products.test.tsx
1describe("Products", () => {
2  it("renders", async () => {
3    const { restore } = createSchemaFetch(schema).mockGlobal();
4
5    render(makeClient());
6
7    // make assertions against the rendered DOM output
8
9    restore();
10  });
11});

Modifying a test schema using testSchema.add and testSchema.fork

If you need to make changes to the behavior of a schema after it has been created, you can use the testSchema.add method to add new resolvers to the schema or overwrite existing ones. This can be useful for testing scenarios where the behavior of the schema needs to change inside a test.

TypeScript
products.test.tsx
1describe("Products", () => {
2  it("renders", async () => {
3    const { restore } = createSchemaFetch(schema).mockGlobal();
4
5    render(makeClient());
6
7    // make assertions against the rendered DOM output
8
9    // Here we want to change the return value of the `products` resolver
10    // for the next outgoing query.
11    testSchema.add({
12      resolvers: {
13        Query: {
14          products: () =>
15            Array.from({ length: 5 }, (_element, id) => ({
16              // we want to return ids starting from 5 for the second request
17              id: id + 5,
18              mediaUrl: `https://example.com/image${id + 5}.jpg`,
19            })),
20        },
21      },
22    });
23
24    // trigger a new query with a user interaction
25    userEvent.click(screen.getByText("Fetch more"));
26
27    // make assertions against the rendered DOM output
28
29    restore();
30    testSchema.reset();
31  });
32});
33```
34
35Alternatively, you can use `testSchema.fork` to create a new schema with the same configuration as the original schema,
36but with the ability to make changes to the new isolated schema without affecting the original schema.
37This can be useful if you just want to mock the global fetch function with a different schema for each test without
38having to care about resetting your original testSchema.
39You could also write incremental tests where each test builds on the previous one.
40
41If you use MSW, you will probably end up using `testSchema.add`, as MSW needs to be set up with a single schema for all tests.
42If you are going the `createSchemaFetch` route, you can use `testSchema.fork` to create a new schema for each test,
43and then use `forkedSchema.add` to make changes to the schema for that test.
44
45```tsx
46const baseSchema = createTestSchema(staticSchema, {
47  resolvers: {
48    // ...
49  },
50  scalars: {
51    // ...
52  },
53});
54
55test("a test", () => {
56  const forkedSchema = baseSchema.fork();
57
58  const { restore } = createSchemaFetch(forkedSchema).mockGlobal();
59
60  // make assertions against the rendered DOM output
61
62  forkedSchema.add({
63    // ...
64  });
65
66  restore();
67  // forkedSchema will just be discarded, and there is no need to reset it
68});

FAQ

When should I use createSchemaFetch vs MSW?

There are many benefits to using MSW: it's a powerful tool with a great set of APIs. Read more about its philosophy and benefits here.

Wherever possible, use MSW: it enables more realistic tests that can catch more bugs by intercepting requests after they've been dispatched by an application. MSW also supports both REST and GraphQL handlers, so if your application uses a combination (e.g. to fetch data from a third party endpoint), MSW will provide more flexibility than createSchemaFetch, which is a more lightweight solution.

Should I share a single ApolloClient instance between tests?

No; please create a new instance of ApolloClient for each test. Even if the cache is reset in between tests, the client maintains some internal state that is not reset. This could have some unintended consequences. For example, the ApolloClient instance could have pending queries that could cause the following test's queries to be deduplicated by default.

Instead, create a makeClient function or equivalent so that every test uses the same client configuration as your production client, but no two tests share the same client instance. Here's an example:

Click to expand
TypeScript
src/client.ts
1import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
2
3const httpLink = new HttpLink({
4  uri: "https://example.com/graphql",
5});
6
7export const makeClient = () => {
8  return new ApolloClient({
9    cache: new InMemoryCache(),
10    link: httpLink,
11  });
12};
13
14export const client = makeClient();

This way, every test can use makeClient to create a new client instance, and you can still use client in your production code.

Can I use these testing tools with Vitest?

Unfortunately not at the moment. This is caused by a known limitation with the graphql package and tools that bundle ESM by default known as the dual package hazard.

Please see this issue to track the related discussion on the graphql/graphql-js repository.

Sandbox example

For a working example that demonstrates how to use both Testing Library and Mock Service Worker to write integration tests with createTestSchema, check out this project on CodeSandbox:

Edit Testing React Components

Feedback

Edit on GitHub

Forums