Intro
I am using Next.js
Server Side Rendering (SSR)
API
at work.
So, I wanted to automate the integration test, and started my journey how to accomplish it.π
Please note that this is just my consideration memo.
I chose Playwright
for the test.π
Playwright is an e2e testing framework, and it is an OSS made by Microsoft.
https://playwright.dev/
However, it was not easy to use Playwright for Next.js SSR, and I faced many problems using it.
Problem 1 π€
Playwright is for browser test, so it is easy to test Next.js Client Side Rendering (CSR) by default.
However, I wanted to test Next.js Server Side Rendering (SSR) this time, so I needed extra settings for it.
Solution 1 π
Use another port (e.g., 3001) instead of the default Next.js port (e.g., 3000).
Use Mock Service Worker (MSW)
and intercept the API and return mock response.
MSW is an API mocking library and popular among mock community and constantly having updates.
MSW can make both mock on the browser and mock on the server (Node.js).
https://mswjs.io/
Problem 2 π€
By the way, when you test API you would think to replace the API for many kinds of response.
However, MSW can only set one response at a time.
The best practices of MSW suggest using parameter to switch the mock response.
However, it looked like a way for CSR and is not a way for SSR.
https://mswjs.io/docs/best-practices/dynamic-mock-scenarios
Solution 2 π
According to the best practice of MSW, there is another way to switch API response.
It is using sever.use()
and override the response.
https://mswjs.io/docs/best-practices/network-behavior-overrides
Also, I made a switching API to change test patterns, and then used server.use() to switch the API mock.
Problem 3 π€
When using server.use() the mock will be overridden forever.
So I thought I need to use server.resetHandlers()
to reset the mock.
And this made the code more complicated and longer.
Solution 3 π
{ once: true }
option allows the override only once, and then it turns back to default from the next call.
https://mswjs.io/docs/best-practices/network-behavior-overrides#one-time-override
Problem 4 π€
When running the test, it failed.
Playwright runs the test in parallel by default, but MSW keeps the state globally, so the test failed.
Solution 4 π
I changed the Playwright config to run the test sequentially.
And the test succeeded!π
I will update this post, if I find a way to run it in parallel or another better way to run the test faster.
How to test Next.js SSR API (Playwright + MSW)
I will show how to do it step by step.
I added a beginner friendly comment to the code.
β Install Next.js
npx create-next-app@latest my-app --yes
β Move to my-app folder
cd my-app
β Copy and paste the code
β my-app/src/app/page.tsx
/**
* Pokemon Display Page - Simple Next.js Server Component
*
* This page fetches Pokemon data and displays it in a simple layout.
* During tests, MSW (Mock Service Worker) intercepts the API call and returns
* mock data based on what the test specifies (Charizard, Pikachu, Eevee, or 500 error).
*
* Key concepts for beginners:
* - Server-Side Rendering: This runs on the server first, then sends HTML to browser
* - Fetch API: Makes HTTP requests to get data
* - Error handling: Shows error page when API fails
* - MSW integration: Tests can switch between different Pokemon or trigger errors
*/
// Reusable title component to avoid duplication
const DemoTitle = () => (
<h2 className="text-4xl font-bold text-blue-600 mb-6">
How to test Next.js SSR API
<br />
(Playwright + MSW)
</h2>
);
export default async function Home() {
try {
// Fetch Pokemon data from our mock API
// MSW will intercept this request and return the appropriate mock data
const response = await fetch("http://localhost:3001/api/v2/pokemon/charizard");
// Check if request was successful (status 200-299)
// If not, throw an error to trigger the catch block
if (!response.ok) throw new Error(`HTTP ${response.status}`);
// Parse the JSON response into a JavaScript object
const pokemon = await response.json();
// SUCCESS: Display the Pokemon data
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="text-center">
{/* Page title explaining the demo purpose */}
<DemoTitle />
{/* Pokemon name with first letter capitalized */}
<h1 className="text-3xl font-bold mb-4">
{pokemon.name.charAt(0).toUpperCase() + pokemon.name.slice(1)}
</h1>
{/* Pokemon image */}
<img
src={pokemon.sprites.front_default}
alt={pokemon.name}
className="mx-auto mb-4 w-32 h-32"
/>
{/* Pokemon ID number */}
<p className="text-lg">Pokemon #{pokemon.id}</p>
</div>
</div>
);
} catch (error) {
// ERROR: Something went wrong with the API request
// This could happen if the mock server isn't running or returns an error status
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="text-center">
{/* Page title explaining the demo purpose */}
<DemoTitle />
<h1 className="text-2xl font-bold text-red-600 mb-4">Error</h1>
</div>
</div>
);
}
}
β Test if the Next.js works. (Optional)
npm run dev
β Install Playwright
npm init playwright@latest
β Install Mock Service Worker (MSW)
npm i msw --save-dev
β Install tsx
tsx
will reduce your build from TypeScript to JavaScript and writing a complicated tsconfig.json for the mock.
npm install -D tsx
β Copy and paste the code
β tests/example.spec.ts
import { test, expect } from "@playwright/test";
/**
* Helper function to set up mock Pokemon data for tests
* This function communicates with our mock server to switch which Pokemon data is returned
*
* @param page - Playwright page object for making requests
* @param mockType - Which Pokemon to mock ("pikachu", "eevee", or null for default "charizard")
*/
const setMockPokemon = async (
page: any,
mockType: "pikachu" | "eevee" | "error500" | null
) => {
// If no specific Pokemon is requested, default to charizard
const pokemon = mockType || "charizard";
// Send a request to our mock server to switch the Pokemon data
// This uses MSW's server.use() internally to override the API response
await page.request.post("http://localhost:3001/api/switch-pokemon", {
data: { pokemon },
});
// Wait a short time for the mock server to process the switch
await page.waitForTimeout(100);
// Navigate to the home page to see the new Pokemon
await page.goto("/");
// Wait for all network requests to complete before proceeding with test
await page.waitForLoadState("networkidle");
};
test.describe("Pokemon Basic Tests", () => {
/**
* Test 1: Default Charizard Pokemon
* This test verifies that the default Pokemon (Charizard) is displayed correctly
* when no specific mock is set
*/
test("Charizard (Default Pokemon)", async ({ page }) => {
// Set up the test to use default Charizard data
await setMockPokemon(page, null);
// Verify the Pokemon name appears in the main heading
await expect(page.locator("h1")).toContainText("Charizard");
// Verify the Pokemon ID number is displayed correctly
await expect(page.locator("text=Pokemon #6")).toBeVisible();
// Take a screenshot for visual verification
await page.screenshot({ path: "./test-results/screenshots/charizard.png" });
});
/**
* Test 2: Pikachu Mock
* This test verifies that the mock server can switch to Pikachu data
* and display it correctly on the page
*/
test("Pikachu Mock", async ({ page }) => {
// Switch the mock server to return Pikachu data instead of default Charizard
await setMockPokemon(page, "pikachu");
// Verify Pikachu name appears in the main heading
await expect(page.locator("h1")).toContainText("Pikachu");
// Verify Pikachu's ID number is displayed correctly
await expect(page.locator("text=Pokemon #25")).toBeVisible();
// Take a screenshot for visual verification
await page.screenshot({ path: "./test-results/screenshots/pikachu.png" });
});
/**
* Test 3: Eevee Mock
* This test verifies that the mock server can switch to Eevee data
* and display it correctly on the page
*/
test("Eevee Mock", async ({ page }) => {
// Switch the mock server to return Eevee data instead of default Charizard
await setMockPokemon(page, "eevee");
// Verify Eevee name appears in the main heading
await expect(page.locator("h1")).toContainText("Eevee");
// Verify Eevee's ID number is displayed correctly
await expect(page.locator("text=Pokemon #133")).toBeVisible();
// Take a screenshot for visual verification
await page.screenshot({ path: "./test-results/screenshots/eevee.png" });
});
/**
* Test 4: 500 Error Response
* This test verifies that the error page is displayed correctly
* when the API returns a 500 server error
*/
test("500 Error", async ({ page }) => {
// Switch the mock server to return 500 error
await setMockPokemon(page, "error500");
// Verify error page is displayed
await expect(page.locator("h1")).toContainText("Error");
// Take a screenshot for visual verification
await page.screenshot({ path: "./test-results/screenshots/error500.png" });
});
});
β mocks/handlers.ts
/**
* MSW (Mock Service Worker) Handlers
*
* This file contains the mock data and default handlers for our Pokemon API.
* MSW intercepts network requests and returns mock data instead of making real API calls.
*
* Learn more about MSW: https://mswjs.io/
*/
import { http, HttpResponse } from "msw";
/**
* Mock Pokemon data with HTTP status codes
* This object contains fake Pokemon data and their HTTP response status
* Each entry has: status (HTTP code) and data (Pokemon info or null for errors)
*/
export const mockData = {
// Fire-type Pokemon, ID #6
charizard: {
status: 200,
data: {
name: "charizard",
id: 6,
sprites: {
front_default:
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/6.png",
},
},
},
// Electric-type Pokemon, ID #25 (the most famous Pokemon!)
pikachu: {
status: 200,
data: {
name: "pikachu",
id: 25,
sprites: {
front_default:
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png",
},
},
},
// Normal-type Pokemon, ID #133
eevee: {
status: 200,
data: {
name: "eevee",
id: 133,
sprites: {
front_default:
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/133.png",
},
},
},
// Server error for testing error handling
error500: {
status: 500,
data: null,
},
};
/**
* Default MSW handlers
* These handlers define which API endpoints to intercept and what data to return
*
* By default, we return Charizard data when the Pokemon API is called
* Tests can override this behavior using server.use() in server.ts
*/
export const handlers = [
// Intercept GET requests to the Pokemon API endpoint
http.get("http://localhost:3001/api/v2/pokemon/charizard", () => {
console.log(`MSW: β returning charizard data (default handler)`);
return HttpResponse.json(mockData.charizard.data);
}),
];
β mocks/server.ts
/**
* MSW Server Configuration
*
* This file sets up the MSW server and provides functions to dynamically change
* which Pokemon data is returned during tests.
*
* Key concepts:
* - setupServer: Creates an MSW server instance for Node.js environment
* - server.use() with .once(): Override handler for one request only - auto resets!
*/
import { setupServer } from "msw/node";
import { http } from "msw";
import { handlers, mockData } from "./handlers.js";
/**
* Create MSW server instance with our default handlers
* This server will intercept HTTP requests and return mock data
*/
export const server = setupServer(...handlers);
/**
* Switch which Pokemon data is returned by the API
*
* Uses server.use() with .once() to override handler for one request only.
* Automatically resets after use - no cleanup needed!
*
* @param pokemonType - Which Pokemon to return ("charizard", "pikachu", "eevee", or "error500")
*/
export const setMockPokemon = (
pokemonType: "charizard" | "pikachu" | "eevee" | "error500"
) => {
// Get the mock response (status + data) for the requested type
const mockResponse = mockData[pokemonType];
// Use server.use() with .once() to override the handler for just one request
// This automatically resets after one use - no cleanup needed!
server.use(
http.get(
"http://localhost:3001/api/v2/pokemon/charizard",
() => {
console.log(
`MSW: β charizard endpoint (returning ${pokemonType} with status ${mockResponse.status})`
);
return new Response(
mockResponse.data ? JSON.stringify(mockResponse.data) : null,
{
status: mockResponse.status,
headers: { "Content-Type": "application/json" },
}
);
},
{ once: true }
)
);
};
β mock-server.ts
/**
* Mock Server - API Bridge between Tests and MSW
*
* This server acts as a bridge between our Playwright tests and the MSW (Mock Service Worker).
* Since tests run in a separate process from the mock server, we need HTTP endpoints
* to communicate between them.
*
* What this server does:
* 1. Starts up MSW to intercept API calls
* 2. Provides HTTP endpoints that tests can call to change mock behavior
* 3. Forwards other requests to be processed by MSW
*/
import http from 'http';
import type { IncomingMessage, ServerResponse } from 'http';
import * as serverModule from './src/mocks/server.ts';
// Set up the MSW server instance
// This will intercept HTTP requests and return our mock Pokemon data
const mswServer = serverModule.server;
// Start listening for requests with MSW
// 'bypass' means unhandled requests will pass through normally
mswServer.listen({
onUnhandledRequest: 'bypass',
});
console.log('β
MSW server is now intercepting requests in Node.js environment');
// Port for our bridge server (tests will send requests to this port)
const port: number = 3001;
/**
* HTTP Server - Bridge between tests and MSW
* This server provides API endpoints that tests can call to control MSW behavior
*/
const server = http.createServer(async (req: IncomingMessage, res: ServerResponse) => {
console.log(`π¨ Received request: ${req.method} ${req.url}`);
/**
* API Endpoint: POST /api/switch-pokemon
*
* This endpoint allows tests to switch which Pokemon data MSW returns.
* Tests send a POST request with { pokemon: "pikachu" | "eevee" | "charizard" }
* and we use server.use() to override the mock behavior.
*/
if (req.url === '/api/switch-pokemon' && req.method === 'POST') {
let body = '';
// Collect the request body data
req.on('data', chunk => body += chunk.toString());
// Process the request when all data is received
req.on('end', () => {
const { pokemon } = JSON.parse(body);
// Call our MSW server function to switch the Pokemon data
// This uses server.use() internally to override the default handler
serverModule.setMockPokemon(pokemon);
console.log(`π Switched mock data to: ${pokemon}`);
// Send success response back to the test
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: `Switched to ${pokemon}` }));
});
return;
}
/**
* Handle other requests (not our special API endpoints)
*
* For requests that aren't our control APIs, we attempt to process them
* through MSW. This allows MSW to intercept and mock the responses.
*/
try {
// Forward the request to MSW for processing
const url = `http://localhost:${port}${req.url}`;
const response = await fetch(url, {
method: req.method,
// Forward all headers except 'host' to avoid conflicts
headers: Object.fromEntries(
Object.entries(req.headers).filter(([key]) => key !== 'host')
),
});
// Return the MSW response back to the client
// Handle both JSON and non-JSON responses (like 500 errors with null content)
let data;
try {
data = await response.json();
} catch {
data = null; // For responses like 500 errors that don't have JSON content
}
res.writeHead(response.status, { 'Content-Type': 'application/json' });
res.end(data ? JSON.stringify(data) : null);
} catch (error) {
// Handle any errors that occur during request processing
console.error('β Error processing request:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal server error' }));
}
});
/**
* Start the bridge server
* This server will accept requests from tests and forward them to MSW
*/
server.listen(port, () => {
console.log(`π Mock bridge server listening on port ${port}`);
console.log(`π‘ Ready to receive test commands and forward API requests to MSW`);
});
/**
* Clean shutdown handling
* When the process is terminated (Ctrl+C), gracefully close both servers
*/
process.on('SIGINT', () => {
console.log('\nπ Shutting down servers...');
// Close MSW server
mswServer.close();
// Close HTTP bridge server
server.close();
console.log('β
Servers closed successfully');
process.exit(0);
});
β Set playwright.config.ts
β Delete comment out (//) of
baseURL: 'http://localhost:3000',
β‘ Change from true to false
fullyParallel: false,
β’ Change to 1
workers: 1
β£ Comment out projects of firefox and webkit
β Run mock server (terminal 1)
npx tsx mock-server.ts
β Run Next.js (terminal 2)
npm run dev
β Run Playwright test (terminal 3)
npx playwright test
Screenshot of the test
β Test 1: Default Charizard Pokemon
β Test 4: 500 Error Response
Referred post
https://dev.to/votemike/server-side-mocking-for-playwright-in-nextjs-app-router-using-mock-service-worker-2p4i
Thank you very much!
Outro
Using Playwright
and Mock Service Worker (MSW)
might be a good way to test Next.js Server Side Rendering (SSR) API
.
However, I still need to learn more to use it effectively.
I hope you learned something from this post.π
Thank you for reading.
Happy AI coding!π€
Top comments (0)