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

Log unmatched mocks to the console for tests #10502

Merged
merged 9 commits into from
Feb 3, 2023
5 changes: 5 additions & 0 deletions .changeset/funny-files-suffer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@apollo/client': patch
---

Log a warning to the console when a mock passed to `MockedProvider` or `MockLink` cannot be matched to a query during a test. This makes it easier to debug user errors in the mock setup, such as typos, especially if the query under test is using an `errorPolicy` set to `ignore`, which makes it difficult to know that a match did not occur.
16 changes: 16 additions & 0 deletions docs/source/api/react/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,22 @@ Props to pass down to the `MockedProvider`'s child.
</td>
</tr>

<tr>
<td>

###### `showWarnings`

`boolean`
</td>
<td>

When a request fails to match a mock, a warning is logged to the console to indicate the mismatch. Set this to `false` to silence these warnings.

The default value is `true`.

</td>
</tr>

</tbody>
</table>

Expand Down
13 changes: 10 additions & 3 deletions docs/source/development-testing/testing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -394,12 +394,19 @@ In order to properly test local state using `MockedProvider`, you'll need to pas
`MockedProvider` creates its own ApolloClient instance behind the scenes like this:

```jsx
const { mocks, addTypename, defaultOptions, cache, resolvers, link } =
this.props;
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),
link: link || new MockLink(mocks || [], addTypename, { showWarnings }),
resolvers,
});
```
Expand Down
28 changes: 21 additions & 7 deletions src/core/__tests__/ObservableQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { ApolloLink } from '../../link/core';
import { InMemoryCache, NormalizedCacheObject } from '../../cache';
import { ApolloError } from '../../errors';

import { itAsync, mockSingleLink, subscribeAndCount } from '../../testing';
import { itAsync, MockLink, mockSingleLink, subscribeAndCount } from '../../testing';
import mockQueryManager from '../../testing/core/mocking/mockQueryManager';
import mockWatchQuery from '../../testing/core/mocking/mockWatchQuery';
import wrap from '../../testing/core/wrap';
Expand Down Expand Up @@ -1509,11 +1509,25 @@ describe('ObservableQuery', () => {
};
}

const observableWithVarsVar: ObservableQuery<any> = mockWatchQuery(
reject,
makeMock("a", "b", "c"),
makeMock("d", "e"),
);
// We construct the queryManager manually here rather than using
// `mockWatchQuery` because we need to silence console warnings for
// unmatched variables since. This test checks for calls to
// `console.warn` and unfortunately `mockSingleLink` (used by
// `mockWatchQuery`) does not support the ability to disable warnings
// without introducing a breaking change. Instead we construct this
// manually to be able to turn off warnings for this test.
const mocks = [makeMock('a', 'b', 'c'), makeMock('d', 'e')];
const firstRequest = mocks[0].request;
const queryManager = new QueryManager({
cache: new InMemoryCache({ addTypename: false }),
link: new MockLink(mocks, true, { showWarnings: false })
})

const observableWithVarsVar = queryManager.watchQuery({
query: firstRequest.query,
variables: firstRequest.variables,
notifyOnNetworkStatusChange: false
});

subscribeAndCount(error => {
expect(error.message).toMatch(
Expand All @@ -1536,7 +1550,7 @@ describe('ObservableQuery', () => {
// to call refetch(variables).
observableWithVarsVar.refetch({
variables: { vars: ["d", "e"] },
}).then(result => {
} as any).then(result => {
reject(`unexpected result ${JSON.stringify(result)}; should have thrown`);
}, error => {
expect(error.message).toMatch(
Expand Down
1 change: 1 addition & 0 deletions src/testing/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export {
MockLink,
mockSingleLink,
MockedResponse,
MockLinkOptions,
ResultFunction
} from './mocking/mockLink';
export {
Expand Down
18 changes: 17 additions & 1 deletion src/testing/core/mocking/mockLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export interface MockedResponse<TData = Record<string, any>> {
newData?: ResultFunction<FetchResult>;
}

export interface MockLinkOptions {
showWarnings?: boolean;
}

function requestToKey(request: GraphQLRequest, addTypename: Boolean): string {
const queryString =
request.query &&
Expand All @@ -40,14 +44,18 @@ function requestToKey(request: GraphQLRequest, addTypename: Boolean): string {
export class MockLink extends ApolloLink {
public operation: Operation;
public addTypename: Boolean = true;
public showWarnings: boolean = true;
private mockedResponsesByKey: { [key: string]: MockedResponse[] } = {};

constructor(
mockedResponses: ReadonlyArray<MockedResponse>,
addTypename: Boolean = true
addTypename: Boolean = true,
options: MockLinkOptions = Object.create(null)
) {
super();
this.addTypename = addTypename;
this.showWarnings = options.showWarnings ?? true;

if (mockedResponses) {
mockedResponses.forEach(mockedResponse => {
this.addMockedResponse(mockedResponse);
Expand Down Expand Up @@ -102,6 +110,14 @@ Failed to match ${unmatchedVars.length} mock${
} for this query. The mocked response had the following variables:
${unmatchedVars.map(d => ` ${stringifyForDisplay(d)}`).join('\n')}
` : ""}`);

if (this.showWarnings) {
console.warn(
configError.message +
'\nThis typically indicates a configuration error in your mocks ' +
'setup, usually due to a typo or mismatched variable.'
);
}
} else {
mockedResponses.splice(responseIndex, 1);

Expand Down
5 changes: 4 additions & 1 deletion src/testing/react/MockedProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface MockedProviderProps<TSerializedCache = {}> {
childProps?: object;
children?: any;
link?: ApolloLink;
showWarnings?: boolean;
}

export interface MockedProviderState {
Expand All @@ -40,14 +41,16 @@ export class MockedProvider extends React.Component<
defaultOptions,
cache,
resolvers,
link
link,
showWarnings,
} = this.props;
const client = new ApolloClient({
cache: cache || new Cache({ addTypename }),
defaultOptions,
link: link || new MockLink(
mocks || [],
addTypename,
{ showWarnings }
),
resolvers,
});
Expand Down
150 changes: 144 additions & 6 deletions src/testing/react/__tests__/MockedProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ describe('General use', () => {
};

render(
<MockedProvider mocks={mocks}>
<MockedProvider showWarnings={false} mocks={mocks}>
<Component {...variables2} />
</MockedProvider>
);
Expand Down Expand Up @@ -215,7 +215,7 @@ describe('General use', () => {
};

render(
<MockedProvider mocks={mocks2}>
<MockedProvider showWarnings={false} mocks={mocks2}>
<Component {...variables2} />
</MockedProvider>
);
Expand Down Expand Up @@ -325,7 +325,7 @@ describe('General use', () => {
];

render(
<MockedProvider mocks={mocksDifferentQuery}>
<MockedProvider showWarnings={false} mocks={mocksDifferentQuery}>
<Component {...variables} />
</MockedProvider>
);
Expand Down Expand Up @@ -435,7 +435,10 @@ describe('General use', () => {
return null;
}

const link = ApolloLink.from([errorLink, new MockLink([])]);
const link = ApolloLink.from([
errorLink,
new MockLink([], true, { showWarnings: false })
]);

render(
<MockedProvider link={link}>
Expand Down Expand Up @@ -485,14 +488,149 @@ describe('General use', () => {
expect(errorThrown).toBeFalsy();
});

it('shows a warning in the console when there is no matched mock', async () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
let finished = false;
function Component({ ...variables }: Variables) {
const { loading } = useQuery<Data, Variables>(query, { variables });
if (!loading) {
finished = true;
}
return null;
}

const mocksDifferentQuery = [
{
request: {
query: gql`
query OtherQuery {
otherQuery {
id
}
}
`,
variables
},
result: { data: { user } }
}
];

render(
<MockedProvider mocks={mocksDifferentQuery}>
<Component {...variables} />
</MockedProvider>
);

await waitFor(() => {
expect(finished).toBe(true);
});

expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('No more mocked responses for the query')
);

consoleSpy.mockRestore();
});

it('silences console warning for unmatched mocks when `showWarnings` is `false`', async () => {
const consoleSpy = jest.spyOn(console, 'warn');
let finished = false;
function Component({ ...variables }: Variables) {
const { loading } = useQuery<Data, Variables>(query, { variables });
if (!loading) {
finished = true;
}
return null;
}

const mocksDifferentQuery = [
{
request: {
query: gql`
query OtherQuery {
otherQuery {
id
}
}
`,
variables
},
result: { data: { user } }
}
];

render(
<MockedProvider mocks={mocksDifferentQuery} showWarnings={false}>
<Component {...variables} />
</MockedProvider>
);

await waitFor(() => {
expect(finished).toBe(true);
});

expect(console.warn).not.toHaveBeenCalled();

consoleSpy.mockRestore();
});

it('silences console warning for unmatched mocks when passing `showWarnings` to `MockLink` directly', async () => {
const consoleSpy = jest.spyOn(console, 'warn');
let finished = false;
function Component({ ...variables }: Variables) {
const { loading } = useQuery<Data, Variables>(query, { variables });
if (!loading) {
finished = true;
}
return null;
}

const mocksDifferentQuery = [
{
request: {
query: gql`
query OtherQuery {
otherQuery {
id
}
}
`,
variables
},
result: { data: { user } }
}
];

const link = new MockLink(
mocksDifferentQuery,
false,
{ showWarnings: false }
);

render(
<MockedProvider link={link}>
<Component {...variables} />
</MockedProvider>
);

await waitFor(() => {
expect(finished).toBe(true);
});

expect(console.warn).not.toHaveBeenCalled();

consoleSpy.mockRestore();
});

itAsync('should support custom error handling using setOnError', (resolve, reject) => {
let finished = false;
function Component({ ...variables }: Variables) {
useQuery<Data, Variables>(query, { variables });
return null;
}

const mockLink = new MockLink([]);
const mockLink = new MockLink([], true, { showWarnings: false });
mockLink.setOnError(error => {
expect(error).toMatchSnapshot();
finished = true;
Expand Down Expand Up @@ -521,7 +659,7 @@ describe('General use', () => {
return null;
}

const mockLink = new MockLink([]);
const mockLink = new MockLink([], true, { showWarnings: false });
mockLink.setOnError(() => {
throw new Error('oh no!');
});
Expand Down