Cypress & GraphQL Response Mocking
If you use Cypress and GraphQL in your project, you may want to test workflows that require mocking calls to GraphQL. There is no native support in Cypress for managing a GraphQL mock in an efficient way. Therefore, we need to create something custom. I will show a solution that I adapted from comments on this GitHub issue.
Defining my Criteria
When I first started, I wasn’t sure exactly how I wanted to configure GraphQL mocking. I knew I wanted to satisfy the following criteria:
- Abstract the GraphQL Mock so test configuration was minimized and leverage Cypress functionality.
- Allow the mock to be as specific or generic as a developer would like.
- Properly track the order of calls to GraphQL to ensure reproducibility.
Cypress Commands
Cypress allows you to customize its cy
object. You can override functions that exist or add new ones. At Paperless Post, we have several existing commands, such as a custom login function that mocks a user’s session cookie and JWT and another that triggers a hover state. We also have a shortcut to perform a click after x ms
. To add support for GraphQL queries, we will create two new command
functions.
If you don’t already have support for commands, you can enable it by creating a folder called support
in your cypress
directory. Then create an index.js
and put your commands in it or create separate files for each of your commands and import them. More info on commands is available in the Cypress documentation: https://docs.cypress.io/api/cypress-api/custom-commands.html
The code that adds GraphQL support looks like this:
const GRAPHQL_URL = '/graphql';Cypress.Commands.add('mockGraphQLServer', () => {
// defined in this file. see next section of this blog post.
resetGraphQLMock();
cy.on('window:before:load', win => {
const originalFunction = win.fetch;
function fetch(path, request) {
if (path.includes(GRAPHQL_URL) && request.method === 'POST') {
// defined in this file. see next section of this blog post.
return processGraphQLResponse(request.body);
}
return originalFunction.apply(this, arguments);
}
cy.stub(win, 'fetch', fetch).as('graphqlStub');
});
});
This snippet does the following:
- Resets the GraphQL Mock requests tracker and response map.
- Before the window loads execute the following.
- Store the existing
fetch
for default behavior if not a GraphQL request. - Create a new fetch method that checks if the request is for GraphQL and calls a handler we’ll create to process the quest. Otherwise, use the original fetch.
- Then overwrite the window’s fetch function with the newly created one and alias to
graphqlStub
. This alias is different than the ones used withcy.route
.
If you aren’t 100% migrated to GraphQL, you can still mock other calls.
Caveat: These calls will not show up in the Network tab when running your cypress tests because we are not executing the calls using the polyfill fetch that you can configure in cypress.json
using "experimentalFetchPolyfill": true
. However, your implementation may be different. We are using Apollo Boost as our GQL client.
Add the GraphQL mock handler functions
What We Work With
The signature of the handler function is the same as a raw request to GraphQL. Looking at the request body to GraphQL in the Network tab of a browser, you’ll see the 3 variables accessible to use when mocking.
- query — is the raw GraphQL Query you have defined in your code that is wrapped by the operation name. Variable names are not filled in. I don’t find this to be useful, but it’s good to know it’s there.
- operationName — is what you’ve defined this query to be. It comes from the portion of a query that wraps the variables.
- variables — the values that the GraphQL server will inject into the query to execute it.
Implementation
When determining how to respond to the request, I’ve created two maps to track all requests and responses.
// <String, Number>
let graphQLRequestMap = {};// <String, Map<Number, JSON>>
let graphQLResponseMap = {};
- graphQLRequestMap — uses a string as a key to track the number of executions of a request. The key is the GraphQL request’s
operationName
andvariables
objects run throughJSON.stringify
. Although, sometimes it will just be theoperationName
. - graphQLResponseMap — uses the same key, but has a second map that determines the response based on the number of times that key was called.
This is abstract, but will become clearer once you look at how they are used. The goal is to allow you to configure your mock as specifically or generically as you would like. Let’s take a look at how these are used.
const GLOBAL_LOOKUP = '*';
// <String, Number>
let gqlRequestMap = {};
// <String, Map<Number, JSON>>
let gqlResponseMap = {};
const resetGraphQLMock = () => {
gqlRequestMap = {};
gqlResponseMap = {};
};
const initializeRequestMap = (requestKey) => {
if (gqlRequestMap[requestKey] === undefined) {
gqlRequestMap[requestKey] = 0;
}
}
const responseMapHasKey = (request, count) =>
!gqlResponseMap[request] !== undefined &&
gqlResponseMap[request][count] !== undefined;
const getResponse = (request, count) => {
gqlRequestMap[request] += 1;
const result = gqlResponseMap[request][count];
return getResponseStub(result);
};
const getResponseStub = (result) => {
return Promise.resolve({
json() {
return Promise.resolve(result);
},
text() {
return Promise.resolve(JSON.stringify(result));
},
ok: true
});
};
const getRequest = (requestJSON) => {
const request = JSON.stringify(requestJSON);
initializeRequestMap(request);
const counter = gqlRequestMap[request];
return { request, counter };
}
const processGraphQLResponse = (requestBody) => {
// `body` also contains `query` and we need to remove it
const { operationName, variables } = JSON.parse(requestBody);
// Return the most specific response we can find
const {
request: specificRequest,
counter: specificCounter
} = getRequest({ operationName, variables });
if (responseMapHasKey(specificRequest, specificCounter)) {
return getResponse(specificRequest, specificCounter);
}
if (responseMapHasKey(specificRequest, GLOBAL_LOOKUP)) {
return getResponse(specificRequest, GLOBAL_LOOKUP);
}
// Return a common response to all requests if it exists
const {
request: genericRequest,
counter: genericCounter
} = getRequest({ operationName });
if (responseMapHasKey(genericRequest, genericCounter)) {
return getResponse(genericRequest, genericCounter);
}
if (responseMapHasKey(genericRequest, GLOBAL_LOOKUP)) {
return getResponse(genericRequest, GLOBAL_LOOKUP);
}
console.log(
'Not found counter/operation/variables: ',
specificCounter,
operationName,
variables
);
return getResponseStub({});
};
Focusing on processGraphQLResponse
, you can see that the goal is to load the specific response we can for a given GraphQL request. We start by looking for a response with the operationName
and the variables
that matches the specific counter of the request. If we don’t find one, we look for a global response. Then we fallback to a response that is associated with only the operationName
, using the same specific counter or global lookup logic. This gives us the flexibility to respond appropriately to pagination operations or mutations and reloads.
Having the console.log
is helpful while creating a test or making changes to a test after the application is modified.
Filling the Mock
Now that we know how both tracking maps are used, let’s look at how to fill the mock response data.
Cypress.Commands.add(
'addGraphQLServerResponse',
(requestDetailsInput, responseBody, allRequests = false) => {
let requestDetails;
if (typeof requestDetailsInput === 'string') {
requestDetails = JSON.stringify({
operationName: requestDetailsInput
});
} else {
requestDetails = JSON.stringify(requestDetailsInput);
}
if (allRequests) {
if (gqlResponseMap[requestDetails] === undefined) {
gqlResponseMap[requestDetails] = [];
}
gqlResponseMap[requestDetails][GLOBAL_LOOKUP] = responseBody;
} else if (gqlResponseMap[requestDetails] === undefined) {
gqlResponseMap[requestDetails] = [];
gqlResponseMap[requestDetails][0] = responseBody;
} else {
const array = gqlResponseMap[requestDetails];
gqlResponseMap[requestDetails][array.length] = responseBody;
}
}
);
Let’s look at the method signature first:
requestDetailsInput
— is either astring
orjson
. If it is astring
it should be the GraphQLoperationName
and this method will convert it into an object that is consistent with how the requests are managed. If it isjson
, we expect it to contain theoperationName
and optionally, thevariables
. However, if you don’t providevariables
, you should pass theoperationName
as astring
to keep your code cleaner.responseBody
— is a JSON object (not astring
) that contains the entire graphQL response. It can be stored in a separate file to keep your code clean, but you must load it prior to calling this. We haven’t replicatedcy.route
‘s support for file loading.allRequests
— is a boolean that distinguishes if this configuration should be used if we don’t have anything more specific. The default isfalse
.
Usage
Here is an example of how to use the new cypress commands.
import getMyStudentsResp from './my_students_response';
import getMyStudentsWithStudent3Resp from './my_students_response_with_3';
import DUMMY_STUDENT_DATA from './dummy_student_data';const STUDENT_1_UUID = 'some-uuid-1';
const STUDENT_2_UUID = 'some-uuid-2';
const STUDENT_3_UUID = 'some-uuid-3';
const getStudent1Resp = { STUDENT_1_UUID, ...DUMMY_STUDENT_DATA };
const getStudent2Resp = { STUDENT_2_UUID, ...DUMMY_STUDENT_DATA };
const getStudent3Resp = { STUDENT_3_UUID, ...DUMMY_STUDENT_DATA };context('Test Student List', () => {
beforeEach(() => {
cy.mockGraphQLServer();
cy.addGraphQLServerResponse('getMyStudents', getMyStudentsResp);
}); it('loads student 1 and 2 properly', () => {
cy.addGraphQLServerResponse({
operationName: 'getStudent',
variables: { studentId: STUDENT_1_UUID }
}, getStudent1Resp);
cy.addGraphQLServerResponse({
operationName: 'getStudent',
variables: { studentId: STUDENT_2_UUID }
}, getStudent2Resp); // rest of the test...
}); it('adds student 3 and reload lists', () => {
cy.addGraphQLServerResponse({
operationName: 'addStudent',
variables: { studentId: STUDENT_3_UUID, ...DUMMY_STUDENT_DATA }
}, getStudent3Resp); cy.addGraphQLServerResponse('getMyStudents', getMyStudentsWithStudent3Resp);
// rest of test...
});
});
And there you have it. One way to mock your GraphQL in Cypress. I hope this helps!