Testing Next.js applications with Vitest and Playwright

By Milad Akbari · · Updated

A Next.js application has two kinds of behavior worth testing. Some of it is plain logic with rules, such as which posts are published or which metadata is valid. The rest exists only in the built application: status codes, headers, the metadata in the HTML, redirects, and client code that reacts to what a visitor does. This site tests the first kind with Vitest and the second with Playwright. The examples are the site’s own tests, which at the time of writing are 15 unit tests and 15 browser tests.

Vitest for functions with rules

The Next.js guide for Vitest notes that async Server Components are not supported by Vitest and recommends end-to-end tests for them (Vitest guide). The home page, the blog pages and the root layout are async Server Components, because they read posts. The unit tests therefore leave components alone and cover the functions those components depend on. An excerpt:

import { describe, expect, it } from "vitest";
import { isPublished, parseMetadata } from "./posts";

describe("isPublished", () => {
  it("keeps drafts and future posts unpublished", () => {
    expect(
      isPublished({ draft: true, publishedAt: "2026-10-01" }, "2026-10-24"),
    ).toBe(false);
    expect(
      isPublished({ draft: false, publishedAt: "2026-10-25" }, "2026-10-24"),
    ).toBe(false);
  });
});

describe("parseMetadata", () => {
  const metadata = {
    title: "Title",
    summary: "Summary",
    publishedAt: "2026-10-24",
  };

  it.each<[string, unknown]>([
    ["missing metadata", undefined],
    ["a missing title", { ...metadata, title: " " }],
    ["an unknown field", { ...metadata, tags: ["mdx"] }],
    ["an impossible date", { ...metadata, publishedAt: "2026-02-30" }],
    ["an update before publication", { ...metadata, updatedAt: "2026-10-01" }],
  ])("rejects %s and names the file", (_, invalid) => {
    expect(() => parseMetadata("a-post", invalid)).toThrow(
      "content/blog/a-post.mdx",
    );
  });
});

The table of invalid inputs documents the rules better than prose would, and each row fails for one reason only. These tests need no configuration file: vitest run src finds the test files, and they run in Node because nothing in them touches the DOM. Timings depend on the machine and are not a performance target. The unit suite also exercises the released hostname rules through Next.js’s configuration test helper: the canonical host may become indexable, while aliases remain noindex.

The one Client Component on the site, the analytics loader, is tested in a real browser. The tests cover its canonical-origin gate, automatic initialization, navigation and safe event parameters; a stub replaces the external script.

Playwright against a production build

The Next.js guide for Playwright recommends “running your tests against your production code to more closely resemble how your application will behave” (Playwright guide). The webServer option builds and starts the site before the tests run. The relevant part of the configuration:

export default defineConfig({
  testDir: "./tests",
  use: {
    ...devices["Desktop Chrome"],
    baseURL: "http://127.0.0.1:3100",
  },
  webServer: {
    command: "pnpm build && pnpm start --hostname 127.0.0.1 --port 3100",
    url: "http://127.0.0.1:3100",
    env: {
      VERCEL_ENV: "production",
      SITE_ANALYTICS_ENABLED: "true",
      SITE_INDEXABLE: "false",
    },
    timeout: 240_000,
  },
});

The environment variables make the build behave like the production deployment, with analytics switched on so that it can be tested, and indexing switched off. reuseExistingServer is deliberately not set. Every run starts from a fresh build, and if something else is already listening on the port, Playwright stops with “http://127.0.0.1:3100 is already used” instead of testing whatever answers there. That costs a build per run, but a stale server can never make the tests pass.

Testing what visitors and crawlers see

Most browser tests check a fact that only exists in the built site. Old addresses must redirect permanently, which a request without redirects shows directly:

test("addresses of the previous site redirect permanently", async ({
  request,
}) => {
  for (const [from, to] of [
    ["/bio", "/about"],
    ["/writing", "/blog"],
  ]) {
    const response = await request.get(from, { maxRedirects: 0 });
    expect(response.status()).toBe(308);
    expect(new URL(response.headers().location, site.url).pathname).toBe(to);
  }
});

Others visit each page and check its title, description, canonical URL and share image, that it has exactly one h1, and that no two pages share a title. A build without the indexing flag, like every preview, must send X-Robots-Tag: noindex and a noindex, follow robots tag.

The most important test makes sure unpublished posts stay out of every place a reader or crawler could find them. It reads the post files itself, so it keeps working as posts are published:

for (const post of posts()) {
  const response = await page.goto(`/blog/${post.slug}`);
  if (post.published) {
    expect(response?.status()).toBe(200);
    for (const text of listings) expect(text).toContain(post.slug);
    continue;
  }
  expect(response?.status()).toBe(404);
  await expect(
    page.getByRole("heading", { name: "Page not found" }),
  ).toBeVisible();
  expect(await page.content()).not.toContain(post.title);
  for (const text of [home, ...listings, ...client]) {
    expect(text).not.toContain(post.slug);
    expect(text).not.toContain(post.title);
  }
}

listings holds the blog index, the sitemap and the RSS feed; client holds every JavaScript and CSS file the build serves to browsers. A draft that appeared in a feed, or whose text was bundled into client code, would fail the test even if its page returned 404.

Testing a third-party script without calling it

The analytics code starts automatically on the enabled production origin, https://www.miladcodes.com. Local and noncanonical origins must never load it. Both behaviors are tested without touching the real site or Google. First, the browser context answers requests for the production origin from the local build:

async function serve(context: BrowserContext, url: string) {
  await context.route(`${url}/**`, async (route) => {
    const response = await route.fetch({
      url: route.request().url().replace(url, "http://127.0.0.1:3100"),
    });
    await route.fulfill({ response });
  });
}

route.fetch() “performs the request and fetches result without fulfilling it, so that the response could be modified and then fulfilled”, and because it makes the request itself, the request can go to the local server (Route). Second, every request to Google is recorded and answered with a stub:

async function interceptGoogle(context: BrowserContext) {
  const requests: string[] = [];
  await context.route(/google/, async (route) => {
    requests.push(route.request().url());
    await route.fulfill({
      contentType: "text/javascript",
      body: "window.gtagLoads = (window.gtagLoads ?? 0) + 1;",
    });
  });
  return requests;
}

With both in place, tests assert that gtag.js loads once on the canonical origin, one page view is queued per navigation, and outbound and contact events contain only safe identifiers. Other origins never request the script. A synthetic form containing personal data must not create an event. These checks inspect commands queued for Google; they do not prove receipt or automatic engagement measurement by the real service.

After each test, a hook calls context.unrouteAll({ behavior: "ignoreErrors" }). Requests still in flight when a test ends, such as link prefetches, leave route handlers running, and those throw once the browser context closes. With ignoreErrors, errors that handlers throw after unrouting are “silently caught” (BrowserContext).

What these tests do not prove

  • They run in Chromium only. Firefox and WebKit are configured in many projects and would add coverage, at the cost of time.
  • The stub shows what the page asks gtag.js to do, not what Google records. Whether page views arrive belongs in a check of the real deployment, for example in GA4’s DebugView.
  • next start on a laptop is not Vercel. Platform headers, caching and domains need a check after each deployment.
  • Nothing compares screenshots, so a broken layout passes. Design changes are compared by hand.
  • Reading post metadata with regular expressions duplicates a little of what the loader knows, and the expressions must only look at the metadata export: a post that shows draft: true in a code sample would otherwise count as a draft. Importing the MDX files instead would need the MDX compiler in the tests.

GitHub Actions runs the same steps on every pull request: formatting, linting, type checking, the unit tests, a production build and the browser tests. The number of tests says little on its own. What matters is that each one states a behavior that would hurt if it broke: a draft in the feed, a 200 for a missing page, a request to Google from a preview.