We’re big on Apollo GraphQL here at Swarmia, but we’ve sometimes struggled with mocking. And based on these comments, it looks like we’re not alone. This blog post is here to help!
Why is debugging so difficult?
The problems arise for a few reasons:
- Mocks lack verbosity. It’s not easy to debug why a test case failed if the issue originates from mocks. This post will cover a solution, though, so keep reading.
- Mocks need incredible precision. Even a single-char difference in mocked variables vs. received variables will cause the "No more mocked responses for the query: ...” error.
- There’s no expected vs. actual diffing. There’s an old PR about this.
- There’s no built-in way to assert if all mocks were used. This assertion would be useful to know if a test case ended up doing what was intended. In contrast, for example, nock library allows examining all pending mocks to determine if the code fired all the expected requests.
- It’s common to have multiple problems at the same time. A multitude of things can go wrong when a test case requires mocking. It can be difficult to understand what the core reason is, and sometimes multiple issues appear in parallel. There are a variety of common issues: async UI updates, issues with Apollo mock, jest working its magic, etc. During those cases, you easily blame Apollo mocks for everything (at least I do!), when in reality, there’s an overlapping issue with something else.
Let’s go through five practical tips to make debugging Apollo MockedProvider more enjoyable.
1. Add logging with MockLink
Queries fail silently during a test case if the MockedProvider
doesn’t find a matching mock for the given request. You may eventually figure it out by console-logging the error returned by the useQuery
hook, but that requires additional digging.
Fortunately, we can use MockLink to add logging. I would link to the API docs but couldn’t find any. We have a VerboseMockedProvider
helper in our test utilities which we use as a replacement for MockedProvider
.
import { ApolloError, ApolloLink } from '@apollo/client'
import { onError } from '@apollo/client/link/error'
import {
MockedProvider,
MockedProviderProps,
MockedResponse
} from '@apollo/client/testing'
import { MockLink } from '@apollo/react-testing'
interface Props extends MockedProviderProps {
mocks?: ReadonlyArray<MockedResponse>
children?: React.ReactElement
}
const VerboseMockedProvider = (props: Props) => {
const { mocks = [], ...otherProps } = props
const mockLink = new MockLink(mocks)
const errorLoggingLink = onError(
({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.forEach(
({ message, locations, path }) =>
console.log(
'[GraphQL error]:' +
`Message: ${message},` +
`Location: ${locations},` +
`Path: ${path}`
)
)
}
if (networkError) {
console.log(`[Network error]: ${networkError}`)
}
}
)
const link = ApolloLink.from([errorLoggingLink, mockLink])
return (
<MockedProvider
{...otherProps}
addTypename={false}
mocks={mocks}
link={link}
/>
)
}
Using the helper, we can easily spot missing mocks from the CI logs or the local shell you’re using to run tests:
[Network error]: Error: No more mocked responses for the query:
query getDogParent {
parent {
id
name
}
}
Expected variables: {}
When seeing this error, we know that Apollo MockProvider
received query getDogParent
from a component but no matching mock was found. The phrase “Expected variables” can be interpreted in different ways but it means: “The attempted query had these variables: …”. Everything logged here refers to the attempted query, not to the mocks the MockedProvider does have.
We found out about this trick from a StackOverflow question, kudos to Quentin!
2. Log the error
inside a component
The previous tip helps to understand if an error with mocks exists in the first place. This second tip helps to dive deeper into the underlying problem.
Sometimes the additional logging within component context is the final piece of the puzzle. Let’s say you have this code in a component:
const [setFooMutation, { loading }] = useSetFooMutation()
You can log the error within a component:
const [setFooMutation, { error, loading }] =
useSetFooMutation()
console.log('got error from mutation:', error)
This helps to further narrow the context of the error and allows logging additional details like component props.
3. Make sure the mocks match the queries made by a component exactly
Update (April 2, 2024): Apollo released support for dynamic variable matching in Apollo Client 3.9. They should make variable matching easier and more flexible.
MockLink
is responsible for checking if a request is mocked. The logic it uses to detect matching mocks is pretty unforgiving. In practice, everything needs to match exactly:
- Query: does the attempted GraphQL query match the mocked query? Even a single character typo will cause a mismatch.
- Variables: does the attempted query have the exact same variables? If the attempted query variables contain an array, the items need to be equal and even in the same order in the mock.
- The number of mocks: does each attempted query have a corresponding mock? Even if you are doing the same query three times, you need three separate mock definitions in the
mocks
array prop. Each attempted request that matches a mock, consumes the mocks that are left.
Let’s say we have a mock like this:
import { SetExcludedPullRequestsDocument } from 'src/generated/graphql'
function mockSetExcludedPullRequests(ids: number[]) {
return {
request: {
query: SetExcludedPullRequestsDocument,
variables: { ids }
},
result: () => {
return {
data: {
setExcludedPullRequests: ids
}
}
}
}
}
We then mock the query in a test case:
<VerboseMockedProvider
mocks={[mockSetExcludedPullRequests([10, 11, 14])]}
>
And eventually the component calls the query with IDs in the wrong order:
await setExcludedPullRequestsMutation({
variables: { ids: [11, 10, 14] }
})
Boom. That little slip caused a “No more mocked responses for the query: ...” error. The same would happen if the component had accidentally used id
variable instead of ids
. The same would also happen if we’d called setExcludedPullRequestsMutation
twice, when having only a single mock definition. The possibilities for mistakes are endless.
The only solution is to make sure the mocked query has the exact same query and variables as what the component attempts to send. Sometimes you need a magnifying glass and diffing tools to spot these.
My preferred debugging approach is to log the mock query object (i.e. what mockSetExcludedPullRequests
returns), copy paste it to an editor and manually compare the mock request to the attempted query. If you’re brave enough, there’s a fork of @apollo/react-testing
which implements a jest-like diffing output for query variables.
4. Make sure you mock the correct query
This one sounds obvious but when you’re setting the mocks for MockedProvider
, make sure you’re using the correct mocks. The tip originates from a real issue that I once hit which caused a few hours of waste.
We have shared query mock creator utilities to make them reusable across test suites, for example mockGetSomething()
and mockSetSomething()
. The setter mock creator took zero parameters, so TypeScript didn’t complain about it either.
<VerboseMockedProvider
mocks={[
mockGetSomething(),
// The next item should've mocked the *setter*,
// not the getter
mockGetSomething()
]}
>
There’s nothing wrong about the creator utility pattern, the lesson here is to be more careful with the mock definitions.
One way to make sure that you’re calling the correct mock is to add logging to the result creator function:
import { SetSomethingDocument } from 'src/generated/graphql'
function mockSetSomething() {
return {
request: {
query: SetSomethingDocument
},
result: () => {
console.log('-- THIS SHOULD BE CALLED ')
return {
data: {
setSomething: true
}
}
}
}
}
If you don’t see the log output in stdout, then you know something weird is happening.
5. Make sure only one MockedProvider
is mounted
If multiple MockedProvider
components are mounted at the same time, the one that is deepest in the React component tree will be used for queries. This is an easy pitfall to step into when abstracting the numerous React context providers into utilities.
We often use helper components to reuse the set of providers that are required in all test cases. A test suite sometimes uses the same data and mocks for each individual case to avoid repetition. The helper component could look like this:
const Providers = ({ children }: ProviderProps) => (
<MemoryRouter>
{/* Mock requests relevant for the test cases */}
<VerboseMockedProvider mocks={[mockGetSomething()]}>
{children}
</VerboseMockedProvider>
</MemoryRouter>
)
Then, the actual test cases don’t need to repeat all the providers:
render(
<Providers>
<TestComponent />
</Providers>
)
But sometimes, one might accidentally use two or more overlapping provider abstractions. This leads to mounting multiple MockedProvider
components at the same time. This is what a simplified example would look like:
render(
<Providers>
<VerboseMockedProvider mocks={[mockGetAnotherThing()]}>
<TestComponent />
<VerboseMockedProvider>
</Providers>
)
When the TestComponent
attempts to call query getSomething
, it will fail to “No more mocked responses…” error because the inner VerboseMockedProvider
catches all requests before they reach the outer provider defined in Providers
component.
The simplest solution is to read through the whole React component tree in the test code and make sure there are no overlapping MockedProvider
components. An additional safety layer can be achieved by disallowing direct MockedProvider
imports using ESLint.
That’s it
If you have any additional tips for Apollo mocking, let us know on Twitter. We’ll keep updating this post as we discover new ones and are more than happy to give you credit for contributing.