diff --git a/.api-reports/api-report-core.md b/.api-reports/api-report-core.md index 0bc6fab994b..e3138c81d29 100644 --- a/.api-reports/api-report-core.md +++ b/.api-reports/api-report-core.md @@ -1642,7 +1642,7 @@ class QueryInfo { // Warning: (ae-forgotten-export) The symbol "CacheWriteBehavior" needs to be exported by the entry point index.d.ts // // (undocumented) - markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): void; + markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): typeof result; // (undocumented) networkError?: Error | null; // (undocumented) diff --git a/.api-reports/api-report-react.md b/.api-reports/api-report-react.md index 504453b9637..1bb039c96dd 100644 --- a/.api-reports/api-report-react.md +++ b/.api-reports/api-report-react.md @@ -1463,7 +1463,7 @@ class QueryInfo { // Warning: (ae-forgotten-export) The symbol "CacheWriteBehavior" needs to be exported by the entry point index.d.ts // // (undocumented) - markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): void; + markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): typeof result; // (undocumented) networkError?: Error | null; // (undocumented) diff --git a/.api-reports/api-report-react_components.md b/.api-reports/api-report-react_components.md index cf07c01004a..71852806729 100644 --- a/.api-reports/api-report-react_components.md +++ b/.api-reports/api-report-react_components.md @@ -1265,7 +1265,7 @@ class QueryInfo { // Warning: (ae-forgotten-export) The symbol "CacheWriteBehavior" needs to be exported by the entry point index.d.ts // // (undocumented) - markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): void; + markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): typeof result; // (undocumented) networkError?: Error | null; // (undocumented) diff --git a/.api-reports/api-report-react_context.md b/.api-reports/api-report-react_context.md index fe5b3e684cb..3bdc1ed79b5 100644 --- a/.api-reports/api-report-react_context.md +++ b/.api-reports/api-report-react_context.md @@ -1175,7 +1175,7 @@ class QueryInfo { // Warning: (ae-forgotten-export) The symbol "CacheWriteBehavior" needs to be exported by the entry point index.d.ts // // (undocumented) - markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): void; + markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): typeof result; // (undocumented) networkError?: Error | null; // (undocumented) diff --git a/.api-reports/api-report-react_hoc.md b/.api-reports/api-report-react_hoc.md index bcfbf8c55c5..6fc66b904e1 100644 --- a/.api-reports/api-report-react_hoc.md +++ b/.api-reports/api-report-react_hoc.md @@ -1243,7 +1243,7 @@ class QueryInfo { // Warning: (ae-forgotten-export) The symbol "CacheWriteBehavior" needs to be exported by the entry point index.d.ts // // (undocumented) - markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): void; + markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): typeof result; // (undocumented) networkError?: Error | null; // (undocumented) diff --git a/.api-reports/api-report-react_hooks.md b/.api-reports/api-report-react_hooks.md index 71597296826..052cfd6c223 100644 --- a/.api-reports/api-report-react_hooks.md +++ b/.api-reports/api-report-react_hooks.md @@ -1393,7 +1393,7 @@ class QueryInfo { // Warning: (ae-forgotten-export) The symbol "CacheWriteBehavior" needs to be exported by the entry point index.d.ts // // (undocumented) - markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): void; + markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): typeof result; // (undocumented) networkError?: Error | null; // (undocumented) diff --git a/.api-reports/api-report-react_ssr.md b/.api-reports/api-report-react_ssr.md index 4e617cff33e..95a9ed6957f 100644 --- a/.api-reports/api-report-react_ssr.md +++ b/.api-reports/api-report-react_ssr.md @@ -1162,7 +1162,7 @@ class QueryInfo { // Warning: (ae-forgotten-export) The symbol "CacheWriteBehavior" needs to be exported by the entry point index.d.ts // // (undocumented) - markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): void; + markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): typeof result; // (undocumented) networkError?: Error | null; // (undocumented) diff --git a/.api-reports/api-report-testing.md b/.api-reports/api-report-testing.md index 70b9d593258..fdff4903c39 100644 --- a/.api-reports/api-report-testing.md +++ b/.api-reports/api-report-testing.md @@ -892,7 +892,11 @@ export interface MockedResponse, TVariables = Record // (undocumented) request: GraphQLRequest; // (undocumented) - result?: FetchResult | ResultFunction>; + result?: FetchResult | ResultFunction, TVariables>; + // Warning: (ae-forgotten-export) The symbol "VariableMatcher" needs to be exported by the entry point index.d.ts + // + // (undocumented) + variableMatcher?: VariableMatcher; } // @public (undocumented) @@ -1238,7 +1242,7 @@ class QueryInfo { // Warning: (ae-forgotten-export) The symbol "CacheWriteBehavior" needs to be exported by the entry point index.d.ts // // (undocumented) - markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): void; + markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): typeof result; // (undocumented) networkError?: Error | null; // (undocumented) @@ -1497,7 +1501,7 @@ interface Resolvers { } // @public (undocumented) -export type ResultFunction = () => T; +export type ResultFunction> = (variables: V) => T; // @public (undocumented) type SafeReadonly = T extends object ? Readonly : T; @@ -1623,6 +1627,9 @@ interface UriFunction { (operation: Operation): string; } +// @public (undocumented) +type VariableMatcher> = (variables: V) => boolean; + // @public (undocumented) export function wait(ms: number): Promise; diff --git a/.api-reports/api-report-testing_core.md b/.api-reports/api-report-testing_core.md index 2dc55496b8d..3cf490dd0d3 100644 --- a/.api-reports/api-report-testing_core.md +++ b/.api-reports/api-report-testing_core.md @@ -848,7 +848,11 @@ export interface MockedResponse, TVariables = Record // (undocumented) request: GraphQLRequest; // (undocumented) - result?: FetchResult | ResultFunction>; + result?: FetchResult | ResultFunction, TVariables>; + // Warning: (ae-forgotten-export) The symbol "VariableMatcher" needs to be exported by the entry point index.d.ts + // + // (undocumented) + variableMatcher?: VariableMatcher; } // @public (undocumented) @@ -1194,7 +1198,7 @@ class QueryInfo { // Warning: (ae-forgotten-export) The symbol "CacheWriteBehavior" needs to be exported by the entry point index.d.ts // // (undocumented) - markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): void; + markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): typeof result; // (undocumented) networkError?: Error | null; // (undocumented) @@ -1455,7 +1459,7 @@ interface Resolvers { } // @public (undocumented) -export type ResultFunction = () => T; +export type ResultFunction> = (variables: V) => T; // @public (undocumented) type SafeReadonly = T extends object ? Readonly : T; @@ -1581,6 +1585,9 @@ interface UriFunction { (operation: Operation): string; } +// @public (undocumented) +type VariableMatcher> = (variables: V) => boolean; + // @public (undocumented) export function wait(ms: number): Promise; diff --git a/.api-reports/api-report-utilities.md b/.api-reports/api-report-utilities.md index 410b9ded70b..cc97fb9dbe6 100644 --- a/.api-reports/api-report-utilities.md +++ b/.api-reports/api-report-utilities.md @@ -1905,7 +1905,7 @@ class QueryInfo { // Warning: (ae-forgotten-export) The symbol "CacheWriteBehavior" needs to be exported by the entry point index.d.ts // // (undocumented) - markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): void; + markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): typeof result; // (undocumented) networkError?: Error | null; // (undocumented) diff --git a/.api-reports/api-report.md b/.api-reports/api-report.md index 01950a8f066..8c933201f08 100644 --- a/.api-reports/api-report.md +++ b/.api-reports/api-report.md @@ -2017,7 +2017,7 @@ class QueryInfo { // Warning: (ae-forgotten-export) The symbol "CacheWriteBehavior" needs to be exported by the entry point index.d.ts // // (undocumented) - markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): void; + markResult(result: FetchResult, document: DocumentNode, options: Pick, cacheWriteBehavior: CacheWriteBehavior): typeof result; // (undocumented) networkError?: Error | null; // (undocumented) diff --git a/.changeset/sour-sheep-walk.md b/.changeset/sour-sheep-walk.md new file mode 100644 index 00000000000..b0270d5ee68 --- /dev/null +++ b/.changeset/sour-sheep-walk.md @@ -0,0 +1,7 @@ +--- +"@apollo/client": minor +--- + +Ability to dynamically match mocks + +Adds support for a new property `MockedResponse.variableMatcher`: a predicate function that accepts a `variables` param. If `true`, the `variables` will be passed into the `ResultFunction` to help dynamically build a response. diff --git a/docs/source/development-testing/testing.mdx b/docs/source/development-testing/testing.mdx index 06a69d5db25..7fcb53662d3 100644 --- a/docs/source/development-testing/testing.mdx +++ b/docs/source/development-testing/testing.mdx @@ -101,7 +101,7 @@ Each mock object defines a `request` field (indicating the shape and variables o Alternatively, the `result` field can be a function that returns a mocked response after performing arbitrary logic: ```jsx -result: () => { +result: (variables) => { // `variables` is optional // ...arbitrary logic... return { @@ -150,6 +150,46 @@ it("renders without error", async () => { +### 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: + +```ts +import { MockedResponse } from "@apollo/client/testing"; + +const dogMock: MockedResponse = { + 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: + +```ts +import { MockedResponse } from "@apollo/client/testing"; + +const dogMock: MockedResponse = { + 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). diff --git a/src/testing/core/mocking/mockLink.ts b/src/testing/core/mocking/mockLink.ts index e02d8aaf794..bd798bd6395 100644 --- a/src/testing/core/mocking/mockLink.ts +++ b/src/testing/core/mocking/mockLink.ts @@ -19,16 +19,21 @@ import { print, } from "../../../utilities/index.js"; -export type ResultFunction = () => T; +export type ResultFunction> = (variables: V) => T; + +export type VariableMatcher> = ( + variables: V +) => boolean; export interface MockedResponse< TData = Record, TVariables = Record, > { request: GraphQLRequest; - result?: FetchResult | ResultFunction>; + result?: FetchResult | ResultFunction, TVariables>; error?: Error; delay?: number; + variableMatcher?: VariableMatcher; newData?: ResultFunction; } @@ -93,6 +98,9 @@ export class MockLink extends ApolloLink { if (equal(requestVariables, mockedResponseVars)) { return true; } + if (res.variableMatcher && res.variableMatcher(operation.variables)) { + return true; + } unmatchedVars.push(mockedResponseVars); return false; }) @@ -131,7 +139,7 @@ ${unmatchedVars.map((d) => ` ${stringifyForDisplay(d)}`).join("\n")} const { newData } = response; if (newData) { - response.result = newData(); + response.result = newData(operation.variables); mockedResponses.push(response); } @@ -165,7 +173,7 @@ ${unmatchedVars.map((d) => ` ${stringifyForDisplay(d)}`).join("\n")} if (response.result) { observer.next( typeof response.result === "function" - ? (response.result as ResultFunction)() + ? response.result(operation.variables) : response.result ); } @@ -195,8 +203,26 @@ ${unmatchedVars.map((d) => ` ${stringifyForDisplay(d)}`).join("\n")} if (query) { newMockedResponse.request.query = query; } + this.normalizeVariableMatching(newMockedResponse); return newMockedResponse; } + + private normalizeVariableMatching(mockedResponse: MockedResponse) { + const variables = mockedResponse.request.variables; + if (mockedResponse.variableMatcher && variables) { + throw new Error( + "Mocked response should contain either variableMatcher or request.variables" + ); + } + + if (!mockedResponse.variableMatcher) { + mockedResponse.variableMatcher = (vars) => { + const requestVariables = vars || {}; + const mockedResponseVariables = variables || {}; + return equal(requestVariables, mockedResponseVariables); + }; + } + } } export interface MockApolloLink extends ApolloLink { diff --git a/src/testing/react/__tests__/MockedProvider.test.tsx b/src/testing/react/__tests__/MockedProvider.test.tsx index 8dd2b3be043..e3c8a660c16 100644 --- a/src/testing/react/__tests__/MockedProvider.test.tsx +++ b/src/testing/react/__tests__/MockedProvider.test.tsx @@ -7,8 +7,8 @@ import { itAsync, MockedResponse, MockLink } from "../../core"; import { MockedProvider } from "../MockedProvider"; import { useQuery } from "../../../react/hooks"; import { InMemoryCache } from "../../../cache"; -import { ApolloLink } from "../../../link/core"; -import { spyOnConsole } from "../../internal"; +import { ApolloLink, FetchResult } from "../../../link/core"; +import { Observable } from "zen-observable-ts"; const variables = { username: "mock_username", @@ -62,7 +62,7 @@ interface Variables { let errorThrown = false; const errorLink = new ApolloLink((operation, forward) => { - let observer = null; + let observer: Observable | null = null; try { observer = forward(operation); } catch (error) { @@ -98,6 +98,100 @@ describe("General use", () => { }).then(resolve, reject); }); + itAsync( + "should pass the variables to the result function", + async (resolve, reject) => { + function Component({ ...variables }: Variables) { + useQuery(query, { variables }); + return null; + } + + const mock2: MockedResponse = { + request: { + query, + variables, + }, + result: jest.fn().mockResolvedValue({ data: { user } }), + }; + + render( + + + + ); + + waitFor(() => { + expect(mock2.result as jest.Mock).toHaveBeenCalledWith(variables); + }).then(resolve, reject); + } + ); + + itAsync( + "should pass the variables to the variableMatcher", + async (resolve, reject) => { + function Component({ ...variables }: Variables) { + useQuery(query, { variables }); + return null; + } + + const mock2: MockedResponse = { + request: { + query, + }, + variableMatcher: jest.fn().mockReturnValue(true), + result: { data: { user } }, + }; + + render( + + + + ); + + waitFor(() => { + expect(mock2.variableMatcher as jest.Mock).toHaveBeenCalledWith( + variables + ); + }).then(resolve, reject); + } + ); + + itAsync( + "should use a mock if the variableMatcher returns true", + async (resolve, reject) => { + let finished = false; + + function Component({ username }: Variables) { + const { loading, data } = useQuery(query, { + variables, + }); + if (!loading) { + expect(data!.user).toMatchSnapshot(); + finished = true; + } + return null; + } + + const mock2: MockedResponse = { + request: { + query, + }, + variableMatcher: (v) => v.username === variables.username, + result: { data: { user } }, + }; + + render( + + + + ); + + waitFor(() => { + expect(finished).toBe(true); + }).then(resolve, reject); + } + ); + itAsync("should allow querying with the typename", (resolve, reject) => { let finished = false; function Component({ username }: Variables) { @@ -191,6 +285,41 @@ describe("General use", () => { } ); + itAsync( + "should error if the variableMatcher returns false", + async (resolve, reject) => { + let finished = false; + function Component({ ...variables }: Variables) { + const { loading, error } = useQuery(query, { + variables, + }); + if (!loading) { + expect(error).toMatchSnapshot(); + finished = true; + } + return null; + } + + const mock2: MockedResponse = { + request: { + query, + }, + variableMatcher: () => false, + result: { data: { user } }, + }; + + render( + + + + ); + + waitFor(() => { + expect(finished).toBe(true); + }).then(resolve, reject); + } + ); + itAsync( "should error if the variables do not deep equal", (resolve, reject) => { @@ -522,7 +651,7 @@ describe("General use", () => { }); it("shows a warning in the console when there is no matched mock", async () => { - using _consoleSpy = spyOnConsole("warn"); + const consoleSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); let finished = false; function Component({ ...variables }: Variables) { const { loading } = useQuery(query, { variables }); @@ -562,10 +691,12 @@ describe("General use", () => { 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 () => { - using _consoleSpy = spyOnConsole("warn"); + const consoleSpy = jest.spyOn(console, "warn"); let finished = false; function Component({ ...variables }: Variables) { const { loading } = useQuery(query, { variables }); @@ -602,10 +733,12 @@ describe("General use", () => { }); expect(console.warn).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); }); it("silences console warning for unmatched mocks when passing `showWarnings` to `MockLink` directly", async () => { - using _consoleSpy = spyOnConsole("warn"); + const consoleSpy = jest.spyOn(console, "warn"); let finished = false; function Component({ ...variables }: Variables) { const { loading } = useQuery(query, { variables }); @@ -646,6 +779,8 @@ describe("General use", () => { }); expect(console.warn).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); }); itAsync( diff --git a/src/testing/react/__tests__/__snapshots__/MockedProvider.test.tsx.snap b/src/testing/react/__tests__/__snapshots__/MockedProvider.test.tsx.snap index 5fecc4e98d7..727f5edbb85 100644 --- a/src/testing/react/__tests__/__snapshots__/MockedProvider.test.tsx.snap +++ b/src/testing/react/__tests__/__snapshots__/MockedProvider.test.tsx.snap @@ -18,6 +18,20 @@ Expected variables: {"username":"mock_username"} ] `; +exports[`General use should error if the variableMatcher returns false 1`] = ` +[ApolloError: No more mocked responses for the query: query GetUser($username: String!) { + user(username: $username) { + id + __typename + } +} +Expected variables: {"username":"mock_username"} + +Failed to match 1 mock for this query. The mocked response had the following variables: + {} +] +`; + exports[`General use should error if the variables do not deep equal 1`] = ` [ApolloError: No more mocked responses for the query: query GetUser($username: String!) { user(username: $username) { @@ -87,3 +101,10 @@ exports[`General use should support custom error handling using setOnError 1`] = Expected variables: {"username":"mock_username"} ] `; + +exports[`General use should use a mock if the variableMatcher returns true 1`] = ` +Object { + "__typename": "User", + "id": "user_id", +} +`;