타입 안전한 컴포넌트 작성과 단위·컴포넌트 테스트로 품질을 확보한다.
타입스크립트와 테스트·디버깅
입문편에서 defineProps와 defineEmits의 런타임 선언 방식을 익혔다면, 이제는 타입스크립트와 결합해 컴파일 시점에 오류를 잡는 타입 기반 선언으로 한 단계 올라갈 차례입니다. 여기에 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)
})
})
비동기 컴포저블은 flushPromises나 await를 활용해 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와 디버깅
타입스크립트 오류가 없는데 런타임에 예상치 못한 동작이 나타날 때는 다음 순서로 접근합니다.
- Vue Devtools에서 컴포넌트 트리와 props·state를 실시간 확인합니다.
console.log대신watchEffect로 반응형 의존성 추적을 확인합니다.
import { watchEffect } from 'vue'
// 어떤 반응형 값에 의존하는지 자동 추적·로깅
watchEffect(() => {
console.log('[debug] count:', count.value, 'user:', user.value?.name)
})
- TypeScript strict 모드(
"strict": true)를tsconfig.json에서 활성화해 잠재적 null 참조를 미리 잡습니다.
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}
- 테스트가 실패할 때
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를 활용하면 테스트가 리팩터링에 강해진다.
연습문제
-
items: T[]와keyField: keyof T를 props로 받아,keyField를:key로 사용하는 제네릭 리스트 컴포넌트TypedList.vue를 타입 기반defineProps로 작성해 보세요. -
로컬 스토리지에 값을 저장·읽어오는
useLocalStorage<T>(key, defaultValue)컴포저블을 타입스크립트로 구현하고, Vitest로 "값을 저장하면 읽어올 수 있다"와 "키가 없으면 기본값을 반환한다" 두 케이스를 검증하세요. -
아래
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> - 이메일과 패스워드 입력 후 제출 시
-
Pinia 스토어
useCartStore(state:items: Product[], action:addItem(p: Product))가 있을 때,CartList.vue가 스토어의items를 렌더링하고 "추가" 버튼 클릭 시addItem이 호출되는지 테스트하세요.
힌트 —
setActivePinia(createPinia())로 매 테스트마다 스토어를 초기화하고,vi.spyOn(store, 'addItem')으로 호출 여부를 검증하세요.
💡 연습문제 풀이
불러오는 중…
댓글 0
“Vue.js 심화” 강좌에 대한 댓글입니다.