Vuex를 사용하는 컴포넌트 테스트

actions를 mock 하기

아래의 컴포넌트는 vuex의 actions를 사용하는 코드이다. Vuex를 사용하는 컴포넌트를 테스트시에 주의해야 할 점은 actions가 어떻게 동작하는지는 중요하지 않다. 우리가 사용하고자 하는 action이 실제로 호출되었는지 여부만 테스트 한다.
action이 정확하게 동작하는지 여부는 vuex store 테스트시에 하게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<template>
<div class="text-align-center">
<input type="text" @input="actionInputIfTrue">
<button @click="actionClick()">Click</button>
</div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { mapActions } from 'vuex'

@Component({
methods: {
...mapActions(['actionClick'])
},
})
export default class Actions extends Vue {
actionInputIfTrue(event:any):void {
const inputValue = event.target.value;
if (inputValue === 'input') {
this.$store.dispatch('actionInput', { inputValue });
}
}
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import { shallowMount, createLocalVue, Wrapper } from '@vue/test-utils';
import Vuex from 'vuex';
import Actions from '../Actions.vue';

const localVue = createLocalVue();

localVue.use(Vuex);

describe('Actions.vue', () => {
let wrapper:Wrapper<Vue>;
let actions:any;
let store:any;

beforeEach(() => {
actions = {
actionClick: jest.fn(),
actionInput: jest.fn()
};

store = new Vuex.Store({
actions,
});

wrapper = shallowMount(VuexComponent, { store, localVue });
});

it('dispatches "actionInput" when input event value is "input"', () => {
const input = wrapper.find('input');
input.element.value = 'input';
input.trigger('input');
expect(actions.actionInput).toHaveBeenCalled();
});

it('does not dispatch "actionInput" when event value is not "input"', () => {
const input = wrapper.find('input');
input.element.value = 'no input';
input.trigger('input');
expect(actions.actionInput).not.toHaveBeenCalled();
});

it('calls store action "actionClick" when button is clicked', () => {
const button = wrapper.find('button');
button.trigger('click');
expect(actions.actionClick).toHaveBeenCalled();
})
});

첫번째 주의해야 할 점은 테스트 시에는 localVue를 사용해야 한다는 것이다.
두번째 는 actions를 jest의 mock functions를 사용하여 테스트한다. 이를 이용하면 actions를 실제로 구현하지 않아도 호출되었는지 여부를 확인 할수 있다.
세번째 는 함수의 호출 여부를 확인 하는 assertion은 toHaveBeenCalled를 사용한다.


getters를 mock 하기

getters도 actions와 마찬가지로 어떻게 동작하는지는 중요하지 않다. getters의 결과가 실제로 렌더링 되는지 여부만 확인 해보면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<div>
<p v-if="inputValue">{{inputValue}}</p>
<p v-if="clicks">{{clicks}}</p>
</div>
</template>

<script>
import { mapGetters } from 'vuex'

@Component({
computed: mapGetters(['clicks', 'inputValue'])
})
export default class Getters extends Vue {

}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { shallowMount, createLocalVue, Wrapper } from '@vue/test-utils';
import Vuex from 'vuex';
import Getters from '../Getters.vue';

const localVue = createLocalVue();

localVue.use(Vuex);

describe('Getters.vue', () => {
let getters:any;
let store:any;
let wrapper:Wrapper<Vue>;

beforeEach(() => {
getters = {
clicks: () => 2,
inputValue: () => 'input'
};
store = new Vuex.Store({
getters
});
wrapper = shallowMount(VuexComponent, { store, localVue });
});

it('Renders "store.getters.inputValue" in first p tag', () => {
expect(wrapper.find('.input-value').text()).toBe(getters.inputValue());
});

it('Renders "store.getters.clicks" in second p tag', () => {
expect(wrapper.findAll('p').at(1).text()).toBe(getters.clicks().toString());
});
});

첫번째 는 actions와는 달리 getters는 jest의 mock functions를 사용하지 않는다. getters 객체를 작성하지만 로직은 중요하지 않는다.
두번째 는 getters의 리턴 값이 렌더링되었는지 여부를 확인 하는 건 wrapper의 text 메소드를 이용한다.


vuex의 모듈 방식 테스트하기

vuex의 모듈 방식을 활용한 테스트도 기존의 테스트랑 별 다를게 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
<div>
<button @click="moduleActionClick()">Click</button>
<p>{{moduleClicks}}</p>
</div>
</template>

<script>
import { Vue, Component } from 'vue-property-decorator';
import { mapActions, mapGetters } from 'vuex';

@Component({
methods: {
...mapActions(['moduleActionClick'])
},
computed: mapGetters(['moduleClicks'])
})
export default class MyModuleVuex extends Vue {

}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import { shallowMount, createLocalVue, Wrapper } from '@vue/test-utils';
import Vuex from 'vuex';
import MyModuleVuex from '../MyModuleVuex.vue';
import myModule from '../../store/myModule';

const localVue = createLocalVue();

localVue.use(Vuex);

describe('MyModuleVuex', () => {
let actions:any;
let state:any;
let store:any;
let wrapper:Wrapper<Vue>;

beforeEach(() => {
actions = {
moduleActionClick: jest.fn()
};
state = {
clicks: 2
};
store = new Vuex.Store({
modules: {
myModule: {
state,
actions,
getters: myModule.getters
}
}
});
wrapper = shallowMount(MyModuleVuex, { store, localVue });
});

it('calls store action "moduleActionClick" when button is clicked', () => {
wrapper.find('button').trigger('click');
expect(actions.moduleActionClick).toHaveBeenCalled();
})

it('renders "state.clicks" in first p tag', () => {
expect(wrapper.find('p').text()).toBe(state.clicks.toString());
});
});

Vuex Store 테스트하기

지금까지는 Vuex를 사용하는 컴포넌트에 대해서 알아보았다. 그렇기 때문에 실제 Vuex의 내부 동작과는 무관한 테스트였다. 이번에는 Vuex가 정확하게 동작하는지 여부를 테스트 하기 위한 방법이다.
Vuex Store 테스트는 두가지 방법이 있다. 첫번째는 getters, mutations, actions를 독립적으로 테스트 하는 것이다. 두번째는 실제 Vuex Store를 생성하는 방법이다.
아래는 테스트에서 사용할 mutations와 getters 코드이다.

1
2
3
4
5
export default {
increment(state:any) {
state.count++;
}
}
1
2
3
4
5
export default {
evenOrOdd(state:any) {
return state.count % 2 === 0 ? 'even' : 'odd'
}
}

getters, mutations, actions를 독립적으로 테스트하기

독립적으로 테스트 하는 경우에는 좀더 상세하게 테스트를 진행할수 있다. 테스트가 실패하더라도 어디에서 실패했는지 찾기가 쉽다. 단점으로는 commit, dispatch같은 Vuex의 함수들을 mock 해야할 필요가 있다. 이는 유닛 테스트는 성공할지라도 mock이 정확하지 않기 때문에 실제 production 코드는 실패할수도 있다.

1
2
3
4
5
6
7
import mutations from '../mutations';

test('"increment" increments "state.count" by 1', () => {
const state = { count: 1 };
mutations.increment(state);
expect(state.count).toBe(2);
});
1
2
3
4
5
6
7
8
9
10
11
import getters from '../getters';

test('"evenOrOdd" returns even if "state.count" is even', () => {
const state = { count: 2 };
expect(getters.evenOrOdd(state)).toBe('even');
});

test('"evenOrOdd" returns odd if "state.count" is odd', () => {
const state = { count: 1 };
expect(getters.evenOrOdd(state)).toBe('odd');
});

store를 사용한 테스트

실제로 Vuex sotre를 사용한 방법이다. 이 테스트의 장점은 Vuex function들을 mock 할 필요가 없다는 것이다. 하지만 테스트 실패시에 어디에서 문제가 발생했는지 찾기가 어렵다.

1
2
3
4
5
6
7
8
9
10
import mutations from './mutations';
import getters from './getters';

export default {
state: {
count: 0
},
mutations,
getters
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import storeConfig from '../store-config';
import { cloneDeep } from 'lodash';

const localVue = createLocalVue();
let store:any;
localVue.use(Vuex);

beforeEach(() => {
store = new Vuex.Store(cloneDeep(storeConfig));
});

test('increments "count" value when "increment" is committed', () => {
expect(store.state.count).toBe(0);
store.commit('increment');
expect(store.state.count).toBe(1);
})

test('updates "evenOrOdd" getter when "increment" is committed', () => {
expect(store.getters.evenOrOdd).toBe('even');
store.commit('increment');
expect(store.getters.evenOrOdd).toBe('odd');
})

해당 테스트에서는 cloneDeep를 사용하고 있는데 이는 각 테스트에 store를 클린하게 사용하기 위해서 이다.

그리고 생각해 보았을 때 상황에 맞추어서 하나씩만 적용 하던가 아니면 두개 모두를 적용해서 테스트 코드를 작성 하면 좋을거 같다.

Comment and share

vue 라우터 테스트 시에 주의 할점

vue 라우터를 테스트 할 시에 global Vue에 직접 추가를 해서는 안된다. vue 라우터를 설치 할 경우에는 $route와 $router가 vue 프로퍼티에 추가되어진다. 이는 $route와 $router를 mock 해서 테스트 하는 경우에 테스트를 실패하게 만든다.
vue-test-utils에서 제공하는 createLocalVue을 사용하는 것을 권장한다.

1
2
3
4
5
6
7
8
9
10
11
import { shallowMount, createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'

const localVue = createLocalVue()
localVue.use(VueRouter)
const router = new VueRouter()

shallowMount(Component, {
localVue,
router
})

위 두가지 컴포넌트를 사용하는 컴포넌트를 테스트 하는 방법은 두가지가 존재한다.


1. stubs 사용하기

stubs를 사용해서 자식 컴포넌트를 렌더링하는 것을 피한다.

1
2
3
4
5
import { shallowMount } from '@vue/test-utils'

shallowMount(Component, {
stubs: ['router-link', 'router-view']
});

2. localVue를 사용하기

localVue를 이용해서 실제로 VueRouoter를 추가하는 방식이다.

1
2
3
4
5
6
7
8
9
import { shallowMount, createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'

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

shallowMount(Component, {
localVue
});

$route, $router mock 하기

$route, $router 객체를 가지고 컴포넌트 테스트를 해야 할 경우가 있다. 이때는 $route, $router를 mock 하여 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
import { shallowMount } from '@vue/test-utils'

const $route = {
path: '/some/path'
}

const wrapper = shallowMount(Component, {
mocks: {
$route
}
});

wrapper.vm.$route.path // 이렇게 접근이 가능해진다.

Comment and share

데이터 테스트 하기

js에서는 데이터를 검증 할때 아래와 같이 작성한다.

1
expect(wrapper.vm.message).toBe('message');

ts에서도 위와 같은 코드를 작성한 후에 테스트를 돌리니 아래와 같은 에러가 발생하였다.

1
2
TypeScript diagnostics (customize using `[jest-config].globals.ts-jest.diagnostics` option):
src/components/__tests__/HelloWorld.test.ts:12:23 - error TS2339: Property 'message' does not exist on type 'CombinedVueInstance<Vue, object, object, object, Record<never, any>>'.

그래서 정상적으로 테스트 하기 위한 방법은 두가지 정도 인거 같다.


1. vm.$data 사용하기

vm에는 data값을 가지고 있는 $data 객체가 존재한다. 해당 객체를 통해서 검증을 하면 가능하다.

1
expect(wrapper.vm.$data.message).toBe('message');

2. jest.config.js 수정하기

ts-jest를 사용하면 diagnostics옵션의 디폴트값이 true인데 위 코드 처럼 사용할 경우 에러를 발생시킨다. 이를 false로 설정하면 정상적으로 테스트가 가능하다.

1
2
3
4
5
6
7
8
module.exports = {
/// ...
globals: {
'ts-jest': {
diagnostics: false
}
}
}

Comment and share

타입스크립트를 사용할때 추가적으로 설치해야할 라이브러리

뷰를 jest를 통해서 테스트 하고자 할때 설치해야할 라이브러리 목록은 here를 통해서 확인이 가능하다.

  1. ts-jest
  2. @types/jest

jest.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
module.export = {
// ...
"moduleFileExtensions": [
// ...
"ts"
],
"transform": {
// ...
"^.+\\.tsx?$": "ts-jest"
},

// jest는 기본적으로 js만을 테스트 파일로 찾기 때문에 .ts 파일도 테스트 파일로 찾을수 있도록 설정 해준다.
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$"
}

tsconfig.json

types에 jest를 추가해준다.

1
2
3
4
5
6
7
8
9
{
"compilerOptions": {
// ...
"types": [
// ...
"jest"
],
}
}

Comment and share

Setup

테스트 코드를 작성하다 보면 테스트마다 공통적으로 사용이 되는 코드가 존재한다. 그중에서 테스트 코드 로직을 실행 하기 이전에 실행이 되어야 하는 공통 코드가 있다. 이때는 사용되는 함수가 beforeEach 함수이다.
해당하는 함수는 각 테스트 코드가 실행되기 이전에 호출된다.

1
2
3
beforeEach(() => {
// 보통 변수의 초기화 등의 코드가 들어가거나 테스트 코드 전에 실행되어야 할 로직이 들어간다.
});

테스트 종료시 호출

각 테스트가 종료 후에 호출이 되는 함수도 존재한다. 이는 afterEach 함수이다.

1
2
3
afterEach(() => {
// 테스트 종류 후에 작업 해야 할 로직이 들어간다.
})

Comment and share

프로퍼티

1. vm

vm은 Vue 인스턴스이며, wrapper.vm으로 vm의 모든 메소드와 프로퍼티에 접근이 가능하다.

1
2
wrapper.vm.count;
wrapper.vm.method();

메소드

1. html

Wrapper DOM 노드의 HTML을 문자열로 반환을 해줍니다. 그래서 태그를 포함한 결과를 반환합니다.

1
expect(wrapper.html()).toBe('<div></div>');

2. text

Wrapper의 text만을 반환해줍니다.

1
expect(wrapper.text()).toBe('문자열');

3. find

선택자에 해당하는 DOM 노드 또는 해당하는 뷰 컴포넌트 중에 선번째 Wrapper를 반환해줍니다.

  • Arguments:

    • {string | Component} selector
  • Returns: {Wrapper}

1
2
wrapper.find('div');
wrapper.find(Component);

3. findAll

해당 하는 모든 Wrapper를 반환해줍니다.

  • Arguments:

    • {string | Component} selector
  • Returns: {WrapperArray}


4. exists

해당 Wrapper가 존재하는지 여부를 반환해줍니다.

1
expect(wrapper.exists()).toBeTruthy();

5. contains

Wrapper가 매칭되는 element 또는 컴포넌트를 포함하고 있는지 여부를 반환해줍니다.

1
2
expect(wrapper.contains('div')).toBeTruthy();
expect(wrapper.contains(Component)).toBeFalsy();

6. setData

data에 값을 할당 할수있습니다.

1
wrapper.setData({ data키:값 })

7. setProps

props에 값을 할당 할수있습니다.

1
wrapper.setProps({ prop키:값 })

8. trigger

trigger는 wrapper의 이벤트를 발동시켜줍니다. trigger는 추가적으로 option을 줄수 있습니다.
그리고 trigger의 결과를 테스트 할 때는 wrapper.vm.$nextTick를 이용하여 이벤트가 처리 된 후에 테스트 할수 있습니다.

1
2
3
4
wrapper.trigger('click');
wrapper.trigger('click', {
ctrlKey: true // @click.ctrl을 테스트 할수 있다.
})

9. emitted

wrapper의 vm에 의해 발동되어진 커스텀 이벤트를 포함하는 객체를 반환해 줍니다.

1
2
3
4
5
6
7
8
wrapper.vm.$emit('custom');
wrapper.vm.$emit('custom', 123);
wrapper.emitted();
/**
{
custom: [[], [123]]
}
*/

Comment and share

뷰 비동기

in vue, vue 팁

비동기 갱신 큐

Vue는 비동기 적으로 DOM을 업데이트 합니다. 데이터의 변경이 발견 되면 큐를 열고 같은 이벤트 루프에서 발생한 변경된 모든 데이터를 버퍼에 담습니다. 같은 wather가 여러번 호출 되더라도 큐에서 한번만 푸시 됩니다. 이러한 동작은 불필요한 계산과 렌더링을 방지해줍니다. 그리고 다음 이벤트 루프인 “tick”에서 큐를 모두 비우고 실제 행동을 수행합니다.


예를 들어 vm.someData = 'new value' 라고 설정 할때, Vue는 바로 렌더링을 수행하지 않고 큐가 비워진 후인 다음 “tick”에서 업데이트 됩니다. 대부분의 상황에서는 이러한 작업이 영향을 받지 않지만, DOM state가 업데이트 된 후 작업을 수행하려는 겨우 신경을 써야 할수 있습니다. 데이터가 변경 된 후, DOM에 업데이가 완료되는 것을 기다리려면 데이터가 변경된 직후에 Vue.nextTick(callback)을 사용하면 됩니다. 콜백은 DOM이 업데이트 된 후 호출이 됩니다.


nextTick 사용하기

아래의 코드를 실행해보면 data가 변경 되더라도 실제로 바로 렌더링되지 않는 다는 것을 확인 할수 있습니다.
그리고 nextTic 메소드의 콜백함수가 DOM 업데이트가 완료 된 후 실행되었음을 확인할수 있습니다.

1
<div id="example">{{ message }}</div>
1
2
3
4
5
6
7
8
9
10
11
var vm = new Vue({
el: '#example',
data: {
message: '123'
}
})
vm.message = 'new message'
vm.$el.textContent === 'new message' // false
Vue.nextTick(function () {
vm.$el.textContent === 'new message' // true
})

https://vuejs.org/v2/guide/reactivity.html를 참조하였습니다.

Comment and share

new Vue를 이용한 컴포넌트 테스트

아래의 방법은 실제로 컴포넌트를 생성하여 테스트 하는 방법입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- HelloWorld.js -->

<template>
<div>{{ message }}</div>
</template>

<script>
export default {
data() {
return {
message: "Hello"
}
}
}
</script>

<style>

</style>
1
2
3
4
5
6
7
8
9
10
11
12
// helloworld.test.js

import Vue from 'vue';
import HelloWorld from '../components/HelloWorld.vue';

describe(('HelloWorld'), () => {
it('new Vue를 이용한 테스트', () => {
const helloWorld = new Vue(HelloWorld).$mount();
const message = 'Hello';
expect(helloWorld.message).toBe(message);
});
});

Vue Test Utils 이용한 컴포넌트 테스트

Vue Test Utils에서 제공 하는 mount 함수를 이용해서 좀더 간편 하게 테스트를 할수 있습니다.
message에 접근 하기 위해서 vm 프로퍼티를 이용한다는게 다릅니다.

1
2
3
4
5
6
7
8
9
10
11
12
// helloworld.test.js

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

describe(('HelloWorld'), () => {
it('new Vue를 이용한 테스트', () => {
const wrapper = mount(HelloWorld);
const message = 'Hello';
expect(wrapper.vm.message).toBe(message);
});
});

Comment and share

파일 import 불가

Jest에서 Import를 사용 하는 경우 아래와 같이 에러가 발생한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Test suite failed to run

Jest encountered an unexpected token

This usually means that you are trying to import a file which Jest cannot parse, e.g. it's not plain JavaScript.

By default, if Jest sees a Babel config, it will use that to transform your files, ignoring "node_modules".

Here's what you can do:
• To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config.
• If you need a custom transformation specify a "transform" option in your config.
• If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option.

You'll find more details and examples of these config options in the docs:
https://jestjs.io/docs/en/configuration.html

Details:

D:\GIT 소스코드 저장소\TDD\JEST\vue-jest\vue-jest-start\src\test\helloworld.test.js:1
({"Object.<anonymous>":function(module,exports,require,__dirname,__filename,global,jest){import { mount } from '@vue/test-utils';

1. 해당 라이브러리가 정상적으로 설치 되어 있는지 확인한다.

1) jest
2) vue-jest
3) @babel/core
4) @babel/preset-env
5) babel-jest
6) @vue/test-utils
7) babel-core@^7.0.0-bridge.0 (바벨이 7.0 이상 사용중일때)


2. jest.config.js

package.json 또는 jest.config.js에 아래와 같이 입력 한다.

1
2
3
4
5
6
7
8
9
10
11
module.export = {
"moduleFileExtensions": [
"js",
"json",
"vue"
],
"transform": {
"^.+\\.js$": "babel-jest",
"^.+\\.vue$": "vue-jest"
}
}

3. babel.config.js

package.json 또는 babel.config.js에 아래와 같이 입력 한다.

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = {
presets: [
[
'@babel/preset-env',
{
targets: {
node: 'current',
},
},
],
],
};

정리

구글링 결과 Jest에서 Import를 사용하기 위해서는 Babel을 설치해야 하는 듯하다.
그리고 babel 7 이상을 사용 한다면 babel-core@^7.0.0-bridge.0를 설치해 주어야 한다.

Comment and share

Moon Star

author.bio


author.job