# 테스트에서 보일러플레이트 줄이기

이 글은 Vue.js Courses에서 스크린캐스트(코딩하는 화면 같이 컴퓨터 화면을 녹화한 비디오)로 이용할 수 있습니다. 여기를 확인해주세요.

보통은 컴포넌트의 새 복사본을 가지고 각각의 유닛 테스트를 진행하는 게 이상적입니다. 더 나아가서 여러분의 앱이 더 커지고 복잡해질수록, 컴포넌트는 많고 다양한 props를 가질 것이고, 아마 Vuetify, VueRouter나 Vuex 같은 3rd 파티 라이브러리를 여러 개 설치했을 것입니다. 이런 부분은 여러분의 테스트에 많은 보일러플레이트 코드를 야기할 수 있습니다. 보일러플레이트 코드는 테스트와 직접적으로 관련이 없는 코드를 의미합니다.

이 글은 Vuex, VueRouter를 사용하는 컴포넌트를 가지고, 유닛 테스트를 위한 설정 코드의 양을 줄이는 일을 도와주는 몇 가지 패턴을 설명합니다.

이 페이지에서 설명한 테스트의 소스 코드는 여기서 찾을 수 있습니다.

# Posts 컴포넌트

아래의 코드가 테스트할 컴포넌트입니다. message prop이 보이고, 한 태그가 받고 있습니다. 사용자가 인증된 상태이고, 몇 개의 포스트를 가지고 있다면 새로운 Post 버튼을 보여줍니다. authenticatedposts 객체 둘 다 Vuex 스토어에서 받습니다. 결과적으로 포스트의 링크를 보여주는 router-link 컴포넌트를 렌더합니다.

<template>
  <div>
    <div id="message" v-if="message">{{ message }}</div>

    <div v-if="authenticated">
      <router-link 
        class="new-post" 
        to="/posts/new"
      >
        New Post
      </router-link>
    </div>

    <h1>Posts</h1>
    <div 
      v-for="post in posts" 
      :key="post.id" 
      class="post"
    >
      <router-link :to="postLink(post.id)">
        {{ post.title }}
      </router-link>
    </div>
  </div>
</template>

<script>
export default {
  name: 'Posts',
  props: {
    message: String,
  },

  computed: {
    authenticated() {
      return this.$store.state.authenticated
    },

    posts() {
      return this.$store.state.posts
    }
  },

  methods: {
    postLink(id) {
      return `/posts/${id}`
    }
  }
}
</script>

테스트하고 싶은 부분은 아래와 같습니다.

  • prop을 받았을 때 message가 렌더되는가?
  • posts는 정확하게 렌더되는가?
  • New Post 버튼은 authenticatedtrue일 때는 보이고, false일때는 숨겨지는가?

이상적으로 테스트는 가능한 명료해야 합니다.

# Vuex/VueRouter 팩토리 함수

좀 더 테스트하기 편한 앱을 만들 수 있는 좋은 방법은 Vuex나 VueRouter를 위한 팩토리 함수를 내보내는 것입니다. 보통 여러분은 아래와 같은 코드를 보게 됩니다.

// store.js

export default new Vuex.Store({ ... })

// router.js
export default new VueRouter({ ... })

일반적인 앱에서는 괜찮지만 테스트할 때는 이상적이지 않습니다. 이렇게 하면, 테스트에서 매번 스토어(store)나 라우터(router)를 사용해야 하고, 스토어나 라우터를 추출한 다른 모든 테스트에 걸쳐서 공유될 것입니다. 이상적으로 모든 컴포넌트는 스토어와 라우터의 새 복사본을 가져야 합니다.

이 작업을 수행하는 간단한 방법은 팩토리 함수를 추출하는 것입니다. 팩토리 함수는 객체의 새 인스턴스를 반환하는 함수를 말합니다. 예를 들면 아래와 같습니다.

// store.js
export const store = new Vuex.Store({ ... })
export const createStore = () => {
  return new Vuex.Store({ ... })
}

// router.js
export default new VueRouter({ ... })
export const createRouter = () => {
  return new Vuex.Router({ ... })
}

이제 메인 앱은 import { store } from './store.js'를 할 수 있고, 테스트에서는 import { createStore } from './store.js'를 하고 const store = createStore()로 인스턴스를 만듦으로써 매번 스토어의 새로운 복사본을 가질 수 있습니다. 라우터도 동일합니다. 이게 Posts.vue 예제에서 하려고 하는 일입니다. 스토어 코드는 여기서 라우터 코드는 여기서 찾을 수 있습니다.

# 테스트 (리팩토링 하기 전)

이제 Posts.vue와 스토어와 라우터가 어떻게 보일지 알고, 테스트가 무엇을 해야할지 이해할 수 있습니다.

import Vuex from 'vuex'
import VueRouter from 'vue-router'
import { mount, createLocalVue } from '@vue/test-utils'

import Posts from '@/components/Posts.vue'
import { createRouter } from '@/createRouter'
import { createStore } from '@/createStore'

describe('Posts.vue', () => {
  it('통과하면 메시지를 렌더한다.', () => {
    const localVue = createLocalVue()
    localVue.use(VueRouter)
    localVue.use(Vuex)

    const store = createStore()
    const router = createRouter()
    const message = 'New content coming soon!'
    const wrapper = mount(Posts, {
      propsData: { message },
      store, router,
    })

    expect(wrapper.find("#message").text()).toBe('New content coming soon!')
  })

  it('posts 렌더한다', async () => {
    const localVue = createLocalVue()
    localVue.use(VueRouter)
    localVue.use(Vuex)

    const store = createStore()
    const router = createRouter()
    const message = 'New content coming soon!'

    const wrapper = mount(Posts, {
      propsData: { message },
      store, router,
    })

    wrapper.vm.$store.commit('ADD_POSTS', [{ id: 1, title: 'Post' }])
    await wrapper.vm.$nextTick()

    expect(wrapper.findAll('.post').length).toBe(1)
  })
})

모든 조건을 완벽하게 테스트하지는 않습니다. 간단한 예제이고 시작하기에는 충분합니다. 중복과 반복에 주목하세요. 중복과 반복을 제거하겠습니다.

# 커스텀 createTestVue 함수

아래와 같이 각 테스트의 처음 다섯 줄은 동일합니다.

const localVue = createLocalVue()
localVue.use(VueRouter)
localVue.use(Vuex)

const store = createStore()
const router = createRouter()

한번 수정해보겠습니다. Vue Test Utils의 createLocalVue와 헷갈리지 않게, 새로 만들 함수를 createTestVue라고 부르겠습니다. 아래와 같은 모습입니다.

const createTestVue = () => {
  const localVue = createLocalVue()
  localVue.use(VueRouter)
  localVue.use(Vuex)

  const store = createStore()
  const router = createRouter()
  return { store, router, localVue }
}

이제 하나의 함수 안에 모든 로직을 캡슐화했습니다. store, router 그리고 localVue를 반환합니다. mount 함수에 인자로 넘기는 데 필요하기 때문입니다.

createTestVue를 사용해서 첫 번째 테스트를 리팩토링한다면, 아래와 같은 모습이 됩니다.

it('통과하면 메시지를 렌더한다', () => {
  const { localVue, store, router } = createTestVue()
  const message = 'New content coming soon!'
  const wrapper = mount(Posts, {
    propsData: { message },
    store,
    router,
    localVue
  })

  expect(wrapper.find("#message").text()).toBe('New content coming soon!')
})

좀 더 명료합니다. Vuex 스토어를 사용하고 있는 두 번째 테스트를 리팩토링해 보겠습니다.

it('posts를 렌더한다', async () => {
  const { localVue, store, router } = createTestVue()
  const wrapper = mount(Posts, {
    store,
    router,
  })

  wrapper.vm.$store.commit('ADD_POSTS', [{ id: 1, title: 'Post' }])
  await wrapper.vm.$nextTick()

  expect(wrapper.findAll('.post').length).toBe(1)
})

# createWrapper 메서드 정의하기

위 코드에서 이전 테스트와 수정된 상태를 비교했을 때 확실히 개선이 있었지만, 코드의 반 정도는 아직 중복된 것을 알 수 있습니다. 이 문제를 처리하기 위해서, createWrapper라는 새 메서드를 생성하겠습니다.

const createWrapper = (component, options = {}) => {
  const { localVue, store, router } = createTestVue()
  return mount(component, {
    store,
    router,
    localVue,
    ...options
  })
}

이제 createWrapper를 호출해서, 테스트할 컴포넌트의 새 복사본을 가지면 됩니다. 이제는 테스트가 매우 명료합니다.

it('통과하면 메시지를 렌더한다', () => {
  const message = 'New content coming soon!'
  const wrapper = createWrapper(Posts, {
    propsData: { message },
  })

  expect(wrapper.find("#message").text()).toBe('New content coming soon!')
})

it('posts를 렌더한다', async () => {
  const wrapper = createWrapper(Posts)

  wrapper.vm.$store.commit('ADD_POSTS', [{ id: 1, title: 'Post' }])
  await wrapper.vm.$nextTick()

  expect(wrapper.findAll('.post').length).toBe(1)
})

# 초기 Vuex 상태 설정하기

우리가 할 마지막 개선은 어떻게 Vuex 스토어를 테스트에 붙일까에 대한 것입니다. 실제 애플리케이션에서는 스토어가 복잡할 수 있고, 테스트하기를 원하는 컴포넌트에 상태를 가지려고, 여러 가지 많은 뮤테이션(mutations)이나 액션(actions)을 commit 하거나 dispatch 하는 것은 이상적이지 않습니다. createStore 함수에 약간의 변화를 줘서, 초기 상태를 더 쉽게 설정할 수 있도록 만들겠습니다.

const createStore = (initialState = {}) => new Vuex.Store({
  state: {
    authenticated: false,
    posts: [],
    ...initialState
  },
  mutations: {
    // ...
  }
})

이제 createStore 함수를 원하는 초기 상태로 설정할 수 있습니다. 아래와 같이 createTestVuecreateWrapper를 머지해서 빠르게 리팩토링 할 수 있습니다.

const createWrapper = (component, options = {}, storeState = {}) => {
  const localVue = createLocalVue()
  localVue.use(VueRouter)
  localVue.use(Vuex)
  const store = createStore(storeState)
  const router = createRouter()

  return mount(component, {
    store,
    router,
    localVue,
    ...options
  })
}

이제 테스트는 아래와 같이 쓸 수 있습니다.

it('posts를 렌더한다', async () => {
  const wrapper = createWrapper(Posts, {}, {
    posts: [{ id: 1, title: 'Post' }]
  })

  expect(wrapper.findAll('.post').length).toBe(1)
})

큰 개선입니다! 코드의 절반가량이 보일러플레이트였던 테스트였고, 실제로 어설션(assertion)과 관련이 있지 않았습니다. 두 줄 중 한 줄은 컴포넌트 테스트를 준비하는 코드였고, 나머지 한 줄이 어설션을 위한 코드였습니다.

이 리팩토링 작업의 덤은, 가지고 있는 모든 테스트에서 사용할 수 있는 유연한 createWrapper 함수를 얻었다는 것입니다.

# 개선사항

아래와 같은 몇 가지 잠재적인 개선사항이 있습니다.

  • Vuex의 namespaced 모듈의 초기 상태를 설정하도록 해주는 createStore 함수를 업데이트합니다
  • 특정 라우트를 설정하는 createRouter를 개선합니다
  • 사용자가 createWrappershallowmount 인자를 넘기도록 해줍니다.

# 결론

이 가이드 문서는 아래의 내용에 관해 얘기했습니다.

  • 객체의 새 인스턴스를 얻는 팩토리 함수 사용
  • 일반적인 행동을 추출해서 보일러플레이트와 중복 줄이기

이 페이지에서 설명한 테스트의 소스 코드는 여기에서 찾을 수 있습니다. Vue.js Courses에서 스크린캐스트로 이용할 수도 있습니다.