DEV Community

Cover image for 🎭How to test Next.js SSR API (Playwright + MSW)🎭
Web Developer Hyper
Web Developer Hyper

Posted on

🎭How to test Next.js SSR API (Playwright + MSW)🎭

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
Enter fullscreen mode Exit fullscreen mode

↓ Move to my-app folder

cd my-app
Enter fullscreen mode Exit fullscreen mode

↓ 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>
    );
  }
} 
Enter fullscreen mode Exit fullscreen mode

↓ Test if the Next.js works. (Optional)

npm run dev
Enter fullscreen mode Exit fullscreen mode

↓ Install Playwright

npm init playwright@latest
Enter fullscreen mode Exit fullscreen mode

↓ Install Mock Service Worker (MSW)

npm i msw --save-dev
Enter fullscreen mode Exit fullscreen mode

↓ Install tsx
tsx will reduce your build from TypeScript to JavaScript and writing a complicated tsconfig.json for the mock.

npm install -D tsx
Enter fullscreen mode Exit fullscreen mode

↓ 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" });
  });
});
Enter fullscreen mode Exit fullscreen mode

↓ 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);
  }),
];
Enter fullscreen mode Exit fullscreen mode

↓ 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 }
    )
  );
};
Enter fullscreen mode Exit fullscreen mode

↓ 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);
});
Enter fullscreen mode Exit fullscreen mode

↓ 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
Enter fullscreen mode Exit fullscreen mode

↓ Run Next.js (terminal 2)

npm run dev
Enter fullscreen mode Exit fullscreen mode

↓ Run Playwright test (terminal 3)

npx playwright test
Enter fullscreen mode Exit fullscreen mode

Screenshot of the test

↓ Test 1: Default Charizard Pokemon

↓ Test 2: Pikachu Mock

↓ Test 3: Eevee Mock

↓ 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)