Next.js Server Action 단위 테스트: Vitest로 폼 처리·인증·에러 핸들링 커버하기
App Router로 마이그레이션하면서 Server Action을 본격적으로 쓰기 시작했을 때, 솔직히 테스트를 어떻게 작성해야 할지 한동안 막막했습니다. "서버에서 실행되는 함수인데 Vitest로 그냥 import해서 불러도 되는 건가?" 싶었거든요. 그래서 관련 자료를 찾아봤는데, 마땅한 한국어 자료가 없어서 결국 GitHub Discussion이랑 영어 블로그들을 뒤지는 데 한참 걸렸습니다. 이 글은 그 삽질을 여러분은 안 하셨으면 해서 씁니다.
결론부터 말씀드리면, Server Action은 "use server" 지시어가 붙어있을 뿐 실제로는 순수한 async 함수이기 때문에 Vitest로 직접 import해서 테스트하는 게 가능합니다. HTTP 서버도 필요 없고, Next.js 런타임도 띄울 필요 없습니다. 이 글에서는 FormData 직접 생성, vi.mock()을 이용한 세션·DB 모킹, Zod 검증 실패 케이스, redirect() 처리, 그리고 실무에서 자주 빠뜨리는 next/headers·next/cache 모킹까지 다룹니다.
한 가지 전제를 먼저 말씀드리면, 이 글은 App Router를 실무에서 사용하고 있지만 Server Action 테스트를 아직 작성해보지 않은 개발자를 염두에 두고 쓰였습니다. Zod의 safeParse와 Vitest 기본 사용법은 알고 있다고 가정합니다. 인증·검증·에러 핸들링 각 단계를 독립적인 단위 테스트로 커버하는 구조를 잡는 게 핵심입니다.
핵심 개념
Server Action은 테스트하기 좋은 함수입니다
Server Action이 특별해 보이는 건 사실인데, 테스트 관점에서 보면 오히려 단순합니다. "use server" 지시어는 Next.js 빌드 타임에 처리되는 컴파일러 힌트일 뿐, Vitest가 직접 파일을 import할 때는 아무런 영향을 주지 않습니다.
React 19
useActionState와 연동되는 Server Action은(prevState: unknown, formData: FormData)시그니처를 씁니다. 테스트할 때도 첫 번째 인수로undefined나 이전 상태를 명시적으로 전달해야 합니다.
테스트 대상을 크게 세 가지로 나눠볼 수 있습니다:
| 테스트 대상 | 검증 포인트 | 주요 도구 |
|---|---|---|
| 폼 처리 | FormData 파싱, 필드 유효성 검사 | new FormData(), Zod safeParse |
| 인증 검증 | 미인증 차단, 역할 기반 접근 제어 | vi.mock() + 세션 모킹 |
| 에러 핸들링 | DB 실패, 외부 서비스 오류, 예상치 못한 예외 | vi.mocked().mockRejectedValue() |
Vitest 설정 — happy-dom을 권장합니다
2026년 기준으로 Next.js + Vitest 조합에서는 jsdom 대신 happy-dom을 쓰는 경향이 생겼습니다. ESM 친화적이고 실행 속도가 더 빠릅니다. 다만 JSDOM에 강하게 의존하는 서드파티 라이브러리를 사용 중이라면 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"],
},
});모든 테스트에서 공통으로 챙겨야 할 것들
실제 테스트를 작성하기 전에, 어떤 Action이든 공통으로 필요한 모킹 패턴을 짚어두는 게 좋습니다.
vi.mock()은 파일 최상단에 선언해야 합니다. Vitest가 호이스팅 처리를 해주기 때문에 import 순서에 상관없이 모킹이 먼저 적용됩니다. 그리고 beforeEach에서 vi.clearAllMocks()를 빠뜨리면 이전 테스트의 모킹 상태가 다음 테스트에 남아, 테스트 실행 순서에 따라 결과가 달라지는 골치 아픈 상황이 생깁니다.
// 모든 Action 테스트 파일의 공통 구조
import { describe, it, expect, vi, beforeEach } from "vitest";
// 모킹 선언은 항상 최상단에
vi.mock("@/lib/auth", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/db", () => ({ savePost: vi.fn() }));
// 쿠키·헤더를 사용하는 Action이라면 아래도 추가
vi.mock("next/headers", () => ({ cookies: vi.fn(), headers: vi.fn() }));
// redirect()를 호출하는 Action이라면 아래도 추가
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
// revalidatePath/revalidateTag를 호출하는 Action이라면 아래도 추가
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
}));
import { getSession } from "@/lib/auth";
describe("...", () => {
beforeEach(() => vi.clearAllMocks()); // 항상 초기화
});next/headers와 next/cache 모킹은 처음엔 놓치기 쉽습니다. 특히 세션 쿠키를 직접 읽는 Action이나 CRUD 성공 후 revalidatePath()를 호출하는 Action은 이걸 모킹하지 않으면 테스트 자체가 에러로 종료됩니다. Action에서 어떤 Next.js 내장 함수를 import하는지 훑어보고 모킹 목록을 챙겨두는 것을 권장합니다.
실전 적용
예시 1: 테스트 대상 Server Action 작성
아래 예시를 기준으로 이후 테스트들이 작성됩니다. 인증 → 입력 검증 → 비즈니스 로직 → 에러 핸들링의 흐름입니다.
// 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) {
// 컴포넌트에 의존하지 않고 Action 내부에서 독립적으로 인증 검사
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"); // 성공 후 캐시 무효화
return { success: true };
} catch {
return { error: "저장에 실패했습니다. 잠시 후 다시 시도해주세요." };
}
}예시 2: 인증 검증 테스트
// 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 — 인증 검증", () => {
beforeEach(() => vi.clearAllMocks());
it("미인증 사용자는 Unauthorized 에러를 반환합니다", 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("인증된 사용자는 포스트를 저장하고 캐시를 무효화합니다", 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");
});
});예시 3: 역할 기반 접근 제어 테스트
이제 역할 기반 접근 제어를 추가해봅시다. 저도 처음엔 컴포넌트에서 조건부 렌더링으로만 처리했다가, Action 자체는 열려있어서 보안 리뷰에서 지적받은 경험이 있습니다. UI에서 버튼을 숨겼더라도 Action은 직접 호출이 가능한 공개 엔드포인트거든요.
// 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" }; // 역할 검사
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 — 역할 기반 접근 제어", () => {
beforeEach(() => vi.clearAllMocks());
it("USER 역할은 접근이 거부됩니다", 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("ADMIN 역할은 정상 처리됩니다", 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);
});
});예시 4: Zod 검증 실패 케이스를 it.each로 한번에
비슷한 입력 변형을 여러 케이스로 테스트하고 싶을 때 it.each가 아주 편합니다. 케이스가 늘어날수록 테스트 코드는 최소한으로 유지되면서 커버리지는 늘어납니다.
// actions/signup.test.ts (일부)
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 — 이메일 검증", () => {
beforeEach(() => vi.clearAllMocks());
const cases = [
{ email: "", expected: "이메일을 입력해주세요" },
{ email: "not-email", expected: "올바른 이메일 형식이 아닙니다" },
{ email: "valid@test.com", expected: null }, // 에러 없음
];
it.each(cases)(
"이메일 '$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();
}
}
);
});예시 5: DB 실패 시나리오와 redirect() 모킹
DB 연결 타임아웃이나 외부 서비스 장애는 실제로 재현하기 어려운 상황인데, mockRejectedValue로 간단하게 시뮬레이션할 수 있습니다.
그리고 한 가지 꼭 알아두셔야 할 점이 있는데, Next.js의 redirect()는 내부적으로 예외를 throw하는 방식으로 구현되어 있습니다. 모킹 없이 테스트하면 예외가 잡히지 않아 테스트 자체가 실패합니다.
// actions/create-post.test.ts (에러 핸들링 + redirect 파트)
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createPost } from "./create-post";
import { loginAction } from "./login"; // createPost와 동일한 패턴으로 작성된 Action
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() })); // 반드시 모킹
import { getSession } from "@/lib/auth";
import { savePost } from "@/lib/db";
import { redirect } from "next/navigation";
describe("createPost — 에러 핸들링", () => {
beforeEach(() => vi.clearAllMocks());
it("DB 저장 실패 시 사용자 친화적인 에러 메시지를 반환합니다", 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 — 리다이렉트", () => {
beforeEach(() => vi.clearAllMocks());
it("로그인 성공 시 대시보드로 리다이렉트됩니다", 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");
});
});실무에서 가장 흔한 실수
테스트를 막 작성하기 시작하면 이 네 가지에서 한 번씩은 걸립니다. 먼저 알아두시면 시간을 많이 아낄 수 있습니다.
-
vi.clearAllMocks()를 빠뜨리는 것:beforeEach에서 초기화하지 않으면 이전 테스트의 모킹 상태가 다음 테스트에 그대로 남아, 실행 순서에 따라 테스트가 성공하거나 실패하는 불안정한 상황이 생깁니다. 단독 실행에서는 통과하는데 전체 실행에서는 실패하는 테스트가 있다면 가장 먼저 여기를 의심해볼 수 있습니다. -
redirect()모킹을 잊는 것: Next.js의redirect()는 예외를 throw하는 방식이라,vi.mock("next/navigation")으로 모킹하지 않으면 성공 케이스를 테스트할 때 예외가 잡히지 않아 테스트가 에러로 종료됩니다. -
next/headers와next/cache모킹을 빠뜨리는 것:cookies(),headers(),revalidatePath()를 쓰는 Action은 이것들을 모킹하지 않으면 테스트 환경에서 에러가 납니다. Action 파일에서 어떤 Next.js 내장 함수를 import하는지 한 번 훑어보고 모킹 목록을 챙겨두는 것을 권장합니다. -
Server Action을 컴포넌트에서만 인증 처리하는 것: UI에서 버튼을 숨겼더라도 Action 자체는 직접 호출이 가능한 공개 엔드포인트입니다. Action 내부에서 독립적으로 인증을 검증하고, 이를 테스트로 명시해두는 것이 좋습니다. 이게 테스트로 명시되어 있으면 리뷰 없이 인증 우회 코드가 들어오기도 어렵습니다.
이 방법의 한계와 보완 방법
장점
| 항목 | 내용 |
|---|---|
| 빠른 피드백 | Vitest는 수십 ms 내 테스트 재실행이 가능해서 개발 흐름이 끊기지 않습니다 |
| HTTP 서버 불필요 | 일반 async 함수 호출 방식이라 Next.js 런타임 없이 바로 테스트할 수 있습니다 |
| 에러 경로 완전 제어 | DB 연결 실패, 타임아웃 등 재현하기 어려운 상황을 mockRejectedValue로 자유롭게 만들 수 있습니다 |
| Zod 스키마 재사용 | Action 코드와 동일한 스키마가 테스트에도 그대로 적용되어 검증 일관성이 보장됩니다 |
| 보안 검증 강제화 | 인증·인가 로직을 테스트로 명시하면 리뷰 없이 인증 우회 코드가 들어오기 어렵습니다 |
단점 및 주의사항
표를 써놓고 보니 한계가 꽤 있어 보이는데, 실제로 쓰다 보면 Playwright E2E 병행만 잘 해도 대부분 커버됩니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| async Server Component 렌더링 불가 | Vitest는 현재 async Server Component 렌더링을 지원하지 않습니다 | Playwright E2E 테스트로 컴포넌트 레벨 검증을 보완합니다 |
| 실제 미들웨어 체인 미검증 | 쿠키·헤더 처리, next/server 미들웨어 동작은 모킹으로 한계가 있습니다 |
통합 테스트 또는 E2E로 실제 흐름을 검증합니다 |
| 과도한 모킹의 위험 | DB 스키마 변경 등 실제 통합 오류가 단위 테스트를 통과할 수 있습니다 | 핵심 경로는 Playwright로 실제 DB와 함께 E2E 검증을 병행합니다 |
| FormData 환경 차이 | Node.js와 브라우저의 FormData 동작이 미묘하게 다를 수 있습니다 | 폼 처리 엣지케이스는 E2E로 보완합니다 |
용어 보충: E2E(End-to-End) 테스트는 실제 브라우저와 서버를 모두 띄워 사용자 시나리오 전체를 검증하는 테스트입니다. Playwright가 대표적인 도구이며, Vitest 단위 테스트와 역할이 다릅니다. 단위 테스트가 "함수가 올바른 값을 반환하는가"를 확인한다면, E2E는 "버튼을 클릭했을 때 실제로 화면이 바뀌는가"를 확인합니다.
마치며
Server Action은 순수한 async 함수이고, 인증 → 검증 → 비즈니스 로직 → 에러 핸들링 각 단계를 독립적인 단위 테스트로 커버하는 구조를 잡는 게 핵심입니다.
처음엔 "use server"가 붙어있으니 뭔가 특별한 설정이 필요할 것 같아 보이지만, 실제로 테스트를 작성해보면 일반 유틸 함수를 테스트하는 것과 다르지 않습니다. 기능이 추가될수록 안전망이 두꺼워지는 걸 느끼다 보면, 테스트 없이 Action을 작성하는 게 오히려 불안하게 느껴지기 시작합니다. 이 글을 읽은 뒤 "테스트가 많아지면 어떻게 구조를 잡지?", "팀 전체에 이 패턴을 어떻게 전파하지?"라는 질문이 생긴다면, 그게 바로 다음 단계로 넘어갈 신호입니다.
지금 바로 시작해볼 수 있는 3단계:
-
가장 단순한 Action 하나를 골라 테스트 파일 작성: 의존성 설치(
pnpm add -D vitest @vitejs/plugin-react vite-tsconfig-paths happy-dom)와vitest.config.mts설정은 이미 설명했으니, 오늘 당장 테스트 파일 하나만 만들어볼 수 있습니다. 미인증 케이스 하나, 유효하지 않은 입력 케이스 하나, 정상 케이스 하나 — 이 세 가지만 먼저 작성해보는 것으로 충분합니다. -
Action에서 import하는 Next.js 내장 함수를 모두 모킹 목록에 추가:
next/navigation,next/headers,next/cache를 쓰고 있는지 확인하고 처음부터 모킹 선언을 챙겨두는 것을 권장합니다. -
pnpm test --watch로 개발 중 실시간 확인: Action을 수정할 때마다 테스트가 자동으로 재실행되면서 회귀를 즉시 잡을 수 있습니다. 이 흐름에 익숙해지면 테스트 없이 Action을 작성하기가 오히려 불안하게 느껴집니다.
참고 자료
- Testing: Vitest | Next.js 공식 문서
- Guides: Testing | Next.js 공식 문서
- 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 공식 문서
- How to Handle Forms in Next.js with Server Actions and Zod | freeCodeCamp
- Unit and E2E Tests with Vitest & Playwright | Strapi Blog