52 KiB
type | stage | group | info |
---|---|---|---|
reference, dev | none | Development | See the Technical Writers assigned to Development Guidelines: https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments-to-development-guidelines |
GraphQL
Getting Started
Helpful Resources
General resources:
GraphQL at GitLab:
- GitLab Unfiltered GraphQL playlist
- GraphQL at GitLab: Deep Dive (video) by Nick Thomas
- An overview of the history of GraphQL at GitLab (not frontend-specific)
- GitLab Feature Walkthrough with GraphQL and Vue Apollo (video) by Natalia Tepluhina
- A real-life example of implementing a frontend feature in GitLab using GraphQL
- History of client-side GraphQL at GitLab (video) Illya Klymov and Natalia Tepluhina
- From Vuex to Apollo (video) by Natalia Tepluhina
- An overview of when Apollo might be a better choice than Vuex, and how one could go about the transition
- 🛠 Vuex -> Apollo Migration: a proof-of-concept project
- A collection of examples that show the possible approaches for state management with Vue+GraphQL+(Vuex or Apollo) apps
Libraries
We use Apollo (specifically Apollo Client) and Vue Apollo when using GraphQL for frontend development.
If you are using GraphQL in a Vue application, the Usage in Vue section can help you learn how to integrate Vue Apollo.
For other use cases, check out the Usage outside of Vue section.
We use Immer for immutable cache updates; see Immutability and cache updates for more information.
Tooling
Apollo GraphQL VS Code extension
If you use VS Code, the Apollo GraphQL extension supports autocompletion in .graphql
files. To set up
the GraphQL extension, follow these steps:
-
Generate the schema:
bundle exec rake gitlab:graphql:schema:dump
-
Add an
apollo.config.js
file to the root of yourgitlab
local directory. -
Populate the file with the following content:
module.exports = { client: { includes: ['./app/assets/javascripts/**/*.graphql', './ee/app/assets/javascripts/**/*.graphql'], service: { name: 'GitLab', localSchemaFile: './tmp/tests/graphql/gitlab_schema.graphql', }, }, };
-
Restart VS Code.
Exploring the GraphQL API
Our GraphQL API can be explored via GraphiQL at your instance's
/-/graphql-explorer
or at GitLab.com. Consult the
GitLab GraphQL API Reference documentation
where needed.
You can check all existing queries and mutations on the right side of GraphiQL in its Documentation explorer. You can also write queries and mutations directly on the left tab and check their execution by clicking Execute query button on the top left:
Apollo Client
To save duplicated clients getting created in different apps, we have a default client that should be used. This sets up the Apollo client with the correct URL and also sets the CSRF headers.
Default client accepts two parameters: resolvers
and config
.
resolvers
parameter is created to accept an object of resolvers for local state management queries and mutationsconfig
parameter takes an object of configuration settings:cacheConfig
field accepts an optional object of settings to customize Apollo cachebaseUrl
allows us to pass a URL for GraphQL endpoint different from our main endpoint (for example,${gon.relative_url_root}/api/graphql
)assumeImmutableResults
(set tofalse
by default) - this setting, when set totrue
, assumes that every single operation on updating Apollo Cache is immutable. It also setsfreezeResults
totrue
, so any attempt on mutating Apollo Cache throws a console warning in development environment. Please ensure you're following the immutability pattern on cache update operations before setting this option totrue
.fetchPolicy
determines how you want your component to interact with the Apollo cache. Defaults to "cache-first".
Multiple client queries for the same object
If you are make multiple queries to the same Apollo client object you might encounter the following error: "Store error: the application attempted to write an object with no provided ID but the store already contains an ID of SomeEntity". This error only should occur when you have made a query with an ID field for a portion, then made another that returns what would be the same object, but is missing the ID field.
Please note this is being tracked in this issue and the documentation will be updated when this issue is resolved.
GraphQL Queries
To save query compilation at runtime, webpack can directly import .graphql
files. This allows webpack to pre-process the query at compile time instead
of the client doing compilation of queries.
To distinguish queries from mutations and fragments, the following naming convention is recommended:
all_users.query.graphql
for queries;add_user.mutation.graphql
for mutations;basic_user.fragment.graphql
for fragments.
If you are using queries for the CustomersDot GraphQL endpoint, end the filename with .customer.query.graphql
, .customer.mutation.graphql
, or .customer.fragment.graphql
.
Fragments
Fragments are a way to make your complex GraphQL queries more readable and re-usable. Here is an example of GraphQL fragment:
fragment DesignListItem on Design {
id
image
event
filename
notesCount
}
Fragments can be stored in separate files, imported and used in queries, mutations, or other fragments.
#import "./design_list.fragment.graphql"
#import "./diff_refs.fragment.graphql"
fragment DesignItem on Design {
...DesignListItem
fullPath
diffRefs {
...DesignDiffRefs
}
}
More about fragments: GraphQL documentation
Global IDs
The GitLab GraphQL API expresses id
fields as Global IDs rather than the PostgreSQL
primary key id
. Global ID is a convention
used for caching and fetching in client-side libraries.
To convert a Global ID to the primary key id
, you can use getIdFromGraphQLId
:
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
const primaryKeyId = getIdFromGraphQLId(data.id);
Immutability and cache updates
From Apollo version 3.0.0 all the cache updates need to be immutable. It needs to be replaced entirely with a new and updated object.
To facilitate the process of updating the cache and returning the new object we use the library Immer. When possible, follow these conventions:
- The updated cache is named
data
. - The original cache data is named
sourceData
.
A typical update process looks like this:
...
const sourceData = client.readQuery({ query });
const data = produce(sourceData, draftState => {
draftState.commits.push(newCommit);
});
client.writeQuery({
query,
data,
});
...
As shown in the code example by using produce
, we can perform any kind of direct manipulation of the
draftState
. Besides, immer
guarantees that a new state which includes the changes to draftState
is generated.
Finally, to verify whether the immutable cache update is working properly, we need to change
assumeImmutableResults
to true
in the default client configuration. See Apollo Client for more information.
If everything is working properly assumeImmutableResults
should remain set to true
.
Usage in Vue
To use Vue Apollo, import the Vue Apollo plugin as well as the default client. This should be created at the same point the Vue application is mounted.
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
new Vue({
...,
apolloProvider,
...
});
Read more about Vue Apollo in the Vue Apollo documentation.
Local state with Apollo
It is possible to manage an application state with Apollo by passing
in a resolvers object when creating the default client. The default state can be set by writing
to the cache after setting up the default client. In the example below, we are using query with @client
Apollo directive to write the initial data to Apollo cache and then get this state in the Vue component:
// user.query.graphql
query User {
user @client {
name
surname
age
}
}
// index.js
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import userQuery from '~/user/user.query.graphql'
Vue.use(VueApollo);
const defaultClient = createDefaultClient();
defaultClient.cache.writeQuery({
query: userQuery,
data: {
user: {
name: 'John',
surname: 'Doe',
age: 30
},
},
});
const apolloProvider = new VueApollo({
defaultClient,
});
// App.vue
import userQuery from '~/user/user.query.graphql'
export default {
apollo: {
user: {
query: userQuery
}
}
}
Along with creating local data, we can also extend existing GraphQL types with @client
fields. This is extremely helpful when we need to mock an API response for fields not yet added to our GraphQL API.
Mocking API response with local Apollo cache
Using local Apollo Cache is helpful when we have a need to mock some GraphQL API responses, queries, or mutations locally (such as when they're still not added to our actual API).
For example, we have a fragment on DesignVersion
used in our queries:
fragment VersionListItem on DesignVersion {
id
sha
}
We also need to fetch the version author and the created at
property to display in the versions dropdown. But, these changes are still not implemented in our API. We can change the existing fragment to get a mocked response for these new fields:
fragment VersionListItem on DesignVersion {
id
sha
author @client {
avatarUrl
name
}
createdAt @client
}
Now Apollo tries to find a resolver for every field marked with @client
directive. Let's create a resolver for DesignVersion
type (why DesignVersion
? because our fragment was created on this type).
// resolvers.js
const resolvers = {
DesignVersion: {
author: () => ({
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
name: 'Administrator',
__typename: 'User',
}),
createdAt: () => '2019-11-13T16:08:11Z',
},
};
export default resolvers;
We need to pass a resolvers object to our existing Apollo Client:
// graphql.js
import createDefaultClient from '~/lib/graphql';
import resolvers from './graphql/resolvers';
const defaultClient = createDefaultClient(resolvers);
For each attempt to fetch a version, our client fetches id
and sha
from the remote API endpoint. It then assigns our hardcoded values to the author
and createdAt
version properties. With this data, frontend developers are able to work on their UI without being blocked by backend. When the response is added to the API, our custom local resolver can be removed. The only change to the query/fragment is to remove the @client
directive.
Read more about local state management with Apollo in the Vue Apollo documentation.
Using with Vuex
When the Apollo Client is used in Vuex and fetched data is stored in the Vuex store, the Apollo Client cache does not need to be enabled. Otherwise we would have data from the API stored in two places - Vuex store and Apollo Client cache. With Apollo's default settings, a subsequent fetch from the GraphQL API could result in fetching data from Apollo cache (in the case where we have the same query and variables). To prevent this behavior, we need to disable Apollo Client cache by passing a valid fetchPolicy
option to its constructor:
import fetchPolicies from '~/graphql_shared/fetch_policy_constants';
export const gqClient = createGqClient(
{},
{
fetchPolicy: fetchPolicies.NO_CACHE,
},
);
Working on GraphQL-based features when frontend and backend are not in sync
Any feature that requires GraphQL queries/mutations to be created or updated should be carefully planned. Frontend and backend counterparts should agree on a schema that satisfies both client-side and server-side requirements. This enables both departments to start implementing their parts without blocking each other.
Ideally, the backend implementation should be done prior to the frontend so that the client can immediately start querying the API with minimal back and forth between departments. However, we recognize that priorities don't always align. For the sake of iteration and delivering work we're committed to, it might be necessary for the frontend to be implemented ahead of the backend.
Implementing frontend queries and mutations ahead of the backend
In such case, the frontend will define GraphQL schemas or fields that do not correspond to any
backend resolver yet. This is fine as long as the implementation is properly feature-flagged so it
does not translate to public-facing errors in the product. However, we do validate client-side
queries/mutations against the backend GraphQL schema with the graphql-verify
CI job.
You must confirm your changes pass the validation if they are to be merged before the
backend actually supports them. Below are a few suggestions to go about this.
Using the @client
directive
The preferred approach is to use the @client
directive on any new query, mutation, or field that
isn't yet supported by the backend. Any entity with the directive is skipped by the
graphql-verify
validation job.
Additionally Apollo will attempt to resolve them client-side, which can be used in conjunction with Mocking API response with local Apollo cache. This provides a convenient way of testing your feature with fake data defined client-side. When opening a merge request for your changes, it can be a good idea to provide local resolvers as a patch that reviewers can apply in their GDK to easily smoke-test your work.
Make sure to track the removal of the directive in a follow-up issue, or as part of the backend implementation plan.
Adding an exception to the list of known failures
GraphQL queries/mutations validation can be completely turned off for specific files by adding their
paths to the
config/known_invalid_graphql_queries.yml
file, much like you would disable ESLint for some files via an .eslintignore
file.
Bear in mind that any file listed in here will not be validated at all. So if you're only adding
fields to an existing query, use the @client
directive approach so that the rest of the query
is still validated.
Again, make sure that those overrides are as short-lived as possible by tracking their removal in the appropriate issue.
Feature flags in queries
Sometimes it may be helpful to have an entity in the GraphQL query behind a feature flag. One example is working on a feature where the backend has already been merged but the frontend has not. In this case, you may consider putting the GraphQL entity behind a feature flag to allow smaller merge requests to be created and merged.
To do this we can use the @include
directive to exclude an entity if the if
statement passes.
query getAuthorData($authorNameEnabled: Boolean = false) {
username
name @include(if: $authorNameEnabled)
}
Then in the Vue (or JavaScript) call to the query we can pass in our feature flag. This feature flag needs to be already set up correctly. See the feature flag documentation for the correct way to do this.
export default {
apollo: {
user: {
query: QUERY_IMPORT,
variables() {
return {
authorNameEnabled: gon?.features?.authorNameEnabled,
};
},
}
},
};
Manually triggering queries
Queries on a component's apollo
property are made automatically when the component is created.
Some components instead want the network request made on-demand, for example a dropdown with lazy-loaded items.
There are two ways to do this:
- Use the
skip
property
export default {
apollo: {
user: {
query: QUERY_IMPORT,
skip() {
// only make the query when dropdown is open
return !this.isOpen;
},
}
},
};
- Using
addSmartQuery
You can manually create the Smart Query in your method.
handleClick() {
this.$apollo.addSmartQuery('user', {
// this takes the same values as you'd have in the `apollo` section
query: QUERY_IMPORT,
}),
};
Working with pagination
The GitLab GraphQL API uses Relay-style cursor pagination for connection types. This means a "cursor" is used to keep track of where in the data set the next items should be fetched from. GraphQL Ruby Connection Concepts is a good overview and introduction to connections.
Every connection type (for example, DesignConnection
and DiscussionConnection
) has a field pageInfo
that contains an information required for pagination:
pageInfo {
endCursor
hasNextPage
hasPreviousPage
startCursor
}
Here:
startCursor
andendCursor
display the cursor of the first and last items respectively.hasPreviousPage
andhasNextPage
allow us to check if there are more pages available before or after the current page.
When we fetch data with a connection type, we can pass cursor as after
or before
parameter, indicating a starting or ending point of our pagination. They should be
followed with first
or last
parameter respectively to indicate how many items
we want to fetch after or before a given endpoint.
For example, here we're fetching 10 designs after a cursor (let us call this projectQuery
):
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query {
project(fullPath: "root/my-project") {
id
issue(iid: "42") {
designCollection {
designs(atVersion: null, after: "Ihwffmde0i", first: 10) {
edges {
node {
id
}
}
pageInfo {
...PageInfo
}
}
}
}
}
}
Note that we are using the pageInfo.fragment.graphql
to populate the pageInfo
information.
Using fetchMore
method in components
This approach makes sense to use with user-handled pagination. For example, when the scrolling to fetch more data or explicitly clicking a Next Page button. When we need to fetch all the data initially, it is recommended to use a (non-smart) query, instead.
When making an initial fetch, we usually want to start a pagination from the beginning. In this case, we can either:
- Skip passing a cursor.
- Pass
null
explicitly toafter
.
After data is fetched, we can use the update
-hook as an opportunity to customize
the data that is set in the Vue component property. This allows us to get a hold of the pageInfo
object among other data.
In the result
-hook, we can inspect the pageInfo
object to see if we need to fetch
the next page. Note that we also keep a requestCount
to ensure that the application
does not keep requesting the next page, indefinitely:
data() {
return {
pageInfo: null,
requestCount: 0,
}
},
apollo: {
designs: {
query: projectQuery,
variables() {
return {
// ... The rest of the design variables
first: 10,
};
},
update(data) {
const { id = null, issue = {} } = data.project || {};
const { edges = [], pageInfo } = issue.designCollection?.designs || {};
return {
id,
edges,
pageInfo,
};
},
result() {
const { pageInfo } = this.designs;
// Increment the request count with each new result
this.requestCount += 1;
// Only fetch next page if we have more requests and there is a next page to fetch
if (this.requestCount < MAX_REQUEST_COUNT && pageInfo?.hasNextPage) {
this.fetchNextPage(pageInfo.endCursor);
}
},
},
},
When we want to move to the next page, we use an Apollo fetchMore
method, passing a
new cursor (and, optionally, new variables) there. In the updateQuery
hook, we have
to return a result we want to see in the Apollo cache after fetching the next page.
Immer
s produce
-function can help us with the immutability here:
fetchNextPage(endCursor) {
this.$apollo.queries.designs.fetchMore({
variables: {
// ... The rest of the design variables
first: 10,
after: endCursor,
},
updateQuery(previousResult, { fetchMoreResult }) {
// Here we can implement the logic of adding new designs to existing ones
// (for example, if we use infinite scroll) or replacing old result
// with the new one if we use numbered pages
const { designs: previousDesigns } = previousResult.project.issue.designCollection;
const { designs: newDesigns } = fetchMoreResult.project.issue.designCollection
return produce(previousResult, draftData => {
// `produce` gives us a working copy, `draftData`, that we can modify
// as we please and from it will produce the next immutable result for us
draftData.project.issue.designCollection.designs = [...previousDesigns, ...newDesigns];
});
},
});
}
Using a recursive query in components
When it is necessary to fetch all paginated data initially an Apollo query can do the trick for us.
If we need to fetch the next page based on user interactions, it is recommend to use a smartQuery
along with the fetchMore
-hook.
When the query resolves we can update the component data and inspect the pageInfo
object. This allows us
to see if we need to fetch the next page, calling the method recursively.
Note that we also keep a requestCount
to ensure that the application does not keep
requesting the next page, indefinitely.
data() {
return {
requestCount: 0,
isLoading: false,
designs: {
edges: [],
pageInfo: null,
},
}
},
created() {
this.fetchDesigns();
},
methods: {
handleError(error) {
this.isLoading = false;
// Do something with `error`
},
fetchDesigns(endCursor) {
this.isLoading = true;
return this.$apollo
.query({
query: projectQuery,
variables() {
return {
// ... The rest of the design variables
first: 10,
endCursor,
};
},
})
.then(({ data }) => {
const { id = null, issue = {} } = data.project || {};
const { edges = [], pageInfo } = issue.designCollection?.designs || {};
// Update data
this.designs = {
id,
edges: [...this.designs.edges, ...edges];
pageInfo: pageInfo;
};
// Increment the request count with each new result
this.requestCount += 1;
// Only fetch next page if we have more requests and there is a next page to fetch
if (this.requestCount < MAX_REQUEST_COUNT && pageInfo?.hasNextPage) {
this.fetchDesigns(pageInfo.endCursor);
} else {
this.isLoading = false;
}
})
.catch(this.handleError);
},
},
Pagination and optimistic updates
When Apollo caches paginated data client-side, it includes pageInfo
variables in the cache key.
If you wanted to optimistically update that data, you'd have to provide pageInfo
variables
when interacting with the cache via .readQuery()
or .writeQuery()
.
This can be tedious and counter-intuitive.
To make it easier to deal with cached paginated queries, Apollo provides the @connection
directive.
The directive accepts a key
parameter that is used as a static key when caching the data.
You'd then be able to retrieve the data without providing any pagination-specific variables.
Here's an example of a query using the @connection
directive:
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query DastSiteProfiles($fullPath: ID!, $after: String, $before: String, $first: Int, $last: Int) {
project(fullPath: $fullPath) {
siteProfiles: dastSiteProfiles(after: $after, before: $before, first: $first, last: $last)
@connection(key: "dastSiteProfiles") {
pageInfo {
...PageInfo
}
edges {
cursor
node {
id
# ...
}
}
}
}
}
In this example, Apollo stores the data with the stable dastSiteProfiles
cache key.
To retrieve that data from the cache, you'd then only need to provide the $fullPath
variable,
omitting pagination-specific variables like after
or before
:
const data = store.readQuery({
query: dastSiteProfilesQuery,
variables: {
fullPath: 'namespace/project',
},
});
Read more about the @connection
directive in Apollo's documentation.
Managing performance
The Apollo client batches queries by default. Given 3 deferred queries, Apollo groups them into one request, sends the single request to the server, and responds after all 3 queries have completed.
If you need to have queries sent as individual requests, additional context can be provided to tell Apollo to do this.
export default {
apollo: {
user: {
query: QUERY_IMPORT,
context: {
isSingleRequest: true,
}
}
},
};
Polling and Performance
While the Apollo client has support for simple polling, for performance reasons, our Etag-based caching is preferred to hitting the database each time.
Once the backend is set up, there are a few changes to make on the frontend.
First, get your resource ETag path from the backend. In the example of the pipelines graph, this is called the graphql_resource_etag
. This will be used to create new headers to add to the Apollo context:
/* pipelines/components/graph/utils.js */
/* eslint-disable @gitlab/require-i18n-strings */
const getQueryHeaders = (etagResource) => {
return {
fetchOptions: {
method: 'GET',
},
headers: {
/* This will depend on your feature */
'X-GITLAB-GRAPHQL-FEATURE-CORRELATION': 'verify/ci/pipeline-graph',
'X-GITLAB-GRAPHQL-RESOURCE-ETAG': etagResource,
'X-REQUESTED-WITH': 'XMLHttpRequest',
},
};
};
/* eslint-enable @gitlab/require-i18n-strings */
/* component.vue */
apollo: {
pipeline: {
context() {
return getQueryHeaders(this.graphqlResourceEtag);
},
query: getPipelineDetails,
pollInterval: 10000,
..
},
},
Then, because ETags depend on the request being a GET
instead of GraphQL's usual POST
, but our default link library does not support GET
we need to let our default Apollo client know to use a different library.
/* componentMountIndex.js */
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
useGet: true,
},
),
});
Keep in mind, this means your app will not batch queries.
Once subscriptions are mature, this process can be replaced by using them and we can remove the separate link library and return to batching queries.
Testing
Generating the GraphQL schema
Some of our tests load the schema JSON files. To generate these files, run:
bundle exec rake gitlab:graphql:schema:dump
You should run this task after pulling from upstream, or when rebasing your
branch. This is run automatically as part of gdk update
.
NOTE:
If you use the RubyMine IDE, and have marked the tmp
directory as
"Excluded", you should "Mark Directory As -> Not Excluded" for
gitlab/tmp/tests/graphql
. This will allow the JS GraphQL plugin to
automatically find and index the schema.
Mocking response as component data
With Vue Test Utils one can quickly test components that
fetch GraphQL queries. The simplest way is to use shallowMount
and then set
the data on the component:
it('tests apollo component', () => {
const vm = shallowMount(App);
vm.setData({
...mockData
});
});
Testing loading state
To test how a component renders when results from the GraphQL API are still loading, mock a loading state into respective Apollo queries/mutations:
function createComponent({
loading = false,
} = {}) {
const $apollo = {
queries: {
designs: {
loading,
},
},
};
wrapper = shallowMount(Index, {
sync: false,
mocks: { $apollo }
});
}
it('renders loading icon', () => {
createComponent({ loading: true });
expect(wrapper.element).toMatchSnapshot();
})
Testing Apollo components
If we use ApolloQuery
or ApolloMutation
in our components, in order to test their functionality we need to add a stub first:
import { ApolloMutation } from 'vue-apollo';
function createComponent(props = {}) {
wrapper = shallowMount(MyComponent, {
sync: false,
propsData: {
...props,
},
stubs: {
ApolloMutation,
},
});
}
ApolloMutation
component exposes mutate
method via scoped slot. If we want to test this method, we need to add it to mocks:
const mutate = jest.fn().mockResolvedValue();
const $apollo = {
mutate,
};
function createComponent(props = {}) {
wrapper = shallowMount(MyComponent, {
sync: false,
propsData: {
...props,
},
stubs: {
ApolloMutation,
},
mocks: {
$apollo,
}
});
}
Then we can check if mutate
is called with correct variables:
const mutationVariables = {
mutation: createNoteMutation,
update: expect.anything(),
variables: {
input: {
noteableId: 'noteable-id',
body: 'test',
discussionId: '0',
},
},
};
it('calls mutation on submitting form ', () => {
createComponent()
findReplyForm().vm.$emit('submitForm');
expect(mutate).toHaveBeenCalledWith(mutationVariables);
});
Testing with mocked Apollo Client
To test the logic of Apollo cache updates, we might want to mock an Apollo Client in our unit tests. We use mock-apollo-client
library to mock Apollo client and createMockApollo
helper we created on top of it.
To separate tests with mocked client from 'usual' unit tests, create an additional factory and pass the created mockApollo
as an option to the createComponent
-factory. This way we only create Apollo Client instance when it's necessary.
We need to inject VueApollo
to the Vue local instance and, likewise, it is recommended to call localVue.use()
in createMockApolloProvider()
to only load it when it is necessary.
import VueApollo from 'vue-apollo';
import { createLocalVue } from '@vue/test-utils';
const localVue = createLocalVue();
function createMockApolloProvider() {
localVue.use(VueApollo);
return createMockApollo(requestHandlers);
}
function createComponent(options = {}) {
const { mockApollo } = options;
...
return shallowMount(..., {
localVue,
apolloProvider: mockApollo,
...
});
}
After this, you can control whether you need a variable for mockApollo
and assign it in the appropriate describe
-scope:
describe('Some component', () => {
let wrapper;
describe('with Apollo mock', () => {
let mockApollo;
beforeEach(() => {
mockApollo = createMockApolloProvider();
wrapper = createComponent({ mockApollo });
});
});
});
In the createMockApolloProvider
-factory, we need to define an array of handlers for every query or mutation:
import getDesignListQuery from '~/design_management/graphql/queries/get_design_list.query.graphql';
import permissionsQuery from '~/design_management/graphql/queries/design_permissions.query.graphql';
import moveDesignMutation from '~/design_management/graphql/mutations/move_design.mutation.graphql';
describe('Some component with Apollo mock', () => {
let wrapper;
let mockApollo;
function createMockApolloProvider() {
Vue.use(VueApollo);
const requestHandlers = [
[getDesignListQuery, jest.fn().mockResolvedValue(designListQueryResponse)],
[permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
];
...
}
})
After this, we need to create a mock Apollo Client instance using a helper:
import createMockApollo from 'helpers/mock_apollo_helper';
describe('Some component', () => {
let wrapper;
function createMockApolloProvider() {
Vue.use(VueApollo);
const requestHandlers = [
[getDesignListQuery, jest.fn().mockResolvedValue(designListQueryResponse)],
[permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
];
return createMockApollo(requestHandlers);
}
function createComponent(options = {}) {
const { mockApollo } = options;
return shallowMount(Index, {
localVue,
apolloProvider: mockApollo,
});
}
describe('with Apollo mock', () => {
let mockApollo;
beforeEach(() => {
mockApollo = createMockApolloProvider();
wrapper = createComponent({ mockApollo });
});
});
});
When mocking resolved values, ensure the structure of the response is the same
as the actual API response. For example, root property should be data
.
When testing queries, please keep in mind they are promises, so they need to be resolved to render a result. Without resolving, we can check the loading
state of the query:
it('renders a loading state', () => {
const mockApollo = createMockApolloProvider();
const wrapper = createComponent({ mockApollo });
expect(wrapper.findComponent(LoadingSpinner).exists()).toBe(true)
});
it('renders designs list', async () => {
const mockApollo = createMockApolloProvider();
const wrapper = createComponent({ mockApollo });
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
expect(findDesigns()).toHaveLength(3);
});
If we need to test a query error, we need to mock a rejected value as request handler:
function createMockApolloProvider() {
...
const requestHandlers = [
[getDesignListQuery, jest.fn().mockRejectedValue(new Error('GraphQL error')],
];
...
}
...
it('renders error if query fails', async () => {
const wrapper = createComponent();
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
expect(wrapper.find('.test-error').exists()).toBe(true)
})
Request handlers can also be passed to component factory as a parameter.
Mutations could be tested the same way with a few additional nextTick
s to get the updated result:
function createMockApolloProvider({
moveHandler = jest.fn().mockResolvedValue(moveDesignMutationResponse),
}) {
Vue.use(VueApollo);
moveDesignHandler = moveHandler;
const requestHandlers = [
[getDesignListQuery, jest.fn().mockResolvedValue(designListQueryResponse)],
[permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
[moveDesignMutation, moveDesignHandler],
];
return createMockApollo(requestHandlers);
}
function createComponent(options = {}) {
const { mockApollo } = options;
return shallowMount(Index, {
localVue,
apolloProvider: mockApollo,
});
}
...
it('calls a mutation with correct parameters and reorders designs', async () => {
const mockApollo = createMockApolloProvider({});
const wrapper = createComponent({ mockApollo });
wrapper.find(VueDraggable).vm.$emit('change', {
moved: {
newIndex: 0,
element: designToMove,
},
});
expect(moveDesignHandler).toHaveBeenCalled();
await wrapper.vm.$nextTick();
expect(
findDesigns()
.at(0)
.props('id'),
).toBe('2');
});
To mock multiple query response states, success and failure, Apollo Client's native retry behavior can combine with Jest's mock functions to create a series of responses. These do not need to be advanced manually, but they do need to be awaited in specific fashion.
describe('when query times out', () => {
const advanceApolloTimers = async () => {
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
};
beforeEach(async () => {
const failSucceedFail = jest
.fn()
.mockResolvedValueOnce({ errors: [{ message: 'timeout' }] })
.mockResolvedValueOnce(mockPipelineResponse)
.mockResolvedValueOnce({ errors: [{ message: 'timeout' }] });
createComponentWithApollo(failSucceedFail);
await wrapper.vm.$nextTick();
});
it('shows correct errors and does not overwrite populated data when data is empty', async () => {
/* fails at first, shows error, no data yet */
expect(getAlert().exists()).toBe(true);
expect(getGraph().exists()).toBe(false);
/* succeeds, clears error, shows graph */
await advanceApolloTimers();
expect(getAlert().exists()).toBe(false);
expect(getGraph().exists()).toBe(true);
/* fails again, alert retuns but data persists */
await advanceApolloTimers();
expect(getAlert().exists()).toBe(true);
expect(getGraph().exists()).toBe(true);
});
});
Testing @client
queries
Using mock resolvers
If your application contains @client
queries, you get
the following Apollo Client warning when passing only handlers:
Unexpected call of console.warn() with:
Warning: mock-apollo-client - The query is entirely client-side (using @client directives) and resolvers have been configured. The request handler will not be called.
To fix this you should define mock resolvers
instead of
mock handlers
. For example, given the following @client
query:
query getBlobContent($path: String, $ref: String!) {
blobContent(path: $path, ref: $ref) @client {
rawData
}
}
And its actual client-side resolvers:
import Api from '~/api';
export const resolvers = {
Query: {
blobContent(_, { path, ref }) {
return {
__typename: 'BlobContent',
rawData: Api.getRawFile(path, { ref }).then(({ data }) => {
return data;
}),
};
},
},
};
export default resolvers;
We can use a mock resolver that returns data with the same shape, while mock the result with a mock function:
let mockApollo;
let mockBlobContentData; // mock function, jest.fn();
const mockResolvers = {
Query: {
blobContent() {
return {
__typename: 'BlobContent',
rawData: mockBlobContentData(), // the mock function can resolve mock data
};
},
},
};
const createComponentWithApollo = ({ props = {} } = {}) => {
mockApollo = createMockApollo([], mockResolvers); // resolvers are the second parameter
wrapper = shallowMount(MyComponent, {
localVue,
propsData: {},
apolloProvider: mockApollo,
// ...
})
};
After which, you can resolve or reject the value needed.
beforeEach(() => {
mockBlobContentData = jest.fn();
});
it('shows data', async() => {
mockBlobContentData.mockResolvedValue(data); // you may resolve or reject to mock the result
createComponentWithApollo();
await waitForPromises(); // wait on the resolver mock to execute
expect(findContent().text()).toBe(mockCiYml);
});
Using cache.writeQuery
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:
query fetchLocalUser {
fetchLocalUser @client {
name
}
}
import fetchLocalUserQuery from '~/design_management/graphql/queries/fetch_local_user.query.graphql';
function createMockApolloProvider() {
Vue.use(VueApollo);
const requestHandlers = [
[getDesignListQuery, jest.fn().mockResolvedValue(designListQueryResponse)],
[permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
];
const mockApollo = createMockApollo(requestHandlers, {});
mockApollo.clients.defaultClient.cache.writeQuery({
query: fetchLocalUserQuery,
data: {
fetchLocalUser: {
__typename: 'User',
name: 'Test',
},
},
});
return mockApollo;
}
function createComponent(options = {}) {
const { mockApollo } = options;
return shallowMount(Index, {
localVue,
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:
import fetchLocalUserQuery from '~/design_management/graphql/queries/fetch_local_user.query.graphql';
function createMockApolloProvider(options = {}) {
Vue.use(VueApollo);
const { fetchLocalUserSpy } = options;
const 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:
describe('My Index test with `createMockApollo`', () => {
let wrapper;
let fetchLocalUserSpy;
afterEach(() => {
wrapper.destroy();
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
});
});
});
When you need to configure the mocked apollo client's caching behavior, provide additional cache options when creating a mocked client instance and the provided options will merge with the default cache option:
const defaultCacheOptions = {
fragmentMatcher: { match: () => true },
addTypename: false,
};
function createMockApolloProvider({ props = {}, requestHandlers } = {}) {
Vue.use(VueApollo);
const mockApollo = createMockApollo(
requestHandlers,
{},
{
dataIdFromObject: (object) =>
// eslint-disable-next-line no-underscore-dangle
object.__typename === 'Requirement' ? object.iid : defaultDataIdFromObject(object),
},
);
return mockApollo;
}
Handling errors
The GitLab GraphQL mutations have two distinct error modes: Top-level and errors-as-data.
When utilising a GraphQL mutation, consider handling both of these error modes to ensure that the user receives the appropriate feedback when an error occurs.
Top-level errors
These errors are located at the "top level" of a GraphQL response. These are non-recoverable errors including argument errors and syntax errors, and should not be presented directly to the user.
Handling top-level errors
Apollo is aware of top-level errors, so we are able to leverage Apollo's various error-handling mechanisms to handle these errors. For example, handling Promise rejections after invoking the mutate
method, or handling the error
event emitted from the ApolloMutation
component.
Because these errors are not intended for users, error messages for top-level errors should be defined client-side.
Errors-as-data
These errors are nested in the data
object of a GraphQL response. These are recoverable errors that, ideally, can be presented directly to the user.
Handling errors-as-data
First, we must add errors
to our mutation object:
mutation createNoteMutation($input: String!) {
createNoteMutation(input: $input) {
note {
id
+ errors
}
}
Now, when we commit this mutation and errors occur, the response includes errors
for us to handle:
{
data: {
mutationName: {
errors: ["Sorry, we were not able to update the note."]
}
}
}
When handling errors-as-data, use your best judgement to determine whether to present the error message in the response, or another message defined client-side, to the user.
Usage outside of Vue
It is also possible to use GraphQL outside of Vue by directly importing and using the default client with queries.
import createDefaultClient from '~/lib/graphql';
import query from './query.graphql';
const defaultClient = createDefaultClient();
defaultClient.query({ query })
.then(result => console.log(result));
When using Vuex, disable the cache when:
- The data is being cached elsewhere
- The use case does not need caching if the data is being cached elsewhere, or if there is no need for it for the given use case.
import createDefaultClient from '~/lib/graphql';
import fetchPolicies from '~/graphql_shared/fetch_policy_constants';
const defaultClient = createDefaultClient(
{},
{
fetchPolicy: fetchPolicies.NO_CACHE,
},
);
Making initial queries early with GraphQL startup calls
To improve performance, sometimes we want to make initial GraphQL queries early. In order to do this, we can add them to startup calls with the following steps:
-
Move all the queries you need initially in your application to
app/graphql/queries
; -
Add
__typename
property to every nested query level:query getPermissions($projectPath: ID!) { project(fullPath: $projectPath) { __typename userPermissions { __typename pushCode forkProject createMergeRequestIn } } }
-
If queries contain fragments, you need to move fragments to the query file directly instead of importing them:
fragment PageInfo on PageInfo { __typename hasNextPage hasPreviousPage startCursor endCursor } query getFiles( $projectPath: ID! $path: String $ref: String! ) { project(fullPath: $projectPath) { __typename repository { __typename tree(path: $path, ref: $ref) { __typename pageInfo { ...PageInfo } } } } } }
-
If the fragment is used only once, we can also remove the fragment altogether:
query getFiles( $projectPath: ID! $path: String $ref: String! ) { project(fullPath: $projectPath) { __typename repository { __typename tree(path: $path, ref: $ref) { __typename pageInfo { __typename hasNextPage hasPreviousPage startCursor endCursor } } } } } }
-
Add startup call(s) with correct variables to the HAML file that serves as a view for your application. To add GraphQL startup calls, we use
add_page_startup_graphql_call
helper where the first parameter is a path to the query, the second one is an object containing query variables. Path to the query is relative toapp/graphql/queries
folder: for example, if we need aapp/graphql/queries/repository/files.query.graphql
query, the path isrepository/files
.- current_route_path = request.fullpath.match(/-\/tree\/[^\/]+\/(.+$)/).to_a[1] - add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path || "" }) - add_page_startup_graphql_call('repository/permissions', { projectPath: @project.full_path }) - add_page_startup_graphql_call('repository/files', { nextPageCursor: "", pageSize: 100, projectPath: @project.full_path, ref: current_ref, path: current_route_path || "/"})