One of the most crucial things about writing tests is that they should be deterministic. A particular test should always succeed or always fail. To achieve that, we sometimes need to mock parts of our environment when testing. A good example can be a function that relies on pseudo-random numbers.
Besides the above, we also want our frontend unit tests to be independent of the backend. If there is a bug in our API, we don’t want it to cause frontend tests to fail too. To achieve that, we could mock functions we use to make HTTP requests, such as fetch() and axios.get(). While that would work fine, switching from using fetch to axios or the other way around would force us to adjust our mocks. So instead, the Mock Service Worker library offers a different approach.
Introducing Mock Service Worker (MSW)
The Mock Service Worker library offers two major features. One of them is mocking the API requests in the browser.
1import { rest, setupWorker } from 'msw';
2
3const worker = setupWorker(
4 rest.get(
5 'https://jsonplaceholder.typicode.com/posts',
6 (request, response, context) => {
7 return response(
8 context.json([
9 {
10 id: 1,
11 title: 'First post',
12 body: 'This is the first post',
13 },
14 {
15 id: 2,
16 title: 'Second post',
17 body: 'This is the second post',
18 },
19 ]),
20 );
21 },
22 ),
23);
24
25worker.start()In the callback for the rest.get function, we have three arguments:
- request: an object that stores the information about the matched request,
- response: a function that creates a mocked response object,
- context: an object containing a set of response transformers that help us compose a response.
The response function accepts the response transformers as arguments. Each response transformer modifies the response. Besides using the response transformers provided by the context object, we can create our own.
1rest.get(
2 'https://jsonplaceholder.typicode.com/posts',
3 async (request, response) => {
4 return response(
5 (response) => {
6 response.status = 200;
7 return response;
8 },
9 (response) => {
10 response.headers.set('Content-Type', 'application/json')
11 return response;
12 },
13 );
14 },
15),The setupWorker function creates a Service Worker configuration instance and the worker.start function registers and activates it. Doing the above causes the MSW library to intercept the /posts API request through a Service Worker and return the response we’ve defined. This means we can open the developer tools and see the browser making the API requests. It is a significant improvement from mocking the fetch() function.
If you want to know more bout Service Workers in general, check out The basics of Service Workers: how should our application behave when offline?
The second major feature that MSW offers is mocking API requests in Node.js.
1import { rest } from 'msw';
2import { setupServer } from 'msw/node';
3
4const server = setupServer(
5 rest.get(
6 'https://jsonplaceholder.typicode.com/posts',
7 (request, response, context) => {
8 return response(
9 context.json([
10 {
11 id: 1,
12 title: 'First post',
13 body: 'This is the first post',
14 },
15 {
16 id: 2,
17 title: 'Second post',
18 body: 'This is the second post',
19 },
20 ]),
21 );
22 },
23 ),
24);
25
26server.listen()We can pass multiple arguments to the setupServer and setupWorker functions to mock multiple requests.
It is essential to realize that Node.js does not have a concept of Service Workers. Because of that, MSW has to intercept functions such as fetch and http.get being called.
The use-cases of client-side and Node.js mocking
The first thing to notice is the API for defining both Node.js and browser mocks is the same. Therefore, sharing the mocks between the browser and Node.js is easy.
Mocking client-side API requests might come in handy in a few scenarios. One of them is when the actual API is not ready yet, but we still want to be able to work on the frontend side of our application. This can be a common case in teams where different developers are working on creating the API and on the frontend.
Mocking API requests in the browser can also be helpful if we don’t want our Cypress tests to use a real API. However, we should know that such tests might not be considered end-to-end tests because they don’t verify all of the aspects of the application.
Writing tests with Jest involves running our frontend application through Node.js. Because of that, Node.js API mocks can be crucial to our unit tests. Therefore, in this article, we focus on writing Node.js mocks.
Mocking API calls in a Node.js environment
We can take two different approaches when setting up mocks. Each of them has its advantages and disadvantages.
Setting up API mocks once for all of the tests
The most straightforward way of mocking API calls with MSW is to configure it once for all of the tests. To do that, we need to define the server in a file included in the setupFilesAfterEnv array in our Jest configuration.
1import { rest } from 'msw';
2import { setupServer } from 'msw/node';
3
4const server = setupServer(
5 rest.get(
6 'https://jsonplaceholder.typicode.com/posts',
7 (request, response, context) => {
8 return response(
9 context.json([
10 {
11 id: 1,
12 title: 'First post',
13 body: 'This is the first post',
14 },
15 {
16 id: 2,
17 title: 'Second post',
18 body: 'This is the second post',
19 },
20 ]),
21 );
22 },
23 ),
24);
25
26beforeAll(() => {
27 server.listen();
28});
29afterAll(() => {
30 server.close();
31});In Create React App, setupTests is the default file used for configuring tests.
To verify the above approach, let’s create a straightforward component for displaying a list of posts.
1import React from 'react';
2import usePostsList from './usePostsList';
3
4const PostsList = () => {
5 const { data, hasFailed } = usePostsList();
6
7 if (hasFailed) {
8 return <div>Something went wrong...</div>;
9 }
10
11 return (
12 <div>
13 {data?.map((post) => (
14 <div key={post.id}>
15 <h2>{post.title}</h2>
16 <p>{post.body}</p>
17 </div>
18 ))}
19 </div>
20 );
21};
22
23export default PostsList;Our React component uses a custom hook that sends an HTTP request to the API we’ve mocked.
1import { useEffect, useState } from 'react';
2import Post from '../Post';
3
4function usePostsList() {
5 const [hasFailed, setHasFailed] = useState(false);
6 const [data, setData] = useState<Post[] | null>(null);
7
8 useEffect(() => {
9 setHasFailed(false);
10 fetch('https://jsonplaceholder.typicode.com/posts')
11 .then((response) => {
12 if (response.ok) {
13 return response.json();
14 } else {
15 throw Error();
16 }
17 })
18 .then((postsData) => {
19 setData(postsData);
20 })
21 .catch(() => {
22 setHasFailed(true);
23 });
24 }, []);
25
26 return {
27 data,
28 hasFailed,
29 };
30}
31
32export default usePostsList;Now, let’s write a test to ensure we render the correct data.
1import { render } from '@testing-library/react';
2import PostsList from './PostsList';
3
4describe('The PostsList component', () => {
5 it('should display the tiles of all of the posts', async () => {
6 const postsList = render(<PostsList />);
7
8 await postsList.findByText('First post');
9 await postsList.findByText('Second post');
10 });
11 it('should display the bodies of all of the posts', async () => {
12 const postsList = render(<PostsList />);
13
14 await postsList.findByText('This is the first post');
15 await postsList.findByText('This is the second post');
16 });
17});PASS src/PostsList/PostsList.test.tsx The PostsList component ✓ should display the tiles of all of the posts ✓ should display the bodies of all of the posts
Implementing the above approach has some advantages. It is straightforward to use and keeps our tests short and easy to understand. Unfortunately, it does not give us much control over the API mocks. Our PostsList component can display an error message if the API request fails. Unfortunately, we currently don’t have a way of testing that.
Setting up API mocks per test
Instead of setting API mocks once for all of the tests, we can do it once per test instead. It gives us a lot more control over how our API mocks work.
1import { render } from '@testing-library/react';
2import PostsList from './PostsList';
3import { setupServer, SetupServerApi } from 'msw/node';
4import { rest } from 'msw';
5import Post from '../Post';
6
7describe('The PostsList component', () => {
8 let server: SetupServerApi;
9 afterEach(() => {
10 server.close();
11 });
12 describe('if fetching posts is a success', () => {
13 let posts: Post[];
14 beforeEach(() => {
15 posts = [
16 {
17 id: 1,
18 title: 'First post',
19 body: 'This is the first post',
20 },
21 {
22 id: 2,
23 title: 'Second post',
24 body: 'This is the second post',
25 },
26 ];
27 server = setupServer(
28 rest.get(
29 'https://jsonplaceholder.typicode.com/posts',
30 (request, response, context) => {
31 return response(context.json(posts));
32 },
33 ),
34 );
35 server.listen();
36 });
37 it('should display the titles of all of the posts', async () => {
38 const postsList = render(<PostsList />);
39
40 for (const post of posts) {
41 await postsList.findByText(post.title);
42 }
43 });
44 it('should display the bodies of all of the posts', async () => {
45 const postsList = render(<PostsList />);
46
47 for (const post of posts) {
48 await postsList.findByText(post.body);
49 }
50 });
51 });
52 describe('if fetching posts fails', () => {
53 beforeEach(() => {
54 server = setupServer(
55 rest.get(
56 'https://jsonplaceholder.typicode.com/posts',
57 (request, response, context) => {
58 return response(
59 context.status(500),
60 context.json({
61 errorMessage: 'Something went wrong',
62 }),
63 );
64 },
65 ),
66 );
67 server.listen();
68 });
69 it('should display an error message', async () => {
70 const postsList = render(<PostsList />);
71 await postsList.findByText('Something went wrong...');
72 });
73 });
74});PASS src/PostsList/PostsList.test.tsx The PostsList component if fetching posts is a success ✓ should display the titles of all of the posts ✓ should display the bodies of all of the posts if fetching posts fails ✓ should display an error message
In the above test, we can mock the response from the same endpoint in more than one way. Thanks to that, we can test how our React component behaves in many different scenarios.
While this approach is more powerful, it makes our tests noticeably more complicated. Also, starting the server multiple times adds to the execution time of our tests. The additional control seems to be worth it, though.
Intercepting the request
We sometimes want to assert the request payload, for example, to ensure that our frontend made a valid HTTP request to create a post. To test it, let’s create a simple form.
1import React from 'react';
2import usePostsForm from './usePostsForm';
3
4const PostsForm = () => {
5 const { handleSubmit } = usePostsForm();
6
7 return (
8 <form onSubmit={handleSubmit}>
9 <input name="title" placeholder="title" aria-label="title-input" />
10 <input name="body" placeholder="body" aria-label="body-input" />
11 <button>Create</button>
12 </form>
13 );
14};
15
16export default PostsForm;When the user clicks the button, we send a POST request to the API.
1import { FormEvent } from 'react';
2
3function usePostsForm() {
4 const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
5 event.preventDefault();
6 const formData = new FormData(event.currentTarget);
7 const { title, body } = Object.fromEntries(formData);
8
9 fetch('https://jsonplaceholder.typicode.com/posts', {
10 method: 'POST',
11 headers: {
12 'Content-Type': 'application/json',
13 },
14 body: JSON.stringify({
15 title,
16 body,
17 }),
18 });
19 };
20
21 return {
22 handleSubmit,
23 };
24}
25
26export default usePostsForm;One way to test the above logic would be to create a variable in the test that we modify in our route handler.
1import { fireEvent, render, waitFor } from '@testing-library/react';
2import { setupServer, SetupServerApi } from 'msw/node';
3import { rest } from 'msw';
4import PostsForm from './PostsForm';
5
6describe('The PostsForm component', () => {
7 let server: SetupServerApi;
8 let createPostPayload: {
9 title: string;
10 body: string;
11 };
12 beforeEach(() => {
13 server = setupServer(
14 rest.post(
15 'https://jsonplaceholder.typicode.com/posts',
16 async (request, response, context) => {
17 createPostPayload = await request.json();
18 return response(context.status(201));
19 },
20 ),
21 );
22 server.listen();
23 });
24 afterEach(() => {
25 server.close();
26 });
27 describe('when the user types valid values for body and title', () => {
28 describe('and when the user clicks on the submit button', () => {
29 it('should make a POST request with the form data', async () => {
30 const postsForm = render(<PostsForm />);
31
32 const title = 'New post title';
33 const body = 'New post body';
34
35 const titleInput = await postsForm.findByLabelText('title-input');
36 fireEvent.change(titleInput, { target: { value: title } });
37
38 const bodyInput = await postsForm.findByLabelText('body-input');
39 fireEvent.change(bodyInput, { target: { value: body } });
40
41 const submitButton = await postsForm.getByRole('button');
42 fireEvent.click(submitButton);
43
44 return waitFor(() => {
45 return expect(createPostPayload).toEqual({
46 title,
47 body,
48 });
49 });
50 });
51 });
52 });
53});PASS src/PostsForm/PostsForm.test.tsx The PostsForm component when the user types valid values for body and title and when the user clicks on the submit button ✓ should make a POST request with the form data
We can also use the life cycle events built into MSW to achieve a similar result.
1import { fireEvent, render, waitFor } from '@testing-library/react';
2import { setupServer, SetupServerApi } from 'msw/node';
3import { matchRequestUrl, rest } from 'msw';
4import PostsForm from './PostsForm';
5
6describe('The PostsForm component', () => {
7 let server: SetupServerApi;
8 let createPostPayload: {
9 title: string;
10 body: string;
11 };
12 beforeEach(() => {
13 const postsUrl = 'https://jsonplaceholder.typicode.com/posts';
14 server = setupServer(
15 rest.post(
16 'https://jsonplaceholder.typicode.com/posts',
17 (request, response, context) => {
18 return response(context.status(201));
19 },
20 ),
21 );
22 server.events.on('request:match', async (request) => {
23 if (request.method === 'POST' || matchRequestUrl(request.url, postsUrl).matches) {
24 createPostPayload = await request.json();
25 }
26 });
27 server.listen();
28 });
29 afterEach(() => {
30 server.close();
31 });
32 describe('when the user types valid values for body and title', () => {
33 describe('and when the user clicks on the submit button', () => {
34 it('should make a POST request with the form data', async () => {
35 const postsForm = render(<PostsForm />);
36
37 const title = 'New post title';
38 const body = 'New post body';
39
40 const titleInput = await postsForm.findByLabelText('title-input');
41 fireEvent.change(titleInput, { target: { value: title } });
42
43 const bodyInput = await postsForm.findByLabelText('body-input');
44 fireEvent.change(bodyInput, { target: { value: body } });
45
46 const submitButton = await postsForm.getByRole('button');
47 fireEvent.click(submitButton);
48
49 return waitFor(async () => {
50 return expect(createPostPayload).toEqual({
51 title,
52 body,
53 });
54 });
55 });
56 });
57 });
58});The official documentation shows also shows how to create a waitForRequest function.
Summary
In this article, we’ve gone through the Mock Service Worker library. We’ve learned about different use cases for it – both in the browser and through Node.js. We’ve also used it in various testing scenarios using React Testing Library and Jest. This included learning different ways to set up API mocks to match our needs. Thanks to practicing all of the above, we’ve grasped the basics of working with MSW.