Skip to content

Commit

Permalink
[fei5850.2.hardfail] Add hard fail support to request mocking (#2334)
Browse files Browse the repository at this point in the history
## Summary:
An oft annoying but sometimes useful feature of Apollo's `MockLink` is that it will hard fail if a request is made that has not been mocked. This is annoying because it forces folks to provide a mock for every request, even if they don't care about that request. However, it is useful when debugging tests to see why a mock isn't matching as expected or what requests are happening (the KA_LOG_FETCHES is useful for seeing this info too, but less accessible).

This change adds the ability to turn on hard fails for our request mocking system. I have added this using a fluent API so that it is more easily worked into existing uses of this mocking system.

I've also added tests to verify the new functionality, and added some additional commentary to the types for the mocking framework.

Issue: FEI-5850

## Test plan:
`yarn test`
`yarn typecheck`

Author: somewhatabstract

Reviewers: jeresig

Required Reviewers:

Approved By: jeresig

Checks: ⌛ Publish npm snapshot (ubuntu-latest, 20.x), ⌛ Prime node_modules cache for primary configuration (ubuntu-latest, 20.x), ⌛ gerald, ⌛ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ⏭️  dependabot

Pull Request URL: #2334
  • Loading branch information
somewhatabstract authored Oct 2, 2024
1 parent eb807af commit 16565a8
Show file tree
Hide file tree
Showing 12 changed files with 429 additions and 54 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
28 changes: 24 additions & 4 deletions __docs__/wonder-blocks-testing/exports.mock-fetch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ The `mockFetch` function provides an API to easily mock `fetch()` responses. It

Besides being a function that fits the `fetch()` signature, the return value of `mockFetch()` has an API to customize the behavior of that function. Used in conjunction with the <a href="./?path=/docs/packages-testing-mocking-exports-respondwith--docs">`RespondWith`</a> API, this can create a variety of responses for tests and stories.

| Function | Purpose |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `mockOperation` | When called, any request that matches the defined mock will respond with the given response. |
| Function | Purpose |
| - | - |
| `mockOperation` | When called, any request that matches the defined mock will respond with the given response. |
| `mockOperationOnce` | When called, the first request that matches the defined mock will respond with the given response. The mock is only used once. |
| `configure` | This allows you to configure the behavior of the mock fetch function. |

Both of these functions have the same signature:

Expand All @@ -30,6 +31,25 @@ type FetchMockOperationFn = (
) => FetchMockFn;
```

# Operation Matching

## Configuration

The `configure` function allows you to configure the behavior of the mocked fetch function. It takes a partial configuration and applies that to the existing
configuration. This changes the behavior of all calls to the mocked function.

The full configuration is:

```ts
{
hardFailOnUnmockedRequests: boolean;
}
```

| Configuration Key | Purpose |
| - | - |
| `hardFailOnUnmockedRequests` | When set to `true`, any unmocked request will throw an error, causing tests to fail. When set to `false`, unmocked requests will reject, which in turn gets handled by the relevant error handling in the code under test - this is the default behavior and is usually what you want so that you don't need to mock every single request that may be invoked during your tests. |


## Operation Matching

The `FetchMockOperation` type is either of type `string` or `RegExp`. When specified as a string, the URL of the request must match the string exactly. When specified as a regular expression, the URL of the request must match the regular expression.
26 changes: 22 additions & 4 deletions __docs__/wonder-blocks-testing/exports.mock-gql-fetch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ The `mockGqlFetch` function provides an API to easily mock GraphQL responses for

Besides being a function that fits the <a href="./?path=/docs/packages-data-types-gqlfetchfn--docs">`GqlFetchFn`</a> signature, the return value of `mockGqlFetch()` has an API to customize the behavior of that function. Used in conjunction with the <a href="./?path=/docs/packages-testing-mocking-exports-respondwith--docs">`RespondWith`</a> API, this can create a variety of GraphQL responses for testing and stories.

| Function | Purpose |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mockOperation` | When called, any GraphQL operation that matches the defined mock operation will respond with the given response. |
| Function | Purpose |
| - | - |
| `mockOperation` | When called, any GraphQL operation that matches the defined mock operation will respond with the given response. |
| `mockOperationOnce` | When called, the first GraphQL operation that matches the defined mock operation will respond with the given response. The mock is only used once. |
| `configure` | This allows you to configure the behavior of the mock fetch function. |

Both of these functions have the same signature:

Expand All @@ -35,7 +36,24 @@ type GqlMockOperationFn = <
) => GqlFetchMockFn;
```

# Operation Matching
## Configuration

The `configure` function allows you to configure the behavior of the mocked fetch function. It takes a partial configuration and applies that to the existing
configuration. This changes the behavior of all calls to the mocked function.

The full configuration is:

```ts
{
hardFailOnUnmockedRequests: boolean;
}
```

| Configuration Key | Purpose |
| - | - |
| `hardFailOnUnmockedRequests` | When set to `true`, any unmocked request will throw an error, causing tests to fail. When set to `false`, unmocked requests will reject, which in turn gets handled by the relevant error handling in the code under test - this is the default behavior and is usually what you want so that you don't need to mock every single request that may be invoked during your tests. |

## Operation Matching

The `matchOperation` parameter given to a `mockOperation` or `mockOperationOnce` function is a `GqlMockOperation` defining the actual GraphQL operation to be matched by the mock. The variables and context of the mocked operation change how the mock is matched against requests.

Expand Down
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;
};
Loading

0 comments on commit 16565a8

Please sign in to comment.