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!