iTranslated by AI
Using MSW for Mocking in Frontend Testing is the Recent Trend
Are you writing frontend tests?
When writing frontend tests, it's necessary to mock all API calling processes. Including external API calls in tests can lead to test failures due to external factors like the API server being down. Furthermore, making real API calls every time tests are run can negatively impact external systems by increasing server load.
Traditionally, mocking modules like axios or fetch using Jest mocks has been a common approach.
As a recent testing technique, using Mock Service Worker (hereafter referred to as msw) instead of Jest for mocking API calls is gaining attention. Let's take a look at how it is actually used.
What is msw?
msw is a mock server primarily used during frontend development. Its characteristic feature is that instead of actually starting a mock server on localhost, it intercepts requests at the Service Worker level and returns responses.
The implementation of mock handlers can be written in an Express-like style, making it quite approachable.
Benefits
Let's look at the benefits when compared to the method of using Jest mocks.
- Mocks can be reused across different parts of the project, not just in tests
- The mocking layer becomes a lower-level layer
Mocks can be reused across different parts of the project
Methods like Jest's mock() or spyOn() naturally cannot be reused outside of tests.
When using msw, once you define handlers, you can import and reuse the mock implementations anywhere—such as in local development, Storybook, or tests.
For example, an addon for using msw in Storybook has been released.
The mocking layer becomes a lower-level layer
One of the best practices when writing tests is to "use as few mocks as possible." This is because using many mocks can compromise the reliability of your tests.
For example, suppose a module A depends on a module B, and you are writing a test where module B is mocked. When you first write the test, you will likely create a mock that follows the implementation of module B as closely as possible.
However, if module A is modified later and a bug is introduced into module B, or its interface is changed, module A (which depends on B) should technically break. But if the mock implementation of module B remains unchanged, the test will not fail.
As a result, you might end up in a situation where the test succeeds even though a bug occurs when you actually run the code. (That bug might even occur in the production environment!)
To "use as few mocks as possible," the key is to make the mocking layer as low-level as possible.
As an example, imagine a situation where a component depends on a useFetch module, which in turn depends on axios.
<script setup lang="ts">
import useFetch from '@/composables/useFetch'
const { isLoading, data: users } = useFetch('/api/users')
</script>
<template>
<div v-if="isLoading">Loading...</div>
<ul v-else>
<li v-for="user in users" :key="user.id">...</li>
</ul>
</template>
import axios from 'axios'
const useFetch = async (url: string) => {
const { data } = await axios.get(url)
// ...
}
export default useFetch
In this case, if you mock useFetch when testing the component, the implementations of both the useFetch and axios modules are excluded from the test.
If you mock axios instead, you can ensure that only the axios implementation is excluded from the test.
Furthermore, when using msw, it is mocked at the network level, allowing even the axios implementation to be included in the test target.
Disadvantages
On the other hand, the following disadvantages of using msw can also be considered.
- Increased dependencies required for test execution
- Tedious inspection of arguments and other details
Increased dependencies required for test execution
When simply mocking with Jest, Jest is the only module you depend on during testing, but using msw increases the number of dependencies.
Tedious inspection of arguments and other details
When mocking with Jest, you can use toHaveBeenCalledWith to test what arguments (like query parameters) the mocked function was called with, or toHaveBeenCalledTimes to test how many times the mocked function was called.
msw does not have these features by default, so it is somewhat unsuitable if you want to test things like query parameters or the number of times an API was called.
However, this should not be a major problem if you are writing tests based on the actual behavior of the features.
Update
The following excellent article covers argument inspection using msw!
Actual Code Example
Installing Dependencies
Now, let's actually write a test using msw. Create a project and install the dependency packages.
npm init vue
Need to install the following packages:
create-vue
Ok to proceed? (y)
Vue.js - The Progressive JavaScript Framework
✔ Project name: … vue-project
✔ Add TypeScript? … No / Yes
✔ Add JSX Support? … No / Yes
✔ Add Vue Router for Single Page Application development? … No / Yes
✔ Add Pinia for state management? … No / Yes
✔ Add Cypress for testing? … No / Yes
✔ Add ESLint for code quality? … No / Yes
✔ Add Prettier for code formatting? … No / Yes
npm install @vueuse/core
npm --save-dev install jest @types/jest ts-jest vue-jest@next @testing-library/vue@next msw whatwg-fetch @testing-library/jest-dom
module.exports = {
moduleFileExtensions: ["js", "ts", "json", "vue"],
transform: {
"^.+\\.ts$": "ts-jest",
"^.+\\.vue$": "vue-jest",
},
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
};
{
"compilerOptions": {
...
"types": ["@types/jest"],
...
},
...
}
{
...
"scripts": {
...
"test": "jest"
},
...
}
Creating the Mock Server
Next, let's create a mock server using msw. Create the src/mocks directory.
mkdir src/mocks
Next, create a file to write the mock implementation. While you could write everything in a single file, we will create a file for each endpoint to implement the mocks to prevent the file from becoming bloated as the number of API endpoints increases later.
For example, when implementing a mock for /api/users, create the src/mocks/api/users.ts file and write the implementation for each request method.
import { ResponseResolver, MockedRequest, restContext } from "msw";
const get: ResponseResolver<MockedRequest, typeof restContext> = (
req,
res,
ctx
) => {
return res(
ctx.status(200),
ctx.json([
{
id: 1,
name: "John",
},
{
id: 2,
name: "Alice",
},
{
id: 3,
name: "Bob",
},
])
);
};
export default { get };
The mock implementations created will be grouped in src/mocks/handlers and associated with the request paths.
import { rest } from "msw";
import users from "@/mocks/api/users";
export const handlers = [rest.get("/api/users", users.get)];
Furthermore, create files to start the Service Worker for both browser and Node.js environments. The code for Node.js will be used in the tests.
import { setupWorker } from "msw";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
Creating the Component to be Tested
We will create a simple component to be tested. It uses the useFetch function provided by VueUse to retrieve a list of users from the API.
The component specifications are as follows:
- Display "Loading..." until the API call is completed.
- Display the retrieved user list in a list format if the API call completes successfully.
- Display "Something went wrong..." if the API call fails.
<script setup lang="ts">
import { useFetch } from "@vueuse/core";
import { watch } from "vue";
interface User {
id: number;
name: string;
}
const {
isFetching,
error,
data: users,
} = useFetch<User[]>("/api/users").json();
</script>
<template>
<div v-if="isFetching">Loading...</div>
<div v-else-if="error">Something went wrong...</div>
<ul v-else>
<li v-for="user in users" :key="user.id" data-testid="user">
{{ user.name }}
</li>
</ul>
</template>
Creating the Test Code
Now that the preparation is complete, let's create the test code. Create the src/components/__tests__/UserList.spec.ts file.
import UserList from "../UserList.vue";
import { server } from "@/mocks/server";
import "whatwg-fetch";
describe("UserList.vue", () => {
// Establish API mocking before all tests.
beforeAll(() => server.listen());
// Reset any request handlers that we may add during the tests,
// so they don't affect other tests.
afterEach(() => server.resetHandlers());
// Clean up after the tests are finished.
afterAll(() => server.close());
test("...");
});
We will import and use the mock server created in src/mocks/server. We use beforeAll() to start the mock server before all tests begin and afterAll() to close the mock server after all tests are finished.
Furthermore, to prevent individual tests from interfering with each other, we reset the state of the mock server after each test using afterEach.
Also, since fetch does not exist in the Node.js environment where tests are run, a polyfill for fetch is required. For this reason, we are importing whatwg-fetch.
First, let's test whether the loading state is displayed.
import { render } from "@testing-library/vue";
import "@testing-library/jest-dom";
describe("UserList.vue", () => {
// ...
test("Loading is displayed and the user list is not shown until the API call is complete", async () => {
const { findByText, queryAllByTestId } = render(UserList);
expect(await findByText("Loading...")).toBeInTheDocument();
expect(queryAllByTestId("user")).toHaveLength(0);
});
});
});
We are testing whether the text "Loading..." is displayed using findByText.
Next is the case where the API call completes normally.
test("Once the API call is complete, the loading display is gone and a list of usernames is shown", async () => {
const { findByText, queryByText, findAllByTestId } = render(UserList);
expect(await findAllByTestId("user")).toHaveLength(3);
expect(await findByText("John")).toBeInTheDocument();
expect(queryByText("Loading...")).not.toBeInTheDocument();
});
Since the mock server implementation was set up to return three users, we are checking that three <li> tags are displayed. Let's also verify that the usernames are shown and that "Loading..." is no longer displayed.
In testing-library, you can use await to wait until the elements are actually displayed (i.e., until the API call is complete).
By creating a mock server like this, you no longer need to consider other mocking strategies, which keeps the test code clean and allows you to focus on what you actually want to write.
Next, let's also write a test for error cases.
import { msw } from "msw"
// ...
test("An error message is displayed if the API call fails", async () => {
server.use(
rest.get("/api/users", (req, res, ctx) => {
return res.once(
ctx.status(500),
ctx.json({ message: "Internal Server Error" })
);
})
);
const { findByText, queryByText } = render(UserList);
expect(await findByText("Something went wrong...")).toBeInTheDocument();
expect(queryByText("Loading...")).not.toBeInTheDocument();
});
Since the imported mock server is set to always return a successful response, we need to override the request handler using server.use().
To ensure the request handler is overridden only for that specific test, use res.once() when returning the response.
Conclusion
I have discussed the benefits and disadvantages of using msw in test code along with actual code examples.
Once written, mock implementations can be reused in various places, which is great because it means you don't have to keep worrying about how to write your mocks.
Please refer to the following for the actual code examples.
Discussion