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



This content originally appeared on DEV Community and was authored by Web Developer Hyper

Intro

Last time, I made a Next.js Server Side Rendering (SSR) API test using Playwright and Mock Service Worker (MSW).↓
https://dev.to/webdeveloperhyper/how-to-test-nextjs-ssr-api-playwright-msw-k65

However, because MSW keeps its state globally, I couldn’t run Playwright in parallel and had to run it sequentially instead.πŸš€ This time, I revised the code to make Playwright run in parallel and speed up.πŸš€πŸš€πŸš€πŸš€

Please note that this is just my personal memo.

Summary of revises

1⃣ I changed mock-server.ts to dynamically control how many mock servers run.πŸ™† The number of mock servers is defined by MOCK_SERVER_COUNT in .env.

Before the revise, only one mock server would run.πŸ™…

2⃣ I changed playwright.config.ts to handle parallel tests. I added test.info().workerIndex to the test code to read how many servers to run and apply it to parallel tests.

3⃣ I changed server.ts, handler.ts and page.tsx, to allow dynamic ports in the API path so multiple servers can run at ones.πŸ™†

Before the revise, the API port was fixed to 3001.πŸ™…

4⃣ Because the number of mock servers is now dynamic, you can increase it as much as your PC can handle.πŸ™† All you have to do is to change the value of MOCK_SERVER_COUNT in .env.

Before the revise, port number was fix to only one.πŸ™…

How to test in parallel

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({ searchParams }: { searchParams?: Promise<{ mockPort?: string }> }) {
  try {
    // Await searchParams for Next.js 15 compatibility
    const params = await searchParams;
    // Get the mock port from URL search params (set by tests) or default to 3001
    const mockPort = params?.mockPort || '3001';

    // Fetch Pokemon data from our mock API
    // MSW will intercept this request and return the appropriate mock data
    const response = await fetch(`http://localhost:${mockPort}/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>
    );
  }
} 

↓ 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

↓ Install dotenv
dotenv enables you to load environment variables from .env into your code.

npm install --save-dev dotenv

↓ 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" | "blastoise" | "venusaur" | "jigglypuff" | "gengar" | "machamp" | "alakazam" | "dragonite" | "mewtwo" | "error500" | null
) => {
  // If no specific Pokemon is requested, default to charizard
  const pokemon = mockType || "charizard";

  // Calculate port based on worker index for parallel testing
  const port = 3001 + test.info().workerIndex;

  // 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:${port}/api/switch-pokemon`, {
    data: { pokemon },
  });


  // Navigate to the home page with the mock port parameter
  await page.goto(`/?mockPort=${port}`);

  // 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" });
  });

  test("Blastoise Mock", async ({ page }) => {
    await setMockPokemon(page, "blastoise");
    await expect(page.locator("h1")).toContainText("Blastoise");
    await expect(page.locator("text=Pokemon #9")).toBeVisible();
    await page.screenshot({ path: "./test-results/screenshots/blastoise.png" });
  });

  test("Venusaur Mock", async ({ page }) => {
    await setMockPokemon(page, "venusaur");
    await expect(page.locator("h1")).toContainText("Venusaur");
    await expect(page.locator("text=Pokemon #3")).toBeVisible();
    await page.screenshot({ path: "./test-results/screenshots/venusaur.png" });
  });

  test("Jigglypuff Mock", async ({ page }) => {
    await setMockPokemon(page, "jigglypuff");
    await expect(page.locator("h1")).toContainText("Jigglypuff");
    await expect(page.locator("text=Pokemon #39")).toBeVisible();
    await page.screenshot({ path: "./test-results/screenshots/jigglypuff.png" });
  });

  test("Gengar Mock", async ({ page }) => {
    await setMockPokemon(page, "gengar");
    await expect(page.locator("h1")).toContainText("Gengar");
    await expect(page.locator("text=Pokemon #94")).toBeVisible();
    await page.screenshot({ path: "./test-results/screenshots/gengar.png" });
  });

  test("Machamp Mock", async ({ page }) => {
    await setMockPokemon(page, "machamp");
    await expect(page.locator("h1")).toContainText("Machamp");
    await expect(page.locator("text=Pokemon #68")).toBeVisible();
    await page.screenshot({ path: "./test-results/screenshots/machamp.png" });
  });

  test("Alakazam Mock", async ({ page }) => {
    await setMockPokemon(page, "alakazam");
    await expect(page.locator("h1")).toContainText("Alakazam");
    await expect(page.locator("text=Pokemon #65")).toBeVisible();
    await page.screenshot({ path: "./test-results/screenshots/alakazam.png" });
  });

  test("Dragonite Mock", async ({ page }) => {
    await setMockPokemon(page, "dragonite");
    await expect(page.locator("h1")).toContainText("Dragonite");
    await expect(page.locator("text=Pokemon #149")).toBeVisible();
    await page.screenshot({ path: "./test-results/screenshots/dragonite.png" });
  });

  test("Mewtwo Mock", async ({ page }) => {
    await setMockPokemon(page, "mewtwo");
    await expect(page.locator("h1")).toContainText("Mewtwo");
    await expect(page.locator("text=Pokemon #150")).toBeVisible();
    await page.screenshot({ path: "./test-results/screenshots/mewtwo.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",
      },
    },
  },
  // Water-type Pokemon, ID #9
  blastoise: {
    status: 200,
    data: {
      name: "blastoise",
      id: 9,
      sprites: {
        front_default:
          "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/9.png",
      },
    },
  },
  // Grass-type Pokemon, ID #3
  venusaur: {
    status: 200,
    data: {
      name: "venusaur",
      id: 3,
      sprites: {
        front_default:
          "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/3.png",
      },
    },
  },
  // Normal-type Pokemon, ID #39
  jigglypuff: {
    status: 200,
    data: {
      name: "jigglypuff",
      id: 39,
      sprites: {
        front_default:
          "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/39.png",
      },
    },
  },
  // Ghost-type Pokemon, ID #94
  gengar: {
    status: 200,
    data: {
      name: "gengar",
      id: 94,
      sprites: {
        front_default:
          "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/94.png",
      },
    },
  },
  // Fighting-type Pokemon, ID #68
  machamp: {
    status: 200,
    data: {
      name: "machamp",
      id: 68,
      sprites: {
        front_default:
          "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/68.png",
      },
    },
  },
  // Psychic-type Pokemon, ID #65
  alakazam: {
    status: 200,
    data: {
      name: "alakazam",
      id: 65,
      sprites: {
        front_default:
          "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/65.png",
      },
    },
  },
  // Dragon-type Pokemon, ID #149
  dragonite: {
    status: 200,
    data: {
      name: "dragonite",
      id: 149,
      sprites: {
        front_default:
          "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/149.png",
      },
    },
  },
  // Psychic-type Pokemon, ID #150
  mewtwo: {
    status: 200,
    data: {
      name: "mewtwo",
      id: 150,
      sprites: {
        front_default:
          "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/150.png",
      },
    },
  },
  // Server error for testing error handling
  error500: {
    status: 500,
    data: null,
  },
};

/**
 * Single handler factory function
 * Creates handlers for any port - used by both single and multi-port setups
 * 
 * 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 createHandlers = (port: number) => [
  // Intercept GET requests to the Pokemon API endpoint
  http.get(`http://localhost:${port}/api/v2/pokemon/charizard`, () => {
    console.log(`MSW[${port}]: β†’ returning charizard data (default handler)`);
    return HttpResponse.json(mockData.charizard.data);
  }),
];

↓ 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
 * 4. Can spawn multiple server instances for parallel testing
 */

import http from "http";
import type { IncomingMessage, ServerResponse } from "http";
import { spawn } from 'child_process';
import { config } from 'dotenv';
import { createServer, createSetMockPokemon } from "./src/mocks/server.ts";

// Load environment variables from .env file
config();

// Check MOCK_SERVER_COUNT to determine if we should spawn multiple servers
const mockServerCount = parseInt(process.env.MOCK_SERVER_COUNT || '1');

if (mockServerCount > 1) {
  // Multi-server mode - spawn multiple instances
  const basePort = 3001;

  console.log(`Starting ${mockServerCount} mock servers...`);

  const processes: any[] = [];

  for (let i = 0; i < mockServerCount; i++) {
    const port = basePort + i;
    const child = spawn('npx', ['tsx', 'mock-server.ts', port.toString()], {
      stdio: 'inherit',
      shell: true,
      env: { ...process.env, MOCK_SERVER_COUNT: '1' }
    });

    processes.push(child);
    console.log(`Started mock server on port ${port}`);
  }

  process.on('SIGINT', () => {
    console.log('\nShutting down all mock servers...');
    processes.forEach(proc => proc.kill());
    process.exit(0);
  });

} else {
  // Single server mode
  // Get port from command line argument
  const port: number = parseInt(process.argv[2]) || 3001;

  console.log(`Starting MSW server on port ${port}`);

  // Create MSW server instance using single server factory
  const mswServer = createServer(port);

  // Function to switch mock Pokemon data using single factory
  const setMockPokemon = createSetMockPokemon(port, mswServer);

  // Start listening for requests with MSW
  // 'bypass' means unhandled requests will pass through normally
  mswServer.listen({
    onUnhandledRequest: "bypass",
  });

  console.log(
    `✅ MSW server on port ${port} is now intercepting requests in Node.js environment`
  );

  /**
   * 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(`📨 Port ${port}: 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
          setMockPokemon(pokemon);
          console.log(`🔄 Port ${port}: 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(`❌ Port ${port}: 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(
      `📡 Port ${port}: 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🔄 Port ${port}: Shutting down servers...`);

    // Close MSW server
    mswServer.close();

    // Close HTTP bridge server
    server.close();

    console.log(`✅ Port ${port}: Servers closed successfully`);
    process.exit(0);
  });
}

↓ .env

MOCK_SERVER_COUNT=6

↓ Set playwright.config.ts
β‘  Delete comment out (//) of
baseURL: ‘http://localhost:3000‘,
β‘‘ Delete comment out (//) of
import dotenv from “dotenv”;
import path from “path”;
dotenv.config({ path: path.resolve(__dirname, “.env”) });
β‘’ Change fullyParallel to
fullyParallel: true, (default)
β‘£ Change workers to
workers: parseInt(process.env.MOCK_SERVER_COUNT || “4”),
β‘€ 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

Outro

In my previous code, Playwright + MSW tests for Next.js SSR API could only run sequentially.πŸš€ But in the new code, tests can run in parallel according to your PC’s specifications and speed up.πŸš€πŸš€πŸš€πŸš€ Although I still need to learn more about effective testing.

I hope you learned something from this post.😊
Thank you for reading.
Happy AI coding!πŸ€–


This content originally appeared on DEV Community and was authored by Web Developer Hyper