프로그래밍/Vue.js

Vue.js 테스트 Vuex테스트

p-a-r-k 2019. 1. 6. 00:07
반응형

저장소 설계의 이해

ItemList 의 대상 정보를 Vuex의 상태 관리를 활용한 경우

프로젝트에 Vuex 추가

npm install --save vuex

src/main.js
import Vue from 'vue'
import Vuex from 'vuex'
import App from './App'
import ProgressBar from './components/ProgressBar'
import storeConfig from './store/store-config'
  
Vue.use(Vuex) //
  
const store = new Vuex.Store(storeConfig) //
  
Vue.config.productionTip = false
  
// global progress bar
const bar = new Vue(ProgressBar).$mount()
Vue.prototype.$bar = bar
document.body.appendChild(bar.$el)
  
new Vue({
  el: '#app',
  store, //
  render: h => h(App)
})

Vuex store 파트별 테스트

mutations 테스트

mutations에 들어가는 함수 자체에 대한 테스트

Test Code
구현 Source
src/store/_tests_/mutations.spec.js
import mutations from '../mutations'
  
describe('mutations', () => {
  test('setItems sets state.items to items', () => {
    const items = [{id: 1}, {id: 2}] //
    const state = { //
      items: []
    }
    mutations.setItems(state, { items }) //
    expect(state.items).toBe(items) //
  })
})
src/store/mutations.js
setItems (state, { items }) { //
  state.items = items //
}

Vuex getters 테스트

getters에 들어가는 함수 자체에 대한 테스트

Test Code
구현 Source
src/store/_tests_/getters.spec.js
import getters from '../getters'
  
describe('getters', () => {
  test('displayItems returns the first 20 items from state.items', () => {
    const items = Array(21).fill().map((v, i) => i) //
    const state = { //
      items
    }
    const result = getters.displayItems(state) //
    const expectedResult = items.slice(0, 20)
    expect(result).toEqual(expectedResult) //
  })
  test('maxPage returns a rounded number using the current items', () => {
    const items = Array(49).fill().map((v, i) => i) //
    const result = getters.maxPage({
      items
    })
    expect(result).toBe(3)
  })
})
src/store/getters.js
displayItems (state) {
    return state.items.slice(0, 20) //
}
 
 
 
 
 
 
 
 
 
maxPage(state) {
  return Math.ceil(state.items.length / 20)
}

Vuex actions 테스트

Vuex action의 경우 mutation에 대한 commit을 수행하기 때문에 이에 대한 mock이 필요하다. 또한, action의 경우 주로 상태를 변경할 데이터를 비동기로 만들어서 commit을 하기 때문에 비동기 처리하는 부분에 대한 mock 처리와 commit에 대한 mock 처리 확인을 하는 형태로 테스트가 이루어진다.

Test Code
구현 Source
src/store/_tests_/actions.spec.js
import actions from '../actions'
import { fetchListData } from '../../api/api'
import flushPromises from 'flush-promises'
  
jest.mock('../../api/api')
  
describe('actions', () => {
  test('fetchListData calls commit with the result of fetchListData', async () => {
     expect.assertions(1) //
    const items = [{}, {}] //
    const type = 'top'
    fetchListData.mockImplementation(calledWith => { //
      return calledWith === type
        ? Promise.resolve(items)
        : Promise.resolve()
    })
    const context = { //
      commit: jest.fn()
    }
    actions.fetchListData(context, { type }) //
    await flushPromises() //
    expect(context.commit).toHaveBeenCalledWith('setItems', { items }) //
  })
})
src/store/actions.js
fetchListData({ commit }, { type }) {
    return fetchListData(type) //
      .then(items => commit('setItems', { items })) //
}

Vuex store instance 테스트

상태 변경 테스트 1
상태 변경 테스트 2
test(increment updates state.count by 1', () => {
    Vue.use(Vuex) //
    const store = new Vuex.Store(storeConfig) //
    expect(store.state.count).toBe(0) //
    store.commit('increment') //
    expect(store.state.count).toBe(1) //
  })
test('increment updates state.count by 1', () => {
  Vue.use(Vuex)
  const clonedStoreConfig = cloneDeep(storeConfig) //
  const store = new Vuex.Store(clonedStoreConfig) //
  expect(store.state.count).toBe(0)
  store.commit('increment')
  expect(store.state.count).toBe(1)
})

상태 변경 테스트 1과 상태 변경 테스트 2 의 차이는 테스트할 store 준비에서 나타난다. 1번은 storeConfig 를 통해 store instance가 만들어지고 사용되기 때문에 다른 테스트에 영향을 줄 수 있다. 2번에서는 이런 문제를 차단하기 위해 cloneDeep 기능을 활용하여 별도의 store를 생성해서 테스트하는 코드를 보여준다.

cloneDeep은 lodash.clonedeep을 설치해야 한다.

npm install --save-dev lodash.clonedeep

localVue 생성자 이해

vue기본 생성자를 사용하는 경우 아래 그림과 같이 이전에 세팅한 여러 요소가 포함되어 있을 수 있어서 테스트시 예상치 못한 결과를 얻을 수 있다.

이런 경우 vue 기본 생성자가 오염되었다고 표현할 수 있는데, 이를 방지하기 위해 vue test utils에서는 오염되지 않은 vue 생성자 사용을 위해 localVue 생성자를 지원한다. localVue를 사용하려면, mount시 localVue 생성자를 추가해주어야 한다.

localVue 사용예)

import { createLocalVue, shallowMount } from '@vue/test-utils'
  
// ..
  
const localVue = createLocalVue() //
localVue.use(Vuex) //
  
shallowMount(TestComponent, { //
  localVue
})

vue 생성자와 vuex를 오염없이 사용한 예)

src/store/_tests_/store-config.spec.js
import Vuex from 'vuex'
import { createLocalVue } from '@vue/test-utils'
import cloneDeep from 'lodash.clonedeep'
import flushPromises from 'flush-promises'
import storeConfig from '../store-config'
import { fetchListData } from '../../api/api'
  
jest.mock('../../api/api'//
  
const localVue = createLocalVue()
localVue.use(Vuex) //
  
function createItems () { //
  const arr = new Array(22)
  return arr.fill().map((item, i) => ({id: `a${i}`, name: 'item'}))
}
  
describe('store-config', () => {
  test('calling fetchListData with the type returns top 20 displayItems from displayItems getter', async () => {
    expect.assertions(1)
    const items = createItems() //
    const clonedStoreConfig = cloneDeep(storeConfig) //
    const store = new Vuex.Store(clonedStoreConfig) //
    const type = 'top'
    fetchListData.mockImplementation((calledType) => { //
      return calledType === type
        ? Promise.resolve(items)
        : Promise.resolve()
    })
    store.dispatch('fetchListData', { type }) //
  
    await flushPromises()
  
    expect(store.getters.displayItems).toEqual(items.slice(0, 20))//
  })
})

component에서 Vuex 테스트

ItemList.vue에 vuex를 적용하여 처리되도록 변경하는 작업에 대한 테스트 수행

ItemList.spec.js를 재작성

각 테스트에서 테스트 전 필요한 데이터를 준비하는 등의 처리를 beforeEach에서 하도록하여 중복 제거 - 각 test는 수행되기전에 beforeEach를 먼저 수행한다. beforeEach 함수 예)

ItemList.spec.js
import { shallowMount, createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
import flushPromises from 'flush-promises'
import ItemList from '../ItemList.vue'
import Item from '../../components/Item.vue'
  
const localVue = createLocalVue() //
localVue.use(Vuex) //
  
describe('ItemList.vue', () => {
  let storeOptions //
  let store
  
  beforeEach(() => {
    storeOptions = { //
      getters: {
        displayItems: jest.fn() //
      },
      actions: {
        fetchListData: jest.fn(() => Promise.resolve()) //
      }
    }
    store = new Vuex.Store(storeOptions) //
  })
})

beforeEach가 적용된 item을 store의 displayItems getter를 통해서 리스팅하도록 변경한 ItemList.spec.js의 test code와 ItemList.vue의 변경 source code

Test code
Source code
test('renders an Item with data for each item in displayItems', () => {
  const $bar = {
    start: () => {},
    finish: () => {}
  }
  const items = [{}, {}, {}]
  storeOptions.getters.displayItems.mockReturnValue(items) //
  const wrapper = shallowMount(ItemList, { //
    mocks: {$bar},
    localVue,
    store
  })
  const Items = wrapper.findAll(Item)
  expect(Items).toHaveLength(items.length)
  Items.wrappers.forEach((wrapper, i) => {
    expect(wrapper.vm.item).toBe(items[i])
  })
})
<item
  v-for="item in $store.getters.displayItems"
  :key="item.id"
  :item="item"
/>
test code
Source code
test('dispatches fetchListData with top', async () => {
  expect.assertions(1)
  const $bar = {
    start: () => {},
    finish: () => {}
  }
  store.dispatch = jest.fn(() => Promise.resolve()) //
  shallowMount(ItemList, {mocks: {$bar}, localVue, store})
  expect(store.dispatch).toHaveBeenCalledWith('fetchListData', { //
    type: 'top'
  })
})
test('increment updates state.count by 1', () => {
  Vue.use(Vuex)
  const clonedStoreConfig = cloneDeep(storeConfig) //
  const store = new Vuex.Store(clonedStoreConfig) //
  expect(store.state.count).toBe(0)
  store.commit('increment')
  expect(store.state.count).toBe(1)
})

store가 사용되도록 변경된 $bar 호출 부분과 종료 부분 테스트 코드

test('calls $bar start on load', () => {
    const $bar = {
      start: jest.fn(),
      finish: () => {}
    }
    shallowMount(ItemList, {mocks: {$bar}, localVue, store})
    expect($bar.start).toHaveBeenCalled()
  })
  
  test('calls $bar finish when load successful', async () => {
    expect.assertions(1)
    const $bar = {
      start: () => {},
      finish: jest.fn()
    }
    shallowMount(ItemList, {mocks: {$bar}, localVue, store})
    await flushPromises()
    expect($bar.finish).toHaveBeenCalled()
  })

ItemList.vue에서 loadItems 수행내용을 store의 fetchListData action을 dispatch하도록 변경한 test를 위한 test code와 변경한 source code

test code
변경한 source code
test('dispatches fetchListData with top', async () => {
  expect.assertions(1)
  const $bar = {
    start: () => {},
    finish: () => {}
  }
  store.dispatch = jest.fn(() => Promise.resolve()) //
  shallowMount(ItemList, {mocks: {$bar}, localVue, store})
  expect(store.dispatch).toHaveBeenCalledWith('fetchListData', { //
    type: 'top'
  })
})
loadItems () {
  this.$bar.start()
  this.$store.dispatch('fetchListData', {
    type: 'top'
  })
    .then(items => {
      this.displayItems = items
      this.$bar.finish()
    })
}

fetchListData에서 오류가 생겼을 경우의 테스트 코드와 source code

test code
추가한 source code
test('calls $bar fail when fetchListData throws', async () => {
    expect.assertions(1)
    const $bar = {
      start: jest.fn(),
      fail: jest.fn()
    }
    storeOptions.actions.fetchListData.mockRejectedValue() //
    shallowMount(ItemList, {mocks: {$bar}, localVue, store}) //
    await flushPromises() //
    expect($bar.fail).toHaveBeenCalled() //
  })
.catch(() => this.$bar.fail())

Vuex store의 변경 전이에 대한 체크를 위해 store-config.spec.js에 추가한 테스트 코드

test code
source code
test(' mutated items with add item', async () => {
  //expect.assertions(1)
  const $bar = {
    start: () => {},
    finish: () => {}
  }
 
  let items = createItems() //
  const clonedStoreConfig = cloneDeep(storeConfig) //
  let store = new Vuex.Store(clonedStoreConfig) //
  const type = 'top'
  fetchListData.mockImplementation((calledType) => { //
    return calledType === type
      ? Promise.resolve(items)
      : Promise.resolve()
  })
 
  const wrapper = shallowMount(ItemList, { //
    mocks: {$bar},
    localVue,
    store
  })
 
  await flushPromises()
  expect(store.getters.displayItems).toEqual(items.slice(0, 20))//
  let Items = wrapper.findAll(Item)
  expect(Items).toHaveLength(items.length)
  Items.wrappers.forEach((wrapper, i) => {
    expect(wrapper.vm.item).toBe(items[i])
  })
 
  console.log("before setItems")
  items = createItemsWithNo(22) // 22개로 늘림
  store.commit('setItems', {items}) // 늘린 item에 대한 commit 수행 
  console.log("after setItems")
  expect(store.state.items).toEqual(items)
  expect(store.getters.displayItems).toEqual(items.slice(0, 22))
  expect(wrapper.vm.displayItems.length).toEqual(22)
 
  Items = wrapper.findAll(Item)
  expect(Items).toHaveLength(items.length)
  Items.wrappers.forEach((wrapper, i) => {
    expect(wrapper.vm.item).toBe(items[i])
  })
})

computed: {
    displayItems () {
      console.log('called computed displayItems')
      return this.$store.getters.displayItems
    }
  },

before setItems 출력과 after setItems 출력 사이에 setItems 함수의 수행과 computed의 displayItems 수행 그리고, store.getters에 있는 displayItems 함수가 수행된 이력이 표시됨

반응형