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
```javascript
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:
......@@ -849,14 +849,14 @@ query fetchLocalUser {
```javascript
import fetchLocalUserQuery from '~/design_management/graphql/queries/fetch_local_user.query.graphql';
function createComponentWithApollo() {
function createMockApolloProvider() {
const requestHandlers = [
[getDesignListQuery, jest.fn().mockResolvedValue(designListQueryResponse)],
[permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
];
fakeApollo = createMockApollo(requestHandlers, {});
fakeApollo.clients.defaultClient.cache.writeQuery({
mockApollo = createMockApollo(requestHandlers, {});
mockApollo.clients.defaultClient.cache.writeQuery({
query: fetchLocalUserQuery,
data: {
fetchLocalUser: {
......@@ -864,15 +864,107 @@ function createComponentWithApollo() {
name: 'Test',
},
},
})
});
wrapper = shallowMount(Index, {
return mockApollo;
}
function createComponent(options = {}) {
const { mockApollo } = options;
return shallowMount(Index, {
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
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';
import * as Sentry from '~/sentry/wrapper';
import getGroupsQuery from '../graphql/queries/get_groups.query.graphql';
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 {
name: 'DevopsAdoptionApp',
......@@ -17,6 +17,7 @@ export default {
},
data() {
return {
requestCount: MAX_REQUEST_COUNT,
loadingError: false,
};
},
......@@ -25,7 +26,9 @@ export default {
query: getGroupsQuery,
loadingKey: 'loading',
result() {
if (this.groups?.pageInfo?.nextPage) {
this.requestCount -= 1;
if (this.requestCount > 0 && this.groups?.pageInfo?.nextPage) {
this.fetchNextPage();
}
},
......@@ -55,7 +58,8 @@ export default {
},
updateQuery: (previousResult, { fetchMoreResult }) => {
const { nodes, ...rest } = fetchMoreResult.groups;
const previousNodes = previousResult.groups.nodes;
const { nodes: previousNodes } = previousResult.groups;
return { groups: { ...rest, nodes: [...previousNodes, ...nodes] } };
},
})
......@@ -65,9 +69,9 @@ export default {
};
</script>
<template>
<gl-loading-icon v-if="isLoading" size="md" class="gl-my-5" />
<gl-alert v-else-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3">
<gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3">
{{ $options.i18n.groupsError }}
</gl-alert>
<gl-loading-icon v-else-if="isLoading" size="md" class="gl-my-5" />
<devops-adoption-empty-state v-else-if="isEmpty" />
</template>
import { s__ } from '~/locale';
export const MAX_REQUEST_COUNT = 10;
export const DEVOPS_ADOPTION_STRINGS = {
app: {
groupsError: s__('DevopsAdoption|There was an error fetching Groups'),
......
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { createMockClient } from 'mock-apollo-client';
import Vue from 'vue';
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 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 { 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 { groupNodes, groupPageInfo } from '../mock_data';
import { groupNodes, nextGroupNode, groupPageInfo } from '../mock_data';
const localVue = createLocalVue();
Vue.use(VueApollo);
const initialResponse = {
__typename: 'Groups',
nodes: groupNodes,
pageInfo: groupPageInfo,
};
describe('DevopsAdoptionApp', () => {
let wrapper;
let mockAdapter;
const createComponent = (options = {}) => {
const { data = {} } = options;
const mockClient = createMockClient({
resolvers: devOpsResolvers,
function createMockApolloProvider(options = {}) {
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,
data,
data: {},
});
const apolloProvider = new VueApollo({
defaultClient: mockClient,
});
return mockApollo;
}
function createComponent(options = {}) {
const { mockApollo, data = {} } = options;
return shallowMount(DevopsAdoptionApp, {
localVue,
apolloProvider,
apolloProvider: mockApollo,
data() {
return data;
},
});
};
beforeEach(() => {
mockAdapter = new MockAdapter(axios);
});
}
afterEach(() => {
mockAdapter.restore();
wrapper.destroy();
wrapper = null;
});
describe('when loading', () => {
beforeEach(() => {
wrapper = createComponent();
const mockApollo = createMockApolloProvider();
wrapper = createComponent({ mockApollo });
});
it('does not display the empty state', () => {
......@@ -64,106 +72,204 @@ describe('DevopsAdoptionApp', () => {
});
});
describe('when no data is present', () => {
beforeEach(() => {
const data = {
groups: {
__typename: 'Groups',
nodes: [],
pageInfo: {},
},
};
wrapper = createComponent({ data });
});
describe('initial request', () => {
let groupsSpy;
it('displays the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(true);
afterEach(() => {
groupsSpy = null;
});
it('does not display the loader', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
});
describe('when no data is present', () => {
beforeEach(async () => {
groupsSpy = jest.fn().mockResolvedValueOnce({ __typename: 'Groups', nodes: [] });
const mockApollo = createMockApolloProvider({ groupsSpy });
wrapper = createComponent({ mockApollo });
await waitForPromises();
});
describe('when data is present', () => {
beforeEach(() => {
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('displays the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(true);
});
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', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
describe('when data is present', () => {
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', () => {
expect(wrapper.vm.$apollo.queries.groups.fetchMore).toHaveBeenCalledWith(
expect.objectContaining({
variables: { nextPage: 2 },
}),
);
describe('when error is thrown in the initial request', () => {
const error = 'Error: foo!';
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', () => {
const error = 'Error: foo!';
describe('fetchMore request', () => {
let groupsSpy;
beforeEach(() => {
jest.spyOn(Sentry, 'captureException');
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));
afterEach(() => {
groupsSpy = null;
});
it('does not display the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
});
describe('when data is present', () => {
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', () => {
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).toHaveBeenCalledTimes(1);
expect(wrapper.vm.$apollo.queries.groups.fetchMore).toHaveBeenCalledWith(
expect.objectContaining({
variables: { nextPage: 2 },
}),
);
});
});
it('should fetch more data', () => {
expect(wrapper.vm.$apollo.queries.groups.fetchMore).toHaveBeenCalledWith(
expect.objectContaining({
variables: { nextPage: 2 },
}),
);
describe('when fetching too many pages of data', () => {
beforeEach(async () => {
// Always send the same page
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', () => {
const alert = wrapper.find(GlAlert);
expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(DEVOPS_ADOPTION_STRINGS.app.groupsError);
expect(Sentry.captureException).toHaveBeenCalledWith(error);
describe('when error is thrown in the fetchMore request', () => {
const error = 'Error: foo!';
beforeEach(async () => {
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 = [
},
];
export const nextGroupNode = {
__typename: 'Group',
full_name: 'Baz',
id: 'baz',
};
export const groupPageInfo = {
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