@nuxt/test-utils 단위·E2E 테스트, 서버/클라이언트 에러 처리와 디버깅.
테스트·디버깅·에러 핸들링
입문편에서 라우팅, 데이터 패칭, 서버 라우트, 상태 관리 등 Nuxt의 핵심 기능을 익혔다면, 이제 그 기능들이 실제로 올바르게 동작하는지 검증하고, 예외 상황을 안전하게 처리하는 방법을 다룰 차례입니다. 기능 구현만큼이나 테스트 가능성(testability)과 에러 복원력(resilience)이 프로덕션 품질을 가르는 기준이 됩니다.
이 레슨은 @nuxt/test-utils와 Vitest로 컴포넌트와 composable을 단위 테스트하고, Playwright를 통해 SSR 환경을 포함한 E2E 시나리오를 검증하며, Nuxt의 에러 처리 API(error.vue, createError, useError)로 서버·클라이언트 에러를 일관되게 다루는 체계를 구축하는 데 집중합니다.
학습 목표
@nuxt/test-utils와 Vitest를 프로젝트에 설정하고,mountSuspended로 비동기 컴포넌트를 테스트할 수 있다.mockNuxtImport로 자동 임포트된 composable을 모킹하여 격리된 단위 테스트를 작성할 수 있다.- Playwright와
@nuxt/test-utils를 연동해 E2E 테스트와 SSR 출력 검증을 수행할 수 있다. error.vue,createError,showError를 활용해 전역 에러 페이지를 설계할 수 있다.- 소스맵, Nuxt DevTools, 디버그 모드로 SSR 이슈를 추적하고 진단할 수 있다.
@nuxt/test-utils + Vitest 환경 구성
@nuxt/test-utils는 Nuxt 애플리케이션 컨텍스트 안에서 컴포넌트와 composable을 테스트할 수 있도록 설계된 공식 테스트 유틸리티입니다. Vue Test Utils 위에 Nuxt 자동 임포트, 플러그인, Pinia 스토어 등의 컨텍스트를 자동으로 주입해 줍니다.
설치 및 설정
# 패키지 설치
npm install -D @nuxt/test-utils vitest @vue/test-utils happy-dom playwright-core
vitest.config.ts를 프로젝트 루트에 생성합니다.
// vitest.config.ts
import { defineVitestConfig } from '@nuxt/test-utils/config'
export default defineVitestConfig({
test: {
environment: 'nuxt', // Nuxt 컨텍스트 활성화
environmentOptions: {
nuxt: {
domEnvironment: 'happy-dom', // jsdom 대신 happy-dom 사용 (더 빠름)
},
},
globals: true, // describe/it/expect 전역 사용
},
})
package.json에 스크립트를 추가합니다.
{
"scripts": {
"test": "vitest",
"test:unit": "vitest run",
"test:e2e": "vitest run --project e2e"
}
}
💡 TIP
environment: 'nuxt'를 설정하면 각 테스트 파일이 Nuxt 앱 컨텍스트(자동 임포트, 런타임 설정 등)를 자동으로 가집니다. 기존 Vitest 프로젝트에 부분 도입할 경우 파일 상단에// @vitest-environment nuxt주석으로 파일 단위 지정도 가능합니다.
mountSuspended·mockNuxtImport로 컴포넌트·composable 단위 테스트
mountSuspended — 비동기 컴포넌트 마운트
Nuxt 컴포넌트는 <Suspense>와 async setup()을 자주 사용합니다. Vue Test Utils의 mount는 이 비동기 초기화를 올바르게 처리하지 못하는 경우가 있어 mountSuspended를 사용해야 합니다.
<!-- components/UserProfile.vue -->
<script setup lang="ts">
const { data: user } = await useFetch('/api/user/me')
</script>
<template>
<div>
<h2>{{ user?.name }}</h2>
<p>{{ user?.email }}</p>
</div>
</template>
// tests/components/UserProfile.test.ts
import { mountSuspended, mockNuxtImport } from '@nuxt/test-utils/runtime'
import UserProfile from '~/components/UserProfile.vue'
// useFetch를 모킹 — 실제 HTTP 요청을 차단
mockNuxtImport('useFetch', () => {
return () => ({
data: ref({ name: '홍길동', email: 'hong@example.com' }),
pending: ref(false),
error: ref(null),
})
})
describe('UserProfile', () => {
it('사용자 이름과 이메일을 렌더링한다', async () => {
const wrapper = await mountSuspended(UserProfile)
expect(wrapper.find('h2').text()).toBe('홍길동')
expect(wrapper.find('p').text()).toBe('hong@example.com')
})
})
⚠️ 주의
mockNuxtImport는 파일 최상위 레벨(describe 블록 바깥)에서 호출해야 합니다. 내부에서 호출하면 모킹이 정상 적용되지 않습니다.
mockNuxtImport — 자동 임포트 composable 격리
자동 임포트된 composable은 일반 vi.mock으로 모킹하기 까다롭습니다. mockNuxtImport는 Nuxt의 자동 임포트 레이어를 인식하여 올바르게 대체해 줍니다.
// tests/composables/useCart.test.ts
import { mockNuxtImport } from '@nuxt/test-utils/runtime'
import { useCart } from '~/composables/useCart'
// useAuth composable을 격리
mockNuxtImport('useAuth', () => {
return () => ({
user: ref({ id: 'user-1', name: '테스트유저' }),
isLoggedIn: computed(() => true),
})
})
describe('useCart', () => {
it('로그인 상태에서 장바구니에 상품을 추가한다', () => {
const { items, addItem } = useCart()
addItem({ id: 'prod-1', name: '상품A', price: 10000 })
expect(items.value).toHaveLength(1)
expect(items.value[0].name).toBe('상품A')
})
it('총액을 올바르게 계산한다', () => {
const { items, total, addItem } = useCart()
addItem({ id: 'prod-1', name: '상품A', price: 10000 })
addItem({ id: 'prod-2', name: '상품B', price: 5000 })
expect(total.value).toBe(15000)
})
})
컴포넌트 슬롯·이벤트 테스트
// tests/components/Modal.test.ts
import { mountSuspended } from '@nuxt/test-utils/runtime'
import Modal from '~/components/Modal.vue'
describe('Modal', () => {
it('슬롯 콘텐츠를 렌더링한다', async () => {
const wrapper = await mountSuspended(Modal, {
slots: {
default: '<p>모달 내용</p>',
},
props: { open: true },
})
expect(wrapper.find('p').text()).toBe('모달 내용')
})
it('닫기 버튼 클릭 시 close 이벤트를 발생시킨다', async () => {
const wrapper = await mountSuspended(Modal, {
props: { open: true },
})
await wrapper.find('[data-testid="close-btn"]').trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
})
})
Playwright 연동 E2E 테스트와 SSR 출력 검증
단위 테스트만으로는 SSR 렌더링, 하이드레이션, 미들웨어 체인 등 Nuxt 특유의 동작을 충분히 검증하기 어렵습니다. @nuxt/test-utils는 Playwright를 통해 실제 Nuxt 서버를 구동하는 E2E 테스트를 지원합니다.
E2E 테스트 설정
// vitest.config.ts (E2E 프로젝트 추가)
import { defineVitestConfig } from '@nuxt/test-utils/config'
export default defineVitestConfig({
test: {
projects: [
{
name: 'unit',
environment: 'nuxt',
include: ['tests/unit/**/*.test.ts'],
},
{
name: 'e2e',
environment: 'node',
include: ['tests/e2e/**/*.test.ts'],
},
],
},
})
브라우저 E2E 테스트
// tests/e2e/home.test.ts
import { setup, $fetch, createPage, url } from '@nuxt/test-utils/e2e'
import { describe, it, expect, beforeAll } from 'vitest'
// Nuxt 개발 서버를 테스트 전에 한 번 구동
await setup({
rootDir: fileURLToPath(new URL('../..', import.meta.url)),
server: true,
browser: true,
})
describe('홈 페이지 E2E', () => {
it('페이지 제목이 올바르게 렌더링된다', async () => {
const page = await createPage('/')
const title = await page.title()
expect(title).toContain('내 사이트')
await page.close()
})
it('네비게이션 링크가 동작한다', async () => {
const page = await createPage('/')
await page.click('a[href="/about"]')
await page.waitForURL('**/about')
expect(page.url()).toContain('/about')
await page.close()
})
})
SSR 출력 검증 — $fetch
$fetch는 브라우저 없이 서버 응답 HTML을 직접 가져옵니다. SEO에 중요한 메타 태그, 초기 렌더링된 데이터, 구조화된 마크업을 검증할 때 유용합니다.
// tests/e2e/ssr.test.ts
import { setup, $fetch } from '@nuxt/test-utils/e2e'
await setup({ rootDir: fileURLToPath(new URL('../..', import.meta.url)) })
describe('SSR 출력 검증', () => {
it('상품 목록 페이지가 서버에서 렌더링된다', async () => {
const html = await $fetch('/products')
// SSR로 렌더링된 상품 이름이 초기 HTML에 포함되어야 함
expect(html).toContain('data-nuxt-component')
expect(html).toContain('상품')
})
it('OG 메타 태그가 올바르게 설정된다', async () => {
const html = await $fetch('/products/1')
expect(html).toContain('<meta property="og:title"')
expect(html).toContain('<meta property="og:description"')
})
it('인증이 필요한 페이지가 /login 으로 리다이렉트된다', async () => {
// $fetch는 기본적으로 리다이렉트를 따라가므로 최종 URL 확인
const response = await $fetch('/dashboard', {
redirect: 'manual',
ignoreResponseError: true,
})
// 리다이렉트 응답 확인은 fetch 헤더로
// 또는 Playwright page로 URL 검증
})
})
| 방법 | 대상 | 특징 |
|---|---|---|
mountSuspended | 단일 컴포넌트 | 빠름, Nuxt 컨텍스트 있음, 브라우저 없음 |
$fetch | 전체 페이지 HTML | SSR 출력 검증, 빠름, UI 인터랙션 없음 |
createPage (Playwright) | 전체 앱 | 하이드레이션·인터랙션·네트워크 검증 가능 |
error.vue·createError·showError로 전역 에러 페이지 설계
Nuxt는 ~/error.vue 파일 하나로 전역 에러 페이지를 정의합니다. 이 파일은 pages/ 바깥의 루트 레벨에 위치하며, 처리되지 않은 에러가 발생했을 때 레이아웃과 완전히 분리된 방식으로 렌더링됩니다.
error.vue 구현
<!-- error.vue -->
<script setup lang="ts">
import type { NuxtError } from '#app'
const props = defineProps<{
error: NuxtError
}>()
const handleError = () => clearError({ redirect: '/' })
const statusMessage = computed(() => {
switch (props.error.statusCode) {
case 404: return '페이지를 찾을 수 없습니다'
case 403: return '접근 권한이 없습니다'
case 500: return '서버 내부 오류가 발생했습니다'
default: return props.error.message || '알 수 없는 오류'
}
})
</script>
<template>
<div class="error-page">
<h1>{{ error.statusCode }}</h1>
<p>{{ statusMessage }}</p>
<button @click="handleError">홈으로 돌아가기</button>
</div>
</template>
createError — 에러 객체 생성
createError는 서버와 클라이언트 양쪽에서 표준화된 NuxtError 객체를 생성합니다.
// server/api/products/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
const product = await db.products.findById(id)
if (!product) {
// ✅ 서버에서 createError 사용 — 클라이언트에 statusCode 전달
throw createError({
statusCode: 404,
statusMessage: '상품을 찾을 수 없습니다',
data: { id }, // 추가 컨텍스트 (개발 모드에서만 노출)
})
}
return product
})
// pages/products/[id].vue (클라이언트 측)
const { data, error } = await useFetch(`/api/products/${route.params.id}`)
// ❌ 에러를 그냥 throw하면 전역 에러 페이지로 이동
// throw new Error('상품 없음')
// ✅ createError + fatal: true 로 전역 에러 페이지 강제 전환
if (error.value) {
throw createError({
statusCode: error.value.statusCode ?? 500,
message: error.value.message,
fatal: true, // error.vue 렌더링 트리거
})
}
showError — 프로그래매틱 에러 표시
showError는 비동기 이벤트 핸들러나 조건부 로직에서 명시적으로 에러 상태를 트리거할 때 사용합니다.
// composables/usePayment.ts
export const usePayment = () => {
const processPayment = async (amount: number) => {
try {
const result = await $fetch('/api/payment', {
method: 'POST',
body: { amount },
})
return result
} catch (err: any) {
if (err.statusCode === 402) {
// 결제 실패는 전역 에러로 처리하지 않고 로컬 핸들링
return { success: false, reason: 'insufficient_funds' }
}
// 예상치 못한 서버 오류는 전역 에러 페이지로
showError(createError({
statusCode: err.statusCode ?? 500,
message: '결제 처리 중 오류가 발생했습니다',
fatal: true,
}))
}
}
return { processPayment }
}
💡 TIP
fatal: true없이createError를 throw하면 에러는useError()로 읽을 수 있는 상태가 되지만error.vue전환은 일어나지 않습니다. 페이지 전체를 에러 상태로 전환해야 할 때만fatal: true를 사용하세요.
useError·onErrorCaptured·Nitro 에러 훅으로 서버/클라이언트 에러 구분 처리
useError — 현재 에러 상태 읽기
<!-- layouts/default.vue -->
<script setup lang="ts">
const error = useError()
// 에러가 있으면 전용 UI 표시
const hasError = computed(() => !!error.value)
</script>
<template>
<div>
<AppHeader />
<main>
<!-- 에러가 없을 때만 슬롯 렌더링 -->
<slot v-if="!hasError" />
<ErrorBanner v-else :error="error" @retry="clearError()" />
</main>
</div>
</template>
onErrorCaptured — 컴포넌트 트리 에러 캡처
Vue의 onErrorCaptured 훅은 자식 컴포넌트에서 발생한 에러를 부모가 캡처하여 전파를 막거나 로깅할 수 있게 합니다. 에러 바운더리 패턴 구현에 활용합니다.
<!-- components/ErrorBoundary.vue -->
<script setup lang="ts">
const props = defineProps<{
fallback?: string
}>()
const capturedError = ref<Error | null>(null)
onErrorCaptured((err, instance, info) => {
capturedError.value = err
// 에러 리포팅 서비스로 전송
console.error('[ErrorBoundary]', {
message: err.message,
component: instance?.$options.name,
lifecycleHook: info,
})
// true를 반환하면 에러 전파 중단 (상위로 전달 안 됨)
return true
})
</script>
<template>
<slot v-if="!capturedError" />
<div v-else class="error-boundary">
<p>{{ props.fallback ?? '컴포넌트를 불러오지 못했습니다.' }}</p>
<button @click="capturedError = null">다시 시도</button>
</div>
</template>
<!-- 사용 예 -->
<ErrorBoundary fallback="위젯을 불러올 수 없습니다">
<DashboardWidget />
</ErrorBoundary>
Nitro 에러 훅 — 서버 사이드 에러 처리
Nitro는 서버 런타임의 에러를 훅으로 가로챌 수 있습니다. ~/server/plugins/ 에 Nitro 플러그인을 작성합니다.
// server/plugins/error-handler.ts
export default defineNitroPlugin((nitroApp) => {
// 모든 서버 에러를 캡처하는 훅
nitroApp.hooks.hook('error', (error, { event }) => {
// H3Error가 아닌 예상치 못한 서버 에러만 로깅
if (!error.statusCode || error.statusCode >= 500) {
console.error('[Nitro Error]', {
url: event?.path,
method: event?.method,
message: error.message,
stack: error.stack,
})
// 외부 에러 추적 서비스 연동 (예: Sentry)
// Sentry.captureException(error)
}
})
// API 응답 전 훅 — 에러 응답 포맷 표준화
nitroApp.hooks.hook('beforeResponse', (event, response) => {
// 에러 응답일 경우 민감한 스택 정보 제거
if (response.body && typeof response.body === 'object') {
const body = response.body as Record<string, unknown>
if (body.stack && process.env.NODE_ENV === 'production') {
delete body.stack
}
}
})
})
| 에러 처리 위치 | API | 사용 시점 |
|---|---|---|
| 서버 API 핸들러 | createError + throw | 특정 API 엔드포인트 에러 |
| 페이지 컴포넌트 | createError + fatal: true | 페이지 데이터 로드 실패 |
| 컴포넌트 트리 | onErrorCaptured | 하위 컴포넌트 에러 격리 |
| 전역 에러 상태 | useError / clearError | 에러 UI 표시 및 복구 |
| Nitro 서버 전역 | nitroApp.hooks.hook('error') | 서버 에러 로깅·알림 |
소스맵·Nuxt DevTools·디버그 모드로 SSR 이슈 추적
소스맵 활성화
SSR 환경에서 에러 스택은 번들된 파일 경로를 가리키므로 원본 TypeScript 파일과 줄 번호를 알기 어렵습니다. 소스맵을 활성화하면 서버 사이드 에러도 원본 코드 위치로 추적할 수 있습니다.
// nuxt.config.ts
export default defineNuxtConfig({
sourcemap: {
server: true, // 서버 번들 소스맵 생성
client: true, // 클라이언트 번들 소스맵 생성
},
})
⚠️ 주의
sourcemap: { server: true }는 서버 번들 크기를 크게 늘립니다. 프로덕션에서는 소스맵을 별도 서버(예: Sentry)에 업로드한 뒤 로컬 파일은 제외하는 것이 좋습니다.
Nuxt DevTools 활용
Nuxt DevTools는 개발 모드에서 자동으로 활성화되는 브라우저 내장 디버깅 패널입니다. 별도 설정 없이 http://localhost:3000 의 하단 아이콘을 클릭해 열 수 있습니다.
주요 탭별 활용법:
| 탭 | 확인할 수 있는 것 |
|---|---|
| Pages | 현재 라우트 트리, 매칭된 미들웨어 목록 |
| Components | 렌더링 트리, 각 컴포넌트의 props/state |
| Imports | 자동 임포트된 항목 목록과 소스 파일 |
| Payload | useNuxtData 캐시, useState 값, SSR 페이로드 내용 |
| Server Routes | Nitro API 라우트 목록과 직접 테스트 기능 |
디버그 모드 설정
특정 Nuxt 모듈이나 내부 동작에 대한 상세 로그가 필요할 때 환경 변수로 디버그 레벨을 제어할 수 있습니다.
# Nuxt 전체 디버그 로그
DEBUG=nuxt:* nuxt dev
# Nitro 라우터만 디버그
DEBUG=nitro:* nuxt dev
# Vite 번들러 로그
DEBUG=vite:* nuxt dev
// nuxt.config.ts — 특정 기능 디버그 설정
export default defineNuxtConfig({
debug: true, // 일반 디버그 정보 활성화
nitro: {
logLevel: 'debug', // Nitro 서버 상세 로그
},
vite: {
build: {
sourcemap: true, // Vite 빌드 소스맵
},
},
})
SSR 하이드레이션 불일치 추적
하이드레이션 불일치(hydration mismatch)는 서버와 클라이언트가 다른 HTML을 생성할 때 발생하며, 콘솔에 Vue 경고로 표시됩니다.
<script setup lang="ts">
// ❌ 하이드레이션 불일치 원인: 서버/클라이언트에서 값이 다름
const timestamp = new Date().toISOString()
// ✅ 클라이언트 전용 실행으로 분리
const timestamp = ref<string>('')
onMounted(() => {
timestamp.value = new Date().toISOString()
})
</script>
<template>
<div>
<!-- ✅ ClientOnly로 클라이언트 전용 렌더링 -->
<ClientOnly>
<span>{{ timestamp }}</span>
</ClientOnly>
</div>
</template>
// nuxt.config.ts — 하이드레이션 불일치 상세 경고 활성화
export default defineNuxtConfig({
vue: {
compilerOptions: {
// 개발 모드에서 하이드레이션 불일치를 에러로 처리
// (기본값은 경고)
},
},
})
요약
@nuxt/test-utils는 Nuxt 컨텍스트(자동 임포트, 플러그인)를 포함한 격리된 테스트 환경을 제공하며,mountSuspended로async setup컴포넌트를 올바르게 테스트할 수 있다.mockNuxtImport는 자동 임포트된 composable을 파일 최상위 레벨에서 모킹하여 단위 테스트의 외부 의존성을 제거한다.$fetch로 SSR HTML을 직접 검증하고, PlaywrightcreatePage로 하이드레이션 이후의 인터랙션까지 E2E 테스트할 수 있다.error.vue+createError({ fatal: true })의 조합으로 페이지 수준 에러를 처리하고,onErrorCaptured로 컴포넌트 트리 에러를 격리하며, Nitro 훅으로 서버 에러를 전역 로깅한다.- 소스맵과
DEBUG=nuxt:*환경 변수, Nuxt DevTools의 Payload 탭을 함께 활용하면 SSR 특유의 하이드레이션 불일치와 서버 에러를 효과적으로 추적할 수 있다.
연습문제
-
useCountercomposable이 있고, 내부적으로useState를 사용합니다.mockNuxtImport를 사용하지 않고useState의 실제 구현을 활용하면서mountSuspended로CounterButton.vue컴포넌트를 테스트하는 코드를 작성하세요. 버튼 클릭 시 카운터가 증가하는지 검증합니다.힌트
@nuxt/test-utils/runtime의mountSuspended에global.plugins로 Pinia를 주입하거나,useState가 Nuxt 컨텍스트에서 자동으로 동작하는 점을 활용하세요. -
/api/products/[id]서버 라우트에서 상품이 없을 때 404 에러를createError로 던지도록 구현하고,$fetch를 사용한 E2E 테스트로 404 응답이 올바르게 반환되는지 검증하는 테스트를 작성하세요.힌트
$fetch에ignoreResponseError: true옵션을 주면 4xx/5xx 응답을 예외로 던지지 않고 응답 객체를 반환합니다. -
ErrorBoundary컴포넌트를 구현하세요.onErrorCaptured로 자식 에러를 캡처하고 슬롯 대신 에러 메시지를 보여주어야 합니다. 에러를 발생시키는 테스트용 자식 컴포넌트와 함께mountSuspended로 테스트하세요.힌트 에러를 강제로 발생시키는 컴포넌트를
defineComponent로 인라인 정의하고,onMounted에서throw new Error(...)를 실행하면onErrorCaptured에서 캡처됩니다. -
nuxt.config.ts에sourcemap: { server: true }를 설정하고 빌드한 뒤,.output/server/디렉터리에서 소스맵 파일이 생성되었는지 확인하세요. 또한 개발 서버에서DEBUG=nuxt:*로그에 출력되는 정보 중 라우트 초기화 관련 메시지를 확인하고 어떤 정보를 제공하는지 설명하세요.힌트
.output/server/chunks/아래에.map확장자 파일이 생성됩니다.nuxt build && ls .output/server/chunks/ | grep .map으로 확인할 수 있습니다.
💡 연습문제 풀이
불러오는 중…
댓글 0
“Nuxt.js 심화” 강좌에 대한 댓글입니다.