diff --git a/.changeset/great-paws-ring.md b/.changeset/great-paws-ring.md new file mode 100644 index 000000000..cf3b76b07 --- /dev/null +++ b/.changeset/great-paws-ring.md @@ -0,0 +1,6 @@ +--- +"@khanacademy/wonder-blocks-testing-core": minor +"@khanacademy/wonder-blocks-testing": minor +--- + +Add support for hard fails to the request mocking features diff --git a/packages/wonder-blocks-testing-core/src/__tests__/mock-requester.test.ts b/packages/wonder-blocks-testing-core/src/__tests__/mock-requester.test.ts index 0f5ef6e55..3f412d4d9 100644 --- a/packages/wonder-blocks-testing-core/src/__tests__/mock-requester.test.ts +++ b/packages/wonder-blocks-testing-core/src/__tests__/mock-requester.test.ts @@ -35,7 +35,17 @@ describe("#mockRequester", () => { ); }); - it("should throw with helpful details formatted by operationToString if no matching mock is found", async () => { + it("should provide a configuration API", () => { + // Arrange + + // Act + const result = mockRequester(jest.fn(), jest.fn()); + + // Assert + expect(result).toHaveProperty("configure", expect.any(Function)); + }); + + it("should reject with helpful details formatted by operationToString if no matching mock is found", async () => { // Arrange const mockFn = mockRequester( jest.fn(), @@ -209,4 +219,56 @@ describe("#mockRequester", () => { await expect(result).resolves.toBe("TWO"); }); }); + + describe("configure", () => { + it("should reject promise on unmocked requests by default", async () => { + // Arrange + const matcher = jest.fn().mockReturnValue(false); + const operationToString = jest.fn(); + const mockFn = mockRequester(matcher, operationToString); + + // Act + const result = mockFn("DO SOMETHING"); + + // Assert + await expect(result).rejects.toThrowErrorMatchingInlineSnapshot(` + "No matching mock response found for request: + undefined" + `); + }); + + it("should cause hard fail on unmocked requests when hardFailOnUnmockedRequests is set to true", () => { + // Arrange + const matcher = jest.fn().mockReturnValue(false); + const operationToString = jest.fn(); + const mockFn = mockRequester(matcher, operationToString); + + // Act + mockFn.configure({hardFailOnUnmockedRequests: true}); + const underTest = () => mockFn("DO SOMETHING"); + + // Assert + expect(underTest).toThrowErrorMatchingInlineSnapshot(` + "No matching mock response found for request: + undefined" + `); + }); + + it("should reject promise on unmocked requests when hardFailOnUnmockedRequests is set to false ", async () => { + // Arrange + const matcher = jest.fn().mockReturnValue(false); + const operationToString = jest.fn(); + const mockFn = mockRequester(matcher, operationToString); + + // Act + mockFn.configure({hardFailOnUnmockedRequests: false}); + const result = mockFn("DO SOMETHING"); + + // Assert + await expect(result).rejects.toThrowErrorMatchingInlineSnapshot(` + "No matching mock response found for request: + undefined" + `); + }); + }); }); diff --git a/packages/wonder-blocks-testing-core/src/fetch/mock-fetch.ts b/packages/wonder-blocks-testing-core/src/fetch/mock-fetch.ts index f3a30ddd1..54f12cae8 100644 --- a/packages/wonder-blocks-testing-core/src/fetch/mock-fetch.ts +++ b/packages/wonder-blocks-testing-core/src/fetch/mock-fetch.ts @@ -6,7 +6,7 @@ import type {FetchMockFn, FetchMockOperation} from "./types"; * A mock for the fetch function passed to GqlRouter. */ export const mockFetch = (): FetchMockFn => - mockRequester( + mockRequester( fetchRequestMatchesMock, // NOTE(somewhatabstract): The indentation is expected on the lines // here. diff --git a/packages/wonder-blocks-testing-core/src/fetch/types.ts b/packages/wonder-blocks-testing-core/src/fetch/types.ts index dc56f1634..a25fc978f 100644 --- a/packages/wonder-blocks-testing-core/src/fetch/types.ts +++ b/packages/wonder-blocks-testing-core/src/fetch/types.ts @@ -1,14 +1,65 @@ import type {MockResponse} from "../respond-with"; +import {ConfigureFn} from "../types"; export type FetchMockOperation = RegExp | string; -type FetchMockOperationFn = ( - operation: FetchMockOperation, - response: MockResponse, -) => FetchMockFn; +interface FetchMockOperationFn { + ( + /** + * The operation to match. + * + * This is a string for an exact match, or a regex. This is compared to + * to the URL of the fetch request to determine if it is a matching + * request. + */ + operation: FetchMockOperation, + + /** + * The response to return when the operation is matched. + */ + response: MockResponse, + ): FetchMockFn; +} export type FetchMockFn = { + /** + * The mock fetch function. + * + * This function is a drop-in replacement for the fetch function. You should + * not need to call this function directly. Just replace the normal fetch + * function implementation with this. + */ (input: RequestInfo, init?: RequestInit): Promise; + + /** + * Mock a fetch operation. + * + * This adds a response for a given mocked operation. Regardless of how + * many times this mock is matched, it will be used. + * + * @returns The mock fetch function for chaining. + */ mockOperation: FetchMockOperationFn; + + /** + * Mock a fetch operation once. + * + * This adds a response for a given mocked operation that will only be used + * once and discarded. + * + * @returns The mock fetch function for chaining. + */ mockOperationOnce: FetchMockOperationFn; + + /** + * Configure the mock fetch function with the given configuration. + * + * This function is provided as a convenience to allow for configuring the + * mock fetch function in a fluent manner. The configuration is applied + * to all mocks for a given fetch function; the last configuration applied + * will be the one that is used for all mocked operations. + * + * @returns The mock fetch function for chaining. + */ + configure: ConfigureFn; }; diff --git a/packages/wonder-blocks-testing-core/src/index.ts b/packages/wonder-blocks-testing-core/src/index.ts index 6cac2335f..210301fa1 100644 --- a/packages/wonder-blocks-testing-core/src/index.ts +++ b/packages/wonder-blocks-testing-core/src/index.ts @@ -15,6 +15,8 @@ export type { OperationMock, OperationMatcher, MockOperationFn, + MockConfiguration, + ConfigureFn, } from "./types"; // Test harness framework diff --git a/packages/wonder-blocks-testing-core/src/mock-requester.ts b/packages/wonder-blocks-testing-core/src/mock-requester.ts index 736eb158b..7b8ceafb7 100644 --- a/packages/wonder-blocks-testing-core/src/mock-requester.ts +++ b/packages/wonder-blocks-testing-core/src/mock-requester.ts @@ -1,23 +1,30 @@ import type {MockResponse} from "./respond-with"; -import type {OperationMock, OperationMatcher, MockFn} from "./types"; +import type { + OperationMock, + OperationMatcher, + MockFn, + MockConfiguration, +} from "./types"; /** * A generic mock request function for using when mocking fetch or gqlFetch. */ -export const mockRequester = ( +export const mockRequester = ( operationMatcher: OperationMatcher, operationToString: (...args: Array) => string, -): MockFn => { +): MockFn => { // We want this to work in jest and in fixtures to make life easy for folks. // This is the array of mocked operations that we will traverse and // manipulate. const mocks: Array> = []; - // What we return has to be a drop in replacement for the mocked function - // which is how folks will then use this mock. - const mockFn: MockFn = ( + const configuration: MockConfiguration = { + hardFailOnUnmockedRequests: false, + }; + + const getMatchingMock = ( ...args: Array - ): Promise => { + ): OperationMock | null => { // Iterate our mocked operations and find the first one that matches. for (const mock of mocks) { if (mock.onceOnly && mock.used) { @@ -26,24 +33,46 @@ export const mockRequester = ( } if (operationMatcher(mock.operation, ...args)) { mock.used = true; - return mock.response(); + return mock; } } + return null; + }; - // Default is to reject with some helpful info on what request - // we rejected. + // What we return has to be a drop in replacement for the mocked function + // which is how folks will then use this mock. + const mockFn: MockFn = ( + ...args: Array + ): Promise => { + const matchingMock = getMatchingMock(...args); + if (matchingMock) { + return matchingMock.response(); + } + + // If we get here, there is no match. const operation = operationToString(...args); - return Promise.reject( + const noMatchError = new Error(`No matching mock response found for request: - ${operation}`), - ); + ${operation}`); + if (configuration.hardFailOnUnmockedRequests) { + // When we are set to hard fail, we do what Apollo's MockLink + // does and throw an error immediately. This catastrophically fails + // test cases when a request wasn't matched, which can be brutal + // in some cases, though is also helpful for debugging. + throw noMatchError; + } + + // Our default is to return a rejected promise so that errors + // are handled by the code under test rather than hard failing + // everything. + return Promise.reject(noMatchError); }; const addMockedOperation = ( operation: TOperation, - response: MockResponse, + response: MockResponse, onceOnly: boolean, - ): MockFn => { + ): MockFn => { const mockResponse = () => response.toPromise(); mocks.push({ operation, @@ -56,13 +85,22 @@ export const mockRequester = ( mockFn.mockOperation = ( operation: TOperation, - response: MockResponse, - ): MockFn => addMockedOperation(operation, response, false); + response: MockResponse, + ): MockFn => + addMockedOperation(operation, response, false); mockFn.mockOperationOnce = ( operation: TOperation, - response: MockResponse, - ): MockFn => addMockedOperation(operation, response, true); + response: MockResponse, + ): MockFn => + addMockedOperation(operation, response, true); + + mockFn.configure = ( + config: Partial, + ): MockFn => { + Object.assign(configuration, config); + return mockFn; + }; return mockFn; }; diff --git a/packages/wonder-blocks-testing-core/src/types.ts b/packages/wonder-blocks-testing-core/src/types.ts index baa41e69a..52f9fc2f8 100644 --- a/packages/wonder-blocks-testing-core/src/types.ts +++ b/packages/wonder-blocks-testing-core/src/types.ts @@ -14,11 +14,46 @@ export type GraphQLJson> = }>; }; -export type MockFn = { +export interface MockFn { + /** + * The mock fetch function. + * + * This function is a drop-in replacement for the fetch function being + * mocked. It is recommended that a more strongly-typed definition is + * provided in the consuming codebase, as this definition is intentionally + * loose to allow for mocking any fetch operation. + */ (...args: Array): Promise; - mockOperation: MockOperationFn; - mockOperationOnce: MockOperationFn; -}; + + /** + * Mock a fetch operation. + * + * This adds a response for a given mocked operation of the given type. + * Matches are determined by the operation matcher provided to the + * mockRequester function that creates the mock fetch function. + */ + mockOperation: MockOperationFn; + + /** + * Mock a fetch operation once. + * + * This adds a response for a given mocked operation of the given type that + * will only be used once and discarded. Matches are determined by the + * operation matcher provided to the mockRequester function that creates the + * mock fetch function. + */ + mockOperationOnce: MockOperationFn; + + /** + * Configure the mock fetch function with the given configuration. + * + * This function is provided as a convenience to allow for configuring the + * mock fetch function in a fluent manner. The configuration is applied + * to all mocks for a given fetch function; the last configuration applied + * will be the one that is used for all mocked operations. + */ + configure: ConfigureFn; +} export type OperationMock = { operation: TOperation; @@ -32,9 +67,41 @@ export type OperationMatcher = ( ...args: Array ) => boolean; -export type MockOperationFn = < +export type MockOperationFn = < TOperation extends TOperationType, >( operation: TOperation, - response: MockResponse, -) => MockFn; + response: MockResponse, +) => MockFn; + +/** + * Configuration options for mocked fetches. + */ +export type MockConfiguration = { + /** + * If true, any requests that don't match a mock will throw an error + * immediately on the request being made; otherwise, if false, unmatched + * requests will return a rejected promise. + * + * Defaults to false. When true, this is akin to the Apollo MockLink + * behavior that throws upon the request being. This is useful as it will + * clearly fail a test early, indicating that a request was not mocked. + * However, that mode requires all requests to be mocked, which can be + * cumbersome and unncessary. Having unmocked requests return a rejected + * promise is more flexible and allows for more granular control over + * mocking, allowing developers to mock only the requests they care about + * and let the error handling of their code deal with the rejected promises. + */ + hardFailOnUnmockedRequests: boolean; +}; + +export interface ConfigureFn { + /** + * Configure the mock fetch function with the given configuration. + * + * @param config The configuration changes to apply to the mock fetch + * function. + * @returns The mock fetch function . + */ + (config: Partial): MockFn; +} diff --git a/packages/wonder-blocks-testing/src/gql/__tests__/mock-gql-fetch.test.tsx b/packages/wonder-blocks-testing/src/gql/__tests__/mock-gql-fetch.test.tsx index b36cf637f..1637f4168 100644 --- a/packages/wonder-blocks-testing/src/gql/__tests__/mock-gql-fetch.test.tsx +++ b/packages/wonder-blocks-testing/src/gql/__tests__/mock-gql-fetch.test.tsx @@ -2,7 +2,10 @@ import * as React from "react"; import {render, screen, waitFor} from "@testing-library/react"; import {GqlRouter, useGql} from "@khanacademy/wonder-blocks-data"; -import {RespondWith} from "@khanacademy/wonder-blocks-testing-core"; +import { + RespondWith, + testHarness, +} from "@khanacademy/wonder-blocks-testing-core"; import {mockGqlFetch} from "../mock-gql-fetch"; describe("#mockGqlFetch", () => { @@ -41,6 +44,49 @@ describe("#mockGqlFetch", () => { ); }); + it("should throw an error when there are no mocks and hardFailOnUnmockedRequests is true", () => { + // Arrange + jest.spyOn(console, "error").mockImplementation(() => { + /* react will log an error - this keeps the output clean */ + }); + const mockFetch = mockGqlFetch(); + mockFetch.configure({hardFailOnUnmockedRequests: true}); + const RenderError = () => { + const [result, setResult] = React.useState(null); + const gqlFetch = useGql(); + React.useEffect(() => { + gqlFetch({ + type: "query", + id: "getMyStuff", + }).catch((e: any) => { + setResult(e.message); + }); + }, [gqlFetch]); + + return
{result}
; + }; + const captureError = jest.fn(); + const Harnessed = testHarness(RenderError, { + boundary: captureError, + }); + + // Act + render( + + + , + ); + const result = captureError.mock.calls[0][0]; + + // Assert + expect(result).toMatchInlineSnapshot(` + [Error: No matching mock response found for request: + Operation: query getMyStuff + Variables: None + Context: {}] + `); + }); + it("should provide data when response gives data", async () => { // Arrange const mockFetch = mockGqlFetch(); diff --git a/packages/wonder-blocks-testing/src/gql/mock-gql-fetch.ts b/packages/wonder-blocks-testing/src/gql/mock-gql-fetch.ts index 291d2a8ac..ae270b0c9 100644 --- a/packages/wonder-blocks-testing/src/gql/mock-gql-fetch.ts +++ b/packages/wonder-blocks-testing/src/gql/mock-gql-fetch.ts @@ -1,4 +1,7 @@ -import {mockRequester} from "@khanacademy/wonder-blocks-testing-core"; +import { + GraphQLJson, + mockRequester, +} from "@khanacademy/wonder-blocks-testing-core"; import {gqlRequestMatchesMock} from "./gql-request-matches-mock"; import type {GqlFetchMockFn, GqlMockOperation} from "./types"; @@ -6,7 +9,7 @@ import type {GqlFetchMockFn, GqlMockOperation} from "./types"; * A mock for the fetch function passed to GqlRouter. */ export const mockGqlFetch = (): GqlFetchMockFn => - mockRequester>( + mockRequester, GraphQLJson>( gqlRequestMatchesMock, // Note that the identation at the start of each line is important. // TODO(somewhatabstract): Make a stringify that indents each line of diff --git a/packages/wonder-blocks-testing/src/gql/types.ts b/packages/wonder-blocks-testing/src/gql/types.ts index e069a64e4..433609b09 100644 --- a/packages/wonder-blocks-testing/src/gql/types.ts +++ b/packages/wonder-blocks-testing/src/gql/types.ts @@ -1,9 +1,16 @@ import type {GqlOperation, GqlContext} from "@khanacademy/wonder-blocks-data"; import type { + ConfigureFn, GraphQLJson, MockResponse, } from "@khanacademy/wonder-blocks-testing-core"; +/** + * A GraphQL operation to be mocked. + * + * This is used to specify what a request must match in order for a mock to + * be used. + */ export type GqlMockOperation< TData extends Record, TVariables extends Record, @@ -14,22 +21,77 @@ export type GqlMockOperation< context?: TContext; }; -type GqlMockOperationFn = < - TData extends Record, - TVariables extends Record, - TContext extends GqlContext, - TResponseData extends GraphQLJson, ->( - operation: GqlMockOperation, - response: MockResponse, -) => GqlFetchMockFn; +interface GqlMockOperationFn { + < + TData extends Record, + TVariables extends Record, + TContext extends GqlContext, + TResponseData extends GraphQLJson, + >( + /** + * The operation to match. + */ + operation: GqlMockOperation, + /** + * The response to return when the operation is matched. + */ + response: MockResponse, + ): GqlFetchMockFn; +} -export type GqlFetchMockFn = { +export interface GqlFetchMockFn { + /** + * The mock fetch function. + * + * This function is a drop-in replacement for the gqlFetch function used + * by Wonder Blocks Data. You should not need to call this function + * directly. Just pass this in places where you would pass a gqlFetch + * function, as provided by the GqlRouter. + */ ( operation: GqlOperation, variables: Record | null | undefined, context: GqlContext, ): Promise; + + /** + * Mock a fetch operation. + * + * This adds a response for a given mocked operation. Operations are + * matched greedily, so if only the GraphQL operation is provided, then + * all requests for that operation will be matched, regardless of + * variables or context. + * + * Regardless of how many times this mock is matched, it will be used. + * + * @returns The mock fetch function for chaining. + */ mockOperation: GqlMockOperationFn; + + /** + * Mock a fetch operation once. + * + * This adds a response for a given mocked operation. Operations are + * matched greedily, so if only the GraphQL operation is provided, then + * all requests for that operation will be matched, regardless of + * variables or context. + * + * Once the added mock is used, it will be discarded and no longer match + * any requests. + * + * @returns The mock fetch function for chaining. + */ mockOperationOnce: GqlMockOperationFn; -}; + + /** + * Configure the mock fetch function with the given configuration. + * + * This function is provided as a convenience to allow for configuring the + * mock fetch function in a fluent manner. The configuration is applied + * to all mocks for a given fetch function; the last configuration applied + * will be the one that is used for all mocked operations. + * + * @returns The mock fetch function for chaining. + */ + configure: ConfigureFn, GraphQLJson>; +}