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
testProxyfrom 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_usersmissing) - 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-tempGotchas
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!