테스트는 코드의 신뢰성을 보장하는 핵심 수단입니다. Vue에서는 Vitest + Vue Test Utils 조합이 표준입니다.

면접에서 "Vue 컴포넌트를 어떻게 테스트하나요?"라고 물으면, **무엇을 테스트해야 하는지 **(구현이 아닌 동작)를 먼저 설명하면 좋은 인상을 줍니다.


테스트 환경 설정

TYPESCRIPT
// 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
<!-- 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>
TYPESCRIPT
// 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 테스트

TYPESCRIPT
// 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)
  })
})

비동기 테스트

TYPESCRIPT
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 스토어 테스트

TYPESCRIPT
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 테스트

TYPESCRIPT
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 테스트

TYPESCRIPT
// 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)
  })
})

테스트 원칙

PLAINTEXT
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은 일반 함수처럼 독립적으로 테스트할 수 있습니다.

댓글 로딩 중...