Skip to content

Commit

Permalink
[fei5850.2.hardfail] Add hard fail support to request mocking
Browse files Browse the repository at this point in the history
  • Loading branch information
somewhatabstract committed Oct 1, 2024
1 parent 3c3f13f commit b151481
Show file tree
Hide file tree
Showing 10 changed files with 383 additions and 46 deletions.
6 changes: 6 additions & 0 deletions .changeset/great-paws-ring.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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"
`);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {FetchMockFn, FetchMockOperation} from "./types";
* A mock for the fetch function passed to GqlRouter.
*/
export const mockFetch = (): FetchMockFn =>
mockRequester<FetchMockOperation>(
mockRequester<FetchMockOperation, any>(
fetchRequestMatchesMock,
// NOTE(somewhatabstract): The indentation is expected on the lines
// here.
Expand Down
59 changes: 55 additions & 4 deletions packages/wonder-blocks-testing-core/src/fetch/types.ts
Original file line number Diff line number Diff line change
@@ -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<any>,
) => 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<any>,
): 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<Response>;

/**
* 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<FetchMockOperation, any>;
};
2 changes: 2 additions & 0 deletions packages/wonder-blocks-testing-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export type {
OperationMock,
OperationMatcher,
MockOperationFn,
MockConfiguration,
ConfigureFn,
} from "./types";

// Test harness framework
Expand Down
76 changes: 57 additions & 19 deletions packages/wonder-blocks-testing-core/src/mock-requester.ts
Original file line number Diff line number Diff line change
@@ -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 = <TOperationType>(
export const mockRequester = <TOperationType, TResponseData>(
operationMatcher: OperationMatcher<any>,
operationToString: (...args: Array<any>) => string,
): MockFn<TOperationType> => {
): MockFn<TOperationType, TResponseData> => {
// 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<OperationMock<any>> = [];

// 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<TOperationType> = (
const configuration: MockConfiguration = {
hardFailOnUnmockedRequests: false,
};

const getMatchingMock = (
...args: Array<any>
): Promise<Response> => {
): OperationMock<any> | null => {
// Iterate our mocked operations and find the first one that matches.
for (const mock of mocks) {
if (mock.onceOnly && mock.used) {
Expand All @@ -26,24 +33,46 @@ export const mockRequester = <TOperationType>(
}
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<TOperationType, TResponseData> = (
...args: Array<any>
): Promise<Response> => {
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 = <TOperation>(
operation: TOperation,
response: MockResponse<any>,
response: MockResponse<TResponseData>,
onceOnly: boolean,
): MockFn<TOperationType> => {
): MockFn<TOperationType, TResponseData> => {
const mockResponse = () => response.toPromise();
mocks.push({
operation,
Expand All @@ -56,13 +85,22 @@ export const mockRequester = <TOperationType>(

mockFn.mockOperation = <TOperation>(
operation: TOperation,
response: MockResponse<any>,
): MockFn<TOperationType> => addMockedOperation(operation, response, false);
response: MockResponse<TResponseData>,
): MockFn<TOperationType, TResponseData> =>
addMockedOperation(operation, response, false);

mockFn.mockOperationOnce = <TOperation>(
operation: TOperation,
response: MockResponse<any>,
): MockFn<TOperationType> => addMockedOperation(operation, response, true);
response: MockResponse<TResponseData>,
): MockFn<TOperationType, TResponseData> =>
addMockedOperation(operation, response, true);

mockFn.configure = (
config: Partial<MockConfiguration>,
): MockFn<TOperationType, TResponseData> => {
Object.assign(configuration, config);
return mockFn;
};

return mockFn;
};
81 changes: 74 additions & 7 deletions packages/wonder-blocks-testing-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,46 @@ export type GraphQLJson<TData extends Record<any, any>> =
}>;
};

export type MockFn<TOperationType> = {
export interface MockFn<TOperationType, TResponseData> {
/**
* 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<any>): Promise<Response>;
mockOperation: MockOperationFn<TOperationType>;
mockOperationOnce: MockOperationFn<TOperationType>;
};

/**
* 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<TOperationType, TResponseData>;

/**
* 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<TOperationType, TResponseData>;

/**
* 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<TOperationType, TResponseData>;
}

export type OperationMock<TOperation> = {
operation: TOperation;
Expand All @@ -32,9 +67,41 @@ export type OperationMatcher<TOperation> = (
...args: Array<any>
) => boolean;

export type MockOperationFn<TOperationType> = <
export type MockOperationFn<TOperationType, TResponseData> = <
TOperation extends TOperationType,
>(
operation: TOperation,
response: MockResponse<any>,
) => MockFn<TOperationType>;
response: MockResponse<TResponseData>,
) => MockFn<TOperationType, TResponseData>;

/**
* 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<TOperationType, TResponseData> {
/**
* 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<MockConfiguration>): MockFn<TOperationType, TResponseData>;
}
Loading

0 comments on commit b151481

Please sign in to comment.