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;
}
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>
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,
}
}
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');
});
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"
}
]
});
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');
});
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?
- View the complete code for Playwright, MSW or both into a vanilla NextJS repo.
- In my example, the endpoint is mocked in each test. However, you could extract your mocking out into separate functions or files depending on your use-case.
- See how to do this in pages router at my old article - https://dev.to/votemike/server-side-mocking-for-playwright-in-nextjs-using-mock-service-worker-4p6g