Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ability to dynamically match mocks #6701

Merged
merged 20 commits into from
Sep 19, 2023
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/sour-sheep-walk.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 37 additions & 1 deletion docs/source/development-testing/testing.mdx
Original file line number Diff line number Diff line change
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,42 @@ it("renders without error", async () => {

</ExpansionPanel>

### Dynamic variables
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file has been updated several times since this PR was first opened. Some updates were warranted, I tried not to take too many liberties. Critical feedback is welcome 🙏🏻


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 return 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:

```js
const dogMock = {
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:

```js
const dogMock = {
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
30 changes: 26 additions & 4 deletions src/testing/core/mocking/mockLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,19 @@ 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> = (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 @@ -94,6 +97,9 @@ export class MockLink extends ApolloLink {
if (equal(requestVariables, mockedResponseVars)) {
return true;
}
if (res.variableMatcher && res.variableMatcher(operation.variables)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was one of the conflict areas that was a bit murkier to resolve. I believe this logic is correct since we still want to return true on an exact match first. The tests pass, but a skeptical eye would be helpful here.

return true;
}
unmatchedVars.push(mockedResponseVars);
return false;
}) : -1;
Expand Down Expand Up @@ -127,7 +133,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 @@ -160,7 +166,7 @@ ${unmatchedVars.map(d => ` ${stringifyForDisplay(d)}`).join('\n')}
if (response.result) {
observer.next(
typeof response.result === 'function'
? (response.result as ResultFunction<FetchResult>)()
? (response.result as ResultFunction<FetchResult>)(operation.variables)
: response.result
);
}
Expand Down Expand Up @@ -188,8 +194,24 @@ ${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
111 changes: 111 additions & 0 deletions src/testing/react/__tests__/MockedProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,87 @@ describe('General use', () => {
}).then(resolve, reject);
});

itAsync('should pass the variables to the result function', async (resolve, reject) => {
function Component({ ...variables }: Variables) {
useQuery<Data, Variables>(query, { variables });
return null;
}

const mock2: MockedResponse<Data, Variables> = {
request: {
query,
variables
},
result: jest.fn().mockResolvedValue({ data: { user } })
};

render(
<MockedProvider mocks={[mock2]}>
<Component {...variables} />
</MockedProvider>
);

waitFor(() => {
expect(mock2.result as jest.Mock).toHaveBeenCalledWith(variables);
}).then(resolve, reject);
});

itAsync('should pass the variables to the variableMatcher', async (resolve, reject) => {
function Component({ ...variables }: Variables) {
useQuery<Data, Variables>(query, { variables });
return null;
}

const mock2: MockedResponse<Data, Variables> = {
request: {
query
},
variableMatcher: jest.fn().mockReturnValue(true),
result: { data: { user } }
};

render(
<MockedProvider mocks={[mock2]}>
<Component {...variables} />
</MockedProvider>
);

waitFor(() => {
expect(mock2.variableMatcher as jest.Mock).toHaveBeenCalledWith(variables);
}).then(resolve, reject);
});

itAsync('should use a mock if the variableMatcher returns true', async (resolve, reject) => {
let finished = false;

function Component({ username }: Variables) {
const { loading, data } = useQuery<Data, Variables>(query, { variables });
if (!loading) {
expect(data!.user).toMatchSnapshot();
finished = true;
}
return null;
}

const mock2: MockedResponse<Data, Variables> = {
request: {
query
},
variableMatcher: (v: Variables) => v.username === variables.username,
result: { data: { user } }
};

render(
<MockedProvider mocks={[mock2]}>
<Component {...variables} />
</MockedProvider>
);

waitFor(() => {
expect(finished).toBe(true);
alessbell marked this conversation as resolved.
Show resolved Hide resolved
}).then(resolve, reject);
});

itAsync('should allow querying with the typename', (resolve, reject) => {
let finished = false;
function Component({ username }: Variables) {
Expand Down Expand Up @@ -185,6 +266,36 @@ describe('General use', () => {
}).then(resolve, reject);
});

itAsync('should error if the variableMatcher returns false', async (resolve, reject) => {
let finished = false;
function Component({ ...variables }: Variables) {
const { loading, error } = useQuery<Data, Variables>(query, { variables });
if (!loading) {
expect(error).toMatchSnapshot();
finished = true;
}
return null;
}

const mock2: MockedResponse<Data, Variables> = {
request: {
query
},
variableMatcher: () => false,
result: { data: { user } }
};

render(
<MockedProvider showWarnings={false} mocks={[mock2]}>
<Component {...variables} />
</MockedProvider>
);

waitFor(() => {
expect(finished).toBe(true);
}, ).then(resolve, reject);
});

itAsync('should error if the variables do not deep equal', (resolve, reject) => {
let finished = false
function Component({ ...variables }: Variables) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ Expected variables: {"username":"mock_username"}
]
`;

exports[`General use should error if the variableMatcher returns false 1`] = `
[ApolloError: No more mocked responses for the query: query GetUser($username: String!) {
user(username: $username) {
id
__typename
}
}
Expected variables: {"username":"mock_username"}

Failed to match 1 mock for this query. The mocked response had the following variables:
{}
]
`;

exports[`General use should error if the variables do not deep equal 1`] = `
[ApolloError: No more mocked responses for the query: query GetUser($username: String!) {
user(username: $username) {
Expand Down Expand Up @@ -87,3 +101,10 @@ exports[`General use should support custom error handling using setOnError 1`] =
Expected variables: {"username":"mock_username"}
]
`;

exports[`General use should use a mock if the variableMatcher returns true 1`] = `
Object {
"__typename": "User",
"id": "user_id",
}
`;