[Vue] 테스트

배창민·2025년 11월 28일
post-thumbnail

Vue 3 + Vitest 테스트 정리


1. 테스트를 도입하는 이유

테스트를 도입하는 목적은 단순하다.

  • 리팩토링을 해도 기존 기능이 깨지지 않도록 방어
  • UI와 비즈니스 로직을 분리한 구조(Composable, Pinia)가 제대로 작동하는지 검증
  • 컴포넌트 / Composable / Store 각각이 자기 책임만 제대로 수행하는지 확인

결국 목표는

“코드가 올바르게 동작하는지, 눈으로 클릭 테스트를 하지 않고도 검증할 수 있는 상태”

를 만드는 것이다.


2. 사용하는 도구

이 구성을 기준으로 한다.

  • Vitest
    Vite 기반 프로젝트에 최적화된 테스트 러너
  • Vue Test Utils
    Vue 컴포넌트를 테스트용 DOM에 mount해서 동작을 시뮬레이션
  • jsdom
    브라우저 없이 DOM 환경을 흉내내는 테스트 환경

3. 테스트 환경 설정

3.1 패키지 설치

npm install -D vitest @vue/test-utils jsdom

3.2 vitest.config.js 개념

핵심 포인트만 추리면:

  • test 환경을 jsdom으로 설정
  • @ → src alias를 테스트에서도 동일하게 사용
  • setupFiles로 테스트 공통 초기 설정 파일 등록
  • include로 테스트 파일 위치 지정

예시:

// vitest.config.js
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
import path from 'node:path';

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./tests/setupTests.js'],
    include: ['tests/**/*.test.js'],
  },
});

3.3 setupTests.js 역할

모든 테스트 전에 공통으로 실행되는 초기화 스크립트다.

예를 들어:

  • 콘솔 경고를 정리
  • 전역 플러그인 등록
  • 공통 mock 세팅

등을 넣을 수 있다.

예시(최소 형태):

// tests/setupTests.js

// 필요하면 여기서 공통 설정
console.warn = (...args) => {
  console.log('[Vitest warning]', ...args);
};

3.4 package.json 스크립트

{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui"
  }
}

4. 테스트 실행 방법

4.1 전체 테스트 실행

npm run test
# 또는 UI 모드
npm run test:ui

Vitest가 tests 폴더 아래의 테스트 파일들을 모두 찾아 실행한다.

4.2 특정 테스트 파일만 실행

npx vitest "tests/components/CourseListItem.test.js"

IDE(WebStorm, VSCode 플러그인 등)에서 직접 실행하는 것도 가능하다.

4.3 실패한 테스트 해석

Vitest는 다음 정보를 보여준다.

  • 실패한 describe/it 이름
  • 기대값(expect) vs 실제값
  • 실패한 파일/라인 번호

로그만 잘 읽으면 “어떤 동작이 깨졌는지”를 빠르게 파악할 수 있다.


5. 프리젠테이션 컴포넌트 테스트

예시 대상: CourseListItem.vue

역할:

  • props로 전달된 강의 정보 표시
  • isFavorite 여부에 따라 버튼 라벨 변경
  • 버튼 클릭 시 toggle-favorite 이벤트 emit

테스트 포인트:

  1. props 렌더링

    • 제목, 강사, 난이도, 시간 등이 실제 텍스트로 표시되는지
  2. 조건부 라벨

    • isFavorite = false → 버튼 텍스트: 즐겨찾기
    • isFavorite = true → 버튼 텍스트: 즐겨찾기 해제
  3. 이벤트 emit

    • 버튼 클릭 시 toggle-favorite 이벤트가 발생하는지
    • payload로 course.id가 전달되는지

핵심 패턴:

import { mount } from '@vue/test-utils';
import CourseListItem from '@/components/courses/CourseListItem.vue';

describe('CourseListItem', () => {
  const createWrapper = (overrides = {}) => {
    const defaultProps = {
      course: {
        id: 1,
        title: 'Vue.js 입문',
        instructor: '홍길동',
        level: 'beginner',
        duration: 12,
      },
      isFavorite: false,
    };

    return mount(CourseListItem, {
      props: { ...defaultProps, ...overrides },
    });
  };

  it('강의 정보가 화면에 표시되어야 한다.', () => {
    const wrapper = createWrapper();

    expect(wrapper.text()).toContain('Vue.js 입문');
    expect(wrapper.text()).toContain('홍길동');
    expect(wrapper.text()).toContain('beginner');
    expect(wrapper.text()).toContain('12시간');
  });

  it('버튼 클릭 시 toggle-favorite 이벤트가 발생해야 한다.', async () => {
    const wrapper = createWrapper();
    const button = wrapper.get('button');

    await button.trigger('click');

    const emitted = wrapper.emitted('toggle-favorite');
    expect(emitted).toBeTruthy();
    expect(emitted[0][0]).toBe(1); // 첫 payload = course.id
  });
});

이 정도만 테스트해도 컴포넌트의 “계약”이 코드로 문서화된다.

  • props를 이렇게 주면
  • 화면 텍스트는 이렇게 나오고
  • 버튼을 누르면 이런 이벤트가 나간다

6. Composable 테스트

예시 대상: useCourses

역할:

  • 강의 목록 fetch
  • loading / error 상태 관리
  • searchKeyword, levelFilter 기반 필터링

6.1 전략

  1. API 의존성 제거

    vi.mock으로 fetchCourses를 mock 처리해서 항상 같은 더미 데이터를 반환하게 만든다.

  2. 테스트용 호스트 컴포넌트

    defineComponent 안에서 useCourses()를 호출하고,
    wrapper.vm을 통해 ref/computed를 접근한다.

  3. 비동기 처리 대기

    • flushPromises()로 모든 Promise 처리 완료까지 대기
    • await wrapper.vm.$nextTick()으로 DOM 반영까지 기다린 뒤 검증

6.2 검증 내용

  • 초기 로딩 후

    • loading === false
    • error === null
    • courses.length === 3
    • filteredCourses.length === 3
  • 검색 필터

    • "Vue" 검색 시 Vue 관련 강의만 남는지
    • "신사임당" 검색 시 해당 강의만 남는지
  • 난이도 필터

    • levelFilter = 'all' → 전체
    • levelFilter = 'beginner' → beginner만
    • levelFilter = 'advanced' → advanced만

이 테스트들이 통과하면:

UI와 상관없이, useCourses의 로직만으로도
데이터 로딩·검색·필터링이 안정적으로 동작한다는 것을 보장할 수 있다.


7. Pinia Store 테스트

예시 대상: useCourseStore

역할:

  • favoriteIds: 즐겨찾기된 강의 ID 목록
  • preferredLevel: 선호 난이도
  • lastViewedCourseId: 마지막으로 본 강의 ID
  • 관련 getter/action(isFavorite, toggleFavorite, setPreferredLevel, setLastViewedCourse)

7.1 설정

각 테스트마다 새로운 Pinia 인스턴스를 사용한다.

import { setActivePinia, createPinia } from 'pinia';
import { useCourseStore } from '@/stores/courseStore';

beforeEach(() => {
  setActivePinia(createPinia());
});

7.2 검증 내용

  • 초기 상태

    • favoriteIds = []
    • preferredLevel = 'beginner'
    • lastViewedCourseId = null
  • toggleFavorite

    • 1 추가 → [1]
    • 다시 1 토글 → []
  • setPreferredLevel

    • 'beginner''intermediate''advanced'
  • setLastViewedCourse

    • null1099

Pinia Store 테스트는

전역 상태 변경 로직이 언제나 예측 가능한 방식으로 동작하는지

를 보장해 준다.


8. Router 테스트

8.1 Router를 테스트하는 이유

  • 특정 URL → 올바른 View 컴포넌트가 렌더링되는지
  • 라우트 파라미터/쿼리가 제대로 전달되는지
  • 네비게이션 가드(beforeEach 등)가 의도대로 동작하는지

사용자 관점에서 보면 “주소 입력 → 특정 화면” 흐름을 코드로 검증하는 것이다.

8.2 테스트용 Router 생성

실제 브라우저 히스토리가 아니라 메모리 기반 히스토리를 쓴다.

import { createRouter, createMemoryHistory } from 'vue-router';
import HomeView from '@/views/HomeView.vue';
import CoursesBeforeView from '@/views/CoursesBeforeView.vue';
import CoursesAfterView from '@/views/CoursesAfterView.vue';
import PreferencesView from '@/views/PreferencesView.vue';

export function createTestRouter() {
  return createRouter({
    history: createMemoryHistory(),
    routes: [
      { path: '/', name: 'home', component: HomeView },
      { path: '/courses-before', name: 'courses-before', component: CoursesBeforeView },
      { path: '/courses-after', name: 'courses-after', component: CoursesAfterView },
      { path: '/preferences', name: 'preferences', component: PreferencesView },
    ],
  });
}

8.3 기본 페이지 이동 테스트

  • / → HomeView
  • /courses-before → CoursesBeforeView
  • /courses-after → CoursesAfterView
  • /preferences → PreferencesView

이렇게 실제 App.vuerouter + pinia와 함께 mount한 뒤,
router.push()await router.isReady()wrapper.text()로 특정 문구를 찾는 식으로 검증한다.

8.4 네비게이션 가드 테스트

예시 시나리오:

  • /preferences는 로그인된 사용자만 접근 가능하다.
  • meta: { requiresAuth: true } 설정
  • beforeEach에서 로그인 여부에 따라 리다이렉트

테스트에서는 실제 authStore 대신 간단한 플래그로 대체해서 가드 로직만 검증할 수 있다.

  • 비로그인 상태 → /preferences 진입 시 /로 리다이렉트
  • 로그인 상태 → /preferences 접근 허용

9. 통합 테스트: View + Composable + Pinia

예시 대상: CoursesAfterView

시나리오:

  • useCourses에서 넘어온 강의 목록이 화면에 렌더링된다.

  • 각 카드의 “즐겨찾기” 버튼을 누르면

    • courseStore.toggleFavorite(id) 호출
    • courseStore.setLastViewedCourse(id) 호출
  • 그 결과 Pinia store의 상태가 기대대로 바뀐다.

9.1 전략

  • useCourses는 실제 구현 대신 vi.mock으로 간단한 더미 데이터를 반환하게 만든다.
  • 이렇게 하면 네트워크/비동기 의존성 없이 흐름만 검증할 수 있다.

테스트에서 확인하는 것:

  1. CoursesAfterView를 Pinia와 함께 mount

  2. 초기 favoriteIds = [], lastViewedCourseId = null

  3. 첫 번째 강의 카드의 버튼 클릭

  4. 결과:

    • favoriteIds = [1]
    • lastViewedCourseId = 1

이 한 번의 테스트로

  • 프리젠테이션 컴포넌트(CourseListItem)의 emit
  • View의 핸들러(handleToggleFavorite)
  • Pinia store의 액션(toggleFavorite, setLastViewedCourse)

까지 전부 연결되어 있는지 한 번에 검증할 수 있다.


10. 실제 프로젝트에서 추천하는 테스트 전략

초중급 프론트엔드 개발 기준으로 현실적인 전략만 뽑으면 다음 네 가지다.

10.1 Composable / Store를 우선 테스트

가장 중요하다.

  • 비즈니스 로직의 핵심
  • UI가 바뀌어도 계속 살아남는 레이어
  • 리팩토링 시에도 테스트가 방어막 역할

→ 검색, 필터, 정렬, 상태 변경 로직은 웬만하면 Composable/Store 단위로 테스트를 작성해 두는 게 좋다.

10.2 프리젠테이션 컴포넌트는 “행동 중심”으로만

  • 텍스트/스타일은 자주 바뀐다.
  • props를 받으면 무엇을 보여주고, 어떤 이벤트를 emit하는지만 테스트한다.

10.3 Router + Pinia + 화면 통합 테스트는 핵심 플로우 위주로

  • 모든 페이지를 테스트할 필요는 없다.
  • “강의 목록 → 즐겨찾기 토글”, “검색 결과 반영”처럼 핵심 사용자 흐름 1~2개만 잡아도 효과가 크다.

10.4 E2E는 나중에, 정말 필요해졌을 때

  • 이번 구성에서는 Cypress 같은 E2E 도구는 제외
  • 배포가 본격화되거나, 장바구니 → 결제처럼 긴 플로우가 생기면 그때 최소 시나리오만 추가하는 정도로 접근하는 게 현실적이다.

정리하면,

  • Composable: 비즈니스 로직 단위
  • Pinia: 전역 상태
  • 컴포넌트: UI + 이벤트
  • Router: 화면 전환 흐름

이 네 가지를 각각 알맞은 수준에서 테스트하면, 리팩토링과 기능 추가가 훨씬 안전해진다.

profile
개발자 희망자

0개의 댓글