Skip to content
← Journal
13 min readAta Mohammadi

The Senior Frontend Engineering Blueprint, Part 8: Testing the App Router with Vitest, Playwright and Visual Regression

A green Vitest suite that calls render() on an async Server Component is either a compile error or a false positive. Here is the real testing pyramid for the Next.js App Router — Vitest, MSW v2, mocked Prisma, and a Playwright setup project that logs in once for the whole suite.

Try this in a fresh Next.js App Router project: write a Server Component, await a database call inside it, then reach for @testing-library/react's render() the way you would for any other component. It doesn't work. render() expects a synchronous JSX tree, and an async function component isn't one — it's a function that returns a Promise<JSX.Element>. Depending on your setup you get a type error, a runtime warning, or — worse — a test that passes while asserting on nothing meaningful.

That single failure mode is the whole reason App Router testing needs its own chapter. The rest of this series has been building one application across two runtimes: a Node.js server environment running Server Components, Server Actions and Route Handlers, and a browser environment running Client Components and DOM events. A test runner built for the second one doesn't automatically understand the first, and pretending otherwise produces exactly the kind of green build that hides a broken feature — the failure mode we've written about before in a different context. This closes out the series: an enterprise-grade testing pyramid spanning unit, integration, end-to-end and visual regression, matched to the runtime each piece of code actually executes in.

The runtime boundary, mapped to a test runner

Before writing a single test, decide where the code under test actually runs. Getting this wrong is the single biggest source of flaky, slow, or meaningless test suites in an App Router codebase.

Code entity Execution runtime Test runner What you're actually verifying
Pure utilities, Zod schemas Node.js Vitest Deterministic inputs/outputs, edge cases
Data Access Layer Node.js Vitest + mocked Prisma Query shape, transaction behaviour, authorisation checks
Server Actions Node.js Vitest Direct invocation, validation, mutation side effects
Server Components Node.js (V8) Vitest, invoked directly Props-to-output resolution
Client Components jsdom Vitest + React Testing Library Event handling, state transitions
Full route/app flows Chromium, WebKit, Firefox Playwright SSR delivery, hydration, cookies, real user journeys

The pyramid this implies is the usual shape — many isolated unit tests at the bottom, fewer component/integration tests in the middle, a thin layer of full end-to-end journeys at the top — but the reason for the shape is different from a classic SPA. It isn't just "E2E tests are slow." It's that only the top layer actually runs in a browser; everything below it is Node.js pretending to be one, and pretending has limits.

Unit and component testing: Vitest, Testing Library, and MSW v2

Vitest runs on Vite's own transform pipeline and ESM resolution, which is why it resolves TypeScript path aliases and picks up your existing tsconfig.json without a parallel Babel config to keep in sync.

pnpm add -D vitest @vitejs/plugin-react vite-tsconfig-paths jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event msw@latest
// vitest.config.mts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [tsconfigPaths(), react()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./vitest.setup.ts"],
    include: ["src/**/*.{test,spec}.{ts,tsx}"],
    exclude: ["src/e2e/**", "node_modules/**"],
    coverage: {
      provider: "v8",
      reporter: ["text", "json", "html"],
      exclude: ["src/**/*.d.ts", "src/**/types.ts", "src/auth.ts", "src/proxy.ts"],
    },
  },
});
// vitest.setup.ts
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, beforeAll, afterAll } from "vitest";
import { server } from "@/mocks/node";

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));

afterEach(() => {
  cleanup();
  server.resetHandlers();
});

afterAll(() => server.close());

The onUnhandledRequest: "error" option is worth keeping strict. A test suite that silently lets unmocked requests fall through to the real network is a test suite that occasionally passes because a real API happened to be reachable, which is a coin flip you don't want in CI.

Why Mock Service Worker instead of stubbing fetch globally? Because Next.js's own data layer is fetch — extended with caching, tagging and deduplication. Monkey-patch the global and you don't just mock your API call, you disable the machinery the framework uses to cache and stream data, and your test stops resembling production. MSW intercepts at the network layer — it sits below fetch, not in place of it — so the framework's own request handling stays intact while the response is faked.

// src/mocks/handlers.ts
import { http, HttpResponse } from "msw";

export const handlers = [
  http.get("/api/posts", () => {
    return HttpResponse.json([
      { id: "post-1", title: "First Mocked Post", slug: "first-mocked-post" },
      { id: "post-2", title: "Second Mocked Post", slug: "second-mocked-post" },
    ]);
  }),
];
// src/mocks/node.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);

A Client Component test looks like any other React Testing Library test — render it, drive it with userEvent, assert on the DOM:

// src/components/ui/ClientCounter.tsx
"use client";

import { useState } from "react";

export function ClientCounter({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount);
  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}
// src/components/ui/ClientCounter.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { ClientCounter } from "./ClientCounter";

describe("<ClientCounter />", () => {
  it("renders with the provided initial count", () => {
    render(<ClientCounter initialCount={42} />);
    expect(screen.getByText("42")).toBeInTheDocument();
  });

  it("increments the count correctly on click", async () => {
    const user = userEvent.setup();
    render(<ClientCounter initialCount={0} />);

    const button = screen.getByRole("button", { name: /increment/i });
    expect(screen.getByText("0")).toBeInTheDocument();

    await user.click(button);
    expect(screen.getByText("1")).toBeInTheDocument();
  });
});

The senior gotcha: testing an async Server Component

Back to the failure this article opened with. render() cannot mount an async component, because React DOM's client renderer has no way to await it before producing a tree. The fix isn't a library — it's a change in mental model: a Server Component is just an async function that returns JSX. Call it like one, await the result, and hand the resolved element to render().

// src/components/ui/ServerPostCard.tsx
import { ClientCounter } from "./ClientCounter";

interface ServerPostCardProps {
  post: {
    id: string;
    title: string;
    viewCount: number;
    author: { name: string | null };
  };
}

export async function ServerPostCard({ post }: ServerPostCardProps) {
  return (
    <article>
      <h2>{post.title}</h2>
      <p>By {post.author.name ?? "Anonymous"}</p>
      <ClientCounter initialCount={post.viewCount} />
    </article>
  );
}
// src/components/ui/ServerPostCard.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ServerPostCard } from "./ServerPostCard";

// Isolate the RSC shell from its nested Client Component boundary
vi.mock("./ClientCounter", () => ({
  ClientCounter: ({ initialCount }: { initialCount: number }) => (
    <div data-testid="mock-counter">Count: {initialCount}</div>
  ),
}));

describe("<ServerPostCard /> (RSC)", () => {
  const mockPost = {
    id: "post-101",
    title: "Understanding Server Components",
    viewCount: 1540,
    author: { name: "Priya Anand" },
  };

  it("resolves async props and renders the static structure", async () => {
    // Invoke the RSC as a plain async function, THEN render the resolved element
    const resolved = await ServerPostCard({ post: mockPost });
    render(resolved);

    expect(screen.getByRole("heading", { name: /Understanding Server Components/i })).toBeInTheDocument();
    expect(screen.getByText(/By Priya Anand/i)).toBeInTheDocument();
    expect(screen.getByTestId("mock-counter")).toHaveTextContent("Count: 1540");
  });
});

Mocking the nested ClientCounter isn't strictly required here, but it's good practice once a Server Component's tree gets deep: it keeps this test asserting on what ServerPostCard itself does — resolve props into markup — rather than re-testing ClientCounter's own behaviour, which already has its own test above.

Testing Server Actions against a mocked Prisma client

A Server Action is an ordinary async function once you strip away the "use server" directive's wiring. Test it as a Node module: no DOM, no browser, just inputs and assertions on what it called and returned. It assumes the Prisma singleton from earlier in this series:

// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };

export const prisma = globalForPrisma.prisma ?? new PrismaClient();
pnpm add -D vitest-mock-extended
// src/lib/__mocks__/prisma.ts
import { PrismaClient } from "@prisma/client";
import { beforeEach } from "vitest";
import { mockDeep, mockReset } from "vitest-mock-extended";

export const prismaMock = mockDeep<PrismaClient>();

beforeEach(() => {
  mockReset(prismaMock);
});
// src/app/actions/posts.ts
"use server";

import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

const CreatePostSchema = z.object({
  title: z.string().min(5, "Title must have at least 5 characters"),
  content: z.string().min(20, "Content must have at least 20 characters"),
});

export type ActionState = {
  success: boolean;
  message?: string;
  errors?: Record<string, string[]>;
};

export async function createPostAction(
  _prevState: ActionState,
  formData: FormData,
): Promise<ActionState> {
  const validated = CreatePostSchema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
  });

  if (!validated.success) {
    return {
      success: false,
      message: "Validation failed.",
      errors: validated.error.flatten().fieldErrors,
    };
  }

  await prisma.post.create({
    data: {
      ...validated.data,
      slug: validated.data.title.toLowerCase().replace(/\s+/g, "-"),
      authorId: "system-author-id", // resolved from the session in the real Data Access Layer
    },
  });

  revalidatePath("/posts");
  return { success: true, message: "Post published successfully!" };
}
// src/app/actions/posts.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createPostAction } from "./posts";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

vi.mock("@/lib/prisma", () => ({
  prisma: { post: { create: vi.fn() } },
}));

vi.mock("next/cache", () => ({
  revalidatePath: vi.fn(),
}));

describe("createPostAction", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("fails validation when fields are too short", async () => {
    const formData = new FormData();
    formData.append("title", "abc");
    formData.append("content", "short");

    const result = await createPostAction({ success: false }, formData);

    expect(result.success).toBe(false);
    expect(result.errors?.title).toBeDefined();
    expect(prisma.post.create).not.toHaveBeenCalled();
  });

  it("persists the record and revalidates the cache on valid input", async () => {
    const formData = new FormData();
    formData.append("title", "Enterprise Scalability in 2026");
    formData.append("content", "A comprehensive guide covering Next.js architecture end to end.");

    vi.mocked(prisma.post.create).mockResolvedValue({} as never);

    const result = await createPostAction({ success: false }, formData);

    expect(result.success).toBe(true);
    expect(prisma.post.create).toHaveBeenCalledTimes(1);
    expect(revalidatePath).toHaveBeenCalledWith("/posts");
  });
});

Two mocking styles are shown deliberately: vi.mock with a hand-written factory for a quick, self-contained test, and vitest-mock-extended's mockDeep<PrismaClient>() for when you need a fully-typed deep mock across a larger test file without writing out every method signature yourself. Both are legitimate; the deep mock earns its dependency once a DAL has more than two or three methods under test.

End-to-end testing with Playwright

Playwright drives the actual compiled application in a real browser engine — the only layer that can honestly verify SSR HTML delivery, hydration, streaming, and cookies.

pnpm add -D @playwright/test
npx playwright install --with-deps
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./src/e2e",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  reporter: [["html", { open: "never" }], ["list"]],
  use: {
    baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL ?? "http://localhost:3000",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
  },
  projects: [
    { name: "setup", testMatch: /.*\.setup\.ts/ },
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"], storageState: "src/e2e/.auth/user.json" },
      dependencies: ["setup"],
    },
    {
      name: "mobile-safari",
      use: { ...devices["iPhone 14"], storageState: "src/e2e/.auth/user.json" },
      dependencies: ["setup"],
    },
  ],
  webServer: {
    command: "pnpm run build && pnpm run start",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 120 * 1000,
  },
});

The senior gotcha here isn't a compile error, it's a CI bill. Re-running a login form submission before every single test adds real seconds to every test, multiplied by however many tests you have, multiplied by every CI run. A setup project that authenticates once and writes the resulting cookies to a storageState file lets every other test start already logged in:

// src/e2e/auth.setup.ts
import { test as setup, expect } from "@playwright/test";
import path from "node:path";
import fs from "node:fs";

const authFile = path.join(__dirname, ".auth/user.json");

setup("authenticate global test user", async ({ page }) => {
  const dir = path.dirname(authFile);
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });

  await page.goto("/login");
  await page.getByLabel(/email/i).fill("[email protected]");
  await page.getByLabel(/password/i).fill("SuperSecretPassword123!");
  await page.getByRole("button", { name: /sign in/i }).click();

  await page.waitForURL("/dashboard");
  await expect(page.getByText(/workspace/i)).toBeVisible();

  await page.context().storageState({ path: authFile });
});
// src/e2e/dashboard-flow.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Dashboard core workflows", () => {
  test("loads the authenticated dashboard instantly from cached session", async ({ page }) => {
    await page.goto("/dashboard");
    await expect(page.getByRole("heading", { name: /platform dashboard/i })).toBeVisible();
  });

  test("handles network failure gracefully", async ({ page }) => {
    await page.route("**/api/posts", (route) =>
      route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ message: "Internal error" }) }),
    );

    await page.goto("/explore");
    await expect(page.getByText(/query error/i)).toBeVisible();
  });
});

Visual regression: catching what functional tests can't see

A component can pass every functional assertion and still render with a distorted button, a collapsed grid, or text overflowing its container — none of which a toBeInTheDocument() check will ever catch. Playwright's toHaveScreenshot() does pixel-diffing against a committed baseline image.

The trick is making it deterministic. Left alone, visual tests are the flakiest tests in any suite, because they're sensitive to things functional tests don't care about at all:

  • Disable animations — force animations: "disabled" in the assertion, or the exact frame you capture becomes a coin flip.
  • Mask dynamic content — timestamps, live counters, and avatars will never match a baseline; mask them explicitly rather than excluding the whole test.
  • Wait for fontsdocument.fonts.ready before capturing, or a font that loads a beat late shifts every line below it.
  • Fix the viewport — pin dimensions and device scale factor; a responsive layout is, by definition, not a fixed target.
// src/e2e/visual-regression.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Visual regression suite", () => {
  test("dashboard shell stays visually stable", async ({ page }) => {
    await page.goto("/dashboard");
    await page.evaluate(() => document.fonts.ready);
    await page.waitForLoadState("networkidle");

    await expect(page).toHaveScreenshot("dashboard-shell.png", {
      maxDiffPixelRatio: 0.02,
      animations: "disabled",
      mask: [page.locator('[data-testid="timestamp"]')],
    });
  });
});
# Re-baseline after a deliberate UI change
npx playwright test --update-snapshots

Even with all four rules followed, expect some cross-platform drift — macOS and Linux CI runners rasterise fonts slightly differently. Run visual tests inside the same Docker image your CI uses, and keep maxDiffPixelRatio in the 0.01–0.03 range rather than demanding pixel-perfect equality.

Wiring it into CI

A production pipeline runs the fast unit/component suite first and shards the slower Playwright suite across parallel jobs, so total wall-clock time doesn't scale linearly with test count.

# .github/workflows/verify.yml
name: Test Suite Verification

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  unit-and-component:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: npx prisma generate
      - run: pnpm tsc --noEmit
      - run: pnpm vitest run --coverage

  e2e-matrix:
    runs-on: ubuntu-latest
    needs: [unit-and-component]
    strategy:
      fail-fast: false
      matrix:
        shard: [1/2, 2/2]
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: npx playwright install --with-deps
      - run: npx playwright test --shard=${{ matrix.shard }}
      - name: Upload artifacts on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-artifacts-shard-${{ strategy.job-index }}
          path: test-results/
          retention-days: 7

Failure modes worth knowing before you hit them

Anti-pattern Root cause Fix
render() crashes on a Server Component Client renderers can't mount an async function component await Component(props) directly, then render() the resolved element
Re-authenticating before every E2E test Each login round-trip adds real time, multiplied by test count A Playwright setup project that writes storageState once per suite
Flaky visual diffs between environments Font rasterisation differs between macOS and Linux CI Run visual tests in the same Docker image as CI; keep tolerance at 1–3%
Mocking global fetch Breaks Next.js's own caching and streaming machinery, not just your call Intercept at the network layer with MSW instead
Shared database state bleeding between tests Server Action tests writing to a real, shared database An isolated test-container database per worker, or wrap writes in a rollback transaction

The compressed version

Match the test runner to the runtime, not to habit: Vitest for anything that executes in Node — utilities, the DAL, Server Actions, and Server Components invoked directly as functions — and Playwright for anything that only makes sense as a real browser talking to a real server. Mock at the network boundary (MSW), not by replacing framework internals (fetch). Pay the authentication cost once per suite, not once per test. And treat a screenshot diff as a real assertion, with the same discipline around determinism you'd apply to any other test — mask what changes, wait for what loads, and pin what should stay fixed.

That closes this series. Eight parts, one application, built the way you'd actually want to defend it in a room full of people asking "why."

Next step

Tell us what is broken or what should exist.

Send the shape of the problem and any constraints you already know — budget, deadline, the stack you are stuck with. You will get a written reply from the engineer who would do the work, not a sales sequence.