`cookies()`, `headers()`, `redirect()` — Next.js App Router 서버 API를 Vitest에서 모킹하는 패턴
Next.js App Router를 쓰다 보면 테스트 파일을 작성하다가 갑자기 이런 에러를 마주치게 됩니다.
Error: This module cannot be imported outside of a request scope저도 처음 이 에러를 봤을 때 잠깐 멍했습니다. 분명 앱은 잘 돌아가는데 테스트 환경에서만 폭발하는 거죠. cookies(), headers(), redirect() 같은 Next.js 서버 API들은 실제 요청 컨텍스트 안에서만 동작하도록 설계되어 있기 때문에, Vitest가 구동하는 Node.js나 jsdom 환경에서 그냥 import하면 바로 에러가 납니다.
이 글에서는 vi.mock()으로 서버 API를 교체하는 기본 패턴부터, redirect() 검증 시 흔히 저지르는 거짓 양성 함정, 그리고 Route Handler 통합 테스트까지 단계별로 풀어봅니다. 실제로 써보면서 정리한 내용이니 바로 프로젝트에 적용해볼 수 있습니다. Next.js App Router를 이미 쓰고 있고 Vitest를 설치해둔 상태라면 딱 맞는 글입니다.
단위 테스트 설정을 한 번만 제대로 잡아두면 이후에는 로직에만 집중할 수 있거든요. 그 기반을 같이 만들어봅시다.
핵심 개념
왜 서버 API가 테스트 환경에서 바로 죽는가
cookies()와 headers()는 next/headers 패키지에서 제공됩니다. 이 함수들은 내부적으로 React의 cache()와 Node.js의 AsyncLocalStorage에 의존해서, 현재 요청 스코프에 바인딩된 데이터를 꺼내 옵니다. Vitest 환경에는 그 스코프 자체가 존재하지 않으니 import하는 순간 에러가 터지는 겁니다.
redirect()와 notFound()는 next/navigation에서 옵니다. 실제 런타임에서는 NEXT_REDIRECT라는 시그널을 throw해서 콜 스택을 강제 종료하는 방식으로 동작합니다. 이 동작 방식이 나중에 mock을 작성할 때 중요한 포인트가 됩니다 — mock에서 throw가 없으면 redirect 이후 코드가 계속 실행되어 테스트가 거짓으로 통과해버리거든요.
AsyncLocalStorage: Node.js에서 비동기 컨텍스트를 전파하는 메커니즘입니다. 요청마다 독립적인 저장 공간을 제공해서, 콜백이나 Promise 체인을 넘어서도 같은 요청의 데이터를 참조할 수 있습니다. Next.js가 쿠키와 헤더를 요청별로 격리하는 내부 구조의 핵심입니다.
Next.js 15+에서 달라진 것
Next.js 15부터 cookies()와 headers()가 완전한 async 함수로 전환되었습니다. Promise를 반환하게 된 거죠. 기존의 동기 방식 mock이 여전히 컴파일은 되지만, Next.js 15 이상 환경을 제대로 테스트하려면 mock도 Promise.resolve()를 반환하도록 맞춰주는 게 좋습니다.
// Next.js 14 이하 스타일 (여전히 동작하지만 async 경로를 정확히 반영하지 않습니다)
cookies: vi.fn(() => ({ get: vi.fn() }))
// Next.js 15+ 스타일 (async 쿠키 접근을 올바르게 모사합니다)
cookies: vi.fn(() => Promise.resolve({ get: vi.fn() }))이 글의 예시는 Next.js 15+ 기준으로 작성했습니다.
테스트 전략의 전체 구조
서버 API가 포함된 코드를 테스트하는 방법은 크게 세 갈래입니다.
| 방식 | 적용 대상 | 도구 |
|---|---|---|
vi.mock() 단위 테스트 |
Server Action, Service 계층 로직 | Vitest |
| 통합 테스트 | Route Handler | next-test-api-route-handler |
| E2E 테스트 | async Server Component, 전체 플로우 | Playwright |
한 가지 미리 말씀드리면, async Server Component를 Vitest로 직접 렌더링하는 단위 테스트는 현재 공식적으로 지원되지 않습니다. 그쪽은 Playwright로 커버하는 게 현재 권장 방향입니다.
실전 적용
개념을 잡았으니 코드로 넘어갑니다. 설정 파일 하나로 시작해서, 실전에서 자주 맞닥뜨리는 시나리오 순서로 풀어봤습니다.
예시 1: vitest.setup.ts 전역 mock 설정
가장 기본이 되는 패턴입니다. 프로젝트에 한 번만 설정해두면 모든 테스트 파일에서 자동으로 적용됩니다.
// vitest.setup.ts
import { vi } from 'vitest'
vi.mock('next/headers', () => ({
cookies: vi.fn(() =>
Promise.resolve({
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
getAll: vi.fn(() => []),
})
),
headers: vi.fn(() =>
Promise.resolve({
get: vi.fn(),
set: vi.fn(),
entries: vi.fn(() => []),
})
),
}))
vi.mock('next/navigation', () => ({
redirect: vi.fn((url: string) => {
throw new Error(`NEXT_REDIRECT: ${url}`)
}),
notFound: vi.fn(() => {
throw new Error('NEXT_NOT_FOUND')
}),
useRouter: vi.fn(),
usePathname: vi.fn(),
useSearchParams: vi.fn(),
}))
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
}))// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
setupFiles: ['./vitest.setup.ts'],
environment: 'happy-dom',
},
})| 항목 | 설명 |
|---|---|
vi.mock('next/headers', ...) |
import 시점 에러를 방지하고 빈 구현체로 교체합니다 |
redirect의 throw |
실제 동작을 흉내 내어 이후 코드 실행을 차단합니다 — 이게 없으면 거짓 양성이 생깁니다 |
setupFiles 등록 |
모든 테스트 파일 실행 전에 자동으로 적용됩니다 |
happy-dom: jsdom 대비 빠른 DOM 환경 구현체로, 커뮤니티에서 Next.js 테스트 시 jsdom 대신 많이 사용됩니다.
vitest.config.ts의environment를'happy-dom'으로 설정해서 활용할 수 있습니다.
예시 2: redirect() 동작 검증 — 거짓 양성 함정
솔직히 이 부분이 가장 많이 실수하는 지점입니다. redirect()를 vi.fn()으로만 정의하면 함수가 호출되고 그냥 undefined를 반환합니다. 실제 런타임에서는 throw가 발생해서 이후 코드가 실행되지 않는데, mock에서 throw가 없으면 redirect 이후 로직이 계속 돌아가고 테스트는 "통과"해버립니다. 이게 거짓 양성입니다.
// auth.action.ts
import { redirect } from 'next/navigation'
import { cookies } from 'next/headers'
export async function protectedAction() {
const cookieStore = await cookies()
const session = cookieStore.get('session')
if (!session) {
redirect('/login') // 실제로는 여기서 throw 발생 후 종료
}
return { data: '민감한 정보' }
}// auth.action.test.ts
import { protectedAction } from './auth.action'
import { redirect } from 'next/navigation'
import { cookies } from 'next/headers'
import { vi, expect, test, beforeEach } from 'vitest'
beforeEach(() => {
vi.clearAllMocks()
})
test('세션 없이 접근하면 /login으로 리다이렉트', async () => {
vi.mocked(cookies).mockResolvedValue({
get: vi.fn(() => undefined),
set: vi.fn(),
delete: vi.fn(),
getAll: vi.fn(() => []),
} as any)
// redirect가 throw하기 때문에 rejects로 검증합니다
await expect(protectedAction()).rejects.toThrow('NEXT_REDIRECT: /login')
expect(redirect).toHaveBeenCalledWith('/login')
})거짓 양성(false positive): 테스트가 통과했지만 실제로는 검증하지 못한 상태입니다.
redirect()mock에 throw가 없으면 이후 코드도 실행되어 "통과"처럼 보이지만 실제 동작과 다릅니다.
beforeEach(() => vi.clearAllMocks())는 테스트 간 상태 오염을 막아줍니다. 이게 없으면 이전 테스트에서 설정한 mock 반환값이 다음 테스트에도 남아서 실행 순서에 따라 결과가 달라지는 불쾌한 상황이 생깁니다.
예시 3: 특정 테스트에서 cookies() 반환값 조작
전역 mock은 기본값만 제공하고, 개별 테스트에서 필요한 시나리오로 덮어씌우는 패턴입니다. getAuthenticatedUser는 세션 쿠키 값으로 데이터베이스에서 유저를 조회하는 서비스 함수라고 가정합니다.
// user.service.test.ts
import { cookies } from 'next/headers'
import { getAuthenticatedUser } from './user.service'
import { vi, expect, test, describe, beforeEach } from 'vitest'
describe('getAuthenticatedUser', () => {
beforeEach(() => {
vi.clearAllMocks()
})
test('세션 쿠키가 있으면 유저 정보를 반환한다', async () => {
vi.mocked(cookies).mockResolvedValue({
get: vi.fn((name: string) => {
if (name === 'session') return { name: 'session', value: 'abc123' }
return undefined
}),
set: vi.fn(),
delete: vi.fn(),
getAll: vi.fn(() => []),
} as any)
// 세션 값 'abc123'으로 DB 조회된 유저의 id가 반환됩니다
const result = await getAuthenticatedUser()
expect(result).toBeDefined()
expect(result?.id).toBe('user-from-abc123')
})
test('세션 쿠키가 없으면 null을 반환한다', async () => {
vi.mocked(cookies).mockResolvedValue({
get: vi.fn(() => undefined),
set: vi.fn(),
delete: vi.fn(),
getAll: vi.fn(() => []),
} as any)
const result = await getAuthenticatedUser()
expect(result).toBeNull()
})
})as any 타입 캐스팅이 필요한 이유가 있습니다. TypeScript strict mode에서 cookies()의 반환 타입은 ReadonlyRequestCookies로 고정되어 있어서, mock 객체를 그대로 넘기면 타입 불일치 에러가 납니다. ReadonlyRequestCookies 인터페이스를 완전히 구현하는 객체를 만드는 것보다 as any로 우회하는 게 현실적으로 충분히 실용적입니다.
예시 4: Route Handler 통합 테스트 — next-test-api-route-handler
mock 없이 실제 Next.js 내부 resolver를 통해 Route Handler를 호출하는 방식입니다. cookies와 headers가 실제처럼 동작하는 환경에서 테스트할 수 있습니다.
// app/api/user/route.test.ts
import { testApiHandler } from 'next-test-api-route-handler'
import * as handler from '@/app/api/user/route'
import { test, expect } from 'vitest'
test('GET /api/user — 유효한 세션 쿠키로 유저 정보 조회', async () => {
await testApiHandler({
appHandler: handler,
test: async ({ fetch }) => {
const res = await fetch({
method: 'GET',
headers: { cookie: 'session=abc123' },
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body.id).toBeDefined()
},
})
})
test('GET /api/user — 세션 없으면 401 반환', async () => {
await testApiHandler({
appHandler: handler,
test: async ({ fetch }) => {
const res = await fetch({ method: 'GET' })
expect(res.status).toBe(401)
},
})
})주의:
next-test-api-route-handler는 현재 버전 기준으로 테스트 파일의 첫 번째 import여야 합니다. Next.js 내부 구조상 제약으로, 다른 import가 먼저 오면 제대로 동작하지 않습니다. 버전 업데이트에 따라 변경될 수 있으니 사용 전 공식 문서를 확인해보세요.
예시 5: server-only 패키지 처리
server-only가 import된 모듈을 Vitest에서 테스트하면 환경 에러가 납니다. setup 파일에 한 줄 추가해두면 해결됩니다.
// vitest.setup.ts에 추가
vi.mock('server-only', () => ({}))장단점 분석
각 방식이 어떤 상황에 맞는지 표로 정리하고, 실제로 겪기 쉬운 함정도 같이 봐둘게요.
장점
| 항목 | 내용 |
|---|---|
| 빠른 실행 속도 | 실제 Next.js 런타임 없이 동작해서 테스트 사이클이 짧습니다 |
| 격리된 단위 테스트 | 쿠키/세션 의존 없이 비즈니스 로직만 순수하게 검증할 수 있습니다 |
| 설정 간결성 | vitest.setup.ts 한 파일로 전체 프로젝트에 일괄 적용됩니다 |
| Jest 호환 | 기존 Jest 스타일의 mock 패턴을 거의 그대로 쓸 수 있습니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
redirect() 거짓 양성 |
vi.fn()만 쓰면 throw가 없어 이후 코드가 계속 실행됩니다 — 이게 실제로 가장 많이 겪는 함정입니다 |
mock에 반드시 throw new Error(...) 포함 |
| import 시점 에러 | mock 없이 next/headers를 import하면 즉시 에러가 발생합니다 |
vitest.setup.ts에 vi.mock() 사전 등록 |
| async 컴포넌트 렌더링 불가 | Vitest는 async Server Component 직접 렌더링을 미지원합니다 | Playwright E2E로 커버 |
server-only 에러 |
해당 패키지 포함 모듈을 Vitest 환경에서 import 시 에러가 발생합니다 | vi.mock('server-only', () => ({})) 추가 |
| Route Handler 한정 도구 | next-test-api-route-handler는 Route Handler에만 적용됩니다 |
Server Component, Server Action은 별도 전략 필요 |
실무에서 가장 흔한 실수
redirect()mock에서 throw 생략:vi.fn()으로만 정의하면 redirect 이후 코드가 계속 실행되어 테스트가 통과해버립니다. 거짓 양성의 가장 흔한 원인이고, 이것만 빠져도 하루를 날린 경험이 있습니다.setupFiles미등록:vitest.setup.ts를 만들어두고vitest.config.ts에 등록하지 않아서 mock이 적용되지 않는 경우입니다. "분명히 mock 설정했는데 왜 안 되지?"의 1순위 원인입니다.- 테스트 간 상태 오염:
beforeEach(() => vi.clearAllMocks())를 빠뜨리면 이전 테스트의 mock 반환값이 다음 테스트에 남습니다. 테스트 실행 순서에 따라 결과가 달라지는 불안정한 상황이 만들어집니다.
마치며
vi.mock()으로 서버 API를 교체할 때 핵심은 두 가지입니다. redirect()에 반드시 throw를 포함시키는 것, 그리고 vitest.setup.ts 한 곳에서 전역 mock을 일괄 관리하는 것. 이 두 가지만 잡아도 테스트 환경에서 마주치는 대부분의 혼란이 해소됩니다.
지금 바로 시작해볼 수 있는 3단계:
-
vitest.setup.ts생성- 프로젝트 루트에 파일을 만들고
next/headers,next/navigation,next/cachemock 코드를 붙여넣어볼 수 있습니다 server-only를 쓰고 있다면vi.mock('server-only', () => ({}))한 줄도 함께 추가해주세요
- 프로젝트 루트에 파일을 만들고
-
vitest.config.ts연결setupFiles: ['./vitest.setup.ts']와environment: 'happy-dom'을 추가해주세요- 이렇게 하면 모든 테스트에 전역 mock이 자동으로 적용됩니다
-
기존
redirect()테스트 점검redirect()검증이 있는 테스트를expect(...).rejects.toThrow('NEXT_REDIRECT: /경로')패턴으로 바꿔서 거짓 양성 여부를 확인해볼 수 있습니다- Route Handler를 테스트해야 한다면
pnpm add -D next-test-api-route-handler로 설치한 뒤 통합 테스트로 분리해볼 수 있습니다
참고 자료
공식 문서 및 패키지
- Next.js 공식 Vitest 가이드 | nextjs.org
- Next.js 공식 cookies() 함수 레퍼런스 | nextjs.org
- Vitest 공식 모킹 가이드 | vitest.dev
- next-test-api-route-handler | npm
- next-test-api-route-handler | GitHub xunnamius
커뮤니티 및 이슈 트래커
- next/headers 테스트 실패 논의 | GitHub vercel/next.js #44270
- next/navigation redirect 테스트 방법 논의 | GitHub vercel/next.js #59061
- server-only + Vitest 에러 이슈 | GitHub vercel/next.js #60038
- Testing Next.js App Router API routes | Arcjet Blog
- Mocking HTTP Headers in Next.js 서버 사이드 개발 가이드 | Medium
- Next.js v16 + Storybook 컴포넌트 테스트 (Module Mocks) | Mamezou