dev.syw

@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전체 페이지 HTMLSSR 출력 검증, 빠름, 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자동 임포트된 항목 목록과 소스 파일
PayloaduseNuxtData 캐시, useState 값, SSR 페이로드 내용
Server RoutesNitro 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 컨텍스트(자동 임포트, 플러그인)를 포함한 격리된 테스트 환경을 제공하며, mountSuspendedasync setup 컴포넌트를 올바르게 테스트할 수 있다.
  • mockNuxtImport는 자동 임포트된 composable을 파일 최상위 레벨에서 모킹하여 단위 테스트의 외부 의존성을 제거한다.
  • $fetch로 SSR HTML을 직접 검증하고, Playwright createPage로 하이드레이션 이후의 인터랙션까지 E2E 테스트할 수 있다.
  • error.vue + createError({ fatal: true })의 조합으로 페이지 수준 에러를 처리하고, onErrorCaptured로 컴포넌트 트리 에러를 격리하며, Nitro 훅으로 서버 에러를 전역 로깅한다.
  • 소스맵과 DEBUG=nuxt:* 환경 변수, Nuxt DevTools의 Payload 탭을 함께 활용하면 SSR 특유의 하이드레이션 불일치와 서버 에러를 효과적으로 추적할 수 있다.

연습문제

  1. useCounter composable이 있고, 내부적으로 useState를 사용합니다. mockNuxtImport를 사용하지 않고 useState의 실제 구현을 활용하면서 mountSuspendedCounterButton.vue 컴포넌트를 테스트하는 코드를 작성하세요. 버튼 클릭 시 카운터가 증가하는지 검증합니다.

    힌트 @nuxt/test-utils/runtimemountSuspendedglobal.plugins로 Pinia를 주입하거나, useState가 Nuxt 컨텍스트에서 자동으로 동작하는 점을 활용하세요.

  2. /api/products/[id] 서버 라우트에서 상품이 없을 때 404 에러를 createError로 던지도록 구현하고, $fetch를 사용한 E2E 테스트로 404 응답이 올바르게 반환되는지 검증하는 테스트를 작성하세요.

    힌트 $fetchignoreResponseError: true 옵션을 주면 4xx/5xx 응답을 예외로 던지지 않고 응답 객체를 반환합니다.

  3. ErrorBoundary 컴포넌트를 구현하세요. onErrorCaptured로 자식 에러를 캡처하고 슬롯 대신 에러 메시지를 보여주어야 합니다. 에러를 발생시키는 테스트용 자식 컴포넌트와 함께 mountSuspended로 테스트하세요.

    힌트 에러를 강제로 발생시키는 컴포넌트를 defineComponent로 인라인 정의하고, onMounted에서 throw new Error(...)를 실행하면 onErrorCaptured에서 캡처됩니다.

  4. nuxt.config.tssourcemap: { server: true }를 설정하고 빌드한 뒤, .output/server/ 디렉터리에서 소스맵 파일이 생성되었는지 확인하세요. 또한 개발 서버에서 DEBUG=nuxt:* 로그에 출력되는 정보 중 라우트 초기화 관련 메시지를 확인하고 어떤 정보를 제공하는지 설명하세요.

    힌트 .output/server/chunks/ 아래에 .map 확장자 파일이 생성됩니다. nuxt build && ls .output/server/chunks/ | grep .map 으로 확인할 수 있습니다.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

Nuxt.js 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.