October 29, 2020 | 4 minute read

Creating custom dev tools with MSW

#react#msw#webdev#tooling

If you've ever been in a situation where the API for a feature you're adding to your frontend is not ready, then MSW is for you.

At work, this is often the case for me! I have been using MSW for integration testing but I never used it for developing because I was under the impression that it would intercept and reject any requests that weren't mocked.

I was wrong.

It doesn't reject unmocked requests, it just passes it along.

I thought back to Kent C. Dodd's post on dev tools and knew that dynamically mocking API's would really speed up my development workflow (it did).

Here's how I did it.

Making sure it's dev only

1// App.tsx
2const DevTools = React.lazy(() => import("./DevTools"));
3
4function App() {
5 return (
6 <>
7 <Routes />
8 {process.env.NODE_ENV === "development" ? (
9 <React.Suspense fallback={null}>
10 <DevTools />
11 </React.Suspense>
12 ) : null}
13 </>
14 );
15}

Tada! Haha it would be great if that was all, but this is how I made sure the dev tools only loaded within the development environment. A simple dynamic component with a null suspense fallback.

This is the actual DevTools.tsx implementation:

1// DevTools.tsx
2import * as React from "react";
3import { setupWorker, graphql } from "msw";
4
5export const mockServer = setupWorker();
6
7const mocks = {
8 users: [
9 graphql.query("GetUsers", (req, res, ctx) => {
10 // return fake data
11 }),
12 graphql.query("GetUser", (req, res, ctx) => {
13 // return fake data
14 }),
15 ],
16};
17
18function DevTools() {
19 const [mocks, setMocks] = React.useState({});
20 const mockServerReady = React.useRef(false);
21 const activeMocks = React.useMemo(
22 () =>
23 Object.entries(mocks)
24 // we filter out all unchecked inputs
25 .filter(([, shouldMock]) => shouldMock)
26 // since the map is an array of handlers
27 // we want to flatten the array so that the final result isn't nested
28 .flatMap(([key]) => mocks[key]),
29 [mocks]
30 );
31
32 React.useEffect(() => {
33 mockServer.start().then(() => {
34 mockServerReady.current = true;
35 });
36
37 return () => {
38 mockServer.resetHandlers();
39 mockServer.stop();
40 };
41 }, []);
42
43 React.useEffect(() => {
44 if (mockServerReady.current) {
45 flushMockServerHandlers();
46 }
47 }, [state.mock]);
48
49 // if a checkbox was unchecked
50 // we want to make sure that the mock server is no longer mocking those API's
51 // we reset all the handlers
52 // then add them to MSW
53 function flushMockServerHandlers() {
54 mockServer.resetHandlers();
55 addHandlersToMockServer(activeMocks);
56 }
57
58 function addHandlersToMockServer(handlers) {
59 mockServer.use(...handlers);
60 }
61
62 function getInputProps(name: string) {
63 function onChange(event: React.ChangeEvent<HTMLInputElement>) {
64 const apiToMock = event.target.name;
65 const shouldMock = event.target.checked;
66
67 setState((prevState) => ({
68 ...prevState,
69 [apiToMock]: shouldMock,
70 }));
71 }
72
73 return {
74 name,
75 onChange,
76 checked: state.mock[name] ?? false,
77 };
78 }
79
80 return (
81 <div>
82 {Object.keys(mocks).map((mockKey) => (
83 <div key={mockKey}>
84 <label htmlFor={mockKey}>Mock {mockKey}</label>
85 <input {...getInputProps(mockKey)} />
86 </div>
87 ))}
88 </div>
89 );
90}

Let's break that down.

Mock server

Inside the DevTools.tsx file, I initialize the mock server and I add a map of all the API's I want to be able to mock and assign it to mocks. In this example I'm using graphql, but you could easily replace that with whatever REST API you may be using.

1// DevTools.tsx
2import { setupWorker, graphql } from "msw";
3
4export const mockServer = setupWorker();
5
6const mocks = {
7 users: [
8 graphql.query("GetUsers", (req, res, ctx) => {
9 // return fake data
10 }),
11 graphql.query("GetUser", (req, res, ctx) => {
12 // return fake data
13 }),
14 ],
15};

UI

I make a checkbox for every key within mocks. The getInputProps initializes all the props for each checkbox. Each time a checkbox is checked, I'll update the state to reflect which API should be mocked.

1// DevTools.tsx
2
3function DevTools() {
4 const [mocks, setMocks] = React.useState({});
5
6 function getInputProps(name: string) {
7 function onChange(event: React.ChangeEvent<HTMLInputElement>) {
8 const apiToMock = event.target.name;
9 const shouldMock = event.target.checked;
10
11 setState((prevState) => ({
12 ...prevState,
13 [apiToMock]: shouldMock,
14 }));
15 }
16
17 return {
18 name,
19 onChange,
20 checked: state.mock[name] ?? false,
21 };
22 }
23
24 return (
25 <div>
26 {Object.keys(mocks).map((mockKey) => (
27 <div key={mockKey}>
28 <label htmlFor={mockKey}>Mock {mockKey}</label>
29 <input {...getInputProps(mockKey)} />
30 </div>
31 ))}
32 </div>
33 );
34}

Dynamic API Mocking

This part has a little more to unpack.

1// DevTools.tsx
2export const mockServer = setupWorker();
3
4function DevTools() {
5 const [mocks, setMocks] = React.useState({});
6 const mockServerReady = React.useRef(false);
7 const activeMocks = React.useMemo(
8 () =>
9 Object.entries(mocks)
10 .filter(([, shouldMock]) => shouldMock)
11 .flatMap(([key]) => mocks[key]),
12 [mocks]
13 );
14
15 React.useEffect(() => {
16 mockServer.start().then(() => {
17 mockServerReady.current = true;
18 });
19
20 return () => {
21 mockServer.resetHandlers();
22 mockServer.stop();
23 };
24 }, []);
25
26 React.useEffect(() => {
27 if (mockServerReady.current) {
28 flushMockServerHandlers();
29 }
30 }, [state.mock]);
31
32 function flushMockServerHandlers() {
33 mockServer.resetHandlers();
34 addHandlersToMockServer(activeMocks);
35 }
36
37 function addHandlersToMockServer(handlers) {
38 mockServer.use(...handlers);
39 }
40}

First, we create a ref to track whether the mock server is ready.

1function DevTools() {
2 const mockServerReady = React.useRef(false);
3}

Then we create a list of all the active mocks to pass into MSW.

1function DevTools() {
2 const mockServerReady = React.useRef(false);
3 const activeMocks = React.useMemo(
4 () =>
5 Object.entries(mocks)
6 .filter(([, shouldMock]) => shouldMock)
7 .flatMap(([key]) => mocks[key]),
8 [mocks]
9 );
10}

When the dev tools initialize, we want to start the server, and set the mockServerReady ref to true. When it unmounts, we reset all the handlers and stop the server.

1function DevTools() {
2 const mockServerReady = React.useRef(false);
3 const activeMocks = React.useMemo(
4 () =>
5 Object.entries(mocks)
6 .filter(([, shouldMock]) => shouldMock)
7 .flatMap(([key]) => mocks[key]),
8 [mocks]
9 );
10
11 React.useEffect(() => {
12 mockServer.start().then(() => {
13 mockServerReady.current = true;
14 });
15
16 return () => {
17 mockServer.resetHandlers();
18 mockServer.stop();
19 };
20 }, []);
21}

Finally, whenever we check a checkbox, we reset all the mocks and add whichever handlers are checked within mocks.

1function DevTools() {
2 const mockServerReady = React.useRef(false);
3 const activeMocks = React.useMemo(
4 () =>
5 Object.entries(mocks)
6 .filter(([, shouldMock]) => shouldMock)
7 .flatMap(([key]) => mocks[key]),
8 [mocks]
9 );
10
11 React.useEffect(() => {
12 mockServer.start().then(() => {
13 mockServerReady.current = true;
14 });
15
16 return () => {
17 mockServer.resetHandlers();
18 mockServer.stop();
19 };
20 }, []);
21
22 React.useEffect(() => {
23 if (mockServerReady.current) {
24 flushMockServerHandlers();
25 }
26 }, [state.mock]);
27}

That's all folks!