Vitest로 Vue Component 테스트 코드 작성하기

호박고구마·2023년 6월 12일
2
post-custom-banner

환경 설정

1. 설치

  • Vite로 Vue + Typescript 기반으로 Vue 환경 셋팅
    pnpm create vue-component-test --template vue-ts

  • Unit Test Framework인 Vitest(https://vitest.dev/)
    pnpm add -D vitest @vitest/ui
    Vitest외에 Jest, Mocha 등의 라이브러리를 사용할 수 있지만 Vite를 기반으로 생성했다보니 Vite와 가장 궁합이 좋을 것 같아서 Vitest를 선택했다.
    Vitest의 ui 라이브러리는 옵셔널이다. 기본적으로 Vitest의 테스트 결과는 터미널로 확인할 수 있는데 ui 옵션을 사용하면 더 편리하게 확인할 수 있다.

  • Vue Test Utils(https://test-utils.vuejs.org/)
    Vue Component를 테스트하기 위해 사용되는 라이브러리다.
    pnpm add -D @vue/test-utils

2. 셋팅

// vitest.config.ts
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'
import path from 'path'
import { defineConfig } from 'vitest/config'

export default defineConfig({
  plugins: [vue()],
  // test 코드 내에서 @ alias 사용을 위해 셋팅
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  test: {
    // 브라우저 환경에서 테스트하는 것을 명시
    environment: 'jsdom',
    root: fileURLToPath(new URL('./', import.meta.url)),
  },
})

package.json에 test용 script command 추가

{
  ...
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview",
    "test:unit": "vitest --ui"
  },
  ...
}

테스트 코드 작성

1. 테스트 대상

위와 같이 palette 형태의 색상을 선택할 수 있는 Palette 컴포넌트를 만들었다.

Palette 컴포넌트는 다음과 같은 기능을 가진다.
1. hex코드로 작성된 string을 배열을 받아 팔레트 영역을 그린다.
2. width props라 존재하면 해당 width에 맞춰 팔레트 영역의 너비를 그린다.
3. v-model로 양방향 바인딩을 제공한다.
4. Palette에서 특정 컬러 선택시 혹은 v-model로 이미 양방향으로 바인딩된 데이터가 있을 시 선택되었음을 의미하는 dot 형태의 UI가 표출 된다.

Palette 컴포넌트의 Vue 코드는 아래와 같다.

// src/components/color-palette/ColorPalette.vue
<script lang="ts" setup>
import { computed } from 'vue'

import type { ColorPaletteProps } from './types'

const props = withDefaults(defineProps<ColorPaletteProps>(), {
  modelValue: '',
  palette: undefined,
  width: '160',
})

const emit = defineEmits(['update:modelValue'])

const containerW = computed(() => {
  return !props.width.includes('px') ? `${props.width}px` : `${props.width}`
})

const paletteUpperCase = (palette: string[]) => {
  return palette?.map((c: string) => c.toUpperCase())
}

const onClick = (color: string) => {
  emit('update:modelValue', color)
}
</script>

<template>
  <div class="palette" :style="{ width: containerW }">
    <div
      v-for="c in paletteUpperCase(props.palette)"
      class="palette-item"
      :style="{ backgroundColor: c }"
      :key="c"
      @click="onClick(c)"
    >
      <div
        class="palette-item__picked"
        v-if="c === props.modelValue"
        :style="{ backgroundColor: props.modelValue === '#FFFFFF' ? '#000' : '' }"
      ></div>
    </div>
  </div>
</template>
// src/App.vue
<script setup lang="ts">
import { ref } from 'vue'
const colors = ref('')

const palette = ref([
  '#FFFFFF',
  '#F6F6F6',
  '#F1F1F1'
])

</script>

<template>
 <ColorPalette v-model="colors" :palette="palette" width="200" />
</template>

2. 테스트 시나리오 및 코드

// src/components/color-palette/ColorPalette.test.ts
import { mount, shallowMount } from '@vue/test-utils'
import tinycolor from 'tinycolor2'
import { describe, expect, it } from 'vitest'

import ColorPalette from './ColorPalette.vue'

describe('ColorPalette.vue', () => {
  it('mounted', () => {
    const wrapper = mount(ColorPalette, {
      props: {
        palette: [],
        modelValue: '',
      },
    })
    expect(wrapper.classes('palette')).toBe(true)
  })

  it('width props에 따라 palette 컨테이너의 width가 결정된다.', () => {
    const width = '200'
    const wrapper = mount(ColorPalette, {
      props: {
        modelValue: '',
        palette: [],
        width,
      },
    })
    const style = wrapper.find('.palette').attributes('style')
    expect(style?.includes('width')).toBe(true)
    expect(style).toContain(`width: ${width}px`)
  })
  const palettes = ['#F5EFE7', '#D8C4B6', '#4F709C']

  it('palettes props에 따라 palette item이 설정된다.', () => {
    const wrapper = mount(ColorPalette, {
      props: {
        palette: palettes,
        modelValue: '',
      },
    })
    expect(wrapper.findAll('.palette-item')).toHaveLength(palettes.length)
    expect(wrapper.findAll('.palette-item')[1].attributes('style')).toContain(
      `background-color: ${tinycolor(palettes[1]).toRgbString()}`,
    )
  })

  it('팔레트 선택시 update:modelValue emit이벤트가 발생된다.', async () => {
    const wrapper = mount(ColorPalette, {
      props: {
        palette: palettes,
        modelValue: '',
      },
    })
    await wrapper.find('.palette-item').trigger('click')
    const emitEvt = wrapper.emitted('update:modelValue')
    expect(emitEvt).toHaveLength(1)
    expect(emitEvt[0][0]).toEqual(palettes[0])
  })

  it('팔레트 modelValue가 업데이트 되었을 시 선택되었음을 의미하는 UI가 활성화 된다.', async () => {
    const wrapper = shallowMount(ColorPalette, {
      props: {
        palette: palettes,
        modelValue: '',
      },
    })
    expect(wrapper.find('.palette-item__picked').exists()).toBe(false)
    await wrapper.setProps({ modelValue: palettes[0] })
    expect(wrapper.find('.palette-item__picked').exists()).toBe(true)
  })
})

3. 테스트 결과

테스트를 진행하면 위와 같이 테스트 결과를 얻을 수 있다. ui 옵션을 통해 브라우저에서 전체 테스트 결과와 테스트 케이스에 대한 결과 등을 상세하게 확인할 수 있다.

회고

  • 프로젝트 진행하면서 잠깐 시간이 남아서 유닛 테스트를 적용해봤다.
    결과적으로 보면 그냥 테스트 관련 라이브러리 설치하고 적용하고 테스트 코드 작성하고 테스트 진행하면 끝! 으로 아주 심플해보이는데 중간 중간에 잘 몰라서 삽질을 꽤 했다.
    describe, it 과 같은 함수를 이용해서 테스트 코드를 작성하는 법을 잘 몰라서 제대로 된 테스트 코드가 아니라는 에러가 발생했는데 사용한 테스트 라이브러리 이슈인 줄 알고 구글링하는 등.. 이상한 데서 불필요하게 삽질을 했다.
    더불어서 Vue Test Utils을 통해 Vue 컴포넌트의 props를 설정하거나 해당 DOM element의 attribute를 가져오거나 하는 등의 방법도 익숙치 않아서 헤맸던 부분이 있다.

  • 테스트 코드를 어떻게 잘 작성할 수 있을지는 앞으로 더 고민해야 하는 부분이다.
    이번에는 만든 컴포넌트의 UI와 기능을 테스트해봤는데, 테스트 케이스를 정리하는 것부터 고민이었다. Vue 컴포넌트가 DOM에 잘 mount 되는 것만 테스트 하면 되는 건지, props가 잘 전달 되어서 원하는 대로 셋팅 되는 걸 테스트해야 하는 건지, 클릭 등의 이벤트 발생 로직까지 테스트해야 하는 건지 고민이었다.
    그리고 각각의 테스트 코드를 작성하면서 무엇을 테스트가 성공한 것으로 볼 것인지에 대해서도 고민이었다. 작성하면서 UI 오픈소스 라이브러리인 element plus 의 테스트 케이스 등을 참고해봤다. 다음에는 테스트 방법론이나 테스트 케이스, 테스트 코드 작성법 등에 대해서 좀 더 학습해보고 적용해봐야겠다.

코드 확인

작성한 코드는 아래 git 저장소에서 확인할 수 있다.

https://github.com/hyeleex2/vue-component-test

post-custom-banner

0개의 댓글