2026년 Vue 3 테스팅: Vitest, Vue Test Utils 그리고 면접 질문
2026년 Vue 테스팅 실전 가이드. Vitest 설정, Vue Test Utils로 컴포넌트 마운트, 컴포저블과 Pinia 스토어 테스트, API 모킹, 커버리지 측정, 그리고 채용 팀이 실제로 던지는 면접 질문까지 다룹니다.

Vue 테스팅은 자신 있는 리팩터링과 불안한 추측을 갈라놓습니다. 2026년 현재 관련 도구 생태계는 두 라이브러리를 중심으로 정리되었습니다. 러너 역할을 하는 Vitest와 컴포넌트 마운트를 담당하는 Vue Test Utils입니다. 이 가이드는 이 스택을 구성하는 방법부터 컴포넌트와 컴포저블 테스트, 의존성 모킹, 그리고 채용 팀이 실제로 물어보는 Vue 테스팅 면접 질문에 답하는 방법까지 순서대로 살펴봅니다.
Vue 공식 문서가 기본으로 권장하는 조합은 단위 및 컴포넌트 테스트에는 Vitest, 저수준 마운트 API로는 Vue Test Utils, 그리고 종단 간(E2E) 테스트에는 Playwright 또는 Cypress입니다. Vitest는 애플리케이션과 동일한 Vite 설정을 재사용하기 때문에, 테스트가 완전히 같은 변환 파이프라인을 거치며 거의 즉각적인 핫 리로드를 제공합니다.
Vue 3 프로젝트를 위한 Vitest 구성
Vitest는 Vite 파이프라인을 공유하므로, 하나의 설정 파일이 개발 서버와 테스트 러너를 동시에 구동합니다. 컴포넌트 테스트가 렌더링할 DOM을 갖추려면 environment 옵션을 반드시 jsdom 또는 happy-dom으로 지정해야 합니다. 그리고 globals: true 플래그를 켜면 모든 파일에서 별도로 임포트하지 않아도 describe, it, expect를 그대로 사용할 수 있습니다.
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'
export default defineConfig({
plugins: [vue()],
test: {
// jsdom provides document/window so components can mount
environment: 'jsdom',
// expose describe/it/expect globally
globals: true,
// run this before each test file (matchers, cleanup)
setupFiles: ['./tests/setup.ts'],
// only pick up *.spec.ts and *.test.ts files
include: ['**/*.{test,spec}.ts'],
},
resolve: {
alias: {
// mirror the @ alias used across the app
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})Vitest 3에서는 vitest, @vue/test-utils, @vitejs/plugin-vue, jsdom을 설치하고 나면 위 설정이 별도 조정 없이 그대로 동작합니다. 이 별칭이 애플리케이션 설정을 그대로 반영하기 때문에, @/composables/useCart 같은 임포트 경로가 테스트와 운영 환경에서 동일하게 해석됩니다.
Vue Test Utils로 컴포넌트 마운트하기
Vue Test Utils는 두 개의 진입점을 제공합니다. 전체 컴포넌트 트리를 렌더링하는 mount와, 자식 컴포넌트를 스텁으로 대체하는 shallowMount입니다. 대부분의 단위 테스트에서는 실제 렌더링 동작을 검증하는 mount가 더 낫습니다. 반환된 wrapper는 렌더링 결과를 검증할 수 있도록 find, get, text 같은 조회 헬퍼를 제공합니다.
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import PriceTag from '@/components/PriceTag.vue'
describe('PriceTag', () => {
it('formats the amount as currency', () => {
const wrapper = mount(PriceTag, {
// props are passed through the mounting options
props: { amount: 4200, currency: 'EUR' },
})
// get() throws if the selector is missing, unlike find()
expect(wrapper.get('[data-test="price"]').text()).toBe('€42.00')
})
it('applies a discount class when on sale', () => {
const wrapper = mount(PriceTag, {
props: { amount: 4200, currency: 'EUR', onSale: true },
})
// classes() returns the array of applied CSS classes
expect(wrapper.get('[data-test="price"]').classes()).toContain('price--sale')
})
})CSS 클래스 대신 data-test 속성으로 요소를 지정하면 테스트가 훨씬 견고해집니다. .price--sale의 스타일을 바꿔도 선택자는 깨지지 않고, 오직 실제 동작이 바뀔 때만 테스트가 반응합니다. 이러한 분리는 유지보수 가능한 Vue 테스트에서 반복적으로 등장하는 원칙입니다.
사용자 이벤트와 발생 이벤트 테스트하기
상호작용하는 컴포넌트는 클릭이나 입력 이후 무슨 일이 벌어지는지 검증해야 합니다. Vue Test Utils의 trigger는 프로미스를 반환하며, 이를 await하면 Vue의 반응성 큐가 비워져 DOM이 갱신 결과를 반영하게 됩니다. emitted 헬퍼는 컴포넌트가 발생시킨 모든 커스텀 이벤트를 기록하며, 이것이 부모-자식 간 계약을 검증하는 방법입니다.
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import SearchBar from '@/components/SearchBar.vue'
describe('SearchBar', () => {
it('emits search with the typed query', async () => {
const wrapper = mount(SearchBar)
// setValue writes to the input and fires an input event
await wrapper.get('input').setValue('vitest')
// awaiting trigger flushes the reactivity queue before assertions
await wrapper.get('[data-test="submit"]').trigger('click')
// emitted() returns a record of every event and its payloads
const events = wrapper.emitted('search')
expect(events).toHaveLength(1)
// events[0] is the first emission, [0] its first argument
expect(events![0][0]).toBe('vitest')
})
})Vue는 반응성 갱신을 비동기로 일괄 처리합니다. trigger나 setValue 호출을 await하는 것을 잊는 것이 불안정한(flaky) Vue 테스트의 가장 흔한 원인입니다. DOM이 다시 렌더링되기 전에 검증이 실행되어 오래된 출력을 읽어버리기 때문입니다. 이벤트 헬퍼 바깥에서 상태가 바뀌는 경우에는 vue에서 nextTick을 임포트한 뒤, 검증 전에 await nextTick()을 호출해야 합니다.
Vue 컴포저블을 독립적으로 테스트하기
반응성 API(ref, computed, watch)만 사용하는 컴포저블은 컴포넌트가 전혀 필요 없습니다. 반응형 상태를 반환하는 순수 함수이기 때문에, 직접 호출한 뒤 .value를 검증하는 것으로 충분합니다. 이것이 비즈니스 로직을 가장 빠르고 집중적으로 커버하는 방법이며, 고급 Vue 3 컴포저블에서 다루는 패턴과도 잘 어울립니다.
import { describe, it, expect } from 'vitest'
import { useCart } from '@/composables/useCart'
describe('useCart', () => {
it('tracks total price as items change', () => {
// composables using only ref/computed run without mounting
const { items, total, addItem } = useCart()
expect(total.value).toBe(0)
addItem({ id: 1, price: 1500, qty: 2 })
// computed total recalculates synchronously on state change
expect(total.value).toBe(3000)
addItem({ id: 2, price: 500, qty: 1 })
expect(items.value).toHaveLength(2)
expect(total.value).toBe(3500)
})
})onMounted 같은 라이프사이클 훅에 의존하는 컴포저블은 예외입니다. 이들은 컴포넌트 인스턴스 내부에서 실행되어야 합니다. 흔히 쓰이는 우회 방법은 setup에서 컴포저블을 호출하고 그 결과를 노출하는 아주 작은 헬퍼 컴포넌트를 만든 뒤, 그 헬퍼를 Vue Test Utils로 마운트하는 것입니다.
Vue.js / Nuxt.js 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
vi.mock으로 API 호출 모킹하기
실제 네트워크 요청은 테스트를 느리고 비결정적으로 만듭니다. Vitest는 vi.mock으로 모듈을 대체하고 vi.fn으로 스파이 함수를 생성합니다. 아래 패턴은 fetch를 감싸는 모듈을 모킹하여, 테스트 대상 컴포저블이 서버에 전혀 접근하지 않고도 통제된 데이터를 받도록 합니다.
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useProducts } from '@/composables/useProducts'
import { getProducts } from '@/api/products'
// replace the whole module with mocked exports
vi.mock('@/api/products', () => ({
getProducts: vi.fn(),
}))
describe('useProducts', () => {
beforeEach(() => {
// reset call history between tests to avoid leakage
vi.mocked(getProducts).mockReset()
})
it('exposes fetched products and clears loading', async () => {
// define what the mocked API returns for this test
vi.mocked(getProducts).mockResolvedValue([
{ id: 1, name: 'Keyboard' },
])
const { products, loading, load } = useProducts()
await load()
expect(getProducts).toHaveBeenCalledOnce()
expect(loading.value).toBe(false)
expect(products.value[0].name).toBe('Keyboard')
})
})모듈 경계에서 모킹하면 컴포저블 내부 로직은 그대로 유지하면서, 각 테스트가 오류 경로를 포함한 API 응답을 완전히 통제할 수 있습니다. mockResolvedValue 대신 mockRejectedValue를 호출하면, 하나의 테스트로 실패가 오류 상태를 설정하고 스피너를 멈추는지까지 검증할 수 있습니다.
2026년의 Pinia 스토어 테스트
스토어는 여러 컴포넌트에 걸친 상태를 담고 있으며, 공식 @pinia/testing 패키지는 이를 손쉽게 테스트하도록 해줍니다. createTestingPinia는 기본적으로 모든 액션을 스텁으로 대체하므로, 컴포넌트 테스트에서 부수 효과를 실행하지 않고도 액션이 디스패치되었는지 검증할 수 있습니다. Pinia 테스팅 가이드는 컴포넌트 수준 테스트에 이 방식을 권장합니다.
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import CheckoutButton from '@/components/CheckoutButton.vue'
import { useCartStore } from '@/stores/cart'
describe('CheckoutButton', () => {
it('dispatches checkout on click', async () => {
const wrapper = mount(CheckoutButton, {
global: {
plugins: [
// vi.fn spies on actions instead of executing them
createTestingPinia({ createSpy: vi.fn }),
],
},
})
const store = useCartStore()
await wrapper.get('[data-test="checkout"]').trigger('click')
// the action is stubbed, so only the dispatch is asserted
expect(store.checkout).toHaveBeenCalledOnce()
})
})컴포넌트 통합이 아니라 스토어 로직 자체를 테스트하려면 stubActions: false를 전달하여 액션이 정상적으로 실행되도록 한 다음, 그 결과 상태를 검증하면 됩니다. 이렇게 액션을 스텁으로 대체한 컴포넌트 테스트와 실제 액션을 실행하는 스토어 테스트를 나누면, 각 테스트 스위트가 단 하나의 책임에 집중하게 됩니다.
워치 모드로 테스트 실행하고 커버리지 측정하기
Vitest는 저장된 파일의 영향을 받는 테스트만 다시 실행하는 워치 모드를 기본 제공하여, 개발 중 피드백 루프를 순식간에 단축시킵니다. 커버리지 리포팅은 @vitest/coverage-v8 프로바이더를 통해 내장되어 있으며, 지속적 통합(CI) 파이프라인에도 깔끔하게 연동됩니다. 이 파이프라인에서는 실패한 테스트나 커버리지 하락이 병합을 막을 수 있습니다.
export default defineConfig({
test: {
coverage: {
// v8 is the fastest provider and needs no instrumentation step
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
// fail CI when a threshold drops below the target
thresholds: {
statements: 80,
branches: 75,
functions: 80,
},
// exclude generated and config files from the report
exclude: ['**/*.config.ts', '**/types/**'],
},
},
})vitest만 단독으로 실행하면 로컬에서 워치 모드가 시작되고, vitest run --coverage는 CI에 적합한 단일 실행을 수행합니다. 명시적인 임계값을 설정하면 커버리지가 허울뿐인 지표에서 강제되는 품질 게이트로 바뀝니다. 브랜치 커버리지를 75퍼센트 아래로 떨어뜨리는 풀 리퀘스트는 자동으로 실패하게 되고, 이는 기여자가 정상 경로만이 아니라 자신이 추가한 경로까지 테스트하도록 유도합니다.
자주 나오는 Vue 테스팅 면접 질문과 답변
테스팅 질문은 후보자가 유지보수성을 어떻게 사고하는지 드러내기 때문에 대부분의 시니어 Vue 면접에서 등장합니다. 아래 답변은 가장 자주 나오는 개념을 다루며, 전체 세트는 Vue 테스팅 면접 모듈에서 반복 연습할 수 있습니다.
mount 대신 shallowMount는 언제 써야 할까요? 컴포넌트가 무겁거나 이미 테스트된 자식을 렌더링하고, 테스트가 오직 부모 자신의 로직만 신경 쓸 때 shallowMount를 선택합니다. 자식을 스텁으로 대체하면 단위를 격리하고 렌더링 속도를 높일 수 있지만, 컴포넌트 간의 실제 통합은 검증하지 못한다는 대가가 따릅니다.
Vue Test Utils에서 비동기 갱신은 어떻게 처리하나요? Vue는 반응형 변경을 다음 틱에 적용합니다. trigger, setValue 같은 이벤트 헬퍼는 DOM이 갱신된 뒤 완료되는 프로미스를 반환하므로, 이를 await하는 것으로 충분합니다. 그 헬퍼 바깥에서 바뀐 상태에 대해서는 await nextTick()으로 검증 전에 큐를 강제로 비웁니다.
find와 get의 차이는 무엇인가요? find는 일치하는 요소가 없으면 빈 래퍼를 반환하고 결코 예외를 던지지 않으므로, expect(wrapper.find('.error').exists()).toBe(false) 같은 검증에 적합합니다. get은 요소가 없으면 즉시 예외를 던지므로, 요소가 존재하리라고 기대되고 그 부재가 진짜 실패에 해당할 때 더 나은 선택입니다.
왜 클래스 선택자보다 data-test 속성을 선호하나요? 클래스 이름은 스타일과 구조에 따라 바뀌므로, 이를 조회하는 테스트는 겉모습만 손봐도 깨집니다. 전용 data-test 훅은 테스트 의도를 명시적으로 표현하고 리팩터링에도 살아남아, 거짓 실패(false negative)를 줄여줍니다.
onMounted에 의존하는 컴포저블은 어떻게 테스트하나요? 라이프사이클 훅은 컴포넌트 인스턴스 내부에서만 실행되므로 컴포저블을 직접 호출할 수 없습니다. 표준적인 기법은 setup에서 컴포저블을 호출하고 그 반환값을 노출하는 일회용 하니스(harness) 컴포넌트를 만든 뒤, 그 하니스를 Vue Test Utils로 마운트하여 래퍼를 통해 검증하는 것입니다. 이렇게 하면 반응성과 라이프사이클 연결을 온전히 유지하면서도 테스트 대상 로직을 격리할 수 있습니다. 더 많은 개념 및 코딩 질문은 핵심 Vue.js 면접 질문 가이드에 정리되어 있습니다.
결론
2026년의 믿을 수 있는 Vue 테스팅 구성은 결국 몇 가지 의도적인 선택으로 요약됩니다.
environment: 'jsdom'과 Vue 플러그인으로 Vitest를 실행하여, 테스트가 애플리케이션과 완전히 동일한 Vite 변환 파이프라인을 공유하게 만듭니다.- 자식 컴포넌트가 무겁거나 다른 곳에서 이미 커버되지 않는 한,
shallowMount보다mount를 우선합니다. - 타이밍에 의존하는 불안정한 테스트를 없애기 위해, 검증 전에
trigger,setValue,nextTick을 항상await합니다. - 반응성 API만 사용하는 컴포저블은 직접 호출하고
.value를 검증하여 테스트합니다. vi.mock으로 모듈 경계에서 모킹하여 네트워크 동작을 결정적으로 만들고 오류 경로까지 테스트 가능하게 합니다.- 컴포넌트 테스트에는
createTestingPinia({ createSpy: vi.fn })을, 스토어 로직을 실제로 실행할 때는stubActions: false를 사용합니다. data-test속성으로 요소를 조회하여, 테스트가 스타일이 아니라 오직 실제 동작 변화에만 깨지도록 합니다.
이 패턴들을 숙달하면 테스팅은 귀찮은 작업이 아니라 설계 도구가 되어, 회귀가 운영 환경에 도달하기 전에 잡아냅니다. 면접에 대비한 실력을 계속 쌓으려면 Vue와 Nuxt 학습 경로 전체를 살펴보시기 바랍니다.
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
Vue.js / Nuxt.js 코드의 버그를 찾을 수 있나요
실제 코드 한 조각, 숨은 버그 하나, 하루 한 번. 계정 없이 바로 도전할 수 있습니다.

작성자
Anthony Fillion-MailletSharpSkill 창업자
10년 이상 풀스택 개발을 해왔습니다. SharpSkill을 운영하며 이곳에 게시되는 모든 내용에 책임을 집니다.
2026년 7월 6일 업데이트
태그
공유
관련 기사

Vue 3 Reactivity Transform 2026: $ref, $computed 및 기술 면접 대비
Vue 3의 Reactivity Transform($ref, $computed)에 대해 심층적으로 분석합니다. 2026년 현재 상황, 폐기 이유, 현재 권장되는 대안, 그리고 기술 면접에서 자주 출제되는 질문들을 상세히 다룹니다.

Vue 3 컴포저블 심화 가이드: 재사용 가능한 패턴과 기술 면접 질문 2026
Vue 3 고급 컴포저블 패턴을 체계적으로 분석합니다. 비동기 처리, 의존성 주입, 폼 유효성 검사, 테스트 전략, 그리고 2026년 기술 면접에서 자주 출제되는 질문과 답변을 다룹니다.

Vue 3 Pinia vs Vuex 완벽 비교: 2026년 상태 관리 전략과 면접 핵심 질문
Vue 3 생태계에서 Pinia와 Vuex를 비교 분석합니다. Options Store와 Setup Store 패턴, TypeScript 통합, 크로스 스토어 구성, SSR 지원, Vuex에서 Pinia로의 마이그레이션 전략, 그리고 2026년 면접에서 자주 출제되는 상태 관리 질문을 코드 예제와 함께 정리합니다.