Skip to content

Commit

Permalink
Ability to dynamically match mocks (#6701)
Browse files Browse the repository at this point in the history
  • Loading branch information
prowe committed Sep 19, 2023
1 parent c1b8c91 commit 8d2b4e1
Show file tree
Hide file tree
Showing 16 changed files with 269 additions and 26 deletions.
2 changes: 1 addition & 1 deletion .api-reports/api-report-core.md
Expand Up @@ -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<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): void;
markResult<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): typeof result;
// (undocumented)
networkError?: Error | null;
// (undocumented)
Expand Down
2 changes: 1 addition & 1 deletion .api-reports/api-report-react.md
Expand Up @@ -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<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): void;
markResult<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): typeof result;
// (undocumented)
networkError?: Error | null;
// (undocumented)
Expand Down
2 changes: 1 addition & 1 deletion .api-reports/api-report-react_components.md
Expand Up @@ -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<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): void;
markResult<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): typeof result;
// (undocumented)
networkError?: Error | null;
// (undocumented)
Expand Down
2 changes: 1 addition & 1 deletion .api-reports/api-report-react_context.md
Expand Up @@ -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<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): void;
markResult<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): typeof result;
// (undocumented)
networkError?: Error | null;
// (undocumented)
Expand Down
2 changes: 1 addition & 1 deletion .api-reports/api-report-react_hoc.md
Expand Up @@ -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<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): void;
markResult<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): typeof result;
// (undocumented)
networkError?: Error | null;
// (undocumented)
Expand Down
2 changes: 1 addition & 1 deletion .api-reports/api-report-react_hooks.md
Expand Up @@ -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<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): void;
markResult<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): typeof result;
// (undocumented)
networkError?: Error | null;
// (undocumented)
Expand Down
2 changes: 1 addition & 1 deletion .api-reports/api-report-react_ssr.md
Expand Up @@ -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<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): void;
markResult<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): typeof result;
// (undocumented)
networkError?: Error | null;
// (undocumented)
Expand Down
13 changes: 10 additions & 3 deletions .api-reports/api-report-testing.md
Expand Up @@ -892,7 +892,11 @@ export interface MockedResponse<TData = Record<string, any>, TVariables = Record
// (undocumented)
request: GraphQLRequest<TVariables>;
// (undocumented)
result?: FetchResult<TData> | ResultFunction<FetchResult<TData>>;
result?: FetchResult<TData> | ResultFunction<FetchResult<TData>, TVariables>;
// Warning: (ae-forgotten-export) The symbol "VariableMatcher" needs to be exported by the entry point index.d.ts
//
// (undocumented)
variableMatcher?: VariableMatcher<TVariables>;
}

// @public (undocumented)
Expand Down Expand Up @@ -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<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): void;
markResult<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): typeof result;
// (undocumented)
networkError?: Error | null;
// (undocumented)
Expand Down Expand Up @@ -1497,7 +1501,7 @@ interface Resolvers {
}

// @public (undocumented)
export type ResultFunction<T> = () => T;
export type ResultFunction<T, V = Record<string, any>> = (variables: V) => T;

// @public (undocumented)
type SafeReadonly<T> = T extends object ? Readonly<T> : T;
Expand Down Expand Up @@ -1623,6 +1627,9 @@ interface UriFunction {
(operation: Operation): string;
}

// @public (undocumented)
type VariableMatcher<V = Record<string, any>> = (variables: V) => boolean;

// @public (undocumented)
export function wait(ms: number): Promise<void>;

Expand Down
13 changes: 10 additions & 3 deletions .api-reports/api-report-testing_core.md
Expand Up @@ -848,7 +848,11 @@ export interface MockedResponse<TData = Record<string, any>, TVariables = Record
// (undocumented)
request: GraphQLRequest<TVariables>;
// (undocumented)
result?: FetchResult<TData> | ResultFunction<FetchResult<TData>>;
result?: FetchResult<TData> | ResultFunction<FetchResult<TData>, TVariables>;
// Warning: (ae-forgotten-export) The symbol "VariableMatcher" needs to be exported by the entry point index.d.ts
//
// (undocumented)
variableMatcher?: VariableMatcher<TVariables>;
}

// @public (undocumented)
Expand Down Expand Up @@ -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<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): void;
markResult<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): typeof result;
// (undocumented)
networkError?: Error | null;
// (undocumented)
Expand Down Expand Up @@ -1455,7 +1459,7 @@ interface Resolvers {
}

// @public (undocumented)
export type ResultFunction<T> = () => T;
export type ResultFunction<T, V = Record<string, any>> = (variables: V) => T;

// @public (undocumented)
type SafeReadonly<T> = T extends object ? Readonly<T> : T;
Expand Down Expand Up @@ -1581,6 +1585,9 @@ interface UriFunction {
(operation: Operation): string;
}

// @public (undocumented)
type VariableMatcher<V = Record<string, any>> = (variables: V) => boolean;

// @public (undocumented)
export function wait(ms: number): Promise<void>;

Expand Down
2 changes: 1 addition & 1 deletion .api-reports/api-report-utilities.md
Expand Up @@ -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<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): void;
markResult<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): typeof result;
// (undocumented)
networkError?: Error | null;
// (undocumented)
Expand Down
2 changes: 1 addition & 1 deletion .api-reports/api-report.md
Expand Up @@ -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<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): void;
markResult<T>(result: FetchResult<T>, document: DocumentNode, options: Pick<WatchQueryOptions, "variables" | "fetchPolicy" | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior): typeof result;
// (undocumented)
networkError?: Error | null;
// (undocumented)
Expand Down
7 changes: 7 additions & 0 deletions .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.
42 changes: 41 additions & 1 deletion docs/source/development-testing/testing.mdx
Expand Up @@ -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 {
Expand Down Expand Up @@ -150,6 +150,46 @@ it("renders without error", async () => {

</ExpansionPanel>

### 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<Data, Variables> = {
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<Data, Variables> = {
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).
Expand Down
34 changes: 30 additions & 4 deletions src/testing/core/mocking/mockLink.ts
Expand Up @@ -19,16 +19,21 @@ import {
print,
} from "../../../utilities/index.js";

export type ResultFunction<T> = () => T;
export type ResultFunction<T, V = Record<string, any>> = (variables: V) => T;

export type VariableMatcher<V = Record<string, any>> = (
variables: V
) => boolean;

export interface MockedResponse<
TData = Record<string, any>,
TVariables = Record<string, any>,
> {
request: GraphQLRequest<TVariables>;
result?: FetchResult<TData> | ResultFunction<FetchResult<TData>>;
result?: FetchResult<TData> | ResultFunction<FetchResult<TData>, TVariables>;
error?: Error;
delay?: number;
variableMatcher?: VariableMatcher<TVariables>;
newData?: ResultFunction<FetchResult>;
}

Expand Down Expand Up @@ -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;
})
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -165,7 +173,7 @@ ${unmatchedVars.map((d) => ` ${stringifyForDisplay(d)}`).join("\n")}
if (response.result) {
observer.next(
typeof response.result === "function"
? (response.result as ResultFunction<FetchResult>)()
? response.result(operation.variables)
: response.result
);
}
Expand Down Expand Up @@ -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 {
Expand Down

0 comments on commit 8d2b4e1

Please sign in to comment.