Testing React components
Using MockedProvider and associated APIs
This article describes best practices for testing React components that use Apollo Client.
The examples below use Jest and React Testing Library, but the concepts apply to any testing framework.
The MockedProvider
component
Every test for a React component that uses Apollo Client must make Apollo Client available on React's context. In application code, you achieve this by wrapping your component tree with the ApolloProvider
component. In your tests, you use the MockedProvider
component instead.
The MockedProvider
component enables you to define mock responses for individual queries that are executed in your test. This means your test doesn't need to communicate with a GraphQL server, which removes an external dependency and therefore improves the test's reliability.
Example
Let's say we want to test the following Dog
component, which executes a basic query and displays its result:
A basic rendering test for the component looks like this (minus mocked responses):
import "@testing-library/jest-dom";import { render, screen } from "@testing-library/react";import { MockedProvider } from "@apollo/client/testing";import { GET_DOG_QUERY, Dog } from "./dog";const mocks = []; // We'll fill this in nextit("renders without error", async () => {render(<MockedProvider mocks={mocks} addTypename={false}><Dog name="Buck" /></MockedProvider>);expect(await screen.findByText("Loading...")).toBeInTheDocument();});
Note: Usually, you import @testing-library/jest-dom
in your test setup file, which provides certain custom jest matchers (such as toBeInTheDocument
). The import is included in these examples for completeness.
Defining mocked responses
The mocks
prop of MockedProvider
is an array of objects, each of which defines the mock response for a single operation. Let's define a mocked response for GET_DOG_QUERY
when it's passed the name
Buck
:
const mocks = [{request: {query: GET_DOG_QUERY,variables: {name: "Buck"}},result: {data: {dog: { id: "1", name: "Buck", breed: "bulldog" }}}}];
Each mock object defines a request
field (indicating the shape and variables of the operation to match against) and a result
field (indicating the shape of the response to return for that operation).
Your test must execute an operation that exactly matches a mock's shape and variables to receive the associated mocked response.
Alternatively, the result
field can be a function that returns a mocked response after performing arbitrary logic:
result: (variables) => { // `variables` is optional// ...arbitrary logic...return {data: {dog: { id: '1', name: 'Buck', breed: 'bulldog' },},}},
Combining our code above, we get the following complete test:
Reusing mocks
By default, a mock is only used once. If you want to reuse a mock for multiple operations, you can set the maxUsageCount
field to a number indicating how many times the mock should be used:
Passing Number.POSITIVE_INFINITY
will cause the mock to be reused indefinitely.
Dynamic variables
Sometimes, the exact value of the variables being passed are not known. The MockedResponse
object takes a variableMatcher
property that is a function that takes the variables and returns a boolean indication if this mock should match the invocation for the provided query. You cannot specify this parameter and request.variables
at the same time.
For example, this mock will match all dog queries:
import { MockedResponse } from "@apollo/client/testing";const dogMock: MockedResponse<Data, Variables> = {request: {query: GET_DOG_QUERY},variableMatcher: (variables) => true,result: {data: { dog: { id: 1, name: 'Buck', breed: 'poodle' } },},};
This can also be useful for asserting specific variables individually:
import { MockedResponse } from "@apollo/client/testing";const dogMock: MockedResponse<Data, Variables> = {request: {query: GET_DOG_QUERY},variableMatcher: jest.fn().mockReturnValue(true),result: {data: { dog: { id: 1, name: 'Buck', breed: 'poodle' } },},};expect(variableMatcher).toHaveBeenCalledWith(expect.objectContaining({name: 'Buck'}));
Setting addTypename
In the example above, we set the addTypename
prop of MockedProvider
to false
. This prevents Apollo Client from automatically adding the special __typename
field to every object it queries for (it does this by default to support data normalization in the cache).
We don't want to automatically add __typename
to GET_DOG_QUERY
in our test, because then it won't match the shape of the query that our mock is expecting.
Unless you explicitly configure your mocks to expect a __typename
field, always set addTypename
to false
in your tests.
Testing the "loading" and "success" states
To test how your component is rendered after its query completes, Testing Library provides several findBy
methods. From the Testing Library docs:
findBy
queries work when you expect an element to appear but the change to the DOM might not happen immediately.
We can use the asynchronous screen.findByText
method to query the DOM elements containing the loading message first, followed by the success message "Buck is a poodle"
(which appears after our query completes):
it("should render dog", async () => {const dogMock = {delay: 30 // to prevent React from batching the loading state away// delay: Infinity // if you only want to test the loading staterequest: {query: GET_DOG_QUERY,variables: { name: "Buck" }},result: {data: { dog: { id: 1, name: "Buck", breed: "poodle" } }}};render(<MockedProvider mocks={[dogMock]} addTypename={false}><Dog name="Buck" /></MockedProvider>);expect(await screen.findByText("Loading...")).toBeInTheDocument();expect(await screen.findByText("Buck is a poodle")).toBeInTheDocument();});
Testing error states
Your component's error states are just as important to test as its success state, if not more so. You can use the MockedProvider
component to simulate both network errors and GraphQL errors.
- Network errors are errors that occur while your client attempts to communicate with your GraphQL server.
- GraphQL errors are errors that occur while your GraphQL server attempts to resolve your client's operation.
Network errors
To simulate a network error, you can include an error
field in your test's mock object, instead of the result
field:
it("should show error UI", async () => {const dogMock = {request: {query: GET_DOG_QUERY,variables: { name: "Buck" }},error: new Error("An error occurred")};render(<MockedProvider mocks={[dogMock]} addTypename={false}><Dog name="Buck" /></MockedProvider>);expect(await screen.findByText("An error occurred")).toBeInTheDocument();});
In this case, when the Dog
component executes its query, the MockedProvider
returns the corresponding error. This applies the error state to our Dog
component, enabling us to verify that the error is handled gracefully.
GraphQL errors
To simulate GraphQL errors, you define an errors
field inside a mock's result
field. The value of this field is an array of instantiated GraphQLError
objects:
const dogMock = {// ...result: {errors: [new GraphQLError("Error!")],},};
Because GraphQL supports returning partial results when an error occurs, a mock object's result
can include both errors
and data
.
Testing mutations
You test components that use useMutation
similarly to how you test components that use useQuery
. Just like in your application code, the primary difference is that you need to call the mutation's mutate function to actually execute the operation.
Example
The following DeleteButton
component executes the DELETE_DOG_MUTATION
to delete a dog named Buck
from our graph (don't worry, Buck will be fine 🐶):
import React from "react";import { gql, useMutation } from "@apollo/client";export const DELETE_DOG_MUTATION = gql`mutation deleteDog($name: String!) {deleteDog(name: $name) {idnamebreed}}`;export function DeleteButton() {const [mutate, { loading, error, data }] = useMutation(DELETE_DOG_MUTATION);if (loading) return <p>Loading...</p>;if (error) return <p>Error!</p>;if (data) return <p>Deleted!</p>;return (<button onClick={() => mutate({ variables: { name: "Buck" } })}>Click to Delete Buck</button>);}
We can test the initial rendering of this component just like we tested our Dog
component:
import '@testing-library/jest-dom';import userEvent from '@testing-library/user-event';import { render, screen } from '@testing-library/react';import { MockedProvider } from "@apollo/client/testing";import { DeleteButton, DELETE_DOG_MUTATION } from "./delete-dog";it("should render without error", () => {render(<MockedProvider mocks={[]}><DeleteButton /></MockedProvider>);});
In the test above, DELETE_DOG_MUTATION
is not executed, because the mutate function is not called.
The following test does execute the mutation by clicking the button:
it("should render loading and success states on delete", async () => {const deleteDog = { name: "Buck", breed: "Poodle", id: 1 };const mocks = [{request: {query: DELETE_DOG_MUTATION,variables: { name: "Buck" }},result: { data: deleteDog }}];render(<MockedProvider mocks={mocks} addTypename={false}><DeleteButton /></MockedProvider>);// Find the button element...const button = await screen.findByText("Click to Delete Buck");userEvent.click(button); // Simulate a click and fire the mutationexpect(await screen.findByText("Loading...")).toBeInTheDocument();expect(await screen.findByText("Deleted!")).toBeInTheDocument();});
Again, this example is similar to the useQuery
-based component above, but it differs after the rendering is completed. Because this component relies on a button click to fire a mutation, we use Testing Library's user-event library to simulate a click with its click
method. This fires off the mutation, and the rest of the test runs as expected.
Remember that the mock's value for result
can also be a function, so you can perform arbitrary logic (like setting a boolean to indicate that the mutation completed) before returning its result.
Testing error states for mutations is identical to testing them for queries.
Testing with the cache
If your application sets any cache configuration options (such as possibleTypes
or typePolicies
), you should provide MockedProvider
with an instance of InMemoryCache
that sets the exact same options:
const cache = new InMemoryCache({// ...configuration options...})<MockedProvider mocks={mocks} cache={cache}><DeleteButton /></MockedProvider>,
The following sample specifies possibleTypes
and typePolicies
in its cache configuration, both of which must also be specified in relevant tests to prevent unexpected behavior.
Testing local state
In order to properly test local state using MockedProvider
, you'll need to pass a configured cache into MockedProvider
itself.
MockedProvider
creates its own ApolloClient instance behind the scenes like this:
const {mocks,addTypename,defaultOptions,cache,resolvers,link,showWarnings,} = this.props;const client = new ApolloClient({cache: cache || new Cache({ addTypename }),defaultOptions,link: link || new MockLink(mocks || [], addTypename, { showWarnings }),resolvers,});
Therefore if you're using Apollo Client 2.x local resolvers, or Apollo Client 3.x type/field policies, you have to tell the MockedProvider
component what you're going to do with @client
fields. Otherwise the ApolloClient
instance created behind the scenes doesn't know how to handle your tests.
If using Apollo Client 2.x local resolvers, make sure your resolvers object is passed into MockedProvider
:
<MockedProvider mocks={mocks} resolvers={resolvers} ...
If using Apollo Client 3.x type/field policies, make sure your configured cache instance (with your typePolicies) is passed into MockedProvider
:
<MockedProvider mocks={mocks} cache={cache} ...
If you're using Apollo Client 2.x local resolvers, you also need to pass your resolver map:
<MockedProvider mocks={mocks} cache={cache} resolvers={resolvers} ...
This is necessary because otherwise, the MockedProvider
component doesn't know how resolve local-only fields in your queries.
Sandbox example
For a working example that demonstrates how to test components, check out this project on CodeSandbox: