2983 words
~15min read

Testing Next.JS with Cypress and Mock Service Worker

November 8th 2022
7K views

If you’re running a Next.js app and want to test it with Cypress you probably noticed a problem when it came time to test a server side rendered page. There’s no built in way to mock out requests made in getServerSideProps. When I ran into this and searched around for solutions I found this article that has a good explination of the problem (and diagrams), but I wasn’t satisfied with any of the solutions.

  • The Problem

The article offers one solution of running the Next app inside the Cypress test runner and using Nock, but I didn’t like that solution since it seems brittle to run in CI and also you would have to have separate mocking libraries for requests made on the frontend. The article then goes on to describe a solution with Mock Service Worker which looks appealing because it can run on both the server and the client, but there’s still the catch of controlling it from the Cypress tests which the author doesn’t appear to have actually solved.

We were in this “trying to find a good way to mock” boat a few months ago and despite finding a few articles on setting up MSW with Cypress and Next there wasn’t anything that satisfied my goals. The the vercel MSW example has the basics of setting up MSW doesn’t include Cypress in the setup and doesn’t have any suggestions on how to control MSW from a test.

When setting up a mocks for our tests I had a few main goals:

  • One set of mocks for both server and client
  • Easy to use in tests, and easy to write handlers
  • Able to have tests set up specific mocking scenarios
  • Not rely on complicated CI environments
  • Use as few packages as possible
  • As simple as possible, but no simpler

To recap, the couple of problems that make this hard are:

  • Any network calls made in getServerSideProps are made in a node process, where the requests made from the browser are in a separate process
  • When testing, mocks must be set up before a page loads to guarantee all requests (including ones made while server side rendering) are mocked

The Solution#

What I came up with certainly isn’t perfect (see Gotchas below) but it satisfies the goals, works within the constraints of the versions of Next (12.x), Cypress (10.x), and MSW (0.44) we have, and is certainly better than our previous setup.

The solution I came up with uses cookies and custom next api’s to trigger loading the mocks for tests.

The Solution

With this setup your test code will be nice, simple and look like:

describe("a test", () => {
  it("does something", () => {
    cy.addMocks("mockFileName");
    cy.visit(`/a-path-to-test`);

    //then assert and interact as you would
  });
});

And the handler files that will be used for server and client requests just export arrays of MSW handlers:

import { RequestHandler, rest } from "msw";

export const handlers: RequestHandler[] = [
  rest.get("*/some-api-to-mock", (_req, res, ctx) => res(ctx.json([]))),
];

The Details#

There’s a lot of different stuff happening here in lots of different places so lets go through it step by step.

The entrypoint for setting up MSW is in /pages/_app.page.tsx which creates either the server side or client side MSW instance. It checks to see if one is already created to prevent creating duplicates which is important to not confuse which mock handlers are on which instance. There are also a couple environment variable checks to a) make sure MSW doesn’t run in production and b) make sure it doesn’t run in normal development unless you want it to. More on that in the running it section. Finally, it tracks the loading state of the mocks and renders a loader to prevent pages making requests before MSW is initialized.

_app.page.tsx

const AppPage = ({ Component, pageProps, router }: AppProps) => {
  const [mswState, setMswState] = useState<"unused" | "loading" | "ready">(
    "unused"
  );
  // MSW requires initialization of its mock server inside the server code so it can intercept requests being
  // made by Next in getServerSideProps. Unfortunately it cannot be initialized from Cypress since it's
  // running in a separate process.  This should really run when the server starts up (not first request like it is)
  // But there's not an easy way to do that now https://github.com/vercel/next.js/discussions/15341
  if (process.env.NEXT_PUBLIC_IA_ENV !== "production") {
    if (process.env.NEXT_PUBLIC_MOCKS_ENABLED && mswState === "unused") {
      setMswState("loading");
      const mockImport = import("../src/mocks");
      mockImport
        .then(({ initMocks }) => initMocks())
        .then(() => setMswState("ready"));
    }
  }

  if (mswState === "loading") {
    // If MSW is enabled we must wait for it to start up before rendering to make sure all requests
    // are captured for consistency
    return <Loader />;
  }

  // normal return
};

The mocks index file is built off the example one but with the additional checks to make sure there’s only a single instance of both the server and client MSW instance, as well as loading test handlers if the cookie is set.

/src/mocks/index.ts

import Cookies from "js-cookie";

async function initMocks() {
  if (typeof window === "undefined") {
    //Make sure there's only one instance of the server
    if (typeof global.serverMsw === "undefined") {
      const { server } = await import("./server");

      global.serverMsw = {
        server,
      };
      console.log("Initialized SERVER msw");
    }
  } else {
    //Make sure there's only one instance of the worker
    if (window.msw === undefined || window.msw.worker === undefined) {
      const { worker } = await import("./browser");

      window.msw = { worker };

      console.log("Initialized BROWSER msw");

      const mockFile = Cookies.get("cypress:mock-file");
      if (mockFile) {
        const nextRequestRegex = /\/_next.*/;
        await worker.start({
          onUnhandledRequest: (req) => {
            // Don't warn about _next api call's not being mocked
            if (req.url.pathname.match(nextRequestRegex)) return;
            console.warn(
              "[MSW] unmocked request:",
              `${req.method} ${req.url.pathname}`
            );
          },
        });

        const allMocks = await import("mocks/handlers");
        const handlers = (allMocks as any)[mockFile].handlers;

        worker.use(...handlers);

        console.log("Added browser handlers for " + mockFile);
      } else {
        worker.resetHandlers();
        worker.stop();
      }
    }
  }
}

export { initMocks };

Both server and client instances initialize using the global handlers file which handles all the general requests the app makes that aren’t that page specific, and can be overwritten by test specific handlers.

src/mocks/browser.ts

import { handlers } from "./globalHandlers";
import { setupWorker } from "msw";

export const worker = setupWorker(...handlers);

src/mocks/server.ts

import { handlers } from "./globalHandlers";
import { setupServer } from "msw/node";

export const server = setupServer(...handlers);

src/mocks/globalHandlers.ts

import { RequestHandler, rest } from "msw";

export const handlers: RequestHandler[] = [
  rest.get("*/some/api", (_req, res, ctx) => res(ctx.json({}))),
  ...otherHandlersYouHave,
];

We now need a way to tell our Next.js backend that we want to start mocking for a specific test, and then be able to reset the mocks after the test is done. By setting up a Next API route we can do just that!

The first api lets us add a mock file and start MSW on the server. A lot of it is error handling but the main part is importing the handlers index file, starting MSW, then the server.use(...handlers) call to add the mock handlers. The API also adds a Set-Cookie header which is important for using this setup outside of Cypress, but more on that later.

pages/api/test-mock/add.api.ts

import { NextApiRequest, NextApiResponse } from "next";

import { RequestHandler } from "msw";
import { serialize } from "cookie";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (process.env.NEXT_PUBLIC_IA_ENV === "production") {
    res.status(405);
    return;
  }

  try {
    res.setHeader("Cache-Control", "no-cache");

    const fileName = req.query.file;
    if (!fileName || typeof fileName !== "string") {
      res.status(400).json({ status: "Missing file param", fileName });
      return;
    }

    const allMocks = await import("mocks/handlers");
    const handlers: RequestHandler[] =
      allMocks[fileName as keyof typeof allMocks].handlers;

    if (
      typeof global.serverMsw === "undefined" &&
      process.env.NEXT_PUBLIC_MOCKS_ENABLED
    ) {
      //Auto start up MSW if it hasn't been yet.  This can happen if this is the first request
      //from cypress before a page has loaded
      const { initMocks } = await import("mocks");
      await initMocks();
    }

    if (typeof global.serverMsw !== "undefined") {
      global.serverMsw.server.listen({ onUnhandledRequest: "warn" });
      global.serverMsw.server.use(...handlers);
      res.setHeader(
        "Set-Cookie",
        serialize("cypress:mock-file", fileName, {
          path: "/",
          sameSite: "lax",
        })
      );

      res.status(200).json({
        status: "Added Server Handlers",
        handlers: handlers.map((h) => h.info.header),
      });
    } else {
      res.status(400).json({ status: "Mock server not initialized." });
    }
  } catch (e: any) {
    if (req.query.file) {
      console.error("Error trying to add mock " + req.query.file);
    }
    console.error(e);
    res.status(500).json({ error: e.toString() });
  }
}

The reset api is very straight forward to stop the server MSW instance

pages/api/test-mock/reset.api.ts

import { NextApiRequest, NextApiResponse } from "next";

import { serialize } from "cookie";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (process.env.NEXT_PUBLIC_IA_ENV === "production") {
    res.status(405);
    return;
  }

  try {
    res.setHeader("Cache-Control", "no-cache");

    if (typeof global.serverMsw !== "undefined") {
      global.serverMsw.server.resetHandlers();
      global.serverMsw.server.close();
      res.setHeader(
        "Set-Cookie",
        serialize("cypress:mock-file", "", {
          path: "/",
          sameSite: "lax",
        })
      );

      res.status(200).json({ status: "Reset Handlers" });
    } else {
      res.status(400).json({ status: "Mock server not initialized" });
    }
  } catch (e: any) {
    // eslint-disable-next-line no-console
    console.error(e);
    res.status(500).json({ error: e.toString() });
  }
}

Ok we now have most of the plumbing going but now we need to set up a way to use test specific handlers. I originally had dynamic imports set up so no index file would be necessary but there’s a limitation with webpack on how dynamic the path can be which means the whole path can’t be dynamic, only the filename. This meant every test handler file had to go in one directory and quickly became an unorganized mess. So to fix that (as well as fixing file change auto restarts which were broken with dynamic imports) we switched to a handler index file that exports all the test specific handlers. An important part here is that the name that they’re exported as is the canonical name you’ll use to add them in tests.

src/mocks/handlers/index.ts

export * as mockFileName from "./testName/mockFileName";

// ... there will be a whole bunch more for different tests and scenarios

The test specific handlers just export an array of MSW handlers which can be as complicated or as simple as your test needs. Remember that these handlers complement everything in the globalHandlers.ts file. New api’s can be handled here and global handlers can be overwritten for a specific test scenario.

src/mocks/handlers/testName/mockFileName

import { RequestHandler, rest } from "msw";

export const handlers: RequestHandler[] = [
  rest.get("*/exhibits", (_req, res, ctx) => res(ctx.json([]))),
];

Finally we need to integrate with Cypress to make it easy to add mocks so we’ll use custom Cypress commands to add and reset mocks.

cypress/support/commands.js

Cypress.Commands.add("addMocks", (fileName) => {
  //Set cookie is still necessary here even though the api sets cookies to get it
  //set on the right cypress browser instance
  cy.setCookie("cypress:mock-file", fileName);

  cy.request({
    method: "GET",
    url: `/api/test-mock/add?file=${encodeURIComponent(fileName)}`,
    failOnStatusCode: true,
  });
});

Cypress.Commands.add("resetMocks", () => {
  cy.clearCookie("cypress:mock-file");

  // Reset snooped requests and listeners
  cy.window().then((window) => {
    const worker = window?.msw?.worker;
    if (worker) {
      worker.events.removeAllListeners();
    }
  });
  mockedRequests = [];

  return cy.request({
    method: "GET",
    url: `/api/test-mock/reset`,
    // Turn off failing on status code for smoke tests that won't have mocks set up
    failOnStatusCode: false,
  });
});

let mockedRequests = [];

Cypress.Commands.add("startSnoopingBrowserMockedRequest", () => {
  cy.window().then((window) => {
    const worker = window?.msw?.worker;

    if (!worker) {
      reject(new Error(`MSW Browser worker not instantiated`));
    }

    worker.events.on("request:match", (req) => {
      mockedRequests.push(req);
    });
  });
});

/**
 * URL is a pattern matching URL that uses the same behavior as handlers URL matching
 * e.g. '* /events/groups/:groupId' without the space
 */
Cypress.Commands.add("findBrowserMockedRequests", ({ method, url }) => {
  return new Cypress.Promise((resolve, reject) => {
    if (
      !method ||
      !url ||
      typeof method !== "string" ||
      typeof url !== "string"
    ) {
      return reject(`Invalid parameters passed. Method: ${method} Url: ${url}`);
    }
    resolve(
      mockedRequests.filter((req) => {
        const matchesMethod =
          req.method && req.method.toLowerCase() === method.toLowerCase();
        const matchesUrl = matchRequestUrl(req.url, url).matches;
        return matchesMethod && matchesUrl;
      })
    );
  });
});

And in the e2e file we’ll make sure to reset the mocks after each test:

cypress/support/e2e.js

// Always reset the MSW mock overrides between each test so they don't pollute each other
afterEach(() => {
  cy.resetMocks();
});

Mocked Request Assertion#

You probably noticed there’s a bit of extra stuff in the Cypress commands for snooping on network requests. The the official MSW doc says:

When testing, it may be tempting to write assertions against a dispatched request. Adding such assertions, however, is implementation details testing and is highly discouraged. Asserting requests in such way is testing how your application is written, instead of what it does.

I disagree with this take however since a big part what an application does is interact with APIs. You probably want to know if you’re screwing up a critical API call when testing. So it can be very valuable to assert that requests were made correctly, especially POST/PATCH requests.

To do this, add the following to your test body: cy.startSnoopingBrowserMockedRequest();

This will start capturing all the subsequent requests for the test that are mocked out by MSW. Important to note in the name, this will only capture the browser client side requests due to the MSW setup.

Once the test performs whatever action it needs to, you can then get all matching requests and assert like so:

cy.findBrowserMockedRequests({ method: "PATCH", url: "*/an/api/:params" }).then(
  (patchRequests) => {
    assert.equal(patchRequests.length, 1);
    const body = patchRequests[0].body;

    assert.equal(body.data, { something });
  }
);

The resetMocks command run after each test will reset the snooping and request log so tests don’t pollute each other.

Test Code#

Once all that’s set up your test code will look like

cypress/e2e/specs/sample.js

import { dataOrIdsMocked } from "mocks/handlers/testName/mockFileName";

describe("a test", () => {
  it("does something", () => {
    cy.addMocks("mockFileName");
    cy.visit(`/a-path-to-test`);

    //then assert and interact as you would, using imported dataOrIdsMocked as needed
  });
});

Running It#

You can set up your dev scripts however you want of course as long as the right environment variables are set up, but here’s a suggested way to set it up so locally you can just run yarn cypress which will start both the Next.js server and Cypress with all the config handled using the concurrently npm package.

package.json

{
  "scripts": {
    "dev:mocked": "NEXT_PUBLIC_MOCKS_ENABLED=1 yarn dev",
    "dev": "concurrently -n NEXT,TS -c magenta,cyan \"yarn dev:start\" \"yarn ts --watch --pretty\"",
    "cypress:config-and-open": "node_modules/.bin/cypress open --config specPattern=cypress/e2e/specs",
    "cypress": "concurrently -n Dev,Cypress -c blue,yellow  \"yarn dev:mocked\" \"yarn cypress:config-and-open\""
  }
}

A real nice bonus about getting MSW set up like this is using it outside of testing just for developing new features where an API isn’t finished yet. Devs can just run yarn dev:mocked which will start up Next with the mocks enabled, they can set up a handler file the same as they would for a test scenario, manually go to /api/test-mock/add?file=mockFileName, and then use the app as normal with a new API mocked out. Once they’re done developing, that mock handler file can then be directly turned into a Cypress test.

Gotchas#

As said in the intro, this setup is not without flaws that I wish had solutions. If you can think of solutions please leave them in the comments!

1. Mocks must be added before navigating to a page#

Because mocks must be loaded before a page starts loaded so no requests are missed, they must be added before navigating to a page. The browser side setup loads the mocks as the page is loading.

So you can’t call cy.addMocks multiple times in the same test file unless you are visiting multiple pages in the same test.

cy.addMocks("some-mock-file");

cy.visit("page1"); // some-mock-file mocks added here

cy.addMocks("more-mocks");

// more-mocks haven't been loaded in yet
cy.get("[data-cy=abc123]").should("exist");

// now the more-mocks get loaded in
cy.visit("page2");

2. It’s hard to dynamically change mocks during or between tests#

If you need the same endpoint to return different values throughout a test file there’s two strategies:

Create multiple mock files and add them in where needed.

cypress/e2e/specs/sample.js

describe("a test", () => {
  it("does something", () => {
    cy.addMocks("mockFileName");
    cy.visit(`/a-path-to-test`);

    // ...
  });

  it("does something else", () => {
    cy.addMocks("mockFileNameWithDifferences");
    cy.visit(`/a-path-to-test`);

    //...
  });
});

This is usually the best course of action since it makes it clear what the mock setup for each test is. It does lead to lots of handler files with slight differences between them so you’ll need to use normal software best practices to not duplicate a whole bunch of stuff in each handler file. Util functions shared between handlers that return arrays of handlers are a good way to do that.

An alternative approach that works better for some kinds of tests is to use one handler file, but maintain state in it as requests are made. See next point for the caveat about this but here’s an example of what that looks like:

/mocks/handlers/statefulHandler.ts

import { RequestHandler, rest } from "msw";

let testState = 0;

const returnObjects = [
  { status: 404, body: { error: "Testing error" } },
  { status: 200, body: { someObject } },
  { status: 200, body: { someOtherObject } },
];

export const handlers: RequestHandler[] = [
  rest.get("*/jobs", (_req, res, ctx) => {
    const ret = returnObjects[testState];

    if (testState === 1) {
      testState = 2;
    }

    return res(ctx.status(ret.status), ctx.json(ret.body));
  }),
  rest.post("*/jobs", (_req, res, ctx) => {
    testState = 1;
    return res(ctx.json({ whatever }));
  }),
];

3. State is tricky in handlers#

In the above example there’s a big gotcha: the handler state is independent between the client and server. So if some requests are made in server side rendering then the browser makes some requests, each will start at the initial state. This is of course because of the two different instances of MSW, one on the server, one on the browser.

4. Mock files get imported multiple times#

The mock handler files get imported by several places like when the page loads, and when the server side mocks are enabled via API. If the tests are importing ids or other data from the handlers, these must be static values. One thing that tripped us up was using sequenced id’s in factories created by the fishery library. If you try this you’ll find that the test import will get a different value returned than the mocks are using.

The End#

Hope you found this useful, at the very least as another approach to consider. I spent way more time than I thought getting Next, Cypress, and Mock Service Work set up in a way I was ok with. There were some good articles out there on basics but nothing that covered everything I wanted so here I am filling in some gaps.

If you think you have a better way, or something to improve upon with this approach please let me know!

Want the inside scoop?

Sign up and be the first to see new posts

No spam, just the inside scoop and $10 off any photo print!