Chapter 3.2.2.3: Using Mock Service Worker with Cypress
Although Cypress offers network request mocking capabilities out of the box, using it with the Mock Service Worker library enables us to use the same mocks for both unit and application tests.
Disclaimer: You are reading an early version of the text! The final version of the book will be revised and may contain additional content.
Unlike Vitest, Cypress offers network request mocking capabilities out of the box. However, one of our goals when writing tests is to use an API that is as consistent as possible and specifically to be able to reuse the mocking layer between different types of tests. Therefore, we will refrain from using Cypress's mocking functionality. Instead, we will also use the Mock Service Worker library for our Cypress tests.
However, we must keep one thing in mind: Cypress test code runs in Node.js, but the Mock Service Worker code must run in the browser context. So, for our tests to work, we need to create a bundle of our app, including the Mock Service Worker library. And secondly, we need a solution to control the mocking layer in our Cypress tests.
However, the first part of creating a bundle of our app, including MSW, brings another advantage: we can use mocks not only for automated testing but also during the development phase. For example, let's imagine we are working on a new feature for which there is no backend API yet. In this case, we can use the mock response we have already defined for our tests, even during development. Doing so allows us to work on implementing the feature in the frontend without any constraints while a backend developer is still busy implementing the API. It is also straightforward to develop a simple prototype using this approach.
To make this possible, we first create a new file src/__test__/mock-endpoint.ts
:
// src/__test__/mock-endpoint.ts
import { rest, setupWorker } from 'msw';
const mockWorker = setupWorker();
mockWorker.start();
const ENDPOINT_MOCKS_KEY = `__ENDPOINT_MOCKS__`;
export const mockEndpoint = (endpoint, {
body,
httpVerb = `get`,
status = 200,
}) => {
mockWorker.use(
rest[httpVerb](endpoint, (req, res, ctx) => res(ctx.status(status), ctx.json(body))),
);
};
export const activateStoredMocks = () => {
let mocksRaw = localStorage.getItem(ENDPOINT_MOCKS_KEY);
let mocks = mocksRaw ? JSON.parse(mocksRaw) : [];
mocks.forEach(mock => mockEndpoint(mock.endpoint, mock.options));
};
First, we initialize a new MSW worker. The worker uses the browser's Service Worker API to intercept network requests. Next, we define a mockEndpoint()
function again. This time we use the mockWorker
instead of the mockServer
we used in the Vitest example. Otherwise, the code is identical.
Additionally we define an activateStoredMocks()
function. This loads mocks from the Local Storage, if any, and activates them. Loading mocks from the Local Storage is handy for persisting mocks across page reloads.
In the next step, we still need to make adjustments to our app's main.ts
file to enable the mocking functionality.
import { createApp } from 'vue';
import App from './App.vue';
-createApp(App).mount(`#app`);
+(async () => {
+ if (import.meta.env.DEV) {
+ let { mockEndpoint, activateStoredMocks } = await import(`./__test__/mock-endpoint`);
+ window.mockEndpoint = mockEndpoint;
+
+ activateStoredMocks();
+ }
+
+ createApp(App).mount(`#app`);
+})();
Here we see how we can include the MSW Library in our application's src/main.ts
entry file without affecting the production build. Of course, when we publish the app, we don't want the MSW Library to increase our bundle size unnecessarily, or even use mocks in production. Thanks to dead-code elimination, all modern bundlers like webpack and Vite can determine that the condition if (import.meta.env.DEV)
can never be true
in the production build. Thus, this block is marked as dead code and removed from the final bundle.
Now that we have created a bundle including MSW for test and dev builds, the next step is to look at how we can mock network requests in Cypress Tests using MSW.
To do this, we create another utils.ts
file, but this time in the Cypress Driver folder: tests/driver/cypress/utils.ts
. Again we define a mockEndpoint()
method.
// tests/driver/cypress/utils.ts
import { cy } from 'local-cypress';
const ENDPOINT_MOCKS_KEY = `__ENDPOINT_MOCKS__`;
export const mockEndpoint = (endpoint, options) => {
cy.window().then(({ localStorage, mockEndpoint: windowMockEndpoint }) => {
// Immediately mock endpoint if possible.
if (windowMockEndpoint) {
windowMockEndpoint(endpoint, options);
}
// Store mocks in Local Storage so they're available after a reload.
let mocksRaw = localStorage.getItem(ENDPOINT_MOCKS_KEY);
let mocks = mocksRaw ? JSON.parse(mocksRaw) : [];
localStorage.setItem(ENDPOINT_MOCKS_KEY, JSON.stringify([...mocks, {
endpoint,
options,
}]));
});
};
Above, we defined mockEndpoint()
as a global variable (app scope). If it is already available in the cy.window().then()
(app scope) callback when we run the Cypress mockEndpoint()
(test scope) function, we run windowMockEndpoint()
immediately. In either case, we also store the mock information in Local Storage. That way, using activateStoredMocks()
in our main.ts
file, we ensure that mocks are activated again even after a page reload.
This code can seem a bit complicated because we first need to understand that the code outside cy.window().then()
and the code inside the cy.window().then()
callback is running in different contexts. It appears as if Cypress executes both in the test scope. However, in the background, Cypress injects globally available variables (in this case, localStorage
and mockEndpoint
from the app scope into the test scope. Thanks to the injected variables, we can access the mockEndpoint()
function that we registered globally (window.mockEndpoint = mockEndpoint
) in our app bundle.
Using MSW in a Cypress test
Now we have made all necessary adjustments to be able to use our mockEndpoint()
function also in Cypress for mocking network requests:
// tests/specs/hello-vue.spec.ts
// ...
import { mockEndpoint } from '../driver/cypress/utils';
// ...
it(`should render the first post`, () => {
mockEndpoint(`https://jsonplaceholder.typicode.com/posts/1`, {
body: {
id: 1,
title: `First Post Title`,
},
});
cy.visit(`/`);
cy.findByText(`First Post Title`).should(`exist`);
});
We can see that the API is the same as how we use the mocking layer in our Vitest unit tests. Thus it is easier to write different tests with mocks because we only have to remember a single API. In the next chapter, we'll look at how the unified API allows us to use the same mocks in both application and unit tests without repeating ourselves. And later, we will also see that we have laid the foundation to run application tests in Cypress and Vitest, for example.
Wrapping it up
Mocking specific JavaScript modules can be very useful in some cases. Still, the Mock Service Worker library is the better choice for decoupling our Tests from external APIs. This is because modules are part of our application, and tests should cover all parts of our application. In theory, we can achieve 100% coverage with separate unit tests for every module. But suppose we don't have a test that tests multiple modules in combination, as we actually use them in our application. In that case, we can never be sure that the individual modules also communicate with each other correctly.
On the other hand, the network layer and the APIs we access to fetch or send data are clearly not part of our application. Decoupling our tests from APIs and other parts of the system makes sense because of multiple reasons we already discussed earlier. The MSW package allows us to do just that. This way, we can ensure that we do not make any requests to real APIs in our tests, but at the same time, we can still test all modules of our application in combination.
However, this approach creates a new problem: Although our tests are now physically perfectly decoupled from APIs outside our application, conceptually there is strong coupling.
it(`should render the first post`, async () => {
mockEndpoint(`https://jsonplaceholder.typicode.com/posts/1`, {
body: {
id: 1,
title: `First Post Title`,
},
});
render(HelloWorld, { props: { msg: `Hello World` } });
expect(await screen.findByText(`First Post Title`)).toBeInTheDocument();
});
If we look at this test with a critical eye, we notice that it now not only contains the information that the component makes a network request to https://jsonplaceholder.typicode.com
, but the test also exactly knows what response we expect. So what we have here is maximum coupling to not only the implementation details of the component but also the API's implementation details. But, of course, we must avoid this at all costs if we want our tests to remain maintainable in the long term!
Stay tuned and subscribe if you don’t want to miss the next chapter!
I'm honestly enjoying learning from you, Markus. The detailed, story-telling way in which you explain why and when we need to do things and not just a simple how is truly valuable to me. Information not only stays put, it starts painting a picture that makes sense, and thus easier to remember.