Vue 앱을 완성하려면 라우팅과 테스팅이 필수입니다. Vue Router 심화, 네비게이션 가드, Vitest와 Vue Test Utils로 견고한 앱을 만들어봅시다.
Routing and testing are essential to complete a Vue app. Let's build robust apps with Vue Router deep dive, navigation guards, and Vitest with Vue Test Utils.
Vue Router 기본 설정 Vue Router Basic Setup
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue')
},
{
path: '/user/:id',
name: 'User',
component: () => import('@/views/User.vue'),
props: true // params를 props로 전달
}
]
export const router = createRouter({
history: createWebHistory(),
routes
})
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue')
},
{
path: '/user/:id',
name: 'User',
component: () => import('@/views/User.vue'),
props: true // params를 props로 전달
}
]
export const router = createRouter({
history: createWebHistory(),
routes
})
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue')
},
{
path: '/user/:id',
name: 'User',
component: () => import('@/views/User.vue'),
props: true // Pass params as props
}
]
export const router = createRouter({
history: createWebHistory(),
routes
})
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue')
},
{
path: '/user/:id',
name: 'User',
component: () => import('@/views/User.vue'),
props: true // Pass params as props
}
]
export const router = createRouter({
history: createWebHistory(),
routes
})
네비게이션 가드 Navigation Guards
라우트 전환 시 인증 확인, 데이터 로딩 등을 처리할 수 있습니다.
Handle authentication checks, data loading, etc. during route transitions.
// 전역 가드
router.beforeEach((to, from) => {
const isAuthenticated = checkAuth()
if (to.meta.requiresAuth && !isAuthenticated) {
return { name: 'Login' }
}
})
// 라우트별 가드
{
path: '/admin',
component: AdminPanel,
meta: { requiresAuth: true, role: 'admin' },
beforeEnter: (to, from) => {
if (!isAdmin()) {
return { name: 'Home' }
}
}
}
router.beforeEach((to, from) => {
const isAuthenticated = checkAuth()
if (to.meta.requiresAuth && !isAuthenticated) {
return { name: 'Login' }
}
})
// 라우트별 가드
{
path: '/admin',
component: AdminPanel,
meta: { requiresAuth: true, role: 'admin' },
beforeEnter: (to, from) => {
if (!isAdmin()) {
return { name: 'Home' }
}
}
}
// Global guard
router.beforeEach((to, from) => {
const isAuthenticated = checkAuth()
if (to.meta.requiresAuth && !isAuthenticated) {
return { name: 'Login' }
}
})
// Per-route guard
{
path: '/admin',
component: AdminPanel,
meta: { requiresAuth: true, role: 'admin' },
beforeEnter: (to, from) => {
if (!isAdmin()) {
return { name: 'Home' }
}
}
}
router.beforeEach((to, from) => {
const isAuthenticated = checkAuth()
if (to.meta.requiresAuth && !isAuthenticated) {
return { name: 'Login' }
}
})
// Per-route guard
{
path: '/admin',
component: AdminPanel,
meta: { requiresAuth: true, role: 'admin' },
beforeEnter: (to, from) => {
if (!isAdmin()) {
return { name: 'Home' }
}
}
}
컴포넌트 내 가드 In-Component Guards
// Composition API
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
const answer = confirm('저장하지 않은 변경사항이 있습니다. 계속할까요?')
if (!answer) return false
}
})
onBeforeRouteUpdate(async (to, from) => {
// 같은 컴포넌트에서 params가 변경될 때
await fetchUser(to.params.id)
})
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
const answer = confirm('저장하지 않은 변경사항이 있습니다. 계속할까요?')
if (!answer) return false
}
})
onBeforeRouteUpdate(async (to, from) => {
// 같은 컴포넌트에서 params가 변경될 때
await fetchUser(to.params.id)
})
// Composition API
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
const answer = confirm('You have unsaved changes. Continue?')
if (!answer) return false
}
})
onBeforeRouteUpdate(async (to, from) => {
// When params change in the same component
await fetchUser(to.params.id)
})
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
const answer = confirm('You have unsaved changes. Continue?')
if (!answer) return false
}
})
onBeforeRouteUpdate(async (to, from) => {
// When params change in the same component
await fetchUser(to.params.id)
})
Vitest로 컴포넌트 테스트 Component Testing with Vitest
// Counter.spec.js
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.text()).toContain('0')
})
it('클릭하면 증가한다', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('1')
})
})
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.text()).toContain('0')
})
it('클릭하면 증가한다', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('1')
})
})
// Counter.spec.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('Counter', () => {
it('starts at 0', () => {
const wrapper = mount(Counter)
expect(wrapper.text()).toContain('0')
})
it('increments on click', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('1')
})
})
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('Counter', () => {
it('starts at 0', () => {
const wrapper = mount(Counter)
expect(wrapper.text()).toContain('0')
})
it('increments on click', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('1')
})
})
Props와 Emit 테스트 Testing Props & Emit
it('props를 받아 렌더링한다', () => {
const wrapper = mount(UserCard, {
props: {
user: { name: 'Kim', age: 30 }
}
})
expect(wrapper.text()).toContain('Kim')
})
it('이벤트를 emit한다', async () => {
const wrapper = mount(DeleteButton)
await wrapper.find('button').trigger('click')
expect(wrapper.emitted()).toHaveProperty('delete')
expect(wrapper.emitted('delete')).toHaveLength(1)
})
const wrapper = mount(UserCard, {
props: {
user: { name: 'Kim', age: 30 }
}
})
expect(wrapper.text()).toContain('Kim')
})
it('이벤트를 emit한다', async () => {
const wrapper = mount(DeleteButton)
await wrapper.find('button').trigger('click')
expect(wrapper.emitted()).toHaveProperty('delete')
expect(wrapper.emitted('delete')).toHaveLength(1)
})
it('renders with props', () => {
const wrapper = mount(UserCard, {
props: {
user: { name: 'Kim', age: 30 }
}
})
expect(wrapper.text()).toContain('Kim')
})
it('emits events', async () => {
const wrapper = mount(DeleteButton)
await wrapper.find('button').trigger('click')
expect(wrapper.emitted()).toHaveProperty('delete')
expect(wrapper.emitted('delete')).toHaveLength(1)
})
const wrapper = mount(UserCard, {
props: {
user: { name: 'Kim', age: 30 }
}
})
expect(wrapper.text()).toContain('Kim')
})
it('emits events', async () => {
const wrapper = mount(DeleteButton)
await wrapper.find('button').trigger('click')
expect(wrapper.emitted()).toHaveProperty('delete')
expect(wrapper.emitted('delete')).toHaveLength(1)
})
Pinia 스토어 테스트 Testing Pinia Stores
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from '@/stores/counter'
describe('Counter Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('increment 액션이 동작한다', () => {
const store = useCounterStore()
expect(store.count).toBe(0)
store.increment()
expect(store.count).toBe(1)
})
it('getter가 올바른 값을 반환한다', () => {
const store = useCounterStore()
store.count = 5
expect(store.doubleCount).toBe(10)
})
})
import { useCounterStore } from '@/stores/counter'
describe('Counter Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('increment 액션이 동작한다', () => {
const store = useCounterStore()
expect(store.count).toBe(0)
store.increment()
expect(store.count).toBe(1)
})
it('getter가 올바른 값을 반환한다', () => {
const store = useCounterStore()
store.count = 5
expect(store.doubleCount).toBe(10)
})
})
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from '@/stores/counter'
describe('Counter Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('increment action works', () => {
const store = useCounterStore()
expect(store.count).toBe(0)
store.increment()
expect(store.count).toBe(1)
})
it('getter returns correct value', () => {
const store = useCounterStore()
store.count = 5
expect(store.doubleCount).toBe(10)
})
})
import { useCounterStore } from '@/stores/counter'
describe('Counter Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('increment action works', () => {
const store = useCounterStore()
expect(store.count).toBe(0)
store.increment()
expect(store.count).toBe(1)
})
it('getter returns correct value', () => {
const store = useCounterStore()
store.count = 5
expect(store.doubleCount).toBe(10)
})
})
✨ 테스트 베스트 프랙티스 ✨ Testing Best Practices
- 구현이 아닌 동작을 테스트
- 사용자 관점에서 테스트 작성
- 테스트 간 독립성 유지 (beforeEach)
- 의미있는 테스트 이름 사용
- Test behavior, not implementation
- Write tests from user perspective
- Maintain test independence (beforeEach)
- Use meaningful test names
⚠️ 흔한 실수들 ⚠️ Common Mistakes
- 내부 구현 세부사항 테스트
- 비동기 테스트에서
await누락 - 테스트 간 상태 공유
- 너무 많은 것을 mocking
- Testing internal implementation details
- Missing
awaitin async tests - Sharing state between tests
- Mocking too much
