Commit 251bfd72 authored by Michael Lunøe's avatar Michael Lunøe Committed by Andrew Fontaine

Test(DevOps Adoption): `groups` local resolver

Improve the tests for the `groups` local resolver
in the Admin/DevOps Adoption tab.

This should serve as a good example to how to test
local resolvers for GraphQL going forward
parent 58e891ad
...@@ -833,7 +833,7 @@ If your application contains `@client` queries, most probably you will have an A ...@@ -833,7 +833,7 @@ If your application contains `@client` queries, most probably you will have an A
```javascript ```javascript
import createMockApollo from 'jest/helpers/mock_apollo_helper'; import createMockApollo from 'jest/helpers/mock_apollo_helper';
... ...
fakeApollo = createMockApollo(requestHandlers, {}); mockApollo = createMockApollo(requestHandlers, resolvers);
``` ```
Sometimes we want to test a `result` hook of the local query. In order to have it triggered, we need to populate a cache with correct data to be fetched with this query: Sometimes we want to test a `result` hook of the local query. In order to have it triggered, we need to populate a cache with correct data to be fetched with this query:
...@@ -849,14 +849,14 @@ query fetchLocalUser { ...@@ -849,14 +849,14 @@ query fetchLocalUser {
```javascript ```javascript
import fetchLocalUserQuery from '~/design_management/graphql/queries/fetch_local_user.query.graphql'; import fetchLocalUserQuery from '~/design_management/graphql/queries/fetch_local_user.query.graphql';
function createComponentWithApollo() { function createMockApolloProvider() {
const requestHandlers = [ const requestHandlers = [
[getDesignListQuery, jest.fn().mockResolvedValue(designListQueryResponse)], [getDesignListQuery, jest.fn().mockResolvedValue(designListQueryResponse)],
[permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)], [permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
]; ];
fakeApollo = createMockApollo(requestHandlers, {}); mockApollo = createMockApollo(requestHandlers, {});
fakeApollo.clients.defaultClient.cache.writeQuery({ mockApollo.clients.defaultClient.cache.writeQuery({
query: fetchLocalUserQuery, query: fetchLocalUserQuery,
data: { data: {
fetchLocalUser: { fetchLocalUser: {
...@@ -864,15 +864,107 @@ function createComponentWithApollo() { ...@@ -864,15 +864,107 @@ function createComponentWithApollo() {
name: 'Test', name: 'Test',
}, },
}, },
}) });
wrapper = shallowMount(Index, { return mockApollo;
}
function createComponent(options = {}) {
const { mockApollo } = options;
return shallowMount(Index, {
localVue, localVue,
apolloProvider: fakeApollo, apolloProvider: mockApollo,
}); });
} }
``` ```
Sometimes it is necessary to control what the local resolver returns and inspect how it is called by the component. This can be done by mocking your local resolver:
```javascript
import fetchLocalUserQuery from '~/design_management/graphql/queries/fetch_local_user.query.graphql';
function createMockApolloProvider(options = {}) {
const { fetchLocalUserSpy } = options;
mockApollo = createMockApollo([], {
Query: {
fetchLocalUser: fetchLocalUserSpy,
},
});
// Necessary for local resolvers to be activated
mockApollo.clients.defaultClient.cache.writeQuery({
query: fetchLocalUserQuery,
data: {},
});
return mockApollo;
}
```
In the test you can then control what the spy is supposed to do and inspect the component after the request have returned:
```javascript
describe('My Index test with `createMockApollo`', () => {
let wrapper;
let fetchLocalUserSpy;
afterEach(() => {
wrapper.destroy();
wrapper = null;
fetchLocalUserSpy = null;
});
describe('when loading', () => {
beforeEach(() => {
const mockApollo = createMockApolloProvider();
wrapper = createComponent({ mockApollo });
});
it('displays the loader', () => {
// Assess that the loader is present
});
});
describe('with data', () => {
beforeEach(async () => {
fetchLocalUserSpy = jest.fn().mockResolvedValue(localUserQueryResponse);
const mockApollo = createMockApolloProvider(fetchLocalUserSpy);
wrapper = createComponent({ mockApollo });
await waitForPromises();
});
it('should fetch data once', () => {
expect(fetchLocalUserSpy).toHaveBeenCalledTimes(1);
});
it('displays data', () => {
// Assess that data is present
});
});
describe('with error', () => {
const error = 'Error!';
beforeEach(async () => {
fetchLocalUserSpy = jest.fn().mockRejectedValueOnce(error);
const mockApollo = createMockApolloProvider(fetchLocalUserSpy);
wrapper = createComponent({ mockApollo });
await waitForPromises();
});
it('should fetch data once', () => {
expect(fetchLocalUserSpy).toHaveBeenCalledTimes(1);
});
it('displays the error', () => {
// Assess that the error is displayed
});
});
});
```
## Handling errors ## Handling errors
GitLab's GraphQL mutations currently have two distinct error modes: [Top-level](#top-level-errors) and [errors-as-data](#errors-as-data). GitLab's GraphQL mutations currently have two distinct error modes: [Top-level](#top-level-errors) and [errors-as-data](#errors-as-data).
......
...@@ -3,7 +3,7 @@ import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; ...@@ -3,7 +3,7 @@ import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import * as Sentry from '~/sentry/wrapper'; import * as Sentry from '~/sentry/wrapper';
import getGroupsQuery from '../graphql/queries/get_groups.query.graphql'; import getGroupsQuery from '../graphql/queries/get_groups.query.graphql';
import DevopsAdoptionEmptyState from './devops_adoption_empty_state.vue'; import DevopsAdoptionEmptyState from './devops_adoption_empty_state.vue';
import { DEVOPS_ADOPTION_STRINGS } from '../constants'; import { DEVOPS_ADOPTION_STRINGS, MAX_REQUEST_COUNT } from '../constants';
export default { export default {
name: 'DevopsAdoptionApp', name: 'DevopsAdoptionApp',
...@@ -17,6 +17,7 @@ export default { ...@@ -17,6 +17,7 @@ export default {
}, },
data() { data() {
return { return {
requestCount: MAX_REQUEST_COUNT,
loadingError: false, loadingError: false,
}; };
}, },
...@@ -25,7 +26,9 @@ export default { ...@@ -25,7 +26,9 @@ export default {
query: getGroupsQuery, query: getGroupsQuery,
loadingKey: 'loading', loadingKey: 'loading',
result() { result() {
if (this.groups?.pageInfo?.nextPage) { this.requestCount -= 1;
if (this.requestCount > 0 && this.groups?.pageInfo?.nextPage) {
this.fetchNextPage(); this.fetchNextPage();
} }
}, },
...@@ -55,7 +58,8 @@ export default { ...@@ -55,7 +58,8 @@ export default {
}, },
updateQuery: (previousResult, { fetchMoreResult }) => { updateQuery: (previousResult, { fetchMoreResult }) => {
const { nodes, ...rest } = fetchMoreResult.groups; const { nodes, ...rest } = fetchMoreResult.groups;
const previousNodes = previousResult.groups.nodes; const { nodes: previousNodes } = previousResult.groups;
return { groups: { ...rest, nodes: [...previousNodes, ...nodes] } }; return { groups: { ...rest, nodes: [...previousNodes, ...nodes] } };
}, },
}) })
...@@ -65,9 +69,9 @@ export default { ...@@ -65,9 +69,9 @@ export default {
}; };
</script> </script>
<template> <template>
<gl-loading-icon v-if="isLoading" size="md" class="gl-my-5" /> <gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3">
<gl-alert v-else-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3">
{{ $options.i18n.groupsError }} {{ $options.i18n.groupsError }}
</gl-alert> </gl-alert>
<gl-loading-icon v-else-if="isLoading" size="md" class="gl-my-5" />
<devops-adoption-empty-state v-else-if="isEmpty" /> <devops-adoption-empty-state v-else-if="isEmpty" />
</template> </template>
import { s__ } from '~/locale'; import { s__ } from '~/locale';
export const MAX_REQUEST_COUNT = 10;
export const DEVOPS_ADOPTION_STRINGS = { export const DEVOPS_ADOPTION_STRINGS = {
app: { app: {
groupsError: s__('DevopsAdoption|There was an error fetching Groups'), groupsError: s__('DevopsAdoption|There was an error fetching Groups'),
......
import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import Vue from 'vue';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { createMockClient } from 'mock-apollo-client';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import getGroupsQuery from 'ee/admin/dev_ops_report/graphql/queries/get_groups.query.graphql';
import DevopsAdoptionApp from 'ee/admin/dev_ops_report/components/devops_adoption_app.vue'; import DevopsAdoptionApp from 'ee/admin/dev_ops_report/components/devops_adoption_app.vue';
import DevopsAdoptionEmptyState from 'ee/admin/dev_ops_report/components/devops_adoption_empty_state.vue'; import DevopsAdoptionEmptyState from 'ee/admin/dev_ops_report/components/devops_adoption_empty_state.vue';
import { DEVOPS_ADOPTION_STRINGS } from 'ee/admin/dev_ops_report/constants'; import { DEVOPS_ADOPTION_STRINGS } from 'ee/admin/dev_ops_report/constants';
import { resolvers as devOpsResolvers } from 'ee/admin/dev_ops_report/graphql';
import getGroupsQuery from 'ee/admin/dev_ops_report/graphql/queries/get_groups.query.graphql';
import axios from '~/lib/utils/axios_utils';
import * as Sentry from '~/sentry/wrapper'; import * as Sentry from '~/sentry/wrapper';
import { groupNodes, groupPageInfo } from '../mock_data'; import { groupNodes, nextGroupNode, groupPageInfo } from '../mock_data';
const localVue = createLocalVue(); const localVue = createLocalVue();
Vue.use(VueApollo);
const initialResponse = {
__typename: 'Groups',
nodes: groupNodes,
pageInfo: groupPageInfo,
};
describe('DevopsAdoptionApp', () => { describe('DevopsAdoptionApp', () => {
let wrapper; let wrapper;
let mockAdapter;
const createComponent = (options = {}) => {
const { data = {} } = options;
const mockClient = createMockClient({ function createMockApolloProvider(options = {}) {
resolvers: devOpsResolvers, const { groupsSpy } = options;
const mockApollo = createMockApollo([], {
Query: {
groups: groupsSpy,
},
}); });
mockClient.cache.writeQuery({ // Necessary for local resolvers to be activated
mockApollo.defaultClient.cache.writeQuery({
query: getGroupsQuery, query: getGroupsQuery,
data, data: {},
}); });
const apolloProvider = new VueApollo({ return mockApollo;
defaultClient: mockClient, }
});
function createComponent(options = {}) {
const { mockApollo, data = {} } = options;
return shallowMount(DevopsAdoptionApp, { return shallowMount(DevopsAdoptionApp, {
localVue, localVue,
apolloProvider, apolloProvider: mockApollo,
data() {
return data;
},
}); });
}; }
beforeEach(() => {
mockAdapter = new MockAdapter(axios);
});
afterEach(() => { afterEach(() => {
mockAdapter.restore();
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
}); });
describe('when loading', () => { describe('when loading', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); const mockApollo = createMockApolloProvider();
wrapper = createComponent({ mockApollo });
}); });
it('does not display the empty state', () => { it('does not display the empty state', () => {
...@@ -64,106 +72,204 @@ describe('DevopsAdoptionApp', () => { ...@@ -64,106 +72,204 @@ describe('DevopsAdoptionApp', () => {
}); });
}); });
describe('when no data is present', () => { describe('initial request', () => {
beforeEach(() => { let groupsSpy;
const data = {
groups: {
__typename: 'Groups',
nodes: [],
pageInfo: {},
},
};
wrapper = createComponent({ data });
});
it('displays the empty state', () => { afterEach(() => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(true); groupsSpy = null;
}); });
it('does not display the loader', () => { describe('when no data is present', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); beforeEach(async () => {
}); groupsSpy = jest.fn().mockResolvedValueOnce({ __typename: 'Groups', nodes: [] });
}); const mockApollo = createMockApolloProvider({ groupsSpy });
wrapper = createComponent({ mockApollo });
await waitForPromises();
});
describe('when data is present', () => { it('displays the empty state', () => {
beforeEach(() => { expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(true);
const data = { });
groups: {
__typename: 'Groups',
nodes: groupNodes,
pageInfo: groupPageInfo,
},
};
wrapper = createComponent({ data });
jest.spyOn(wrapper.vm.$apollo.queries.groups, 'fetchMore').mockReturnValue(
new Promise(resolve => {
resolve({
groups: {
__typename: 'Groups',
nodes: [],
pageInfo: {},
},
});
}),
);
});
it('does not display the empty state', () => { it('does not display the loader', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false); expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
}); });
it('does not display the loader', () => { describe('when data is present', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); beforeEach(async () => {
groupsSpy = jest.fn().mockResolvedValueOnce({ ...initialResponse, pageInfo: null });
const mockApollo = createMockApolloProvider({ groupsSpy });
wrapper = createComponent({ mockApollo });
jest.spyOn(wrapper.vm.$apollo.queries.groups, 'fetchMore');
await waitForPromises();
});
it('does not display the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
});
it('does not display the loader', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
it('should fetch data once', () => {
expect(groupsSpy).toHaveBeenCalledTimes(1);
});
it('should not fetch more data', () => {
expect(wrapper.vm.$apollo.queries.groups.fetchMore).not.toHaveBeenCalled();
});
}); });
it('should fetch more data', () => { describe('when error is thrown in the initial request', () => {
expect(wrapper.vm.$apollo.queries.groups.fetchMore).toHaveBeenCalledWith( const error = 'Error: foo!';
expect.objectContaining({
variables: { nextPage: 2 }, beforeEach(async () => {
}), jest.spyOn(Sentry, 'captureException');
); groupsSpy = jest.fn().mockRejectedValueOnce(error);
const mockApollo = createMockApolloProvider({ groupsSpy });
wrapper = createComponent({ mockApollo });
jest.spyOn(wrapper.vm.$apollo.queries.groups, 'fetchMore');
await waitForPromises();
});
it('does not display the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
});
it('does not display the loader', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
it('should fetch data once', () => {
expect(groupsSpy).toHaveBeenCalledTimes(1);
});
it('should not fetch more data', () => {
expect(wrapper.vm.$apollo.queries.groups.fetchMore).not.toHaveBeenCalled();
});
it('displays the error message and calls Sentry', () => {
const alert = wrapper.find(GlAlert);
expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(DEVOPS_ADOPTION_STRINGS.app.groupsError);
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(error);
});
}); });
}); });
describe('when error is thrown', () => { describe('fetchMore request', () => {
const error = 'Error: foo!'; let groupsSpy;
beforeEach(() => { afterEach(() => {
jest.spyOn(Sentry, 'captureException'); groupsSpy = null;
const data = {
groups: {
__typename: 'Groups',
nodes: groupNodes,
pageInfo: groupPageInfo,
},
};
wrapper = createComponent({ data });
jest
.spyOn(wrapper.vm.$apollo.queries.groups, 'fetchMore')
.mockImplementation(jest.fn().mockRejectedValue(error));
}); });
it('does not display the empty state', () => { describe('when data is present', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false); beforeEach(async () => {
}); groupsSpy = jest
.fn()
.mockResolvedValueOnce(initialResponse)
// `fetchMore` response
.mockResolvedValueOnce({ __typename: 'Groups', nodes: [nextGroupNode], nextPage: null });
const mockApollo = createMockApolloProvider({ groupsSpy });
wrapper = createComponent({ mockApollo });
jest.spyOn(wrapper.vm.$apollo.queries.groups, 'fetchMore');
await waitForPromises();
});
it('does not display the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
});
it('does not display the loader', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
it('does not display the loader', () => { it('should fetch data twice', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); expect(groupsSpy).toHaveBeenCalledTimes(2);
});
it('should fetch more data', () => {
expect(wrapper.vm.$apollo.queries.groups.fetchMore).toHaveBeenCalledTimes(1);
expect(wrapper.vm.$apollo.queries.groups.fetchMore).toHaveBeenCalledWith(
expect.objectContaining({
variables: { nextPage: 2 },
}),
);
});
}); });
it('should fetch more data', () => { describe('when fetching too many pages of data', () => {
expect(wrapper.vm.$apollo.queries.groups.fetchMore).toHaveBeenCalledWith( beforeEach(async () => {
expect.objectContaining({ // Always send the same page
variables: { nextPage: 2 }, groupsSpy = jest.fn().mockResolvedValue(initialResponse);
}), const mockApollo = createMockApolloProvider({ groupsSpy });
); wrapper = createComponent({ mockApollo, data: { requestCount: 2 } });
jest.spyOn(wrapper.vm.$apollo.queries.groups, 'fetchMore');
await waitForPromises();
});
it('does not display the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
});
it('does not display the loader', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
it('should fetch data twice', () => {
expect(groupsSpy).toHaveBeenCalledTimes(2);
});
it('should not fetch more than `requestCount`', () => {
expect(wrapper.vm.$apollo.queries.groups.fetchMore).toHaveBeenCalledTimes(1);
});
}); });
it('displays the error message and calls Sentry', () => { describe('when error is thrown in the fetchMore request', () => {
const alert = wrapper.find(GlAlert); const error = 'Error: foo!';
expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(DEVOPS_ADOPTION_STRINGS.app.groupsError); beforeEach(async () => {
expect(Sentry.captureException).toHaveBeenCalledWith(error); jest.spyOn(Sentry, 'captureException');
groupsSpy = jest
.fn()
.mockResolvedValueOnce(initialResponse)
// `fetchMore` response
.mockRejectedValueOnce(error);
const mockApollo = createMockApolloProvider({ groupsSpy });
wrapper = createComponent({ mockApollo });
jest.spyOn(wrapper.vm.$apollo.queries.groups, 'fetchMore');
await waitForPromises();
});
it('does not display the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
});
it('does not display the loader', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
it('should fetch data twice', () => {
expect(groupsSpy).toHaveBeenCalledTimes(2);
});
it('should fetch more data', () => {
expect(wrapper.vm.$apollo.queries.groups.fetchMore).toHaveBeenCalledWith(
expect.objectContaining({
variables: { nextPage: 2 },
}),
);
});
it('displays the error message and calls Sentry', () => {
const alert = wrapper.find(GlAlert);
expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(DEVOPS_ADOPTION_STRINGS.app.groupsError);
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(error);
});
}); });
}); });
}); });
...@@ -17,6 +17,12 @@ export const groupNodes = [ ...@@ -17,6 +17,12 @@ export const groupNodes = [
}, },
]; ];
export const nextGroupNode = {
__typename: 'Group',
full_name: 'Baz',
id: 'baz',
};
export const groupPageInfo = { export const groupPageInfo = {
nextPage: 2, nextPage: 2,
}; };
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment