Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Testing utility docs #11805

Merged
merged 12 commits into from
Apr 24, 2024
1 change: 1 addition & 0 deletions docs/source/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"Developer tools": "/development-testing/developer-tooling",
"Using TypeScript": "/development-testing/static-typing",
"Testing React components": "/development-testing/testing",
"Schema-driven testing": "/development-testing/schema-driven-testing",
"Mocking schema capabilities": "/development-testing/client-schema-mocking",
"Reducing bundle size": "/development-testing/reducing-bundle-size"
},
Expand Down
336 changes: 336 additions & 0 deletions docs/source/development-testing/schema-driven-testing.mdx
alessbell marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,336 @@
---
title: Schema-driven testing
description: Using createTestSchema and associated APIs
---
alessbell marked this conversation as resolved.
Show resolved Hide resolved

This article describes best practices for writing integration tests using testing utilities released in v3.10. It allows 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](react/api/link/introduction).

## Guiding principles

Kent C. Dodds [said it best](https://twitter.com/kentcdodds/status/977018512689455106): "The more your tests resemble the way your software is used, the more confidence they can give you." This means validating the behavior of the code path your users' requests will travel, from the UI to the network layer and back.
alessbell marked this conversation as resolved.
Show resolved Hide resolved

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.
alessbell marked this conversation as resolved.
Show resolved Hide resolved

> This page is heavily inspired by the excellent [Redux documentation](https://redux.js.org/usage/writing-tests#guiding-principles). We believe the same principles apply to Apollo Client :)

<MinVersion version="3.10">
alessbell marked this conversation as resolved.
Show resolved Hide resolved

## `createTestSchema`

</MinVersion>

### Installation

First, ensure you have installed Apollo Client v3.10 or greater. Then, install the following peer dependencies:
alessbell marked this conversation as resolved.
Show resolved Hide resolved

```bash
npm 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:

<ExpansionPanel title="Click to expand">

```jsx title="products.tsx"
alessbell marked this conversation as resolved.
Show resolved Hide resolved
import { gql, TypedDocumentNode, useSuspenseQuery } from "@apollo/client";

type ProductsQuery = {
products: Array<{
__typename: "Product";
id: string;
title: string;
mediaUrl: string;
}>;
};

const PRODUCTS_QUERY: TypedDocumentNode<ProductsQuery> = gql`
query ProductsQuery {
products {
id
title
mediaUrl
}
}
`;

export function Products() {
const { data, error } = useSuspenseQuery(PRODUCTS_QUERY);

return (
<div>
{error ? <p>Error :(</p> : ""}
alessbell marked this conversation as resolved.
Show resolved Hide resolved
{data.products.map((product) => (
<p key={product.id}>
<a href={product.mediaUrl}>
{product.title} - {product.id}
</a>
</p>
))}
</div>
);
}
```
</ExpansionPanel>

Instead of using `MockedProvider` to statically mock the response data for a single operation, `ProductsQuery`, let's use `createTestSchema` to create a schema with mock resolvers and default scalar values.
alessbell marked this conversation as resolved.
Show resolved Hide resolved

### Configuring your test environment

These schema-driven testing tools can be used with Jest, Testing Library, and Mock Service Worker (MSW). First, we'll need to polyfill some Node.js globals in order for our JSDOM tests to run correctly. Here's how to set up your test environment:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small observation: this first sentence feels like we're about to show you how to integrate all 3 libraries together, but I see you've split this up by "jest and testing library" and "jest and MSW". Should this reflect that sentiment a bit? Are they mutually exclusive?

Alternatively, we could consider framing this around the utility you're using on the fetch side of things. In the first section, the tests are really about mocking fetch itself, while I assume the MSW section will be about setting up a mock server to intercept network requests (which would use the real fetch). Would it make sense to have those sections titled as such? Perhaps "Testing by mocking fetch" and "Testing with MSW" or something to that effect?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more note, you might consider switching around the last two sentences:

Suggested change
These schema-driven testing tools can be used with Jest, Testing Library, and Mock Service Worker (MSW). First, we'll need to polyfill some Node.js globals in order for our JSDOM tests to run correctly. Here's how to set up your test environment:
These schema-driven testing tools can be used with Jest, Testing Library, and Mock Service Worker (MSW). We'll need to setup our test environment. First, we'll need to polyfill some Node.js globals in order for our JSDOM tests to run correctly:

Having that as the last sentence feels like it sets up the code you're about to see a bit better.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reworded this a bit in one of the latest commits, lmk what you think!


```js title="jest.polyfills.js"
/**
* @note The block below contains polyfills for Node.js globals
alessbell marked this conversation as resolved.
Show resolved Hide resolved
* required for Jest to function when running JSDOM tests.
* These HAVE to be require's and HAVE to be in this exact
* order, since "undici" depends on the "TextEncoder" global API.
*
* Consider migrating to a more modern test runner if
* you don't want to deal with this.
alessbell marked this conversation as resolved.
Show resolved Hide resolved
*/

const { TextDecoder, TextEncoder } = require("node:util");
const { ReadableStream } = require("node:stream/web");
const { clearImmediate } = require("node:timers");
const { performance } = require("node:perf_hooks");

Object.defineProperties(globalThis, {
TextDecoder: { value: TextDecoder },
TextEncoder: { value: TextEncoder },
ReadableStream: { value: ReadableStream },
performance: { value: performance },
clearImmediate: { value: clearImmediate },
});

const { Blob, File } = require("node:buffer");
const { fetch, Headers, FormData, Request, Response } = require("undici");

Object.defineProperties(globalThis, {
fetch: { value: fetch, writable: true },
Response: { value: Response },
Blob: { value: Blob },
File: { value: File },
Headers: { value: Headers },
FormData: { value: FormData },
Request: { value: Request },
});

// Symbol.dispose is not defined
// jest bug: https://github.com/jestjs/jest/issues/14874
// fix is available in https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.3
if (!Symbol.dispose) {
alessbell marked this conversation as resolved.
Show resolved Hide resolved
Object.defineProperty(Symbol, "dispose", {
value: Symbol("dispose"),
});
}
if (!Symbol.asyncDispose) {
Object.defineProperty(Symbol, "asyncDispose", {
value: Symbol("asyncDispose"),
});
}
```

Now, in your `jest.config.ts` or `test.config.js` file, add the following configuration:

```ts title="jest.config.ts"
import type { Config } from "jest";

const config: Config = {
alessbell marked this conversation as resolved.
Show resolved Hide resolved
globals: {
"globalThis.__DEV__": JSON.stringify(true),
},
testEnvironment: "jsdom",
setupFiles: ["./jest.polyfills.js"],
// You may also have an e.g. setupTests.ts file here
setupFilesAfterEnv: ["<rootDir>/setupTests.ts"],
// Opt out of the browser export condition for MSW tests
// for more information, see: https://github.com/mswjs/msw/issues/1786#issuecomment-1782559851
testEnvironmentOptions: {
customExportConditions: [""],
alessbell marked this conversation as resolved.
Show resolved Hide resolved
},
// If we plan on importing .gql/.graphql files in our tests, we need to transform them
transform: {
"\\.(gql|graphql)$": "@graphql-tools/jest-transform",
"^.+\\.tsx?$": [
"ts-jest",
{
diagnostics: {
alessbell marked this conversation as resolved.
Show resolved Hide resolved
warnOnly: process.env.TEST_ENV !== "ci",
},
},
],
},
};

export default config;

```

Node that if you're using MSW, you will need to opt out of the browser export condition using `testEnvironmentOptions`.
alessbell marked this conversation as resolved.
Show resolved Hide resolved

In our `setupTests.ts` file, we'll import `"@testing-library/jest-dom"` as well as disable fragment warnings which can pollute our test output:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be considered a <Tip>?


```ts title="setupTests.ts"
import "@testing-library/jest-dom";
import { gql } from "@apollo/client";

gql.disableFragmentWarnings();
```

### Usage with Jest and Testing Library

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

We'll import `createSchemaFetch` and `createTestSchema` from the new `@apollo/client/testing` entrypoint. We'll also need a local copy of our graph's schema, and for jest to be configured to transform `.gql` or `.graphql` files using `@graphql-tools/jest-transform` (see `jest.config.ts` example above.)
alessbell marked this conversation as resolved.
Show resolved Hide resolved

Here's how we might set up our test file:

```tsx title="products.test.tsx"
import {
createSchemaFetch,
createTestSchema,
} from "@apollo/client/testing/experimental";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { render as rtlRender, screen } from "@testing-library/react";
import graphqlSchema from "../../../schema.graphql";
import { makeClient } from "../../client";
import {
ApolloClient,
alessbell marked this conversation as resolved.
Show resolved Hide resolved
ApolloProvider,
NormalizedCacheObject,
} from "@apollo/client";
import { Products } from "../../products";
import { Suspense } from "react";

// First, we create an executable schema...
const staticSchema = makeExecutableSchema({ typeDefs: graphqlSchema });

// which we then pass as the first argument to `createTestSchema`.
const schema = createTestSchema(staticSchema, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to split up this code block a bit to introduce each of these in pieces? It might make this setup a bit more digestable and allow you to add a bit of explanation for each part of the setup.

For example, the redux docs start with "create a resuable render function", then move to some of the smaller pieces required for the test setup in their own code blocks, finally followed by the test example itself.

Feel free to ignore this if you feel this is already the best way to present this information.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like this idea, I'll implement this in a bit.

// Next, we define our mock resolvers
resolvers: {
Query: {
products: () =>
Array.from({ length: 5 }, (_element, id) => ({
id,
mediaUrl: `https://example.com/image${id}.jpg`,
})),
},
},
// ...and default scalar values
scalars: {
Int: () => 6,
Float: () => 22.1,
String: () => "default string",
},
});

// This `render` helper function would typically be extracted and shared between
// test files.
const render = (renderedClient: ApolloClient<NormalizedCacheObject>) =>
rtlRender(
<ApolloProvider client={renderedClient}>
<Suspense fallback="Loading...">
<Products />
</Suspense>
</ApolloProvider>
);
```

Now let's write some tests!

The first thing we'll do is mock the global fetch function using the `createSchemaFetch` utility. This will allow us to intercept and respond to network requests made by our Apollo Client instance using responses generated against our test schema 🎉
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to also include a section about "When to use createSchemaFetch vs MSW"? Since these utilities are mutually exclusive, it might be best to state that somewhere explicitly in this doc to ensure those that aren't as familiar with those tools don't get confused and try and use both together.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really, really love this idea :) Will work on adding that shortly.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in aebc3a5 let me know what you think!


```tsx title="products.test.tsx"
describe("Products", () => {
it("renders", async () => {
using _fetch = createSchemaFetch(schema).mockGlobal();

render(makeClient());

await screen.findByText("Loading...");

// title is rendering the default string scalar
const findAllByText = await screen.findAllByText(/default string/);
expect(findAllByText).toHaveLength(5);

// the products resolver is returning 5 products
await screen.findByText(/0/);
await screen.findByText(/1/);
await screen.findByText(/2/);
await screen.findByText(/3/);
await screen.findByText(/4/);
});
});
```

#### A note on `using` and explicit resource management
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I LOVE the content you have in here. There is a ton of really great stuff.

Stepping back a bit, I wonder if we should tweak the order on how we introduce the changes with using. The examples above work with the assumption that using is available in your environment, then this section takes a step back and essentially says "whoa, you might not have this available, so here is what you do if not".

Instead, I'd start with the assumption that our useres don't have using available by updating the exmaples above to use plain const with the restore function, then make this section a "if you're using TS 5.2 or greater, check out this great thing you can do to make cleanup even easier!".

I think you can do this with minimal changes to this section. I'd keep a lot of the content in here as-is.


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](https://github.com/tc39/proposal-explicit-resource-management) 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.
alessbell marked this conversation as resolved.
Show resolved Hide resolved

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:

```jsx title="products.test.jsx"
describe("Products", () => {
it("renders", async () => {
const { restore } = createSchemaFetch(schema).mockGlobal();

render(makeClient());

// make assertions against the rendered DOM output

restore();
});
});
```

### Usage with Jest and Mock Service Worker (MSW)



### FAQ

#### Should I share a single `ApolloClient` instance between tests?

No; we recommend creating a new instance of `ApolloClient` for each test. Even if you reset the cache in between tests, the `QueryManager` used by the client is not reset. This means your `ApolloClient` instance could have pending queries that could cause the following test's queries to be deduplicated by default.
alessbell marked this conversation as resolved.
Show resolved Hide resolved

We _do_ recommend establishing a pattern of a `makeClient` function so that every test can use a "real" production client, but no two tests should share the same client instance. Here's an example:
alessbell marked this conversation as resolved.
Show resolved Hide resolved
alessbell marked this conversation as resolved.
Show resolved Hide resolved

<ExpansionPanel title="Click to expand">

```jsx title="src/client.ts"
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";

const httpLink = new HttpLink({
uri: "https://example.com/graphql",
});

export const makeClient = () => {
return new ApolloClient({
cache: new InMemoryCache(),
link: httpLink,
});
};

export const client = makeClient();

```
</ExpansionPanel>

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](https://nodejs.org/api/packages.html#dual-package-hazard).

Please see [this issue](https://github.com/graphql/graphql-js/issues/4062) 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](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/apollographql/docs-examples/tree/main/apollo-client/v3/testing-react-components?file=/src/dog.test.js)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2024-04-23 at 11 01 27 AM

This seemed to render sorta weird, I think because of the way the docs add the external link icon. Do you know by chance if there is any way to avoid this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hah yeah I just noticed this on another page. Will look into whether we can remove the link icon here 👍

Loading