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
// 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"
},
...
}
위와 같이 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>
// 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)
})
})
테스트를 진행하면 위와 같이 테스트 결과를 얻을 수 있다. ui 옵션을 통해 브라우저에서 전체 테스트 결과와 테스트 케이스에 대한 결과 등을 상세하게 확인할 수 있다.
프로젝트 진행하면서 잠깐 시간이 남아서 유닛 테스트를 적용해봤다.
결과적으로 보면 그냥 테스트 관련 라이브러리 설치하고 적용하고 테스트 코드 작성하고 테스트 진행하면 끝! 으로 아주 심플해보이는데 중간 중간에 잘 몰라서 삽질을 꽤 했다.
describe, it 과 같은 함수를 이용해서 테스트 코드를 작성하는 법을 잘 몰라서 제대로 된 테스트 코드가 아니라는 에러가 발생했는데 사용한 테스트 라이브러리 이슈인 줄 알고 구글링하는 등.. 이상한 데서 불필요하게 삽질을 했다.
더불어서 Vue Test Utils을 통해 Vue 컴포넌트의 props를 설정하거나 해당 DOM element의 attribute를 가져오거나 하는 등의 방법도 익숙치 않아서 헤맸던 부분이 있다.
테스트 코드를 어떻게 잘 작성할 수 있을지는 앞으로 더 고민해야 하는 부분이다.
이번에는 만든 컴포넌트의 UI와 기능을 테스트해봤는데, 테스트 케이스를 정리하는 것부터 고민이었다. Vue 컴포넌트가 DOM에 잘 mount 되는 것만 테스트 하면 되는 건지, props가 잘 전달 되어서 원하는 대로 셋팅 되는 걸 테스트해야 하는 건지, 클릭 등의 이벤트 발생 로직까지 테스트해야 하는 건지 고민이었다.
그리고 각각의 테스트 코드를 작성하면서 무엇을 테스트가 성공한 것으로 볼 것인지에 대해서도 고민이었다. 작성하면서 UI 오픈소스 라이브러리인 element plus 의 테스트 케이스 등을 참고해봤다. 다음에는 테스트 방법론이나 테스트 케이스, 테스트 코드 작성법 등에 대해서 좀 더 학습해보고 적용해봐야겠다.
작성한 코드는 아래 git 저장소에서 확인할 수 있다.