Next.js Playwright and MSW E2E Testing

Written by Krisztian Lazar GitHub

09/06/2025 4 min

Learn how to use Playwright and MSW to write end-to-end tests for your Next.js application.

In this article, we'll learn how to use Playwright and MSW to write end-to-end tests for your Next.js application. I'll cover the following:

  • How to setup Playwright and MSW using testProxy from Next.js experimental flags
  • How to use Playwright and MSW to write end-to-end tests
  • Test Server Components
  • Test GraphQL Queries and Mutations using Apollo Client
  • Test and mock Drizzle database queries
  • Gotchas (using common mocks across tests, etc...)

Check out the repository for the full example:

Using Next.js testProxy

Change your next.config.ts|js|mjs to include the experimental flag:

import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  /* config options here */
  experimental: {
    testProxy: true,
  },
};
 
export default nextConfig;

This allows you to use a first-class support for Playwright and MSW integration with Next.js. You can now import all MSW and Playwright functions directly from the following modules:

import {
  test,
  expect,
  http,
  HttpResponse,
  passthrough,
  graphql,
} from "next/experimental/testmode/playwright/msw";

Testing Server Components (External API calls)

Given the following server component:

export default async function BootPage() {
  const [boot, shoe] = await Promise.all([
    fetch("http://my-db/product/boot"),
    fetch("http://my-db/product/shoe"),
  ]);
 
  const [bootData, shoeData] = await Promise.all([boot.json(), shoe.json()]);
 
  return (
    <div>
      <h1>Boot Page</h1>
      <p>{bootData.title}</p>
      <p>{shoeData.title}</p>
    </div>
  );
}

API Endpoints can be localhost or remote, this is how we mock and test it using Next.js's testProxy:

import {
  test,
  expect,
  http,
  HttpResponse,
  passthrough,
} from "next/experimental/testmode/playwright/msw";
 
test.use({
  // MSW Handlers Scoped to this test (see `scope` below)
  mswHandlers: [
    [
      http.get("http://my-db/product/shoe", () => {
        return HttpResponse.json({
          title: "A shoe",
        });
      }),
      // allow all non-mocked routes to pass through
      http.all("*", ({ request }) => {
        console.log(`Not mocking ${request.url.toString()}`);
 
        return passthrough();
      }),
    ],
    { scope: "test" }, // or 'worker'
  ],
});
 
test("/product/shoe", async ({ page, msw }) => {
  // Local only for this test
  msw.use(
    http.get("http://my-db/product/boot", () => {
      return HttpResponse.json({
        title: "A boot",
      });
    })
  );
 
  await page.goto("/product/boot");
 
  await expect(page.locator("body")).toHaveText(/Boot/);
});

Testing Server Actions (External API calls)

Given the following component:

"use client";
 
import { useActionState } from "react";
import { submitForm } from "./action";
 
export default function FormPage() {
  const [state, formAction, isPending] = useActionState(submitForm, null);
 
  return (
    <form action={formAction}>
      <input type="text" name="name" />
      <button type="submit" disabled={isPending}>
        {isPending ? "Submitting..." : "Submit"}
      </button>
 
      <br />
 
      {state?.name ? (
        <p data-testid="submission-name">Name: {String(state.name)}</p>
      ) : null}
    </form>
  );
}

and here is the server action:

"use server";
 
export async function submitForm(prevState: unknown, formData: FormData) {
  const name = formData.get("name");
 
  // Send to API
  await fetch("http://my-db/form", {
    method: "POST",
    body: JSON.stringify({ name }),
  });
 
  return { name };
}

You can also use next-safe-action to handle the server action or any other library that helps with Server Actions. Here is the test:

import {
  test,
  expect,
  http,
  HttpResponse,
  passthrough,
} from "next/experimental/testmode/playwright/msw";
 
test("should navigate to the about page", async ({ page, msw }) => {
  msw.use(
    http.post("http://my-db/form", () => {
      return HttpResponse.json({
        name: "John Doe",
      });
    })
  );
 
  await page.goto("/form");
  await page.fill("input[name='name']", "John Doe");
  await page.click("button[type='submit']");
 
  await expect(page.locator("p[data-testid='submission-name']")).toHaveText(
    "Name: John Doe"
  );
});

Testing GraphQL Queries and Mutations using Apollo Client

Given the following component:

import { getClient } from "@/apollo/client";
import { gql } from "@apollo/client";
 
const GET_PRODUCTS = gql`
  query GetProducts {
    products {
      id
      name
    }
  }
`;
 
export default async function GraphQLPage() {
  // Ideally this would be a typed document node.
  const { data } = await getClient().query<any>({ query: GET_PRODUCTS });
 
  return (
    <div>
      <h1>GraphQL Page</h1>
 
      <ul>
        {data?.products?.map((product: any) => (
          <li key={product.id}>{product.name}</li>
        ))}
      </ul>
    </div>
  );
}

We also have the following GraphQL mutation, which runs in a Server Action:

"use client";
 
import { useActionState } from "react";
import { submitMutation } from "./action";
 
export default function GraphQLMutationPage() {
  const [state, formAction, isPending] = useActionState(submitMutation, null);
 
  return (
    <form action={formAction}>
      <input type="text" name="name" />
      <button type="submit" disabled={isPending}>
        {isPending ? "Submitting..." : "Submit"}
      </button>
 
      {state?.name ? (
        <p data-testid="new-product">New product: {String(state.name)}</p>
      ) : null}
    </form>
  );
}
"use server";
 
import { getClient } from "@/apollo/client";
import { gql } from "@apollo/client";
 
const CREATE_PRODUCT = gql`
  mutation CreateProduct($name: String!) {
    createProduct(name: $name) {
      id
      name
    }
  }
`;
 
export async function submitMutation(prevState: unknown, formData: FormData) {
  const name = formData.get("name");
 
  // Send to API
  await getClient().mutate({ mutation: CREATE_PRODUCT, variables: { name } });
 
  return { name };
}

Here is the test:

import {
  test,
  expect,
  http,
  HttpResponse,
  passthrough,
  graphql,
} from "next/experimental/testmode/playwright/msw";
 
const api = graphql.link("http://my-db/api/graphql");
 
test("should navigate to the graphql page", async ({ page, msw }) => {
  msw.use(
    api.query("GetProducts", () => {
      return HttpResponse.json({
        data: {
          products: [{ id: 1, name: "Product 1" }],
        },
      });
    })
  );
 
  await page.goto("/graphql");
 
  await expect(page.locator("ul li")).toHaveText(/Product 1/);
});
 
test("should navigate to the graphql mutation page and submit a mutation", async ({
  page,
  msw,
}) => {
  msw.use(
    api.mutation("CreateProduct", () => {
      return HttpResponse.json({
        data: {
          createProduct: { id: 1, name: "Product 2" },
        },
      });
    })
  );
 
  await page.goto("/graphql/mutation");
 
  await page.fill("input[name='name']", "Product 2");
  await page.click("button[type='submit']");
 
  await expect(page.locator("p[data-testid='new-product']")).toHaveText(
    /New product: Product 2/
  );
});

E2E-Testing with Drizzle and Server Components

Given the following server component:

import { db } from "@/db";
import { users as usersTable } from "@/db/schema";
 
export default async function UsersPage() {
  const users = await db.select().from(usersTable);
 
  return (
    <div>
      <h1>Users</h1>
      <ul>
        {users.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

Here is how we can test it:

import test, { expect } from "@playwright/test";
import { reset, seed } from "drizzle-seed";
import * as schema from "@/db/schema";
import { pushSchema } from "drizzle-kit/api";
import { db } from "@/db";
 
const SEED = 12345;
 
test.beforeAll(async () => {
  const { apply } = await pushSchema(schema, db);
  await apply();
});
 
test.beforeEach(async () => {
  await seed(db, schema, { seed: SEED });
});
 
test.afterEach(() => {
  reset(db, schema);
});
 
test.describe("Users Page", () => {
  test("should render the users page with seeded users.", async ({ page }) => {
    await page.goto("/users");
 
    await expect(page.locator("h1")).toHaveText("Users");
 
    await expect(page.locator("li")).toHaveCount(10);
  });
});

Couple of caveats:

  • I've tried using PGLite / PG-mem but there are a few quirks with those libaries, when seeding the DB they kept having issues with executing the SQL queries. (ex.: pg_roles, pg_users missing)
  • I'd recommend using a Docker container for your database or using a local database to avoid SQL errors.

Here are two commands I use for mocking the database with Docker:

# Run a temporary PostgreSQL container
docker run --rm --name postgres-temp -e POSTGRES_DB=next-testing -e POSTGRES_USER=user -e POSTGRES_PASSWORD=password -p 5432:5432 postgres:latest
 
# Stop and remove the container
docker stop postgres-temp && docker rm postgres-temp

Gotchas

When using testProxy, there seems to be an issue with types especially when using scope with the mswHandlers option. You can use the worker scope however currently it gives you a TypesScript error. The mocks that you use with mswHandlers will be scoped to a test.

Common mocks shared across tests

Use the following pattern to share mocks across tests:

test.use({
  mswHandlers: [
    [
      // Your common mocks go here.
      
      // Mock any unhandled requests to prevent connection errors
      http.all("*", ({ request }) => {
        console.log(
          `Not mocking unhandled request to: ${request.url.toString()}`,
        );
        return HttpResponse.json({ data: null }, { status: 200 });
      }),
    ],
    { scope: undefined as any }, // 👈 Notice this hack!
  ],
});
 
// Your tests go here...
 

Additional Resources

Got questions? Raise an issue on the repository or send in a PR!

Get in touch
📩

Want to chat? Reach out to me on X or email me at [email protected].