DEV Community

Michael Gwynne
Michael Gwynne

Posted on

Server Side Mocking for Playwright in NextJS (App Router) using Mock Service Worker

Mock Service Worker (MSW) could not mock Server Side calls in NextJS 14 when using App Router. NextJS 15 has fixed that.

When testing using Playwright, if the Playwright tests fail, the CI for that branch will fail and disable Github's merging button. Great. 👍

However, if any of the APIs I rely on are down/broken/misbehaving, this can cause my Playwright tests to fail. And in the case of screenshot testing, updated data in these APIs can again cause the tests to fail.

Mock Service Worker can help with this

Set up a demo app

Create a new NextJS install.
Fetch some data from an API in your page:

// src/app/page.tsx
async function getBulbasaur() {
  const res = await fetch('https://pokeapi.co/api/v2/pokemon/bulbasaur');
  const bulbasaur = await res.json();

  return bulbasaur;
}
Enter fullscreen mode Exit fullscreen mode

And update your page to use the result:

// src/app/page.tsx
export default async function Home() {
  const bulbasaur = await getBulbasaur();
  ...
  <h1>{bulbasaur.name}</h1>
Enter fullscreen mode Exit fullscreen mode

Setting up Playwright

Firstly, let's set up Playwright:
Install Playwright and add "test": "playwright test" to package.json.
Playwright tests a running version of your app. So allow Playwright to start a server that runs your app:

// playwright.config.ts
module.exports = {
  webServer: {
    command: 'npm run start',
    url: 'http://localhost:3000',
    timeout: 120 * 1000,
  }
}
Enter fullscreen mode Exit fullscreen mode

Then add a test:

// tests/index.spec.ts
import { expect, test} from "@playwright/test";

test("Bulbasaur", async ({page}) => {
  await page.goto(`http://localhost:3000/`);
  const name = await page.innerText('h1');
  expect(name).toBe('bulbasaur');
});
Enter fullscreen mode Exit fullscreen mode

Run npm run build && npm run test and you should see your tests pass.
Great.

However, if this API was down or modified in some way, your test would fail. If you mock the result, you can be confidant that any broken tests are a result of your code.

Setting up Mock Service Worker

Install MSW via NPM.
Create a set of Playwright fixtures for the server side mocking:
(Note that you have to set the __prerender_bypass cookie to avoid testing a statically generated version of your page)

// tests/fixture.ts
import {Page, test as base} from "@playwright/test";
import {createServer, Server} from "http";
import {AddressInfo} from "net";
import next from "next";
import path from "path";
import {parse} from "url";
import * as json from "../.next/prerender-manifest.json";
import {setupServer, SetupServerApi} from "msw/node";

export const test = base.extend<{ dynamicPage: Page, port: string, requestInterceptor: SetupServerApi }>({
  dynamicPage: async ({context}, use) => {
    await context.addCookies([{
      name: '__prerender_bypass',
      value: json.preview.previewModeId,
      domain: 'localhost',
      path: '/'
    }]);

    const dynamicPage = await context.newPage();
    await use(dynamicPage);
  },
  port: [
    async ({}, use) => {
      const app = next({dev: false, dir: path.resolve(__dirname, "..")});
      await app.prepare();

      const handle = app.getRequestHandler();

      const server: Server = await new Promise(resolve => {
        const server = createServer((req, res) => {
          const parsedUrl = parse(req.url, true);
          handle(req, res, parsedUrl);
        });

        server.listen((error) => {
          if (error) throw error;
          resolve(server);
        });
      });
      const port = String((server.address() as AddressInfo).port);
      await use(port);
    },
    {
      scope: "worker",
      auto: true
    }
  ],
  requestInterceptor: [
    async ({}, use) => {
      await use((() => {
        const requestInterceptor = setupServer();

        requestInterceptor.listen({
          onUnhandledRequest: "bypass"
        });

        return requestInterceptor
      })());
    },
    {
      scope: "worker"
    }
  ]
});
Enter fullscreen mode Exit fullscreen mode

Update your test file:

// tests/index.spec.ts
import {expect} from "@playwright/test";
import {http, HttpResponse} from "msw";
import {test} from './fixture';

test("Bulbasaur", async ({dynamicPage, port, requestInterceptor}) => {
  requestInterceptor.use(http.get('https://pokeapi.co/api/v2/pokemon/bulbasaur', () => {
    return HttpResponse.json({name: 'squirtle'})
  }));

  await dynamicPage.goto(`http://localhost:${port}/`);
  const name = await dynamicPage.innerText('h1');
  expect(name).toBe('squirtle');
});
Enter fullscreen mode Exit fullscreen mode

You can see that we have mocked the response of the name of "bulbasaur" to be "squirtle" just to prove that we are getting the mocked result.
Since the "port" fixture is creating a new server for our app to run on, we can also delete our previously created playwright.config.ts.
Run npm run build && npm run test again and you should see your tests pass.

So what is happening here?

Before we are running our tests, we are building our production-like app. The "port" fixture is then creating it's own server to run our app on. We can then use the requestInterceptor to intercept calls to certain URLs that the aforementioned server is making and mock their responses.

Bonus

GraphQL queries should be able to be handled in the same way, mocking using graphql.query instead of http.get.

What else?

Top comments (8)

Collapse
 
inigomarquinez profile image
Íñigo Marquínez • Edited

Thank you very much for the article. Very interesting, as I have not found any information and examples on this subject.

I have tried to replicate your example with a Net 15.0.2 application. However, when I launch the tests, I get the following error:

Type error: Cannot find module ‘../.next/prerender-manifest.json’ or its corresponding type declarations.

If I use __NEXT_PREVIEW_MODE_ID instead of json.preview.previewModeId I can run the tests, but I have a new error:

[MSW] Failed to apply interceptor: the global 'WebSocket' property is non-configurable. This is likely an issue with your environment. If you are using a framework, please open an issue about this in their repository.

Collapse
 
votemike profile image
Michael Gwynne

Interesting.
Have you built your application before running the tests?
Is this your own repo from scratch? Or a clone of mine?
If you push it up to GitHub, I could take a quick look.

Collapse
 
inigomarquinez profile image
Íñigo Marquínez • Edited

Thanks @votemike !

I've uploaded the code to this repository.

When I try to build the app after adding the fixture, it fails because it can't find the ../.next/prerender-manifest.json as it's a file that is generated after building the app.

By the way in the repo you'll see to more tests based on an experimental feature of Nextjs v15 to support mocking server side requests

Thread Thread
 
votemike profile image
Michael Gwynne

I've had a play and I get the same error as you. However, I can't see any massive differences between your code and mine.
My code generates a lot more files inside the .next/ directory. Without further investigation, I'm unsure as to why.

Perhaps clone/fork my repo and add your testmode code on top of that.
If it's even better than what I have, you could PR it in to my repo.

Thread Thread
 
inigomarquinez profile image
Íñigo Marquínez

Hi @votemike !

In my case, before adding the fixture, as the build is correct, all those files you mention are correct. However, if I add the fixture AND try to compile again, the compilation files and that's why not all the files are generated. I've cloned your repo and followed the same steps, and in your case the app works.

So very strange, because I can't find the difference. Thanks ayway!

Collapse
 
leejjon_net profile image
Leejjon • Edited

First off, thanks for sharing this. It's terrible that so long after RSC became the advised pattern they still don't offer a good way to test server components with mocks (without running a full mock server like wiremock). Vercel should hire people to fix this. Thanks to amazing devs like you we can make it usable.

To fix the error:
Type error: Cannot find module ‘../.next/prerender-manifest.json’ or its corresponding type declarations.

You could just turn dev mode on. Then you don't need the prerender json either, and can remove it from the fixture.ts (and thus it no longer gives compile errors). Sure, you would prefer to test your playwright against a realistic production like setup, but in my case that wasn't possible anyway because we have configured output: 'standalone' in our next.config.ts (typically happens if you don't run on Vercel) and that gives the error:
"next start" does not work with "output: standalone" configuration. Use "node .next/standalone/server.js" instead.

Edit: This didn't scale very well. If you have a pipeline and it runs multiple dev modes, they all try to modify contents in the .next folder and break each others stuff. So I moved away from starting up a next server for every playwright test.

Collapse
 
ian-g profile image
Ian G

Hi Mike! This article was an absolute game changer for me. This was how I wanted to test my NextJS site, and now I can! Thank you so much!

I wanted to contribute some things that I had to fix locally, but as I was working on a private repo I can't show you the code. I think I am using ESM and you're using CommonJS perhaps? Or I have different TS config?

  1. I got an error with importing the JSON as you had, and had to us this instead: import json from "../.next/prerender-manifest.json assert { type: "json" };
  2. I got an error __dirname is not defined - because I was in ESM scope - but that was easy to fix by googling
  3. I had type errors on scope: "worker". The fix was actually simple, I needed to move the two 'use's with that scope into a second generic argument in the base.extend call, so now I have: export const test = base.extend<{ dynamicPage: Page }, { port: string; ...
Collapse
 
c0rdeiro profile image
Francisco Cordeiro

Thanks, this helped a lot! I've been trying to figure this solution for a while now