테스트
테스트는 코드의 신뢰성을 보장하는 핵심 수단입니다. Vue에서는 Vitest + Vue Test Utils 조합이 표준입니다.
면접에서 "Vue 컴포넌트를 어떻게 테스트하나요?"라고 물으면, **무엇을 테스트해야 하는지 **(구현이 아닌 동작)를 먼저 설명하면 좋은 인상을 줍니다.
테스트 환경 설정
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true
}
})
컴포넌트 테스트 기초
<!-- Counter.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{ initial?: number }>()
const count = ref(props.initial ?? 0)
const increment = () => { count.value++ }
const decrement = () => { count.value-- }
</script>
<template>
<div>
<span data-testid="count">{{ count }}</span>
<button data-testid="increment" @click="increment">+</button>
<button data-testid="decrement" @click="decrement">-</button>
</div>
</template>
// Counter.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('Counter', () => {
it('기본 초기값은 0이다', () => {
const wrapper = mount(Counter)
expect(wrapper.find('[data-testid="count"]').text()).toBe('0')
})
it('initial prop으로 초기값을 설정할 수 있다', () => {
const wrapper = mount(Counter, {
props: { initial: 5 }
})
expect(wrapper.find('[data-testid="count"]').text()).toBe('5')
})
it('+ 버튼 클릭 시 count가 증가한다', async () => {
const wrapper = mount(Counter)
await wrapper.find('[data-testid="increment"]').trigger('click')
expect(wrapper.find('[data-testid="count"]').text()).toBe('1')
})
it('- 버튼 클릭 시 count가 감소한다', async () => {
const wrapper = mount(Counter, { props: { initial: 5 } })
await wrapper.find('[data-testid="decrement"]').trigger('click')
expect(wrapper.find('[data-testid="count"]').text()).toBe('4')
})
})
Emit 테스트
// SearchInput.test.ts
import { mount } from '@vue/test-utils'
import SearchInput from './SearchInput.vue'
describe('SearchInput', () => {
it('Enter 키 입력 시 search 이벤트를 emit한다', async () => {
const wrapper = mount(SearchInput)
await wrapper.find('input').setValue('vue')
await wrapper.find('input').trigger('keyup.enter')
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')![0]).toEqual(['vue'])
})
it('clear 버튼 클릭 시 clear 이벤트를 emit한다', async () => {
const wrapper = mount(SearchInput)
await wrapper.find('[data-testid="clear"]').trigger('click')
expect(wrapper.emitted('clear')).toHaveLength(1)
})
})
비동기 테스트
import { mount, flushPromises } from '@vue/test-utils'
import UserList from './UserList.vue'
import { vi } from 'vitest'
// fetch 모킹
global.fetch = vi.fn()
describe('UserList', () => {
it('마운트 시 사용자 목록을 로드한다', async () => {
const mockUsers = [
{ id: 1, name: '김개발' },
{ id: 2, name: '이서버' }
]
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUsers)
})
const wrapper = mount(UserList)
// 비동기 작업 완료 대기
await flushPromises()
expect(wrapper.findAll('li')).toHaveLength(2)
expect(wrapper.text()).toContain('김개발')
})
})
Pinia 스토어 테스트
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
import CartView from './CartView.vue'
import { useCartStore } from '@/stores/cart'
describe('CartView', () => {
it('장바구니 아이템을 표시한다', () => {
const wrapper = mount(CartView, {
global: {
plugins: [
createTestingPinia({
initialState: {
cart: {
items: [
{ id: 1, name: '노트북', price: 1500000 }
]
}
},
// 액션을 자동으로 mock
createSpy: vi.fn
})
]
}
})
expect(wrapper.text()).toContain('노트북')
})
})
Vue Router 테스트
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import App from './App.vue'
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>홈</div>' } },
{ path: '/about', component: { template: '<div>소개</div>' } }
]
})
describe('라우팅', () => {
it('/about 경로로 이동하면 소개 페이지를 표시한다', async () => {
router.push('/about')
await router.isReady()
const wrapper = mount(App, {
global: { plugins: [router] }
})
expect(wrapper.text()).toContain('소개')
})
})
Composable 테스트
// useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('초기값을 설정할 수 있다', () => {
const { count } = useCounter(10)
expect(count.value).toBe(10)
})
it('increment는 count를 1 증가시킨다', () => {
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)
})
it('reset은 초기값으로 되돌린다', () => {
const { count, increment, reset } = useCounter(5)
increment()
increment()
reset()
expect(count.value).toBe(5)
})
})
테스트 원칙
1. 구현이 아닌 동작을 테스트
✓ "버튼 클릭 시 count가 증가한다"
✗ "increment 함수가 호출된다"
2. 사용자 관점에서 테스트
✓ data-testid, 텍스트, 역할(role)로 요소 찾기
✗ 클래스명, 내부 상태로 요소 찾기
3. 적절한 테스트 수준
✓ 단위: Composable, 유틸리티
✓ 컴포넌트: Props/Emits/Slots 동작
✓ 통합: 여러 컴포넌트 상호작용
면접 팁
- "무엇을 테스트해야 하나요?" — 컴포넌트의 공개 인터페이스(Props, Emits, 렌더링 결과)를 테스트합니다
data-testid를 사용하는 이유: CSS 클래스나 태그 구조는 변경될 수 있지만, 테스트 ID는 안정적입니다flushPromises는 비동기 테스트의 핵심 유틸리티입니다
요약
Vue 테스트는 Vitest + Vue Test Utils 조합으로 작성합니다. 구현이 아닌 동작을 테스트하고, data-testid로 안정적으로 요소를 찾으며, Pinia/Router는 테스트용 플러그인으로 목킹합니다. Composable은 일반 함수처럼 독립적으로 테스트할 수 있습니다.
댓글 로딩 중...