dev.syw

타입 안전한 컴포넌트 작성과 단위·컴포넌트 테스트로 품질을 확보한다.

타입스크립트와 테스트·디버깅

입문편에서 definePropsdefineEmits의 런타임 선언 방식을 익혔다면, 이제는 타입스크립트와 결합해 컴파일 시점에 오류를 잡는 타입 기반 선언으로 한 단계 올라갈 차례입니다. 여기에 Vitest와 Vue Test Utils를 더하면 "동작하는 코드"가 아닌 "증명된 코드"를 작성할 수 있습니다. 이 레슨은 타입 안전성과 테스트를 실무 수준으로 결합하는 방법을 다룹니다.

학습 목표

  • 타입 기반 defineProps·defineEmits 선언으로 런타임 오류를 컴파일 시점에 차단한다.
  • 제네릭 컴포넌트ref·reactive·컴포저블의 명시적 타이핑 패턴을 이해한다.
  • Vitest로 컴포저블과 유틸 함수의 단위 테스트를 작성한다.
  • Vue Test Utils로 컴포넌트 마운트·사용자 상호작용·비동기 동작을 검증한다.
  • 비동기·스토어·라우터가 얽힌 통합 시나리오의 모킹 전략과 테스트 친화적 설계를 익힌다.

defineProps·defineEmits의 타입 기반 선언

defineProps는 두 가지 선언 방식을 지원합니다. 런타임 방식은 객체 리터럴로 Vue가 실행 중에 검사하고, 타입 기반 방식은 TypeScript 제네릭을 사용해 컴파일러가 검사합니다.

<!-- ✅ 타입 기반 선언 — 컴파일 시점 검사 -->
<script setup lang="ts">
interface Props {
  userId: number
  username: string
  role?: 'admin' | 'editor' | 'viewer'
}

// ✅ defineProps는 블록당 한 번만 호출 — 기본값은 withDefaults로 감싼다
const props = withDefaults(defineProps<Props>(), {
  role: 'viewer',
})
</script>
<!-- ❌ 타입 기반과 런타임 방식을 혼합하면 컴파일 에러 -->
<script setup lang="ts">
const props = defineProps<{ name: string }>({ name: String }) // 에러
</script>

defineEmits도 동일하게 타입으로 선언할 수 있습니다. 함수 오버로드 형태로 각 이벤트의 페이로드 타입을 정밀하게 지정할 수 있습니다.

<script setup lang="ts">
// ✅ 콜 시그니처 기반 타입 선언
const emit = defineEmits<{
  change: [value: string]          // 단일 인자
  update: [id: number, data: Partial<User>] // 복수 인자
  close: []                        // 인자 없음
}>()

// 잘못된 인자를 넘기면 TypeScript가 바로 에러를 표시
emit('change', 42)     // ❌ string이 아님
emit('close', 'extra') // ❌ 인자 없어야 함
emit('change', 'ok')   // ✅
</script>

💡 TIP — Vue 3.3부터 defineProps에서 외부 타입(import한 인터페이스)을 직접 사용할 수 있습니다. 이전 버전에서는 같은 파일 내 인터페이스만 허용됐습니다.

제네릭 컴포넌트

목록을 표시하는 컴포넌트처럼 내부 아이템 타입이 유동적일 때, 제네릭 컴포넌트를 사용하면 사용 시점에 타입을 구체화할 수 있습니다.

<!-- GenericList.vue -->
<script setup lang="ts" generic="T extends { id: number }">
defineProps<{
  items: T[]
  selected?: T
}>()

const emit = defineEmits<{
  select: [item: T]
}>()
</script>

<template>
  <ul>
    <li
      v-for="item in items"
      :key="item.id"
      @click="emit('select', item)"
    >
      <slot :item="item" />
    </li>
  </ul>
</template>

사용 시 TypeScript는 items의 요소 타입으로부터 T를 추론합니다.

<script setup lang="ts">
import GenericList from './GenericList.vue'

interface Product {
  id: number
  name: string
  price: number
}

const products: Product[] = [
  { id: 1, name: '사과', price: 1000 },
  { id: 2, name: '배', price: 1500 },
]

function onSelect(item: Product) { // T가 Product로 추론됨
  console.log(item.name)
}
</script>

<template>
  <GenericList :items="products" @select="onSelect">
    <template #default="{ item }">
      {{ item.name }} — {{ item.price }}원
    </template>
  </GenericList>
</template>

⚠️ 주의generic 속성은 Vue 3.3 이상 + Volar 1.6 이상에서 지원됩니다. vite-plugin-vue 버전도 함께 확인하세요.

ref·reactive·컴포저블의 타입 추론과 명시적 타이핑

Vue의 반응형 API는 대부분 타입을 자동 추론합니다. 하지만 초기값이 null이거나 유니언 타입이 필요한 경우에는 명시적 타입이 필요합니다.

import { ref, reactive, computed } from 'vue'

// ✅ 자동 추론 — 단순한 경우
const count = ref(0)           // Ref<number>
const message = ref('hello')   // Ref<string>

// ✅ 명시적 타이핑 — 초기값이 null이거나 유니언일 때
const user = ref<User | null>(null)
const status = ref<'idle' | 'loading' | 'error'>('idle')

// ✅ reactive는 객체 자체를 타입으로
interface CartState {
  items: CartItem[]
  coupon: string | null
}

const cart = reactive<CartState>({
  items: [],
  coupon: null,
})

// ✅ computed도 추론되지만, 반환 타입이 복잡할 때는 명시
const total = computed<number>(() =>
  cart.items.reduce((sum, i) => sum + i.price * i.qty, 0)
)

컴포저블에서 반환 타입을 명시하면 소비하는 쪽에서 IDE 자동완성이 정확해집니다.

// composables/useAsync.ts
import { ref, type Ref } from 'vue'

interface UseAsyncReturn<T> {
  data: Ref<T | null>
  error: Ref<Error | null>
  loading: Ref<boolean>
  execute: () => Promise<void>
}

export function useAsync<T>(fetcher: () => Promise<T>): UseAsyncReturn<T> {
  const data = ref<T | null>(null)
  const error = ref<Error | null>(null)
  const loading = ref(false)

  async function execute() {
    loading.value = true
    error.value = null
    try {
      data.value = await fetcher()
    } catch (e) {
      error.value = e instanceof Error ? e : new Error(String(e))
    } finally {
      loading.value = false
    }
  }

  return { data, error, loading, execute }
}
패턴권장 상황
ref<T>(초기값)초기값이 null이거나 유니언 타입일 때
reactive<T>({...})복잡한 중첩 객체 상태
반환 타입 명시공개 컴포저블, 라이브러리 경계
as const 단언리터럴 타입 배열·객체를 좁혀야 할 때

Vitest로 컴포저블·유틸 단위 테스트

Vitest는 Vite 기반 프로젝트에 최적화된 테스트 프레임워크로, Jest와 거의 동일한 API를 제공합니다. 컴포저블과 순수 함수는 Vue 환경 없이 단독으로 테스트할 수 있어 실행 속도가 빠릅니다.

// composables/useCounter.ts
import { ref } from 'vue'

export function useCounter(initial = 0) {
  const count = ref(initial)
  const increment = () => count.value++
  const decrement = () => count.value--
  const reset = () => { count.value = initial }
  return { count, increment, decrement, reset }
}
// composables/__tests__/useCounter.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { useCounter } from '../useCounter'

describe('useCounter', () => {
  it('초기값 0으로 시작한다', () => {
    const { count } = useCounter()
    expect(count.value).toBe(0)
  })

  it('사용자 지정 초기값을 사용한다', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })

  it('increment 호출 시 1 증가한다', () => {
    const { count, increment } = useCounter()
    increment()
    increment()
    expect(count.value).toBe(2)
  })

  it('reset 호출 시 초기값으로 돌아간다', () => {
    const { count, increment, reset } = useCounter(5)
    increment()
    reset()
    expect(count.value).toBe(5)
  })
})

비동기 컴포저블은 flushPromisesawait를 활용해 Promise가 해소된 후 상태를 검증합니다.

// composables/__tests__/useAsync.test.ts
import { describe, it, expect, vi } from 'vitest'
import { useAsync } from '../useAsync'

describe('useAsync', () => {
  it('성공 시 data가 채워지고 loading이 false가 된다', async () => {
    const fetcher = vi.fn().mockResolvedValue({ id: 1, name: '사과' })
    const { data, loading, execute } = useAsync(fetcher)

    expect(loading.value).toBe(false)
    const promise = execute()
    expect(loading.value).toBe(true)
    await promise
    expect(loading.value).toBe(false)
    expect(data.value).toEqual({ id: 1, name: '사과' })
  })

  it('실패 시 error에 Error 객체가 담긴다', async () => {
    const fetcher = vi.fn().mockRejectedValue(new Error('서버 오류'))
    const { error, execute } = useAsync(fetcher)

    await execute()
    expect(error.value).toBeInstanceOf(Error)
    expect(error.value?.message).toBe('서버 오류')
  })
})

컴포넌트를 마운트하고 DOM을 검증하려면 vitest.config.ts에서 environment: 'jsdom'(또는 happy-dom)이 필요합니다. 반면 위의 useCounter·useAsync 같은 순수 컴포저블/유틸 테스트는 Vue 반응형 시스템(ref·reactive·computed·watch)이 DOM 없이도 동작하므로 jsdom 없이 순수 Node 환경에서 그대로 실행됩니다.

// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true,
  },
})

Vue Test Utils로 컴포넌트 마운트·상호작용 테스트

순수 로직 테스트를 넘어 실제 컴포넌트를 마운트해 DOM 상호작용을 검증할 때는 Vue Test Utils(@vue/test-utils)를 사용합니다.

<!-- components/SearchBar.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const emit = defineEmits<{ search: [query: string] }>()
const query = ref('')

function onSubmit() {
  if (query.value.trim()) emit('search', query.value.trim())
}
</script>

<template>
  <form @submit.prevent="onSubmit">
    <input v-model="query" data-testid="search-input" placeholder="검색어 입력" />
    <button type="submit" data-testid="search-btn">검색</button>
  </form>
</template>
// components/__tests__/SearchBar.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import SearchBar from '../SearchBar.vue'

describe('SearchBar', () => {
  it('검색어를 입력하고 버튼 클릭 시 search 이벤트가 발생한다', async () => {
    const wrapper = mount(SearchBar)

    await wrapper.find('[data-testid="search-input"]').setValue('Vue.js')
    await wrapper.find('[data-testid="search-btn"]').trigger('click')

    // emitted()로 방출된 이벤트와 페이로드 검증
    expect(wrapper.emitted('search')).toBeTruthy()
    expect(wrapper.emitted('search')![0]).toEqual(['Vue.js'])
  })

  it('공백만 입력하면 search 이벤트가 발생하지 않는다', async () => {
    const wrapper = mount(SearchBar)
    await wrapper.find('[data-testid="search-input"]').setValue('   ')
    await wrapper.find('[data-testid="search-btn"]').trigger('click')

    expect(wrapper.emitted('search')).toBeFalsy()
  })
})

💡 TIP — DOM 선택자로 클래스나 태그보다 data-testid 속성을 사용하면 스타일 변경이나 리팩터링에 테스트가 덜 깨집니다. 프로덕션 빌드에서 data-testid를 제거하려면 babel-plugin-remove-test-ids나 Vite 플러그인을 활용하세요.

자식 컴포넌트를 실제로 렌더링하지 않으려면 stubs를 활용합니다.

const wrapper = mount(ParentComponent, {
  global: {
    stubs: {
      ChildComponent: true,         // 빈 스텁
      RouterLink: { template: '<a><slot /></a>' }, // 커스텀 스텁
    },
  },
})

비동기·스토어·라우터가 얽힌 테스트와 모킹

실무 컴포넌트는 Pinia 스토어, Vue Router, API 호출이 뒤섞입니다. 각 의존성을 어떻게 모킹하는지가 테스트 품질을 결정합니다.

Pinia 스토어 모킹

import { setActivePinia, createPinia, type Pinia } from 'pinia'
import { useUserStore } from '@/stores/user'

let pinia: Pinia

beforeEach(() => {
  // 매 테스트마다 깨끗한 단일 Pinia 인스턴스를 만들어 활성화한다
  pinia = createPinia()
  setActivePinia(pinia)
})

it('로그인 상태일 때 사용자 이름이 표시된다', async () => {
  const userStore = useUserStore()
  userStore.user = { id: 1, name: '홍길동', role: 'admin' } // 상태 직접 주입

  const wrapper = mount(ProfileHeader, {
    // ✅ 외부에서 주입한 스토어와 컴포넌트가 같은 인스턴스를 쓰도록 동일한 pinia 전달
    global: { plugins: [pinia] },
  })

  expect(wrapper.text()).toContain('홍길동')
})

Vue Router 모킹

import { createRouter, createMemoryHistory } from 'vue-router'
import { routes } from '@/router'

function makeRouter(initialPath = '/') {
  const router = createRouter({
    history: createMemoryHistory(),
    routes,
  })
  router.push(initialPath)
  return router
}

it('미인증 사용자는 /login으로 리디렉션된다', async () => {
  const router = makeRouter('/dashboard')
  await router.isReady()

  const wrapper = mount(App, {
    global: { plugins: [router, createPinia()] },
  })
  await router.isReady()

  expect(router.currentRoute.value.path).toBe('/login')
})

API 호출 모킹

import { vi } from 'vitest'
import * as api from '@/api/users'

it('사용자 목록을 불러와 렌더링한다', async () => {
  // ✅ 모듈 전체가 아닌 개별 함수만 스파이
  vi.spyOn(api, 'fetchUsers').mockResolvedValue([
    { id: 1, name: '이순신' },
    { id: 2, name: '강감찬' },
  ])

  const wrapper = mount(UserList, {
    // beforeEach에서 setActivePinia로 활성화한 동일 인스턴스를 재사용
    global: { plugins: [pinia] },
  })

  // 비동기 렌더링 완료 대기
  await flushPromises()

  expect(wrapper.findAll('[data-testid="user-item"]')).toHaveLength(2)
  expect(wrapper.text()).toContain('이순신')
})

flushPromises@vue/test-utils에서 가져옵니다.

import { flushPromises } from '@vue/test-utils'

⚠️ 주의vi.mock은 호이스팅되므로 import 전에 실행됩니다. 모킹 대상 모듈을 vi.mock으로 선언하고 실제 구현을 vi.mocked로 타입 안전하게 다루세요.

// ✅ 모듈 전체 자동 모킹 후 반환값 지정
vi.mock('@/api/users')

const fetchUsersMock = vi.mocked(fetchUsers)
fetchUsersMock.mockResolvedValue([...])

테스트 친화적 컴포넌트 구조와 디버깅 전략

좋은 테스트는 좋은 컴포넌트 설계에서 나옵니다. 다음 원칙을 따르면 테스트 작성 비용이 크게 줄어듭니다.

테스트 친화적 설계 원칙

<!-- ❌ 모든 의존성이 컴포넌트 내부에 강하게 결합됨 -->
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { fetchProfile } from '@/api/profile'

const route = useRoute()
const store = useUserStore()

onMounted(async () => {
  const data = await fetchProfile(route.params.id as string)
  store.setProfile(data)
})
</script>
<!-- ✅ 로직을 컴포저블로 분리해 컴포넌트는 뷰만 담당 -->
<script setup lang="ts">
import { useProfilePage } from '@/composables/useProfilePage'

const { profile, loading, error } = useProfilePage()
</script>

<template>
  <div v-if="loading">불러오는 중...</div>
  <div v-else-if="error">{{ error.message }}</div>
  <ProfileCard v-else :profile="profile!" />
</template>

컴포저블 단위로 로직을 분리하면 컴포저블은 Vitest로, 뷰는 Vue Test Utils로 독립적으로 테스트할 수 있습니다.

Vue Devtools와 디버깅

타입스크립트 오류가 없는데 런타임에 예상치 못한 동작이 나타날 때는 다음 순서로 접근합니다.

  1. Vue Devtools에서 컴포넌트 트리와 props·state를 실시간 확인합니다.
  2. console.log 대신 watchEffect로 반응형 의존성 추적을 확인합니다.
import { watchEffect } from 'vue'

// 어떤 반응형 값에 의존하는지 자동 추적·로깅
watchEffect(() => {
  console.log('[debug] count:', count.value, 'user:', user.value?.name)
})
  1. TypeScript strict 모드("strict": true)를 tsconfig.json에서 활성화해 잠재적 null 참조를 미리 잡습니다.
// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true
  }
}
  1. 테스트가 실패할 때 wrapper.html()로 실제 렌더링 결과를 출력해 DOM 상태를 파악합니다.
it('디버깅 예시', async () => {
  const wrapper = mount(MyComponent)
  console.log(wrapper.html()) // 현재 DOM 스냅샷 출력
})

💡 TIP — 스냅샷 테스트(toMatchSnapshot)는 회귀 방지에 유용하지만 의미 없는 스타일 변경에도 실패합니다. UI가 자주 바뀌는 컴포넌트보다는 서버 응답 직렬화나 복잡한 유틸 출력에 적합합니다.

요약

  • defineProps<T>()defineEmits<{...}>()의 타입 기반 선언은 컴파일 시점에 props·이벤트 오류를 차단한다.
  • generic="T" 속성으로 제네릭 컴포넌트를 만들면 아이템 타입이 사용 시점에 추론된다.
  • ref<T>, reactive<T>, 컴포저블 반환 타입 명시는 IDE 자동완성과 타입 안전성을 모두 높인다.
  • Vitest로 컴포저블·유틸의 단위 테스트를, Vue Test Utils로 컴포넌트 마운트·이벤트 검증을 수행한다.
  • Pinia는 setActivePinia(createPinia())로, Router는 createMemoryHistory로, API는 vi.spyOn·vi.mock으로 독립적으로 모킹한다.
  • 로직을 컴포저블로 분리하고 data-testid를 활용하면 테스트가 리팩터링에 강해진다.

연습문제

  1. items: T[]keyField: keyof T를 props로 받아, keyField:key로 사용하는 제네릭 리스트 컴포넌트 TypedList.vue를 타입 기반 defineProps로 작성해 보세요.

  2. 로컬 스토리지에 값을 저장·읽어오는 useLocalStorage<T>(key, defaultValue) 컴포저블을 타입스크립트로 구현하고, Vitest로 "값을 저장하면 읽어올 수 있다"와 "키가 없으면 기본값을 반환한다" 두 케이스를 검증하세요.

  3. 아래 LoginForm.vue 컴포넌트에 대해 Vue Test Utils 테스트를 작성하세요.

    • 이메일과 패스워드 입력 후 제출 시 submit 이벤트에 { email, password }가 담긴다.
    • 이메일이 비어 있으면 submit이 발생하지 않고 에러 메시지가 표시된다.
    <!-- LoginForm.vue -->
    <script setup lang="ts">
    import { ref } from 'vue'
    
    const emit = defineEmits<{ submit: [payload: { email: string; password: string }] }>()
    const email = ref('')
    const password = ref('')
    const error = ref('')
    
    function onSubmit() {
      if (!email.value.trim()) {
        error.value = '이메일을 입력하세요'
        return
      }
      error.value = ''
      emit('submit', { email: email.value, password: password.value })
    }
    </script>
    
    <template>
      <form @submit.prevent="onSubmit">
        <input v-model="email" data-testid="email" type="email" />
        <input v-model="password" data-testid="password" type="password" />
        <p v-if="error" data-testid="error">{{ error }}</p>
        <button type="submit">로그인</button>
      </form>
    </template>
    
  4. Pinia 스토어 useCartStore(state: items: Product[], action: addItem(p: Product))가 있을 때, CartList.vue가 스토어의 items를 렌더링하고 "추가" 버튼 클릭 시 addItem이 호출되는지 테스트하세요.

힌트setActivePinia(createPinia())로 매 테스트마다 스토어를 초기화하고, vi.spyOn(store, 'addItem')으로 호출 여부를 검증하세요.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

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

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