diff --git a/.api-reports/api-report-testing.md b/.api-reports/api-report-testing.md index fdff4903c39..048d40b19f0 100644 --- a/.api-reports/api-report-testing.md +++ b/.api-reports/api-report-testing.md @@ -888,6 +888,8 @@ export interface MockedResponse, TVariables = Record // (undocumented) error?: Error; // (undocumented) + maxUsageCount?: number; + // (undocumented) newData?: ResultFunction; // (undocumented) request: GraphQLRequest; diff --git a/.api-reports/api-report-testing_core.md b/.api-reports/api-report-testing_core.md index 3cf490dd0d3..dafb615346b 100644 --- a/.api-reports/api-report-testing_core.md +++ b/.api-reports/api-report-testing_core.md @@ -844,6 +844,8 @@ export interface MockedResponse, TVariables = Record // (undocumented) error?: Error; // (undocumented) + maxUsageCount?: number; + // (undocumented) newData?: ResultFunction; // (undocumented) request: GraphQLRequest; diff --git a/.changeset/yellow-flies-repeat.md b/.changeset/yellow-flies-repeat.md new file mode 100644 index 00000000000..b6fcff7db25 --- /dev/null +++ b/.changeset/yellow-flies-repeat.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": minor +--- + +Support re-using of mocks in the MockedProvider diff --git a/.github/workflows/api-extractor.yml b/.github/workflows/api-extractor.yml index 5b232f45133..e8d0f5b06d6 100644 --- a/.github/workflows/api-extractor.yml +++ b/.github/workflows/api-extractor.yml @@ -19,8 +19,6 @@ jobs: - name: Install dependencies (with cache) uses: bahmutov/npm-install@v1 - - name: Run build - run: npm run build - + # Builds the library and runs the api extractor - name: Run Api-Extractor run: npm run extract-api diff --git a/.size-limit.cjs b/.size-limit.cjs index ff6372f7591..090bc4c9dc4 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -1,7 +1,7 @@ const checks = [ { path: "dist/apollo-client.min.cjs", - limit: "37986", + limit: "38000", }, { path: "dist/main.cjs", diff --git a/docs/source/development-testing/testing.mdx b/docs/source/development-testing/testing.mdx index 7fcb53662d3..78402c3f021 100644 --- a/docs/source/development-testing/testing.mdx +++ b/docs/source/development-testing/testing.mdx @@ -150,6 +150,37 @@ it("renders without error", async () => { +#### 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: + + + +```jsx title="dog.test.js" +import { GET_DOG_QUERY } from "./dog"; + +const mocks = [ + { + request: { + query: GET_DOG_QUERY, + variables: { + name: "Buck" + } + }, + result: { + data: { + dog: { id: "1", name: "Buck", breed: "bulldog" } + } + }, + maxUsageCount: 2, // The mock can be used twice before it's removed, default is 1 + } +]; +``` + + + +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. diff --git a/package-lock.json b/package-lock.json index 6d8d664607d..8a5038d6d4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,7 +103,7 @@ "npm": "^7.20.3 || ^8.0.0 || ^9.0.0" }, "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0", + "graphql": "^15.0.0 || ^16.0.0", "graphql-ws": "^5.5.5", "react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", diff --git a/package.json b/package.json index 831ed6e9419..dda56091ebd 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "prepdist": "node ./config/prepareDist.js", "prepdist:changesets": "ts-node-script config/prepareChangesetsRelease.ts", "postprocess-dist": "ts-node-script config/postprocessDist.ts", - "extract-api": "ts-node-script config/apiExtractor.ts", + "extract-api": "npm run build && ts-node-script config/apiExtractor.ts", "clean": "rimraf dist coverage lib temp", "check:format": "prettier --check .", "ci:precheck": "node config/precheck.js", diff --git a/src/testing/core/mocking/mockLink.ts b/src/testing/core/mocking/mockLink.ts index bd798bd6395..8b07e91065e 100644 --- a/src/testing/core/mocking/mockLink.ts +++ b/src/testing/core/mocking/mockLink.ts @@ -30,6 +30,7 @@ export interface MockedResponse< TVariables = Record, > { request: GraphQLRequest; + maxUsageCount?: number; result?: FetchResult | ResultFunction, TVariables>; error?: Error; delay?: number; @@ -135,8 +136,11 @@ ${unmatchedVars.map((d) => ` ${stringifyForDisplay(d)}`).join("\n")} ); } } else { - mockedResponses.splice(responseIndex, 1); - + if (response.maxUsageCount! > 1) { + response.maxUsageCount!--; + } else { + mockedResponses.splice(responseIndex, 1); + } const { newData } = response; if (newData) { response.result = newData(operation.variables); @@ -203,6 +207,14 @@ ${unmatchedVars.map((d) => ` ${stringifyForDisplay(d)}`).join("\n")} if (query) { newMockedResponse.request.query = query; } + + mockedResponse.maxUsageCount = mockedResponse.maxUsageCount ?? 1; + invariant( + mockedResponse.maxUsageCount > 0, + `Mock response maxUsageCount must be greater than 0, %s given`, + mockedResponse.maxUsageCount + ); + this.normalizeVariableMatching(newMockedResponse); return newMockedResponse; } diff --git a/src/testing/react/__tests__/MockedProvider.test.tsx b/src/testing/react/__tests__/MockedProvider.test.tsx index e3c8a660c16..b1676890bf7 100644 --- a/src/testing/react/__tests__/MockedProvider.test.tsx +++ b/src/testing/react/__tests__/MockedProvider.test.tsx @@ -1,12 +1,13 @@ import React from "react"; import { DocumentNode } from "graphql"; -import { render, waitFor } from "@testing-library/react"; +import { act, render, waitFor } from "@testing-library/react"; import gql from "graphql-tag"; import { itAsync, MockedResponse, MockLink } from "../../core"; import { MockedProvider } from "../MockedProvider"; import { useQuery } from "../../../react/hooks"; import { InMemoryCache } from "../../../cache"; +import { QueryResult } from "../../../react/types/types"; import { ApolloLink, FetchResult } from "../../../link/core"; import { Observable } from "zen-observable-ts"; @@ -56,6 +57,10 @@ interface Data { }; } +interface Result { + current: QueryResult | null; +} + interface Variables { username: string; } @@ -611,6 +616,243 @@ describe("General use", () => { expect(errorThrown).toBeFalsy(); }); + it("Uses a mock a configured number of times when `maxUsageCount` is configured", async () => { + const result: Result = { current: null }; + function Component({ username }: Variables) { + result.current = useQuery(query, { + variables: { username }, + }); + return null; + } + + const waitForLoaded = async () => { + await waitFor(() => { + expect(result.current?.loading).toBe(false); + expect(result.current?.error).toBeUndefined(); + }); + }; + + const waitForError = async () => { + await waitFor(() => { + expect(result.current?.error?.message).toMatch( + /No more mocked responses/ + ); + }); + }; + + const refetch = () => { + return act(async () => { + try { + await result.current?.refetch(); + } catch {} + }); + }; + + const mocks: ReadonlyArray = [ + { + request: { + query, + variables: { + username: "mock_username", + }, + }, + maxUsageCount: 2, + result: { data: { user } }, + }, + ]; + + const mockLink = new MockLink(mocks, true, { showWarnings: false }); + const link = ApolloLink.from([errorLink, mockLink]); + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + render(, { wrapper: Wrapper }); + await waitForLoaded(); + await refetch(); + await waitForLoaded(); + await refetch(); + await waitForError(); + }); + + it("Uses a mock infinite number of times when `maxUsageCount` is configured with Number.POSITIVE_INFINITY", async () => { + const result: Result = { current: null }; + function Component({ username }: Variables) { + result.current = useQuery(query, { + variables: { username }, + }); + return null; + } + + const waitForLoaded = async () => { + await waitFor(() => { + expect(result.current?.loading).toBe(false); + expect(result.current?.error).toBeUndefined(); + }); + }; + + const refetch = () => { + return act(async () => { + try { + await result.current?.refetch(); + } catch {} + }); + }; + + const mocks: ReadonlyArray = [ + { + request: { + query, + variables: { + username: "mock_username", + }, + }, + maxUsageCount: Number.POSITIVE_INFINITY, + result: { data: { user } }, + }, + ]; + + const mockLink = new MockLink(mocks, true, { showWarnings: false }); + const link = ApolloLink.from([errorLink, mockLink]); + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + render(, { wrapper: Wrapper }); + for (let i = 0; i < 100; i++) { + await waitForLoaded(); + await refetch(); + } + await waitForLoaded(); + }); + + it("uses a mock once when `maxUsageCount` is not configured", async () => { + const result: Result = { current: null }; + function Component({ username }: Variables) { + result.current = useQuery(query, { + variables: { username }, + }); + return null; + } + + const waitForLoaded = async () => { + await waitFor(() => { + expect(result.current?.loading).toBe(false); + expect(result.current?.error).toBeUndefined(); + }); + }; + + const waitForError = async () => { + await waitFor(() => { + expect(result.current?.error?.message).toMatch( + /No more mocked responses/ + ); + }); + }; + + const refetch = () => { + return act(async () => { + try { + await result.current?.refetch(); + } catch {} + }); + }; + + const mocks: ReadonlyArray = [ + { + request: { + query, + variables: { + username: "mock_username", + }, + }, + result: { data: { user } }, + }, + ]; + + const mockLink = new MockLink(mocks, true, { showWarnings: false }); + const link = ApolloLink.from([errorLink, mockLink]); + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + render(, { wrapper: Wrapper }); + await waitForLoaded(); + await refetch(); + await waitForError(); + }); + + it("can still use other mocks after a mock has been fully consumed", async () => { + const result: Result = { current: null }; + function Component({ username }: Variables) { + result.current = useQuery(query, { + variables: { username }, + }); + return null; + } + + const waitForLoaded = async () => { + await waitFor(() => { + expect(result.current?.loading).toBe(false); + expect(result.current?.error).toBeUndefined(); + }); + }; + + const refetch = () => { + return act(async () => { + try { + await result.current?.refetch(); + } catch {} + }); + }; + + const mocks: ReadonlyArray = [ + { + request: { + query, + variables: { + username: "mock_username", + }, + }, + maxUsageCount: 2, + result: { data: { user } }, + }, + { + request: { + query, + variables: { + username: "mock_username", + }, + }, + result: { + data: { + user: { + __typename: "User", + id: "new_id", + }, + }, + }, + }, + ]; + + const mockLink = new MockLink(mocks, true, { showWarnings: false }); + const link = ApolloLink.from([errorLink, mockLink]); + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + render(, { wrapper: Wrapper }); + await waitForLoaded(); + await refetch(); + await waitForLoaded(); + await refetch(); + await waitForLoaded(); + expect(result.current?.data?.user).toEqual({ + __typename: "User", + id: "new_id", + }); + }); + it('should return "Mocked response should contain" errors in response', async () => { let finished = false; function Component({ ...variables }: Variables) {