From 315faf9ca5b326852919ab7fc2082d6ba92bcb59 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Thu, 2 Feb 2023 16:19:45 -0800 Subject: [PATCH] Log unmatched mocks to the console for tests (#10502) * Log a warning to the console when there are unmatched mocks in MockLink for a request. * Silence expected warnings in other MockedProvider tests. * Update docs to document the `silenceWarnings` option. * Add a changeset * Improve warning message displayed in console for unmatched mocks. * Rename silenceWarnings to showWarnings * Minor adjustment to changeset wording * Fix default value reference in docs for showWarnings * Fix broken test due to change in warnings shown to console for MockLink --- .changeset/funny-files-suffer.md | 5 + docs/source/api/react/testing.md | 16 ++ docs/source/development-testing/testing.mdx | 13 +- src/core/__tests__/ObservableQuery.ts | 28 +++- src/testing/core/index.ts | 1 + src/testing/core/mocking/mockLink.ts | 18 ++- src/testing/react/MockedProvider.tsx | 5 +- .../react/__tests__/MockedProvider.test.tsx | 150 +++++++++++++++++- 8 files changed, 218 insertions(+), 18 deletions(-) create mode 100644 .changeset/funny-files-suffer.md diff --git a/.changeset/funny-files-suffer.md b/.changeset/funny-files-suffer.md new file mode 100644 index 00000000000..98e6d46e62d --- /dev/null +++ b/.changeset/funny-files-suffer.md @@ -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. diff --git a/docs/source/api/react/testing.md b/docs/source/api/react/testing.md index a66940b0c56..59b65a0d755 100644 --- a/docs/source/api/react/testing.md +++ b/docs/source/api/react/testing.md @@ -119,6 +119,22 @@ Props to pass down to the `MockedProvider`'s child. + + + +###### `showWarnings` + +`boolean` + + + +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`. + + + + diff --git a/docs/source/development-testing/testing.mdx b/docs/source/development-testing/testing.mdx index a65249b316a..c4026ff3341 100644 --- a/docs/source/development-testing/testing.mdx +++ b/docs/source/development-testing/testing.mdx @@ -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, }); ``` diff --git a/src/core/__tests__/ObservableQuery.ts b/src/core/__tests__/ObservableQuery.ts index 07990ef2bf2..84679f7e9d9 100644 --- a/src/core/__tests__/ObservableQuery.ts +++ b/src/core/__tests__/ObservableQuery.ts @@ -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'; @@ -1509,11 +1509,25 @@ describe('ObservableQuery', () => { }; } - const observableWithVarsVar: ObservableQuery = 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( @@ -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( diff --git a/src/testing/core/index.ts b/src/testing/core/index.ts index c03a7e8cf69..ba3d37b2660 100644 --- a/src/testing/core/index.ts +++ b/src/testing/core/index.ts @@ -2,6 +2,7 @@ export { MockLink, mockSingleLink, MockedResponse, + MockLinkOptions, ResultFunction } from './mocking/mockLink'; export { diff --git a/src/testing/core/mocking/mockLink.ts b/src/testing/core/mocking/mockLink.ts index 7d0086a4c51..4037ee89d2b 100644 --- a/src/testing/core/mocking/mockLink.ts +++ b/src/testing/core/mocking/mockLink.ts @@ -29,6 +29,10 @@ export interface MockedResponse> { newData?: ResultFunction; } +export interface MockLinkOptions { + showWarnings?: boolean; +} + function requestToKey(request: GraphQLRequest, addTypename: Boolean): string { const queryString = request.query && @@ -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, - 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); @@ -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); diff --git a/src/testing/react/MockedProvider.tsx b/src/testing/react/MockedProvider.tsx index b64526411ff..74eb849c89b 100644 --- a/src/testing/react/MockedProvider.tsx +++ b/src/testing/react/MockedProvider.tsx @@ -17,6 +17,7 @@ export interface MockedProviderProps { childProps?: object; children?: any; link?: ApolloLink; + showWarnings?: boolean; } export interface MockedProviderState { @@ -40,7 +41,8 @@ export class MockedProvider extends React.Component< defaultOptions, cache, resolvers, - link + link, + showWarnings, } = this.props; const client = new ApolloClient({ cache: cache || new Cache({ addTypename }), @@ -48,6 +50,7 @@ export class MockedProvider extends React.Component< link: link || new MockLink( mocks || [], addTypename, + { showWarnings } ), resolvers, }); diff --git a/src/testing/react/__tests__/MockedProvider.test.tsx b/src/testing/react/__tests__/MockedProvider.test.tsx index d2355accc7a..292d6c026d8 100644 --- a/src/testing/react/__tests__/MockedProvider.test.tsx +++ b/src/testing/react/__tests__/MockedProvider.test.tsx @@ -175,7 +175,7 @@ describe('General use', () => { }; render( - + ); @@ -215,7 +215,7 @@ describe('General use', () => { }; render( - + ); @@ -325,7 +325,7 @@ describe('General use', () => { ]; render( - + ); @@ -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( @@ -485,6 +488,141 @@ 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(query, { variables }); + if (!loading) { + finished = true; + } + return null; + } + + const mocksDifferentQuery = [ + { + request: { + query: gql` + query OtherQuery { + otherQuery { + id + } + } + `, + variables + }, + result: { data: { user } } + } + ]; + + render( + + + + ); + + 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(query, { variables }); + if (!loading) { + finished = true; + } + return null; + } + + const mocksDifferentQuery = [ + { + request: { + query: gql` + query OtherQuery { + otherQuery { + id + } + } + `, + variables + }, + result: { data: { user } } + } + ]; + + render( + + + + ); + + 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(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( + + + + ); + + 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) { @@ -492,7 +630,7 @@ describe('General use', () => { return null; } - const mockLink = new MockLink([]); + const mockLink = new MockLink([], true, { showWarnings: false }); mockLink.setOnError(error => { expect(error).toMatchSnapshot(); finished = true; @@ -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!'); });