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 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion docs/source/development-testing/testing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ it('renders without error', () => {
});
```

The `mocks` array takes objects with specific `request`s and their associated `result`s. When the provider receives a `GET_DOG_QUERY` with matching `variables`, it returns the corresponding object from the `result` key. A `result` may alternatively be a function returning the object:
The `mocks` array takes objects with specific `request`s and their associated `result`s. When the provider receives a `GET_DOG_QUERY` with matching `variables`, it returns the corresponding object from the `result` key. A `result` may alternatively be a function that takes variables and returns the object:

```js
const mocks = [
Expand Down Expand Up @@ -247,6 +247,43 @@ const dogMock = {
};
```

## Testing with 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 return a bool indication if this mock should match the invocation for the provided query.
You cannot specify this parameter and `request.variables`:

For example, this mock will match all dog queries:

```js
const dogMock = {
request: {
query: GET_DOG_QUERY
},
variableMatcher (variables) => true,
prowe marked this conversation as resolved.
Show resolved Hide resolved
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'
}));
```

## Testing mutation components

`useMutation` based components are tested very similarly to `useQuery` components. The only key difference is how the operation is fired. With `useQuery` the query is fired when the wrapping component _mounts_, whereas with `useMutation` the mutation is fired manually, usually after some user interaction like pressing a button.
Expand Down
117 changes: 117 additions & 0 deletions src/utilities/testing/mocking/__tests__/MockedProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,82 @@ describe('General use', () => {
return wait().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>
);

return wait(() => {
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>
);

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

itAsync('should use a mock if the variableMatcher returns true', async (resolve, reject) => {
function Component({ username }: Variables) {
const { loading, data } = useQuery<Data, Variables>(query, { variables });
if (!loading) {
expect(data!.user).toMatchSnapshot();
}
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>
);

return wait().then(resolve, reject);
});

itAsync('should allow querying with the typename', async (resolve, reject) => {
function Component({ username }: Variables) {
const { loading, data } = useQuery<Data, Variables>(query, { variables });
Expand Down Expand Up @@ -170,6 +246,33 @@ describe('General use', () => {
}).then(resolve, reject);
});

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

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

const link = ApolloLink.from([errorLink, new MockLink([mock2])]);

render(
<MockedProvider link={link}>
<Component {...variables} />
</MockedProvider>
);

return wait(() => {
expect(errorThrown).toBeTruthy();
}).then(resolve, reject);
});

itAsync('should error if the variables do not deep equal', async (resolve, reject) => {
function Component({ ...variables }: Variables) {
useQuery<Data, Variables>(query, { variables });
Expand Down Expand Up @@ -306,6 +409,20 @@ describe('General use', () => {
}).then(resolve, reject);
});

it('should error if both variables and a variableMatcher are provided', () => {
const mock2: MockedResponse<Data, Variables> = {
request: {
query,
variables
},
variableMatcher: () => true,
result: { data: { user } }
};

expect(() => new MockLink([mock2]))
.toThrow('Mocked response should contain either variableMatcher or request.variables');
});

it('should pass down props prop in mock as props for the component', () => {
function Component({ ...variables }) {
expect(variables.foo).toBe('bar');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,10 @@ Object {
},
}
`;

exports[`General use should use a mock if the variableMatcher returns true 1`] = `
Object {
"__typename": "User",
"id": "user_id",
}
`;
33 changes: 25 additions & 8 deletions src/utilities/testing/mocking/mockLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@ import {
cloneDeep,
} from '../../../utilities';

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

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

export interface MockedResponse<TData = Record<string, any>, TVariables = Record<string, any>> {
request: GraphQLRequest;
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 @@ -75,9 +78,7 @@ export class MockLink extends ApolloLink {
let responseIndex: number = 0;
const response = (this.mockedResponsesByKey[key] || []).find(
(res, index) => {
const requestVariables = operation.variables || {};
const mockedResponseVariables = res.request.variables || {};
if (equal(requestVariables, mockedResponseVariables)) {
if (res.variableMatcher && res.variableMatcher(operation.variables)) {
responseIndex = index;
return true;
}
Expand All @@ -99,7 +100,7 @@ export class MockLink extends ApolloLink {
const { newData } = response!;

if (newData) {
response!.result = newData();
response!.result = newData(operation.variables);
this.mockedResponsesByKey[key].push(response!);
}

Expand All @@ -120,7 +121,7 @@ export class MockLink extends ApolloLink {
if (result) {
observer.next(
typeof result === 'function'
? (result as ResultFunction<FetchResult>)()
? (result as ResultFunction<FetchResult>)(operation.variables)
: result
);
}
Expand Down Expand Up @@ -149,8 +150,24 @@ export class MockLink extends ApolloLink {
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