Unit Testing Next.js Server Actions: Covering Form Handling, Authentication, and Error Handling with Vitest
When I started using Server Actions in earnest after migrating to the App Router, I honestly had no idea how to write tests for them. I kept wondering, "These functions run on the server — is it really okay to just import them directly with Vitest?" So I went looking for resources, and finding nothing suitable in English took quite a while as I dug through GitHub Discussions and English-language blogs. I'm writing this post so you don't have to go through the same struggle.
The bottom line: Server Actions are nothing more than pure async functions with a "use server" directive, so it's entirely possible to import them directly and test them with Vitest. No HTTP server required, no Next.js runtime to spin up. This post covers direct FormData construction, mocking sessions and the DB with vi.mock(), Zod validation failure cases, handling redirect(), and mocking next/headers and next/cache — things that are easy to miss in practice.
One prerequisite worth stating upfront: this post is written with developers who are already using the App Router in production but haven't yet written Server Action tests in mind. It assumes you're familiar with Zod's safeParse and Vitest basics. The key is establishing a structure that covers each stage — authentication, validation, and error handling — as independent unit tests.
Core Concepts
Server Actions Are Functions That Are Easy to Test
Server Actions may look special, but from a testing perspective they're actually quite simple. The "use server" directive is just a compiler hint processed at Next.js build time — it has no effect when Vitest imports the file directly.
Server Actions that integrate with React 19's
useActionStateuse the(prevState: unknown, formData: FormData)signature. When testing, you need to explicitly passundefinedor a previous state as the first argument.
You can broadly split what you're testing into three categories:
| Test Target | What to Verify | Key Tools |
|---|---|---|
| Form handling | FormData parsing, field validation | new FormData(), Zod safeParse |
| Authentication | Blocking unauthenticated users, role-based access control | vi.mock() + session mocking |
| Error handling | DB failures, external service errors, unexpected exceptions | vi.mocked().mockRejectedValue() |
Vitest Configuration — happy-dom Is Recommended
As of 2026, the trend in Next.js + Vitest setups has shifted toward using happy-dom over jsdom. It's more ESM-friendly and runs faster. That said, if you depend on third-party libraries that rely heavily on JSDOM, stick with jsdom.
// 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: "happy-dom",
globals: true,
setupFiles: ["./vitest.setup.ts"],
},
});What Every Test File Needs in Common
Before writing any actual tests, it's worth pinning down the mocking patterns that every Action requires in common.
vi.mock() must be declared at the top of the file. Vitest handles hoisting, so the mocks are applied before any imports regardless of order. And if you forget vi.clearAllMocks() in beforeEach, the mock state from one test bleeds into the next, causing results that vary depending on execution order — a nasty situation to debug.
// Common structure for all Action test files
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock declarations always go at the top
vi.mock("@/lib/auth", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/db", () => ({ savePost: vi.fn() }));
// Add the following if the Action uses cookies or headers
vi.mock("next/headers", () => ({ cookies: vi.fn(), headers: vi.fn() }));
// Add the following if the Action calls redirect()
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
// Add the following if the Action calls revalidatePath/revalidateTag
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
}));
import { getSession } from "@/lib/auth";
describe("...", () => {
beforeEach(() => vi.clearAllMocks()); // Always reset
});Mocking next/headers and next/cache is easy to overlook at first. Actions that read session cookies directly, or Actions that call revalidatePath() after a successful CRUD operation, will crash the test entirely if these aren't mocked. It's worth scanning your Action file for which built-in Next.js functions it imports and keeping a mocking checklist ready.
Practical Application
Example 1: The Server Action Under Test
The tests that follow are all written against this example. The flow is: authentication → input validation → business logic → error handling.
// actions/create-post.ts
"use server";
import { z } from "zod";
import { getSession } from "@/lib/auth";
import { savePost } from "@/lib/db";
import { revalidatePath } from "next/cache";
const schema = z.object({
title: z.string().min(1, "제목을 입력해주세요"),
});
export async function createPost(prevState: unknown, formData: FormData) {
// Auth check runs independently inside the Action, not relying on the component
const session = await getSession();
if (!session) return { error: "Unauthorized" };
const result = schema.safeParse({ title: formData.get("title") });
if (!result.success) return { error: result.error.flatten().fieldErrors };
try {
await savePost({ title: result.data.title, userId: session.userId });
revalidatePath("/posts"); // Invalidate cache on success
return { success: true };
} catch {
return { error: "저장에 실패했습니다. 잠시 후 다시 시도해주세요." };
}
}Example 2: Authentication Tests
// actions/create-post.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createPost } from "./create-post";
vi.mock("@/lib/auth", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/db", () => ({ savePost: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { getSession } from "@/lib/auth";
import { savePost } from "@/lib/db";
import { revalidatePath } from "next/cache";
describe("createPost — authentication", () => {
beforeEach(() => vi.clearAllMocks());
it("returns an Unauthorized error for unauthenticated users", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const fd = new FormData();
fd.append("title", "Hello");
const result = await createPost(undefined, fd);
expect(result).toEqual({ error: "Unauthorized" });
expect(savePost).not.toHaveBeenCalled();
});
it("saves the post and invalidates the cache for authenticated users", async () => {
vi.mocked(getSession).mockResolvedValue({ userId: "u1" });
vi.mocked(savePost).mockResolvedValue({ id: "post-1" });
const fd = new FormData();
fd.append("title", "Valid Title");
const result = await createPost(undefined, fd);
expect(result).toEqual({ success: true });
expect(savePost).toHaveBeenCalledOnce();
expect(revalidatePath).toHaveBeenCalledWith("/posts");
});
});Example 3: Role-Based Access Control Tests
Let's add role-based access control. I made the mistake early on of only handling this with conditional rendering in the component — then got flagged in a security review because the Action itself was wide open. Even if you hide a button in the UI, the Action is a publicly callable endpoint.
// actions/admin-delete.ts
"use server";
import { getSession } from "@/lib/auth";
import { savePost } from "@/lib/db";
export async function adminDeleteAction(prevState: unknown, formData: FormData) {
const session = await getSession();
if (!session) return { error: "Unauthorized" };
if (session.role !== "ADMIN") return { error: "Forbidden" }; // Role check
const targetId = formData.get("targetId") as string;
try {
await savePost({ id: targetId, deleted: true, userId: session.userId });
return { success: true };
} catch {
return { error: "삭제에 실패했습니다." };
}
}// actions/admin-delete.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { adminDeleteAction } from "./admin-delete";
vi.mock("@/lib/auth", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/db", () => ({ savePost: vi.fn() }));
import { getSession } from "@/lib/auth";
import { savePost } from "@/lib/db";
describe("adminDeleteAction — role-based access control", () => {
beforeEach(() => vi.clearAllMocks());
it("denies access for the USER role", async () => {
vi.mocked(getSession).mockResolvedValue({ userId: "u1", role: "USER" });
const fd = new FormData();
const result = await adminDeleteAction(undefined, fd);
expect(result).toEqual({ error: "Forbidden" });
expect(savePost).not.toHaveBeenCalled();
});
it("processes the request normally for the ADMIN role", async () => {
vi.mocked(getSession).mockResolvedValue({ userId: "u1", role: "ADMIN" });
vi.mocked(savePost).mockResolvedValue({ id: "item-123" });
const fd = new FormData();
fd.append("targetId", "item-123");
const result = await adminDeleteAction(undefined, fd);
expect(result.success).toBe(true);
});
});Example 4: Covering Zod Validation Failure Cases with it.each
it.each is extremely convenient when you want to test multiple variations of similar inputs. As the number of cases grows, test coverage increases while keeping the test code minimal.
// actions/signup.test.ts (excerpt)
import { describe, it, expect, vi, beforeEach } from "vitest";
import { signupAction } from "./signup";
vi.mock("@/lib/auth", () => ({ getSession: vi.fn() }));
import { getSession } from "@/lib/auth";
describe("signupAction — email validation", () => {
beforeEach(() => vi.clearAllMocks());
const cases = [
{ email: "", expected: "이메일을 입력해주세요" },
{ email: "not-email", expected: "올바른 이메일 형식이 아닙니다" },
{ email: "valid@test.com", expected: null }, // No error
];
it.each(cases)(
"verifies validation result for email '$email'",
async ({ email, expected }) => {
vi.mocked(getSession).mockResolvedValue({ userId: "u1" });
const fd = new FormData();
fd.append("email", email);
const result = await signupAction(undefined, fd);
if (expected) {
expect(result.error?.email?.[0]).toBe(expected);
} else {
expect(result.error).toBeUndefined();
}
}
);
});Example 5: DB Failure Scenarios and Mocking redirect()
DB connection timeouts and external service outages are hard to reproduce in real life, but mockRejectedValue lets you simulate them trivially.
There's also one important thing to know: Next.js's redirect() is implemented by throwing an exception internally. If you test without mocking it, the exception goes uncaught and the test itself fails.
// actions/create-post.test.ts (error handling + redirect section)
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createPost } from "./create-post";
import { loginAction } from "./login"; // An Action written with the same pattern as createPost
vi.mock("@/lib/auth", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/db", () => ({ savePost: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() })); // Must be mocked
import { getSession } from "@/lib/auth";
import { savePost } from "@/lib/db";
import { redirect } from "next/navigation";
describe("createPost — error handling", () => {
beforeEach(() => vi.clearAllMocks());
it("returns a user-friendly error message when the DB save fails", async () => {
vi.mocked(getSession).mockResolvedValue({ userId: "u1" });
vi.mocked(savePost).mockRejectedValue(new Error("Connection timeout"));
const fd = new FormData();
fd.append("title", "My Post");
const result = await createPost(undefined, fd);
expect(result).toEqual({
error: "저장에 실패했습니다. 잠시 후 다시 시도해주세요.",
});
});
});
describe("loginAction — redirect", () => {
beforeEach(() => vi.clearAllMocks());
it("redirects to the dashboard on successful login", async () => {
vi.mocked(getSession).mockResolvedValue({ userId: "u1" });
const fd = new FormData();
fd.append("email", "user@test.com");
fd.append("password", "correct-pass");
await loginAction(undefined, fd);
expect(redirect).toHaveBeenCalledWith("/dashboard");
});
});The Most Common Mistakes in Practice
When you're just getting started writing tests, you'll likely trip over one of these four. Knowing them upfront will save you a lot of time.
-
Forgetting
vi.clearAllMocks(): If you don't reset inbeforeEach, mock state from a previous test persists into the next, creating unstable tests that pass or fail depending on execution order. If you have a test that passes when run alone but fails in a full run, this is the first place to look. -
Forgetting to mock
redirect(): Because Next.js'sredirect()works by throwing an exception, if you don't mock it withvi.mock("next/navigation"), testing a success case will result in an uncaught exception and the test will error out. -
Omitting mocks for
next/headersandnext/cache: Actions that usecookies(),headers(), orrevalidatePath()will error in the test environment without these mocks. It's worth scanning your Action file for any built-in Next.js function imports and keeping a mocking checklist ready. -
Handling authentication only at the component level: Even if you hide a button in the UI, the Action itself is a publicly callable endpoint. Validate authentication independently inside the Action, and make it explicit with a test. Having it documented in a test also makes it much harder for auth-bypass code to sneak in without review.
Limitations and How to Supplement Them
Advantages
| Item | Details |
|---|---|
| Fast feedback | Vitest can re-run tests in tens of milliseconds, keeping your development flow uninterrupted |
| No HTTP server needed | Since it's just a regular async function call, you can test without spinning up the Next.js runtime |
| Full control over error paths | Scenarios like DB connection failures and timeouts — hard to reproduce in real life — can be freely simulated with mockRejectedValue |
| Zod schema reuse | The same schema used in the Action code applies directly in tests, guaranteeing validation consistency |
| Enforced security verification | Documenting auth/authz logic as tests makes it hard for auth-bypass code to slip in without review |
Disadvantages and Caveats
Looking at the table, there are quite a few limitations — but in practice, pairing this with Playwright E2E covers most of them.
| Item | Details | Mitigation |
|---|---|---|
| Cannot render async Server Components | Vitest does not currently support rendering async Server Components | Supplement with Playwright E2E tests for component-level verification |
| Real middleware chain not tested | Mocking has limits when it comes to cookie/header handling and next/server middleware behavior |
Verify real flows with integration or E2E tests |
| Risk of over-mocking | Real integration failures — such as DB schema changes — can pass unit tests | Supplement with Playwright running against a real DB for critical paths |
| FormData environment differences | Node.js and browser FormData behavior can differ subtly | Supplement edge cases in form handling with E2E tests |
Terminology note: E2E (End-to-End) testing verifies entire user scenarios by running both a real browser and a real server. Playwright is the canonical tool for this, and it plays a different role from Vitest unit tests. Where unit tests ask "does the function return the right value?", E2E asks "does clicking the button actually change the screen?"
Closing Thoughts
Server Actions are pure async functions, and the key is establishing a structure that covers each stage — authentication, validation, business logic, and error handling — as independent unit tests.
At first it seems like having "use server" attached means some special configuration must be required, but once you actually write the tests, it's no different from testing an ordinary utility function. As you add features and feel the safety net thickening, you'll find yourself uneasy writing Actions without tests. If after reading this post you find yourself asking "how do I organize things as tests accumulate?" or "how do I spread this pattern across the team?" — that's the signal you're ready for the next step.
Three steps you can take right now:
-
Pick the simplest Action you have and write a test file for it: Dependency installation (
pnpm add -D vitest @vitejs/plugin-react vite-tsconfig-paths happy-dom) andvitest.config.mtssetup were covered above, so you can create a single test file today. Just three cases to start: one unauthenticated case, one invalid input case, and one happy path — that's enough. -
Add all Next.js built-in functions your Action imports to your mocking list: Check whether you're using
next/navigation,next/headers, ornext/cache, and make sure the mock declarations are there from the start. -
Use
pnpm test --watchfor real-time feedback during development: Tests will re-run automatically every time you modify an Action, catching regressions immediately. Once you're used to this flow, writing Actions without tests will start to feel uncomfortable.
References
- Testing: Vitest | Next.js Official Docs
- Guides: Testing | Next.js Official Docs
- Data Fetching: Server Actions and Mutations | Next.js
- How do you unit test server actions? · vercel/next.js Discussion #69036
- Next.js Server Actions: The Complete Guide (2026) | MakerKit
- Next.js Server Actions Security: 5 Vulnerabilities You Must Fix | MakerKit
- next-safe-action Official Docs
- How to Handle Forms in Next.js with Server Actions and Zod | freeCodeCamp
- Unit and E2E Tests with Vitest & Playwright | Strapi Blog