loading...

Creating custom dev tools with MSW

cvr profile image Cristian Velasquez Ramos Originally published at cvr.im ・4 min read

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

// App.tsx
const DevTools = React.lazy(() => import("./DevTools"));

function App() {
  return (
    <>
      <Routes />
      {process.env.NODE_ENV === "development" ? (
        <React.Suspense fallback={null}>
          <DevTools />
        </React.Suspense>
      ) : null}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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:

// DevTools.tsx
import * as React from "react";
import { setupWorker, graphql } from "msw";

export const mockServer = setupWorker();

const mocks = {
  users: [
    graphql.query("GetUsers", (req, res, ctx) => {
      // return fake data
    }),
    graphql.query("GetUser", (req, res, ctx) => {
      // return fake data
    }),
  ],
};

function DevTools() {
  const [mocks, setMocks] = React.useState({});
  const mockServerReady = React.useRef(false);
  const activeMocks = React.useMemo(
    () =>
      Object.entries(mocks)
        // we filter out all unchecked inputs
        .filter(([, shouldMock]) => shouldMock)
        // since the map is an array of handlers
        // we want to flatten the array so that the final result isn't nested
        .flatMap(([key]) => mocks[key]),
    [mocks]
  );

  React.useEffect(() => {
    mockServer.start().then(() => {
      mockServerReady.current = true;
    });

    return () => {
      mockServer.resetHandlers();
      mockServer.stop();
    };
  }, []);

  React.useEffect(() => {
    if (mockServerReady.current) {
      flushMockServerHandlers();
    }
  }, [state.mock]);

  // if a checkbox was unchecked
  // we want to make sure that the mock server is no longer mocking those API's
  // we reset all the handlers
  // then add them to MSW
  function flushMockServerHandlers() {
    mockServer.resetHandlers();
    addHandlersToMockServer(activeMocks);
  }

  function addHandlersToMockServer(handlers) {
    mockServer.use(...handlers);
  }

  function getInputProps(name: string) {
    function onChange(event: React.ChangeEvent<HTMLInputElement>) {
      const apiToMock = event.target.name;
      const shouldMock = event.target.checked;

      setState((prevState) => ({
        ...prevState,
        [apiToMock]: shouldMock,
      }));
    }

    return {
      name,
      onChange,
      checked: state.mock[name] ?? false,
    };
  }

  return (
    <div>
      {Object.keys(mocks).map((mockKey) => (
        <div key={mockKey}>
          <label htmlFor={mockKey}>Mock {mockKey}</label>
          <input {...getInputProps(mockKey)} />
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

// DevTools.tsx
import { setupWorker, graphql } from "msw";

export const mockServer = setupWorker();

const mocks = {
  users: [
    graphql.query("GetUsers", (req, res, ctx) => {
      // return fake data
    }),
    graphql.query("GetUser", (req, res, ctx) => {
      // return fake data
    }),
  ],
};
Enter fullscreen mode Exit fullscreen mode

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.

// DevTools.tsx

function DevTools() {
  const [mocks, setMocks] = React.useState({});

  function getInputProps(name: string) {
    function onChange(event: React.ChangeEvent<HTMLInputElement>) {
      const apiToMock = event.target.name;
      const shouldMock = event.target.checked;

      setState((prevState) => ({
        ...prevState,
        [apiToMock]: shouldMock,
      }));
    }

    return {
      name,
      onChange,
      checked: state.mock[name] ?? false,
    };
  }

  return (
    <div>
      {Object.keys(mocks).map((mockKey) => (
        <div key={mockKey}>
          <label htmlFor={mockKey}>Mock {mockKey}</label>
          <input {...getInputProps(mockKey)} />
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Dynamic API Mocking

This part has a little more to unpack.

// DevTools.tsx
export const mockServer = setupWorker();

function DevTools() {
  const [mocks, setMocks] = React.useState({});
  const mockServerReady = React.useRef(false);
  const activeMocks = React.useMemo(
    () =>
      Object.entries(mocks)
        .filter(([, shouldMock]) => shouldMock)
        .flatMap(([key]) => mocks[key]),
    [mocks]
  );

  React.useEffect(() => {
    mockServer.start().then(() => {
      mockServerReady.current = true;
    });

    return () => {
      mockServer.resetHandlers();
      mockServer.stop();
    };
  }, []);

  React.useEffect(() => {
    if (mockServerReady.current) {
      flushMockServerHandlers();
    }
  }, [state.mock]);

  function flushMockServerHandlers() {
    mockServer.resetHandlers();
    addHandlersToMockServer(activeMocks);
  }

  function addHandlersToMockServer(handlers) {
    mockServer.use(...handlers);
  }
}
Enter fullscreen mode Exit fullscreen mode

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

function DevTools() {
  const mockServerReady = React.useRef(false);
}
Enter fullscreen mode Exit fullscreen mode

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

function DevTools() {
  const mockServerReady = React.useRef(false);
  const activeMocks = React.useMemo(
    () =>
      Object.entries(mocks)
        .filter(([, shouldMock]) => shouldMock)
        .flatMap(([key]) => mocks[key]),
    [mocks]
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

function DevTools() {
  const mockServerReady = React.useRef(false);
  const activeMocks = React.useMemo(
    () =>
      Object.entries(mocks)
        .filter(([, shouldMock]) => shouldMock)
        .flatMap(([key]) => mocks[key]),
    [mocks]
  );

  React.useEffect(() => {
    mockServer.start().then(() => {
      mockServerReady.current = true;
    });

    return () => {
      mockServer.resetHandlers();
      mockServer.stop();
    };
  }, []);
}
Enter fullscreen mode Exit fullscreen mode

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

function DevTools() {
  const mockServerReady = React.useRef(false);
  const activeMocks = React.useMemo(
    () =>
      Object.entries(mocks)
        .filter(([, shouldMock]) => shouldMock)
        .flatMap(([key]) => mocks[key]),
    [mocks]
  );

  React.useEffect(() => {
    mockServer.start().then(() => {
      mockServerReady.current = true;
    });

    return () => {
      mockServer.resetHandlers();
      mockServer.stop();
    };
  }, []);

  React.useEffect(() => {
    if (mockServerReady.current) {
      flushMockServerHandlers();
    }
  }, [state.mock]);
}
Enter fullscreen mode Exit fullscreen mode

That's all folks!

Discussion

pic
Editor guide