Vue Test Utils(VTU)는 Vue.js 환경에서 단위 테스트를 하기 위한 공식(Official) 라이브러리입니다.
Jest는 페이스북에서 만든 테스트 프레임워크로 Vue Test Utils에서 권장하는 테스트 러너입니다.
두 가지 오픈 소스를 이용해 Vue 애플리케이션의 테스트를 진행합니다.
이 글에서는 Jest 24버전 이상을 기준으로 설명합니다.
Jest 23버전 이하를 사용하는 경우 Babel 등의 설치 및 설정이 다릅니다.
단위(Unit) 테스트란 상태, 메소드, 컴포넌트 등의 정의된 프로그램 최소 단위들이 독립적으로 정상 동작하는지 확인하는 것을 말합니다.
이를 통해 프로그램 전체의 신뢰도를 향상하고 코드 리팩토링(Code refactoring)의 부담을 줄일 수 있습니다.
이 글은 TDD(Test Driven Development)를 기준으로 하지 않습니다.
간단한 예제를 통해서 테스트에 대해 이해해 봅시다.
다음과 같이 하나의 인수에 1
을 더한 후 반환하는 addOne
함수를 가진 CommonJS 스타일의 calc.js
모듈이 있습니다.
// calc.js
exports.addOne = function (a) {
return a + 1
}
이 addOne
은 숫자 데이터를 인수로 ‘원하는 대로 정상 동작’하길 기대합니다.
(여기서 기대하는 정상 동작은 숫자 연산의 결과 반환을 의미하며 매우 주관적인 기준입니다)
하지만 다음과 같이 문자 데이터를 인수로 하게 되면 기대하지 않은 결과를 반환합니다.
// main.js
const { addOne } = require('./calc.js')
console.log(
addOne(1), // 2
addOne('1') // '11'
)
매번 콘솔 출력을 통해 기댓값을 하나씩 수동 확인하지 않고, 테스트를 통해서 언제든지 다시 검증할 수 있는 상태를 만들어 봅시다.
다음 테스트는 Jest를 기준으로 작성했습니다.
테스트 설정 및 동작에 관한 내용은 생략합니다.
// calc.test.js
// 테스트 대상을 테스트 환경으로 가져옵니다.
const { addOne } = require('./calc.js')
// Test 1
test('인수가 숫자인 경우', () => {
// expect()의 인수 결과가 .toBe()의 인수 값이 되길 기대합니다.
expect(addOne(1)).toBe(2)
expect(addOne(7)).toBe(8)
})
// Test 2
test('인수가 문자인 경우', () => {
expect(addOne('1')).toBe(2)
expect(addOne('7')).toBe(8)
})
테스트를 동작시키면 다음과 같이 인수가 문자일 경우
에서 테스트가 실패합니다.
위 테스트를 성공시키기 위해서 addOne
함수를 수정(리팩토링)해 봅시다.
다음과 같이 parseFloat
을 사용해 문자 데이터인 경우 숫자 데이터로 변환되도록 수정합니다.
exports.addOne = function (a) {
return parseFloat(a) + 1
}
다시 테스트를 실행하면 다음과 같이 테스트가 통과합니다.
이런 과정을 통해서 우리가 작성한 코드의 신뢰도를 향상할 수 있고,
새로운 로직을 추가하거나 수정할 때도 테스트 통과를 기준으로 문제 발생의 부담이 줄일 수 있습니다.
Jest CLI를 사용하기 위해서 전역으로 설치할 수 있습니다.
$ npm i -g jest
Vue CLI를 사용하면 프로젝트 초기화 과정에서 빠르게 Jest와 Vue Test Utils를 설치 및 설정할 수 있어 편리합니다.
$ npm i -g @vue/cli
$ vue create YOUR_PROJECT_NAME
$ cd YOUR_PROJECT_NAME
Vue CLI 3버전 이상에서는 비슷한 과정으로 설치할 수 있습니다.
[Space] 키로 선택/해제하고, [Enter] 키로 다음 질문으로 넘어갑니다.
Vue CLI로 설치하면, 이미 준비된 테스트 샘플(E.g. tests/unit/example.spec.js
)이 있어서 바로 테스트할 수 있습니다.
(필요치 않으면 삭제하세요)
$ npm run test:unit
이하 다른 예제들을 위해서 다음과 같이 스크립트를 하나 추가하겠습니다.
(일부 환경에서 다음 스크립트가 동작하지 않는 경우 Jest의 전역 설치를 권장합니다)
{
"scripts": {
// ...
"test": "jest"
}
}
run
키워드 없이 좀 더 간단하게 테스트를 시작할 수 있습니다.
$ npm t
혹은 지정할 별도의 Jest CLI 옵션이 없다면, 바로 jest
키워드로 실행할 수 있습니다.
$ jest
이번엔 별도의 Vue 프로젝트에 Jest와 Vue Test Utils의 설치 과정을 소개합니다.
주변 설정이 좀 더 포함되지만 어렵지 않으니 하나씩 살펴봅시다.
테스트를 위한 다음의 여러 필수 모듈을 설치합니다.
$ npm i -D jest @vue/test-utils vue-jest jest-serializer-vue babel-jest babel-core@bridge
모듈 | 설명 |
---|---|
vue-jest | Vue 파일을 Jest가 실행할 수 있는 자바스크립트로 컴파일합니다. |
jest-serializer-vue | 저장된 Jest Snapshot을 VueJS에 맞게 개선하기 위해 사용합니다. |
babel-jest | JS/JSX 파일을 Jest가 실행할 수 있는 자바스크립트로 컴파일합니다. |
babel-core@bridge | Babel 6버전과의 호환을 위해 설치합니다. 관련 이슈가 있습니다. |
비교적 최신 NuxtJS나 Vue CLI를 사용한다면 이미 내부에 @babel/core
와 @babel/preset-env
가 포함되어 있습니다.
자신의 프로젝트를 확인하여 다음 모듈의 추가 설치 여부를 결정하세요.
모듈 | 설명 |
---|---|
@babel/core | Babel 7버전입니다. |
@babel/preset-env | Babel의 지원 스펙을 지정합니다. |
$ npm i -D @babel/core @babel/preset-env
jest.config.js
파일에 Jest 구성 옵션을 설정할 수 있습니다.
다음 구성 옵션은 기본적인 예시입니다.
코드에 간단한 설명을 첨부하니 참고하세요.
더 많은 Jest 구성 옵션은 여기를 참고하시고, 기본값(Default)을 꼭 체크하세요!
// jest.config.js
module.exports = {
// 파일 확장자를 지정하지 않은 경우, Jest가 검색할 확장자 목록입니다.
// 일반적으로 많이 사용되는 모듈의 확장자를 지정합니다.
moduleFileExtensions: [
'js',
'jsx',
'json',
'vue'
],
// `@`나 `~` 같은 경로 별칭을 매핑합니다.
// E.g. `import HelloWorld from '~/components/HelloWorld.vue';`
// `<rootDir>` 토큰을 사용해 루트 경로를 참조할 수 있습니다.
// TODO: 프로젝트에 맞는 경로로 수정하세요!
moduleNameMapper: {
'^~/(.*)$': '<rootDir>/src/$1',
'^@/(.*)$': '<rootDir>/src/$1'
},
// 일치하는 경로에서는 모듈을 가져오지 않습니다.
// `<rootDir>` 토큰을 사용해 루트 경로를 참조할 수 있습니다.
// TODO: 프로젝트에 맞는 경로로 수정하세요!
modulePathIgnorePatterns: [
'<rootDir>/node_modules',
'<rootDir>/build',
'<rootDir>/dist'
],
// 정규식과 일치하는 파일의 변환 모듈을 지정합니다.
transform: {
'^.+\\.vue$': 'vue-jest',
'^.+\\.jsx?$': 'babel-jest'
},
// Jest Snapshot 테스트에 필요한 모듈을 지정합니다.
snapshotSerializers: [
'jest-serializer-vue'
]
}
혹은 별도 파일이 아닌 package.json
에 Jest 구성 옵션을 정리할 수 있습니다.
// package.json
{
"jest": {
"moduleFileExtensions": [
// ...
],
// ...
}
}
.babelrc
혹은 babel.config.js
파일에 다음과 같이 설정합니다.
Babel의 이상적인 구성은 프로젝트에 따라 다를 수 있습니다.
// babel.config.js
module.exports = {
presets: ['@babel/preset-env']
}
테스트 환경에서 Jest는 process.env.NODE_ENV
가 비어있는 경우 자동으로 test
를 지정합니다.
따라서 env
병합 옵션을 통해 테스트 환경에서만 동작할 Babel 구성을 할 수 있습니다.
다음 내용을 테스트하고 싶다면,
--no-cache
Jest CLI 옵션을 사용하세요.
module.exports = {
presets: ['@babel/preset-env'],
env: {
test: {
presets: [[
'@babel/preset-env', {
debug: true
}
]]
}
}
}
이제 마지막으로 package.json
에 다음과 같이 테스트 스크립트를 추가합니다.run
키워드 없이, npm test
및 npm t
를 통해 동작시킬 수 있습니다.
{
"scripts": {
"test": "jest"
}
}
혹은 --watch
Jest CLI 옵션을 사용할 수 있습니다.
이는 파일이 변경되었는지 확인하고 변경된 파일과 관련된 테스트만 다시 실행할 수 있어 편리합니다.
{
"scripts": {
"test": "jest --watch"
}
}
$ npm test
# or
$ npm t
# or
$ jest --watch
Vuetify UI 라이브러리를 사용하는 경우, 테스트 통과 여부와 상관없이 다음과 같은 에러가 발생할 수 있습니다.
관련 이슈는 아직 오픈되어 있으니 지속적인 확인을 권장하며,setupFilesAfterEnv
Jest Config 옵션에 jest.setup.js
파일 경로를 지정해 간단하게 해결할 수 있습니다.
// jest.config.js
module.exports = {
// ...
// 각 테스트 파일이 실행되기 전,
// 테스트 프레임워크를 구성 또는 설정하기 위한 실행 코드의
// 모듈 경로 목록을 지정합니다.
setupFilesAfterEnv: [
'./jest.setup.js'
]
}
// jest.setup.js
import Vue from 'vue'
import Vuetify from 'vuetify'
Vue.use(Vuetify)
추가로, Vuetify의 <v-dialog>
와 같이 기본적으로 <v-app>
루트 컴포넌트에 연결되는 컴포넌트를 사용하는 경우,
테스트에서는 루트 컴포넌트를 찾을 수 없기 때문에 다음과 같은 경고 메시지를 볼 수 있습니다.
따라서 위에서 살펴본 jest.setup.js
파일에 다음과 같이 루트 컴포넌트의 렌더링 결과를 추가해 줍니다.
// jest.setup.js
import Vue from 'vue'
import Vuetify from 'vuetify'
Vue.use(Vuetify)
// <v-app> 루트 컴포넌트 그리고 테스트 컴포넌트로 대체될 요소(<div>) 생성
const app = '<div id="app" data-app="true"><div></div></div>'
document.body.innerHTML += app
그리고 테스트에서 컴포넌트를 연결(Mounting)할 때 attachTo 옵션을 통해 테스트 컴포넌트로 대체될 요소를 선택자(Selector)로 지정합니다.
루트 컴포넌트 구조와 해당 선택자는 프로젝트에 맞게 지정하되,
선택자의 자식 요소로 삽입되는 것이 아닌 ‘대체됨’에 주의합시다.
다음은 attachTo
옵션을 사용하는 예시 코드입니다.
import { mount, createLocalVue } from '@vue/test-utils'
import Vuetify from 'vuetify'
import MyComponent from '../MyComponent'
const localVue = createLocalVue()
localVue.use(Vuetify)
describe('MyComponent', () => {
let wrapper
beforeEach(() => {
wrapper = mount(MyComponent, {
localVue,
attachTo: '#app > div',
vuetify: new Vuetify()
})
})
})
Watchman은 페이스북에서 만든 파일 변경을 감시하는 서비스입니다.
Jest의 --watch
혹은 --watchAll
CLI 옵션과 관련해 문제가 발생하는 경우 설치합니다.
설치에 대한 내용은 Watchman Installation을 참고하세요.
Homebrew를 사용한다면 다음과 같이 간단하게 설치할 수 있습니다.
$ brew update
$ brew install watchman
테스트 코드에서 Jest의 describe
나 test
같은 전역(Global) 메소드나 오브젝트를 사용하면 ESLint에서 에러(no-undef)가 발생할 수 있습니다.
ESLint 구성 옵션에서 env.jest = true
로 설정하면 해결할 수 있습니다.
만약 Jest API의 사전 정의를 활성화해야 하는 경우, IDE 옵션을 설정하지 않아도 Jest의 타입 선언(DefinitelyTyped)을 설치하면 간단하게 해결할 수 있습니다.
$ npm i -D @types/jest
테스트에서 비동기 코드를 작성할 때 다음과 같은 에러가 발생할 수 있습니다.
이 에러는 다양한 이유가 있지만, 많은 경우 간단하게 @babel/polyfill을 포함하는 것으로 해결할 수 있습니다.
$ npm i -D @babel/polyfill
테스트에 직접 포함하거나 jest.setup.js
파일에 포함하면 됩니다.jest.setup.js
설정은 ‘’개선 사항 > Vuetify’ 파트를 참고하세요.
import '@babel/polyfill'
Jest 25.1.0
버전부터 내부에서 사용하는 jsdom이 v11에서 v15로 업그레이드되었습니다.
하지만 이전 버전의 Jest를 사용하거나 jsdom의 최신 버전(권장 사항, 현재 이 글을 쓰는 시점에서 v16)을 사용하려면 다음 모듈과 함께 Jest 구성 옵션을 추가하세요
모듈 | 설명 |
---|---|
jest-environment-jsdom-sixteen | jsdom 16버전을 지정합니다. |
최신 버전의 jsdom에는 Node.js v10 이상이 필요합니다.
# jest-environment-jsdom-MAJOR_VERSION
$ npm i -D jest-environment-jsdom-sixteen
// jest.config.js
module.exports = {
// ...
// 테스트 환경을 지정합니다.
// 기본값은 `'jsdom'`이며 브라우저와 유사한 환경을 구성합니다.
// `'node'`를 작성하면 NodeJS와 유사한 환경을 제공할 수 있습니다.
// jsdom의 최신 버전을 별도 사용하는 경우에는, 다음과 같이 해당 모듈을 설치 후 옵션을 지정합니다.
testEnvironment: 'jest-environment-jsdom-sixteen'
}
Jest를 통해 테스트의 적용 범위(coverage) 보고서를 확인할 수 있습니다.
이 보고서는 작성한 테스트 코드를 통해 테스트 대상(Vue 컴포넌트 등)의 코드가 실행된 범위를 확인합니다.
보고서를 확인하려면 Jest 구성 옵션 collectCoverage
를 사용합니다.
module.exports = {
// ...
collectCoverage: true
}
이 보고서 옵션을 사용하면 테스트 속도가 크게 떨어지기 때문에, 필요에 따라서 확인하는 것도 좋습니다.
매번 구성 옵션을 수정하는 것이 불편하다면 보고서를 확인하는 경우에만 Jest CLI 옵션 --coverage
을 사용할 수 있습니다.
$ jest --coverage
이제 보고서 확인을 위한 테스트 코드를 작성해 보겠습니다.
다음과 같이 간단하게 컴포넌트를 하나 추가합니다.
<!-- HelloJest.vue -->
<template>
<div>
{{ reversedMsg }}
</div>
</template>
<script>
export default {
data () {
return {
msg: 'Test coverage'
}
},
computed: {
reversedMsg () {
return this.reverseString(this.msg)
}
},
methods: {
reverseString (str) {
return str.split('').reverse().join('')
}
}
}
</script>
이제 테스트 코드를 추가합니다.
뭔가 이상하지만, 테스트를 진행합니다.
// HelloJest.test.js
import { shallowMount } from '@vue/test-utils'
import HelloJest from '../HelloJest'
describe('HelloJest', () => {
test('1', () => {
shallowMount(HelloJest)
})
})
$ npm t
# or
$ jest --coverage
테스트 코드를 잘 살펴보면 실제 테스트한 코드가 없지만,
테스트 결과를 보면 Stmts
, Branch
등 모든 항목이 적용 범위 100%로 표시됩니다.
각 항목은 다음 표와 같으며,
백분율(%)은 전체 코드 중 실행(호출)된 코드의 비율입니다.
항목 | 설명 |
---|---|
Statements(Stmts) | 각 구문의 실행을 확인 |
Branches | If, Switch 같은 조건문의 각 분기 실행을 확인 |
Functions(Funcs) | 각 함수 호출을 확인 |
Lines | 각 라인의 실행을 확인(Statements와 비슷) |
Uncovered Line | 테스트를 통해 실행되지 않은 코드 라인을 표시 |
모든 항목이 100%인 이유는, 컴포넌트 마운트(shallowMount)를 통해서 reversedMsg
와 연결된 모든 코드가 실행되었기 때문입니다.
컴포넌트에서 다음과 같이 일부 코드를 주석 처리 후 다시 테스트를 진행합니다.
이제 테스트 대상의 어느 부분이 작성한 테스트를 통해 실행되지 않았는지 확인할 수 있습니다.
만약 보고서를 좀 더 자세하게 확인하고 싶다면, Jest 구성 옵션 coverageReporters
을 통해 보고서 양식을 HTML 문서로 변경할 수 있습니다.
다음과 같이 구성 옵션을 수정하고 테스트를 실행합니다.
module.exports = {
// ...
collectCoverage: true,
coverageReporters: ['html']
}
$ npm t
더 이상 터미널에서는 보고서가 표시되지 않습니다.
지정한 옵션을 통해 루트 경로에 coverage
라는 새로운 디렉터리가 생성되었으며,
내부의 index.html
파일을 실행해 보고서를 확인할 수 있습니다.
변경 사항이 발생하면 ‘새로고침’을 누르세요.
Jest 구성 옵션 coverageThreshold
를 사용하면 보고서의 최소 임곗값(threshold)을 지정할 수 있습니다.
이 테스트가 지정된 임곗값을 충족하지 못하면 테스트가 실패하며, 이를 통해 더 높은 테스트 신뢰도를 유지할 수 있습니다.
양수로 지정하면 백분율을,
음수로 지정하면 허용된 실행(호출)되지 않은 코드 수 의미합니다.
이해를 돕기 위해 임곗값을 충족하지 못하는 간단한 예를 들어보겠습니다.
대상의 총 3개 코드 중 테스트에서 1개 코드만 실행되었다면 2개 코드가 실행되지 않은 것입니다.
만약 임곗값을 50
(양수)으로 지정하면 실행된 코드의 백분율은 33.33%이기 때문에 테스트가 실패하며,
만약 임곗값을 -1
(음수)로 지정하면 실행되지 않은 코드를 1개까지 허용하겠다는 의미이기 때문에 테스트는 실패합니다.
module.exports = {
// ...
collectCoverage: true,
coverageReporters: ['html'],
coverageThreshold: {
global: {
statements: 50,
branches: 50,
functions: 50,
lines: -1
}
}
}
환경 설정에 맞게 테스트가 정상 동작하는지 확인하기 위해 매우 간단한 테스트용 코드를 작성해 보겠습니다.
이 글에서의 기본 프로젝트는 Vue CLI로 설치합니다.
최초 설치는 ‘Vue CLI로 빠른 환경 설정’ 파트를 참고하세요.
src/components/HelloJest.vue
파일을 생성하고 다음과 같이 작성합니다.
<!-- src/components/HelloJest.vue -->
<template>
<div>
{{ msg }}
</div>
</template>
<script>
export default {
data () {
return {
msg: 'Hello Jest~'
}
}
}
</script>
Jest는 구성 옵션(testRegex
)을 통해 .test
혹은 .spec
과 일치하는 프로젝트 내 모든 파일을 탐색합니다.
이 컴포넌트의 테스트를 위해 src/components/
경로에 __tests__
디렉터리와 내부 HelloJest.test.js
파일을 추가합니다.
최종 경로는 src/components/__tests__/HelloJest.test.js
입니다.
Jest는
__tests__
디렉터리를 테스트할 코드와 같은 경로에 생성할 것을 권장합니다.
다음과 같이 테스트를 작성합니다.
// src/components/__tests__/HelloJest.test.js
import { shallowMount } from '@vue/test-utils'
import HelloJest from '../HelloJest'
describe('HelloJest', () => {
let wrapper
beforeEach(() => {
wrapper = shallowMount(HelloJest)
})
test('1', () => {
expect(wrapper.vm.msg).toBe('Hello Jest!')
})
})
테스트를 실행합니다.
$ npm t
다음과 같은 에러가 발생해야 합니다.
이제 무엇을 수정해야 테스트가 통과할지 명확해졌습니다.
잘못된 받은 값(received)과 기댓값(expected)에 의해서도 테스트가 통과할 수 있어서,
의도적인 실패를 통해 테스트 신뢰도를 높일 수 있습니다.
특히 테스트가 복잡해지는 경우 더욱 조심해야 합니다.
describe
는 테스트의 범위를 설정하고,test
는 단위 테스트를 설정합니다.
describe('테스트 범위', () => {
test('단위 테스트 1', () => {})
test('단위 테스트 2', () => {})
})
Jest는 beforeAll
, afterAll
, beforeEach
, afterEach
의 4가지 전역 함수를 제공하며, 이를 통해 테스트 코드 전후를 제어할 수 있습니다.
beforeAll
과 afterAll
은 선언된 describe
범위 안에서 전후 동작하며,beforeEach
와 afterEach
는 선언된 describe
범위 안에서 각 test
단위 전후로 동작합니다.
describe('Jest Hooks', () => {
beforeAll(() => {
console.log('beforeAll')
})
afterAll(() => {
console.log('afterAll')
})
beforeEach(() => {
console.log('beforeEach')
})
afterEach(() => {
console.log('afterEach')
})
test('1', () => {
expect(1 + 1).toBe(2)
console.log('test 1 is done!')
})
test('2', () => {
expect(2 + 1).toBe(3)
console.log('test 2 is done!')
})
})
describe
안에 또 다른 describe
를 중첩하여 사용할 수 있습니다.
describe('Vue Component', () => {
describe('Computed', () => {})
describe('Created', () => {})
describe('Mounted', () => {})
describe('Methods', () => {
describe('Sync', () => {})
describe('Async', () => {})
})
})
다음 예제와 같이 여러 범위를 만들어 다양하게 테스트할 수 있습니다.
import { shallowMount, mount } from '@vue/test-utils'
import HelloJest from '../HelloJest'
describe('HelloJest.vue', () => {
let wrapper
describe('ShallowMount', () => {
beforeEach(() => {
// 얕은 마운트
// 하위 컴포넌트를 포함(렌더링)하지 않습니다.
wrapper = shallowMount(HelloJest)
})
test('1', () => {
expect(wrapper.text()).toBe('Hello')
})
})
describe('Mount', () => {
beforeEach(() => {
// 마운트
// 하위 컴포넌트를 포함합니다.
wrapper = mount(HelloJest)
})
test('1', () => {
expect(wrapper.text()).toBe('Hello World!')
})
})
})
describe
와 test
에 사용할 수 있는 only
와 skip
멤버가 있습니다.
일부 테스트만 실행하고 싶을 때는 only
를 사용하고,
일부 테스트를 중단하고 싶을 때 skip
을 사용합니다.
각 테스트 및 집합을 삭제하거나 주석 처리하지 않아도 되기 때문에 편리합니다.
describe('Vue Component', () => {
describe.only('Computed', () => {
test('1', () => {})
test('2', () => {})
})
describe('Created', () => {
test('1', () => {})
test.only('2', () => {})
})
describe('Methods', () => {
test('1', () => {})
test('2', () => {})
})
})
describe('Vue Component', () => {
describe.skip('Computed', () => {
test('1', () => {})
test('2', () => {})
})
describe('Created', () => {
test('1', () => {})
test.skip('2', () => {})
})
describe('Methods', () => {
test('1', () => {})
test('2', () => {})
})
})
Matcher는 expect(받은_값)
를 통해 받은 값을 확인하는 용도로 사용됩니다.
각 Matcher를 빠르게 정리합니다.
자세한 내용은 해당 Matcher의 공식 문서를 참고하시고,
일부 Matcher의 내용은 하단에 정리했습니다.
기본 사용법은 다음과 같습니다.?
는 선택적(Optional) 인수를 의미합니다.
expect(받은_값).MATCHER(기댓값?);
다음과 같이 중간에 .not
속성을 사용하면 받은 값의 반대를 확인할 수 있습니다.
expect(받은_값).not.MATCHER();
공통
Matcher | 설명 | 비고 | |
---|---|---|---|
📎 | .toBe(값) | 받은 값과 기본 동등 비교 | |
📎 | .toEqual(값) | 받은 값과 깊은(Deep) 동등 비교 | { a: undefined } === {} |
📎 | .toStrictEqual(값) | 받은 값과 엄격한 깊은(Deep) 동등 비교 | { a: undefined } !== {} |
자료형
Matcher | 설명 | 비고 | |
---|---|---|---|
📎 | .toBeTruthy() | 받은 값이 Truthy 값인지 확인 | |
📎 | .toBeFalsy() | 받은 값이 Falsy 값인지 확인 | |
📎 | .toBeDefined() | 받은 값이 정의된 값인지 확인 | undefined 가 아닌 값 |
📎 | .toBeUndefined() | 받은 값이 정의되지 않은 값인지 확인 | undefined 인 값 |
📎 | .toBeNull() | 받은 값이 null 인지 확인 | |
📎 | .toBeNaN() | 받은 값이 NaN 인지 확인 | |
📎 | .toBeInstanceOf(클래스) | 받은 값이 클래스의 인스턴스인지 확인 | instanceof 를 사용 |
📎 | .toBeGreaterThan(숫자) | 받은 값 > number 인지 확인 | |
📎 | .toBeGreaterThanOrEqual(숫자) | 받은 값 >= number 인지 확인 | |
📎 | .toBeLessThan(숫자) | 받은 값 < number 인지 확인 | |
📎 | .toBeLessThanOrEqual(숫자) | 받은 값 <= number 인지 확인 | |
📎 | .toBeCloseTo(숫자, 자릿수?) | 받은 값과 부동 소수점 비교 | |
📎 | .toContain(요소) | 받은 값에 요소가 포함되어 있는지 확인 | 원시 데이터(Primitives) |
📎 | .toContainEqual(요소) | 받은 값에 요소가 포함되어 있는지 깊은(Deep) 확인 | object , Array<object> |
📎 | .toMatch(정규식 | 문자열) | 받은 값이 정규식과 일치 또는 문자열을 포함하는지 확인 | |
📎 | .toMatchObject(객체) | 객체가 받은 값의 하위 집합인지 확인 | object , Array<object> |
스냅샷과 예외
Matcher | 설명 | 비고 | |
---|---|---|---|
📎 | .toMatchSnapshot() | 받은 값의 스냅샷을 생성 or 비교 | |
📎 | .toMatchInlineSnapshot(인라인스냅샷) | 받은 값의 인라인 스냅샷을 생성 or 비교 | 템플릿 리터럴 스냅샷 |
📎 | .toThrowErrorMatchingSnapshot() | 받은 값(함수)이 호출될 때 에러 스냅샷을 생성 or 비교 | |
📎 | .toThrowErrorMatchingInlineSnapshot() | 받은 값(함수)이 호출될 때 에러 인라인 스냅샷을 생성 or 비교 | 템플릿 리터럴 스냅샷 |
📎 | .toThrow(에러?) | 받은 값(함수)이 호출될 때 에러가 발생하는지 확인 | 받은 값은 함수로 랩핑expect(() => errFn(1, 2)) |
toHave
Matcher | 설명 | 비고 | |
---|---|---|---|
📎 | .toHaveBeenCalled() | 받은 값(함수)이 호출되었는지 확인 | |
📎 | .toHaveBeenCalledTimes(횟수) | 받은 값(함수)의 호출 횟수를 확인 | |
📎 | .toHaveBeenCalledWith(인수1, 인수2, ...) | 받은 값(함수)이 해당 인수와 함께 호출되었는지 확인 | |
📎 | .toHaveBeenLastCalledWith(인수1, 인수2, ...) | 받은 값(함수)의 마지막 호출이 해당 인수와 함께 호출되었는지 확인 | |
📎 | .toHaveBeenNthCalledWith(n번째, 인수1, 인수2, ...) | 받은 값(함수)의 n번째 호출이 해당 인수와 함께 호출되었는지 확인 | |
📎 | .toHaveReturned() | 받은 값(함수)이 호출 후 에러 없이 값을 반환하는지 확인 | undefined 반환도 포함 |
📎 | .toHaveReturnedTimes(횟수) | 받은 값(함수)이 호출 후 에러 없이 값을 반환하는 횟수를 확인 | undefined 반환도 포함 |
📎 | .toHaveReturnedWith(반환값) | 받은 값(함수)의 모든 호출이 반환한 값 중 같은 값이 있는지 비교 | |
📎 | .toHaveLastReturnedWith(반환값) | 받은 값(함수)의 마지막 호출이 반환한 값과 비교 | |
📎 | .toHaveNthReturnedWith(n번째, 반환) | 받은 값(함수)의 n번째 호출이 반환한 값과 비교 | |
📎 | .toHaveLength(숫자) | 받은 값에 .length 속성값과 비교 | |
📎 | .toHaveProperty(속성경로, 값?) | 받은 값에 속성경로 존재 또는 속성경로에 해당 값이 존재하는지 확인 |
가장 기본적인 Matcher로, 받은 값과 기댓값이 같은지 비교합니다.
원시 데이터(Primitives)를 비교하는 용도로 사용합니다.
const user = {
name: 'Neo',
age: 85
}
test('Name', () => {
expect(user.name).toBe('Neo')
})
test('Age', () => {
expect(user.age).toBe(85)
})
toEqual
과 toStrictEqual
은 객체나 배열 데이터의 모든 속성(요소)을 재귀적으로 비교하며, 이를 깊은(Deep) 동등 비교라고 합니다.
여기서 ‘깊은(Deep)’은 ‘데이터 안으로 들어가서 모두 꼼꼼하게’ 정도로 의역할 수 있습니다.
우선, toBe
를 사용하는 다음 예제는 실패합니다.
const user = {
name: 'Neo',
age: 85
}
test('User', () => {
expect(user).toBe({
name: 'Neo',
age: 85
})
})
다음과 같이 toBe
를 toStrictEqual
로 변경하면, 테스트가 통과합니다.
// ...
test('User', () => {
// Passes
expect(user).toStrictEqual({
name: 'Neo',
age: 85
})
})
toEqual
과 toStrictEqual
은 비슷하지만, 다음과 같은 일부 엄격함의 차이가 있습니다.
실패하는 테스트만 // Fails
로 표시했습니다.
test('Undefined', () => {
expect({ a: undefined }).toEqual({})
expect({ a: undefined }).toStrictEqual({}) // Fails
expect([undefined, 1]).toEqual([, 1])
expect([undefined, 1]).toStrictEqual([, 1]) // Fails
})
test('Class instance', () => {
class User {
constructor (name) {
this.name = name
}
}
expect(new User('Neo')).toEqual({ name: 'Neo' })
expect(new User('Neo')).toStrictEqual({ name: 'Neo' }) // Fails
})
자바스크립트에서 10진수를 2진수로 변환하게 되면 0.1
이나 0.2
소수점 숫자 같은 경우 무한 소수가 발생하게 됩니다.
따라서 toBe
를 사용하는 다음 테스트는 실패하게 됩니다.
test('Floating point numbers', () => {
expect(0.1 + 0.2).toBe(0.3)
})
toBeCloseTo
로 변경하면, 테스트가 통과합니다.
test('Floating point numbers', () => {
expect(0.1 + 0.2).toBeCloseTo(0.3) // Passes
})
두 Matcher 모두 받은 값(배열)에 특정 요소가 포함되어 있는지 확인합니다.toContain
은 원시 데이터의 포함 여부를, toContainEqual
은 객체 데이터의 포함 여부를 깊게(Deep) 확인하는 차이가 있습니다.
다음 예제를 보면 차이점을 쉽게 이해할 수 있습니다.
test('Primitives', () => {
// 두 Matcher 모두 배열 내 원시 데이터 포함을 확인할 수 있습니다.
const numbers = [1, 2, 3]
expect(numbers).toContain(1)
expect(numbers).toContainEqual(1)
// toContainEqual은 문자열 포함은 확인할 수 없습니다.
const string = 'Hello World'
expect(string).toContain('Hello')
expect(string).toContainEqual('Hello') // Fails
})
test('Objects', () => {
// toContain은 객체 데이터 포함을 확인할 수 없습니다.
const users = [
{ name: 'Neo' },
{ name: 'Evan' },
{ name: 'Lewis' }
]
expect(users).toContain({ name: 'Neo' }) // Fails
expect(users).toContainEqual({ name: 'Neo' })
})
test('Recommended', () => {
// 배열 내 포함 확인은 toContainEqual을 사용하고,
const numbers = [1, 2, 3]
expect(numbers).toContainEqual(1)
// 문자열 포함은 toMatch를 사용하는 것을 추천합니다.
const string = 'Hello World'
expect(string).toMatch('Hello')
})
함수가 호출될 때 에러가 발생하는지 확인합니다.
주의할 점은 에러를 던지는 함수를 별도의 함수로 한 번 랩핑해 실행하거나, 호출 없이 함수 자체를 받은 값 인수로 사용해야 테스트 자체의 에러로 인식되지 않습니다.
다음 예제를 통해 toThrow
에 대해 좀 더 자세하게 이해할 수 있습니다.
function err (isPass) {
if (!isPass) {
throw new Error('I am your error.')
}
}
// 에러가 발생할 함수를 직접 호출하지 마세요!
test('Wrapping to a function', () => {
// 익명 함수로 랩핑
expect(() => { err() }).toThrow()
// 호출 없이 함수 인수
expect(err).toThrow()
// Fails
expect(err()).toThrow()
})
// 선택적으로 다음과 같은 인수를 사용할 수 있습니다.
test('Arguments', () => {
// 오류 메시지의 하위 문자열
expect(() => { err() }).toThrow('I am your error.')
expect(() => { err() }).toThrow('m your err')
// 오류 메시지 패턴과 일치하는 정규식
expect(() => { err() }).toThrow(/^I.*errors?\.$/)
// 오류 메시지와 일치하는 에러 인스턴스
expect(() => { err() }).toThrow(new Error('I am your error.'))
// 에러 클래스
expect(() => { err() }).toThrow(Error)
})
// 해당 함수가 에러를 던지지 않으면 테스트는 실패합니다.
test('None error', () => {
// Fails - Received function did not throw
expect(() => { err(true) }).toThrow()
})
테스트에서 함수가 호출된 적이 있는지 확인합니다.
중요한 것은 해당 함수가 감시(spy)되고 있거나 모의(mock) 함수여야 합니다.
또한 toHaveBeenCalled
는 함수의 호출만 체크하기 때문에 함수에서 에러를 던져도 테스트는 통과합니다.(Try/Catch문을 사용했을 때)
함수에서 에러를 던질 때 테스트가 실패하도록 하려면 toHaveReturned
가 좋은 선택입니다.
const user = {
name: 'Neo',
getName () {
return this.name
}
}
test('Called', () => {
jest.spyOn(user, 'getName') // 이제 해당 함수를 감시합니다.
user.getName() // 호출됨
expect(user.getName).toHaveBeenCalled() // Passes
})
toHaveReturned
는 toHaveBeenCalled
와 비슷하지만, 함수의 호출뿐만 아니라 반환 값이 있어야 합니다.
함수는 return
키워드를 사용하지 않아도 기본적으로 undefined
를 반환하는데, toHaveReturned
는 이 undefined
도 함수의 반환 값으로 확인합니다.
따라서 함수가 에러 없이 정상적으로 호출되는지 확인하는 용도로 사용할 수 있습니다.
그리고 toHaveBeenCalled
와 마찬가지로 해당 함수는 감시(spy)되고 있거나 모의(mock) 함수여야 테스트할 수 있습니다.
만약, 반환 값이 무엇인지 확인하고 싶다면 toHaveReturnedWith
를 사용하세요.
const user = {
name: '', // None name..
getName () {
if (!this.name) {
throw new Error('Not Neo!')
}
return this.name
}
}
function displayName (user) {
try {
return user.getName()
} catch (err) {
// console.error(err)
}
}
test('Called', () => {
jest.spyOn(user, 'getName') // 이제 해당 함수를 감시합니다.
displayName(user)
expect(user.getName).toHaveBeenCalled() // Passes
expect(user.getName).toHaveReturned() // Fails
})
함수가 호출되고 어떤 값을 반환했는지 확인합니다.
함수가 여러 번 호출되고 각각 반환 값이 다른 경우, 기댓값이 모든 호출 중 하나의 반환과 일치하면 테스트는 통과합니다.
const users = {
names: ['Neo', 'Evan', 'Lewis', 'Emily'],
getName (order) {
return this.names[order]
}
}
test('Return value', () => {
jest.spyOn(users, 'getName')
users.getName(0) // Neo
users.getName(1) // Evan
users.getName(2) // Lewis
expect(users.getName).toHaveReturnedWith('Evan') // Passes
expect(users.getName).toHaveReturnedWith('Emily') // Fails
})
객체에서 찾고자 하는 속성의 존재 여부와 그 값을 확인할 수 있습니다.
속성의 경로(keyPath)를 명시할 때 점 표기법이나 속성 경로를 나열하는 배열을 사용할 수 있습니다.
const user = {
name: 'Neo',
age: 85,
address: {
address1: '서울특별시 종로구 종로 6',
sido: '서울특별시',
zonecode: '03187'
},
belongings: [
'phone',
'laptop',
{ mouse: 'MX Vertical' },
[100, 1000]
],
girlFriend: undefined,
getName () {
return this.name
}
}
// 이하 실패하는 테스트는 없습니다!
test('Key and Value', () => {
// user 객체에 age 속성이 존재하는지 확인합니다.
expect(user).toHaveProperty('age')
// age 속성의 값도 확인할 수 있습니다.
expect(user).toHaveProperty('age', 85)
// 점 표기법을 사용하면 더 깊이 들어갈 수 있습니다.
expect(user).toHaveProperty('address.zonecode')
// 역시 속성의 값도 체크할 수 있고요.
expect(user).toHaveProperty('address.zonecode', '03187')
// 점 표기법 대신에 배열에 속성 경로를 나열하는 표기 방법도 사용할 수 있습니다.
expect(user).toHaveProperty(['address', 'zonecode'], '03187')
// 배열로 나열하는 표기 방법은 값이 배열 데이터인 경우 요소를 인덱싱할 수 있습니다.
expect(user).toHaveProperty(['belongings', 0], 'phone')
// 더 깊게 들어갈 수도 있네요!
expect(user).toHaveProperty(['belongings', 2, 'mouse'], 'MX Vertical')
expect(user).toHaveProperty(['belongings', 3, 1], 1000)
// 명시적 undefined를 가지는 속성은 존재한다고 판단하니 주의하세요.
expect(user).toHaveProperty('girlFriend')
// 당연히 메소드도 확인할 수 있습니다.
expect(user).toHaveProperty('getName')
})
많은 경우 프로젝트에서 비동기 코드를 작성하게 되는데,
Jest에서 이 비동기 코드를 쉽게 테스트할 수 있습니다.
0.5초 후 실행되는 단순한 비동기 함수를 예시로, 다음의 테스트 패턴을 정리했습니다.
Promise에 대해서 알고 있다면 어렵지 않을 겁니다.
function asyncFn (hasToFail) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (hasToFail) {
reject(new Error('Fails!'))
}
resolve('Passes!')
}, 500)
})
}
// 다음 테스트는 모두 통과합니다.
describe('Async', () => {
// test()에서 done을 인수로 사용하는 경우,
// done이 호출될 때까지 테스트는 기다립니다.
// done이 호출되지 않으면 테스트는 기본 5초 후 실패합니다.
test('done', done => {
asyncFn().then(r => {
expect(r).toBe('Passes!')
done()
})
})
// test()에서 Promise를 반환하면,
// 그 Promise가 이행(resolved)될 때까지 기다립니다.
// 거부(reject)되면 테스트는 실패합니다.
test('return promise', () => {
return asyncFn().then(r => {
expect(r).toBe('Passes!')
})
})
// 받은 값(Expect)과 기댓값(Matcher) 사이에 2개의 Bridge 속성(.not과 같이)을 사용할 수 있습니다.
// .resolves는 받은 Promise가 이행될 때까지 기다리고, .rejects는 거부될 때까지 기다립니다.
// Assertion(Expect 구문)을 반환(return)해야 테스트는 기다립니다.
test('resolves', () => {
return expect(asyncFn()).resolves.toBe('Passes!')
})
test('rejects', () => {
return expect(asyncFn('hasToFail')).rejects.toThrow('Fails!')
})
// test()의 콜백을 비동기 함수(async function)로 선언하면,
// 테스트는 작성된 await에 맞게 기다립니다.
test('async/await', async () => {
const r = await asyncFn()
expect(r).toBe('Passes!')
})
})
test
함수는 테스트 완료까지 기본적으로 최대 5초까지 기다립니다.
5초가 지나면 테스트가 실패하기 때문에 상황에 따라서 기다리는 시간을 늘려줄 필요가 있습니다.test
함수의 3번째 인수로 밀리초(ms) 단위의 시간을 입력합니다.
기본값은 5000
입니다.
// 위 예제에서 사용한 함수에요!
// 동작 시간을 0.5초에서 6초로 늘렸습니다.
function asyncFn (hasToFail) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (hasToFail) {
reject(new Error('Fails!'))
}
resolve('Passes!')
}, 6000) // 6초
})
}
describe('Async', () => {
// 이 테스트는 최대 5초까지 기다리기 때문에 실패합니다.
test('timeout 5000', async () => {
const h = await asyncFn()
expect(h).toBe('Passes!')
})
// 이 테스트는 최대 7초까지 기다리기 때문에 통과합니다.
// test(이름, 콜백, 시간)
test('timeout 7000', async () => {
const h = await asyncFn()
expect(h).toBe('Passes!')
}, 7000)
})
테스트를 위해 Vue 컴포넌트를 연결 및 렌더링(Mounting)하는 다음 함수들이 있습니다.
함수 | 반환 | 특징 |
---|---|---|
mount() | Wrapper | 마운트(하위 컴포넌트 렌더링) |
shallowMount() | Wrapper | 얕은 마운트(하위 컴포넌트 스텁) |
render() | Promise | 문자열로 렌더링하고 Cheerio 객체(Promise)를 반환 |
renderToString() | Promise | HTML로 렌더링 |
render()
와 renderToString()
을 사용하려면 @vue/server-test-utils
설치가 필요합니다.
$ npm i -D @vue/server-test-utils
다음과 같이 가져옵니다.
import { mount, shallowMount } from '@vue/test-utils'
import { render, renderToString } from '@vue/server-test-utils'
테스트에서 컴포넌트를 연결 및 렌더링하는 mount
와 shallowMount
함수가 있습니다.
이 함수들은 Wrapper 객체를 반환하는데, 이 Wrapper 객체를 통해 Vue 인스턴스(vm)에 접근하거나 제공되는 다양한 메소드를 활용할 수 있습니다.
아래에 msg
를 출력하는 아주 단순한 컴포넌트가 있습니다.
프로젝트 기본 구조는 ‘환경 설정 > 첫 번째 테스트’ 파트를 참고하세요.
mount
로 설명하지만shallowMount
를 적용해도 같은 결과를 볼 수 있습니다.
차이점은 뒤에서 설명합니다.
<!-- src/components/HelloJest.vue -->
<template>
<h1>Hello {{ msg }}</div>
</template>
<script>
export default {
data () {
return {
msg: 'Jest'
}
}
}
</script>
다음은 위 컴포넌트를 테스트하는 코드입니다.
// src/components/__tests__/Hello.test.js
import { mount } from '@vue/test-utils'
import HelloJest from '../HelloJest' // HelloJest.vue
// 이하 테스트는 모두 통과합니다.
describe('HelloJest', () => {
let wrapper
beforeEach(() => {
// 컴포넌트를 마운트하고 Wrapper 객체를 반환합니다.
// mount(컴포넌트, 옵션)
wrapper = mount(HelloJest)
})
test('Vue instance', () => {
// `wrapper.vm`로 Vue 인스턴스에 접근합니다.
expect(wrapper.vm.msg).toBe('Jest')
})
test('Wrapper methods', () => {
// Wrapper 객체는 다양한 메소드를 제공합니다.
// .text()는 렌더링된 컴포넌트의 Text 내용을 반환합니다.(`HTMLElement.innerText`와 비슷합니다)
expect(wrapper.text()).toBe('Hello Jest')
})
})
매우 비슷하지만, mount
와 shallowMount
는 큰 차이점이 있습니다.mount()
는 기본 마운트로 하위 컴포넌트를 렌더링하지만,shallowMount()
는 얕은 마운트로 하위 컴포넌트를 Stub(스텁)합니다.
Stub(스텁)이란 실제로 동작하는 것처럼 보이는 객체를 의미합니다.
역시 예제부터 살펴봅시다.
두 개의 부모/자식 컴포넌트(ChildComp.vue
, ParentComp.vue
)가 있습니다.
<!-- src/components/ChildComp.vue -->
<template>
<span>Child!</span>
</template>
<!-- src/components/ParentComp.vue -->
<template>
<div>
Hello <child-comp />
</div>
</template>
<script>
import ChildComp from './ChildComp'
export default {
components: {
ChildComp
}
}
</script>
테스트 코드를 작성합니다.
콘솔만 확인할 것이기 때문에 Assertion(Expect 구문)은 작성하지 않습니다.
// src/components/__tests__/ParentComp.test.js
import { shallowMount, mount } from '@vue/test-utils'
import ParentComp from '../ParentComp'
describe('ParentComp', () => {
test('mount', () => {
const wrapper = mount(ParentComp)
console.log(wrapper.html())
})
test('shallowMount', () => {
const wrapper = shallowMount(ParentComp)
console.log(wrapper.html())
})
})
다음과 같은 결과를 볼 수 있습니다.
mount
는 하위 컴포넌트도 같이 렌더링했기 때문에 <span>Child!</span>
가 출력되었습니다.
shallowMount
는 하위 컴포넌트를 Stub(스텁) 컴포넌트(<child-comp-stub></child-comp-stub>
)로 대체하여 실제 작동하는 것처럼 보이게 합니다.
이는 하위 컴포넌트를 테스트에 포함하지 않아 독립적인 테스트가 가능합니다.
특히 테스트 컴포넌트가 많은 하위 컴포넌트를 가지고 있는 경우 유용합니다.
테스트에서 컴포넌트를 마운트하면서 다음과 같은 추가 옵션을 지정할 수 있습니다.
shallowMount(컴포넌트, 옵션)
옵션 | 설명 | 타입 | 기본값 | |
---|---|---|---|---|
📎 | context | 오직 Functional components에 전달할 context 를 지정 | Object | |
📎 | slots | 컴포넌트의 Slots에 삽입할 객체를 지정 | Object | |
📎 | scopedSlots | 컴포넌트의 Scoped Slots에 삽입할 객체를 지정 | Object | |
📎 | stubs | 스텁할 하위 컴포넌트들을 지정 | Object / Array | |
📎 | mocks | 모의 객체나 모의 함수를 지정 | Object | |
📎 | localVue | createLocalVue 로 생성된 Vue의 로컬 복사본을 지정 | createLocalVue() | |
📎 | attachTo | window 나 DOM-Events 등을 사용할 때, 마운트할 컴포넌트로 대체될 요소의 선택자를 지정 | HTMLElement / String | null |
📎 | attachToDocument | (Deprecated) window 나 DOM-Events 등을 사용할 때 지정대신 attachTo 옵션 사용을 권장 | Boolean | false |
📎 | attrs | 부모 컴포넌트에게 받을 $attrs 객체를 지정 | Object | |
📎 | propsData | 컴포넌트가 마운트될 때 전달할 Props를 지정 | Object | |
📎 | listeners | 부모 컴포넌트에게 받을 $listeners 객체를 지정 | Object | |
📎 | parentComponent | 부모 컴포넌트를 지정 | Object | |
📎 | provide | inject 에서 사용될 provide 객체를 지정 | Object |
테스트에서 사용할 로컬 Vue 클래스를 반환합니다.
전역 Vue 클래스를 오염시키지 않고 믹스인, 플러그인 등을 추가할 때 사용할 수 있는 일종의 가짜 Vue 객체입니다.
예를 들어 컴포넌트에서 VueRouter의 <router-link>
나 Vuex(Store)의 state
, actions
등을 사용하고 있다면, 다음 예제와 같이 연결할 수 있습니다.
물론 실제 똑같은 환경을 만들지 않고 테스트를 위해 <router-link>
를 스텁하거나(Stubbing) Store을 모의 객체로 대체(Mocking)할 수도 있습니다.
핵심은 테스트에서 Vue 컴포넌트가 동작할 수 있는 환경을 만드는 것입니다.
import { mount, createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'
import Vuex from 'vuex'
import App from '@/App.vue'
import router from '@/router.js'
import store from '@/store.js'
const localVue = createLocalVue()
localVue.use(VueRouter)
localVue.use(Vuex)
const wrapper = mount(App, {
localVue,
router,
store
})
위 테스트 예제는 다음의 익숙한 설정과 비슷합니다.
import Vue from 'vue'
import VueRouter from 'vue-router'
import Vuex from 'vuex'
import App from './App.vue'
import router from './router.js'
import store from './store.js'
Vue.use(VueRouter)
Vue.use(Vuex)
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
테스트에서 mount
나 shallowMount
로 컴포넌트를 마운트하면 Wrapper 객체가 반환됩니다.
Wrapper 객체에서 사용할 수 있는 속성과 메소드를 정리합니다.
자세한 내용은 해당 멤버의 공식 문서를 참고하세요.
기본 사용법은 다음과 같습니다.?
는 선택적(Optional) 인수를 의미합니다.
const wrapper = shallowMount(Component)
const vm = wrapper.vm
const html = wrapper.html()
expect(vm.msg).toBe('Hello Jest~')
expect(html).toBe('<h1 class="title">Hello Jest~</h1>')
그리고 아래 정리된 메소드 중,wrapper.find()
는 찾은 결과의 또 다른 Wrapper를 반환하고,wrapper.findAll()
은 찾은 결과들의 WrapperArray(Wrapper의 배열)를 반환합니다.
Wrapper와 WrapperArray는 사용 가능한 멤버가 일부 다르므로 ‘구분’에 정리했습니다.
구분이 비어있는 것은 Wrapper 전용입니다!
멤버 | 설명 | 구분 | |
---|---|---|---|
📎 | .vm | Vue 인스턴스를 반환(읽기 전용) | |
📎 | .element | 루트 요소를 반환(읽기 전용) | |
📎 | .wrappers | 찾은 결과의 모든 Wrapper의 배열을 반환(읽기 전용) | WrapperArray |
📎 | .length | 배열 길이를 반환(읽기 전용) | WrapperArray |
📎 | .at(숫자) | Zero based 배열 인덱싱(음수는 뒤에서부터), Wrapper를 반환 | WrapperArray |
📎 | .attributes(속성?) | 루트 요소의 HTML 속성 객체 혹은 속성값을 반환 | |
📎 | .classes(클래스?) | 루트 요소의 HTML 클래스 속성값의 배열 혹은 속성값의 유무 반환 | |
📎 | .contains(선택자 | 컴포넌트) | 특정 CSS선택자 혹은 컴포넌트가 포함되어 있는지 확인 | Wrapper / WrapperArray |
📎 | .destroy() | Vue 인스턴스를 제거(beforeDestroy 와 destroyed 라이프사이클 Hook이 동작) | Wrapper / WrapperArray |
📎 | .emitted() | $emit(이름, 값) 된 결과 객체를 반환 | |
📎 | .emittedByOrder() | $emit(이름, 값) 된 결과 객체를 반환 | |
📎 | .exists() | Wrapper(.find() )나 WrapperArray(.findAll() )의 존재 여부를 반환 | |
📎 | .find(선택자 | 컴포넌트) | 특정 CSS선택자 혹은 컴포넌트의 Wrapper를 반환 | |
📎 | .filter(숫자) | Array.prototype.filter와 유사하게 배열 필터 | WrapperArray |
📎 | .findAll(선택자 | 컴포넌트) | 특정 CSS선택자 혹은 컴포넌트의 WrapperArray를 반환 | |
📎 | .html() | HTML을 문자열로 반환 | |
📎 | .get() | .find() 와 동일하게 작동, 존재하지 않으면 에러 | |
📎 | .is(선택자 | 컴포넌트) | 특정 CSS선택자 혹은 컴포넌트와 일치하는지 확인 | Wrapper / WrapperArray |
📎 | .isEmpty() | 자식 요소가 존재하지 않는지 확인 | Wrapper / WrapperArray |
📎 | .isVisible() | (Deprecaed) 보이는 요소인지 확인(display: none 혹은 visibility: hidden 이면 false ) | |
📎 | .isVueInstance() | (Deprecaed) Vue 인스턴스인지 확인 | Wrapper / WrapperArray |
📎 | .name() | 컴포넌트의 이름 혹은 루트 요소의 태그 명을 반환 | |
📎 | .props(Prop?) | Vue 인스턴스의 Props 객체를 반환 | |
📎 | .setChecked(체크_값?) | 체크 값(Checked)을 input 요소(checkbox, radio)에 지정(반응성, 체크 값의 기본값은 true ) | Wrapper / WrapperArray |
📎 | .setData(Data) | Vue 인스턴스의 Data를 업데이트(반응성) | Wrapper / WrapperArray |
📎 | .setMethods(Methods) | (Deprecaed) Vue 인스턴스의 Methods를 업데이트(반응성) | Wrapper / WrapperArray |
📎 | .setProps(Props) | Vue 인스턴스의 Props를 업데이트(반응성) | Wrapper / WrapperArray |
📎 | .setSelected() | select/option 요소 선택(반응성) | |
📎 | .setValue(값) | input 요소(text)의 값을 지정(반응성) | Wrapper / WrapperArray |
📎 | .text() | Text를 반환(.innerText 와 비슷) | |
📎 | .trigger(이벤트, 옵션?) | 비동기적으로 이벤트를 트리거 | Wrapper / WrapperArray |
테스트에 대해서 학습하다 보면 Mock, Spy, Stub 같은 난해한 용어들이 나오기 시작하는데,
이런 용어들을 일반적으로 테스트 더블(Test double)이라고 합니다.
영화 제작의 스턴트 대역(Stunt double)이 실제 배우를 대신하는 것처럼 테스트를 위해 실제 객체를 대신하는 것이죠.
각각 의미가 조금씩 다르고 설명만으로 정확한 의도를 파악하기 어렵지만,
모두 크게는 ‘테스트를 위한 가짜 객체‘ 정도로 정리할 수 있습니다.
일부 문서에서는 혼용되기도 합니다.
용어 | 설명 |
---|---|
더미(Dummy) | 실제로는 동작하지 않고 데이터를 채우기 위한 객체 |
스텁(Stub) | 실제로는 동작하지 않지만, 동작하는 것처럼 보이기 위해 준비된 값만을 반환하는 객체 |
모조품(Fake) | 실제로 동작하지만 프로덕트에는 적합하지 않은 객체 |
모의(Mock) | 실제로 동작하지만 준비된 값만을 반환하는 객체 |
스파이(Spy) | 호출 등 일부 정보를 기록해 다양한 상황을 감시하는 객체 |
테스트 환경에서는 실제 구현한 함수를 그대로 사용할 수 없는 경우가 많습니다.
네트워크 비용이 발생하는 외부 API에 의존하는 함수라던가 함수 실행 후 불필요하게 많은 상태(데이터)가 변경되거나 등등 테스트 자체에 집중할 수 없게 방해되는 외부 요인들은 얼마든지 있습니다.
이때 필요한 것이 바로 모의(Mock) 함수입니다.
말 그대로 ‘가짜’이기 때문에 네트워크 비용 없이 바로 특정 결과를 반환하게 하거나 의도적으로 에러를 던지거나 함수가 몇 번 호출되었는지 감시하거나 등등 원하는 대로 마음껏 주무를 수 있습니다.
따라서 방해 요인을 최소화하고 온전히 테스트에만 집중할 수 있습니다.
개인적으로 테스트에서 처음 ‘Mock(모의)’란 단어를 접했을 때 이해하기 참 어려웠는데 사실은 ‘모의고사’, ‘모의실험’, ‘모의훈련’, ‘목업(Mock-up)’ 등 평소에 많이 접할 수 있는 단어입니다.
[모의]: 실제의 것을 흉내 내어 그대로 해 봄.
간단하게 실제 Todo API를 사용하여 컴포넌트를 구성해 봅시다.
다음은 Axios로 Todo 정보를 가져와서 화면에 제목(title
)을 출력하는 예제입니다.
<!-- HelloJest.vue -->
<template>
<div>
{{ todo.title }}
</div>
</template>
<script>
import axios from 'axios'
export default {
data () {
return {
todo: {}
}
},
created () {
this.fetchTodo()
},
methods: {
async fetchTodo () {
// https://jsonplaceholder.typicode.com/
const { data } = await axios.get('https://jsonplaceholder.typicode.com/todos/1')
this.todo = data
}
}
}
</script>
다음은 실제 응답 결과의 data
값입니다.title
속성만 확인합니다!
{
"completed": false,
"id": 1,
"title": "delectus aut autem",
"userId": 1
}
이제 다음과 같이 테스트를 작성합니다.
주의할 점은, 실제 요청/응답을 통해야 하므로 화면에 내용이 반영될 수 있는 대략적인 시간을 고려해야 합니다.
따라서 setTimeout
을 사용해 1초 후 테스트하도록 작성했습니다.
네트워크가 속도가 느리면 2초, 3초 등으로 시간을 늘려야 할 수 있겠네요.
// HelloJest.test.js
import { shallowMount } from '@vue/test-utils'
import HelloJest from '../HelloJest'
describe('HellJest', () => {
let wrapper
beforeEach(() => {
wrapper = shallowMount(HelloJest)
})
test('가져온 텍스트가 정상적으로 렌더링', done => {
setTimeout(() => {
expect(wrapper.text()).toBe('delectus aut autem')
done()
}, 1000) // 1초
})
})
일반적인 네트워크 속도라면 위 테스트는 통과합니다.
하지만 실제 요청/응답을 사용하기 때문에 만약 네트워크가 오프라인이면 테스트는 실패하며, 이는 외부 요인에 의한 실패가 됩니다.
또한, 네트워크가 온라인이지만 속도가 아주 느리거나 API 서버에 문제가 발생하거나 등등 다양한 외부 요인에 의해 테스트는 실패할 수 있습니다.
그렇다면 이런 외부 요인(네트워크 비용)을 제거하기 위해 axios.get()
을 모의 함수로 만들어(Mocking) 필요한 값만 반환하게 만들어 봅시다.
실제 요청에서는 title
속성이 포함된 todo
객체를 응답받기 때문에 똑같이 작성합니다.
다음과 같이 수정합니다.
// HelloJest.test.js
import { shallowMount } from '@vue/test-utils'
import axios from 'axios'
import HelloJest from '../HelloJest'
describe('HellJest', () => {
let wrapper
beforeEach(() => {
// 반환할 가짜 응답 결과
const response = {
data: {
title: 'delectus aut autem'
}
}
// 컴포넌트가 마운트되기 전에 Mocking합니다.
// axios.get()은 Promise를 반환하기 때문에 mockResolvedValue를 사용합니다.
axios.get = jest.fn().mockResolvedValue(response)
// 위 코드는 다음과 같습니다.
// axios.get = jest.fn(() => new Promise(resolve => resolve(response)))
// 컴포넌트 마운트!
wrapper = shallowMount(HelloJest)
})
test('가져온 텍스트가 정상적으로 렌더링', () => {
// 네트워크에 의존할 필요가 없어서(모의 요청/응답이기 때문에) 더는 setTimeout이 필요하지 않습니다.
expect(wrapper.text()).toBe('delectus aut autem')
})
})
이어서 설명합니다.
위에서 살펴본 것과 같이 jest.fn()
은 모의 함수를 반환하는데, 이 반환될 모의 함수의 구현을 함께 작성할 수 있습니다.
또한 모의 함수는 기본적으로 호출이 감시되며, 추가로 사용할 수 있는 여러 메소드도 가지고 있습니다.
우선, 위에서 작성했던 예제를 기준으로 함수 구현 작성에 대해서 알아봅시다.
실제로 axios.get
은 비동기로 데이터를 가져오며, Promise 객체를 반환합니다.
우리는 axios.get
을 네트워크 비용을 사용하지 않도록 모의 함수로 만들지만, 컴포넌트의 로직이 동작해야 하니 똑같이 Promise가 반환되는 구조를 만들어줘야 합니다.
따라서 다음과 같이 jest.fn()
의 첫 번째 인수로 Promise가 반환될 수 있는 콜백 함수를 만들어 줍니다.jest.fn()
은 모의 함수를 반환하기 때문에 .mockImplementation()
을 사용할 수 있습니다.
모의 함수에 사용할 수 있는 여러 메소드는 아래에서 정리합니다.
axios.get = jest.fn(() => new Promise(resolve => resolve(response)))
// Or
// axios.get = jest.fn().mockImplementation(() => new Promise(resolve => resolve(response)))
특별한 구현이 없고 원하는 값(Promise)만 반환하면 되기 때문에,
위 방법을 .mockResolvedValue()
메소드로 대체할 수 있습니다.
// axios.get = jest.fn(() => new Promise(resolve => resolve(response)))
// Or
// axios.get = jest.fn().mockImplementation(() => new Promise(resolve => resolve(response)))
axios.get = jest.fn().mockResolvedValue(response)
또한 모의 함수는 생성된 후 바로 호출이 감시되는데,axios.get
은 컴포넌트의 fetchTodo
에서, fetchTodo
는 created
Hook에서 호출되기 때문에,
결국 다음과 같이 호출 여부를 확인할 수 있습니다.
(axios.get
은 결과적으로 created
Hook에서 호출되기 때문에 꼭 Mounting 전에 모의 함수로 만들어야 합니다)
// HelloJest.test.js
describe('HellJest', () => {
let wrapper
beforeEach(() => {
// ...
// axios.get = jest.fn(() => new Promise(resolve => resolve(response)))
axios.get = jest.fn().mockResolvedValue(response)
wrapper = shallowMount(HelloJest)
})
test('가져오기 호출 확인', () => {
// 모의 함수의 호출 여부를 확인합니다.
expect(axios.get).toHaveBeenCalled() // Passes
})
})
jest.spyOn()
은 실제 함수를 모의 함수로 덮어쓰지 않고도 호출 감시를 목적으로 아주 유용하게 사용할 수 있습니다.
기존 예제에서 컴포넌트는 fetchTodo
를 created
Hook에서 호출해 원하는 데이터를 가져옵니다.
Mounting 이후 fetchTodo
가 호출되었는지 확인하기 위해 다음과 같이 jest.spyOn()
을 사용합니다.
(fetchTodo
가 created
Hook에서 호출되기 때문에 꼭 Mounting 전에 감시를 시작해야 합니다)
감시 대상(메소드)은 곧 모의 함수입니다.
const spy = jest.spyOn(객체, '메소드 이름')
// HelloJest.test.js
describe('HellJest', () => {
let wrapper
const spy = {}
beforeEach(() => {
// ...
// 컴포넌트가 마운트되기 전에 `fetchTodo` 메소드에 스파이를 심어 감시합니다.
spy.fetchTodo = jest.spyOn(HelloJest.methods, 'fetchTodo')
wrapper = shallowMount(HelloJest)
})
test('가져오기 호출 확인', () => {
// 스파이를 통해 호출 여부를 확인할 수 있습니다.
expect(spy).toHaveBeenCalled() // Passes
})
})
만약 기존 예제의 fetchTodo
를 테스트를 위해 다르게 구현해야 한다면 다음 예제와 같이 작성할 수 있습니다.jest.fn()
과 같이 jest.spyOn()
도 모의 함수를 반환하기 때문에 .mockImplementation()
을 사용할 수 있습니다.
// HelloJest.test.js
describe('HellJest', () => {
let wrapper
const spy = {}
beforeEach(() => {
// ...
// 스파이를 심으면서 모의 구현 부를 만듭니다.
spy.fetchTodo = jest.spyOn(HelloJest.methods, 'fetchTodo').mockImplementation(num => num + 1)
wrapper = shallowMount(HelloJest)
})
test('가져오기 호출 확인', () => {
expect(spy.fetchTodo).toHaveBeenCalled() // Passes
expect(wrapper.vm.fetchTodo(1)).toBe(2) // Passes
})
})
한 가지 더 설명하자면, spy.fetchTodo
는 이전 테스트('가져오기 호출 확인'
)를 통해 호출 정보가 추가되었기 때문에 다음 테스트에 영향을 주게 됩니다.
따라서 다음과 같이 afterEach
Hook에서 jest.clearAllMocks()
등을 사용해 모의 함수 호출 정보를 초기화하는 것이 좋습니다.
// HelloJest.test.js
describe('HellJest', () => {
let wrapper
const spy = {}
beforeEach(() => {
// ...
spy.fetchTodo = jest.spyOn(HelloJest.methods, 'fetchTodo').mockImplementation(num => num + 1)
wrapper = shallowMount(HelloJest)
})
afterEach(() => {
// 모든 모의 함수의 호출 정보를 초기화합니다.
jest.clearAllMocks()
// 원하는 모의 함수만 초기화하고 싶은 경우,
// spy.fetchTodo.mockClear()
})
test('가져오기 호출 확인', () => {
expect(spy.fetchTodo).toHaveBeenCalled() // Passes
expect(wrapper.vm.fetchTodo(1)).toBe(2) // Passes
})
test('가져오기 호출 확인 다음 테스트', () => {
// 모의 함수의 호출 정보를 초기화하지 않으면 호출 횟수는 1이 아닌 3이 됩니다.
expect(spy.fetchTodo),toHaveBeenCalledTimes(1) // Passes
})
})
jest.fn()
와 jest.spyOn()
를 통해 반환된 모의 함수는 다음과 같은 여러 메소드를 사용할 수 있습니다.
위에서 살펴본 익숙한 메소드들도 보입니다.
멤버 | 설명 | |
---|---|---|
📎 | .mock.calls | 모의 함수의 호출 정보 배열 (인수를 포함) |
📎 | .mock.results | 모의 함수가 호출된 결과 정보 배열 (결과 타입과 반환 값을 포함) |
📎 | .mock.instances | 모의 함수로 만들어진 인스턴스들의 배열 (모의 함수가 생성자인 경우) |
📎 | .getMockName() | 모의 함수의 이름을 반환 (기본값 "jest.fn()" ) |
📎 | .mockName(이름) | 모의 함수의 이름을 지정 |
📎 | .mockClear() | 모의 함수의 호출 정보(.mock.xxx ) 배열을 초기화 |
📎 | .mockReset() | 모의 함수의 모든 상태를 초기화 |
📎 | .mockRestore() | 모의 함수의 모든 상태를 초기화하고 실제 함수로 복원 ( jest.spyOn() 을 통한 구현만 복원 가능) |
📎 | .mockReturnThis() | 모의 함수가 this 를 반환 |
📎 | .mockImplementation(구현) | 모의 함수의 구현 부를 작성 |
📎 | .mockReturnValue(값) | 모의 함수가 지정한 값 을 반환 |
📎 | .mockResolvedValue(값) | 비동기 모의 함수가 이행(Resolved)되면 지정한 값 을 반환 |
📎 | .mockRejectedValue(값) | 비동기 모의 함수가 거부(Rejected)되면 지정한 값 을 반환 |
📎 | .mockImplementationOnce(구현) | (한 번만) 모의 함수의 구현 부를 작성 |
📎 | .mockReturnValueOnce(값) | (한 번만) 모의 함수가 지정한 값 을 반환 |
📎 | .mockResolvedValueOnce(값) | (한 번만) 비동기 모의 함수가 이행(Resolved)되면 지정한 값 을 반환 |
📎 | .mockRejectedValueOnce(값) | (한 번만) 비동기 모의 함수가 거부(Rejected)되면 지정한 값 을 반환 |
.mockClear()
는 모의 함수 호출에 대한 모든 정보를 초기화합니다.
호출 정보는 .mock.calls
와 .mock.results
에 배열로 저장됩니다.
const module = {
api: value => value + 'DEF'
}
test('mockClear', () => {
jest.spyOn(module, 'api')
// Or
// module.api = jest.fn().mockImplementation(module.api)
console.log(module.api.mock.calls) // []
console.log(module.api.mock.results) // []
module.api('ABC') // 호출됨
expect(module.api).toHaveBeenCalledTimes(1) // Passes
console.log(module.api.mock.calls) // [['ABC']]
console.log(module.api.mock.results) // [{ type: 'return', value: 'ABCDEF' }]
module.api.mockClear() // 모의 함수의 호출 정보 초기화
expect(module.api).toHaveBeenCalledTimes(0) // Passes
console.log(module.api.mock.calls) // []
console.log(module.api.mock.results) // []
})
.mockReset()
은 모의 함수의 모든 상태를 초기화합니다.
여기서 상태는 모든 호출 정보 및 모의 함수 구현 부를 포함합니다.
따라서 초기화된 모의 함수는 undefined
가 반환됩니다.
.mockReset() = .mockClear() + Reset
const module = {
api: value => value + 'DEF'
}
test('mockReset', () => {
jest.spyOn(module, 'api').mockImplementation(() => 'Fake value..')
// Or
// module.api = jest.fn().mockImplementation(() => 'Fake value..')
expect(module.api('ABC')).toBe('Fake value..') // 호출됨 & Passes
expect(module.api).toHaveBeenCalledTimes(1) // Passes
console.log(module.api.mock.calls) // [['ABC']]
console.log(module.api.mock.results) // [{ type: 'return', value: 'Fake value..' }]
module.api.mockReset() // 모의 함수 초기화
expect(module.api(123)).toBe(undefined) // 호출됨 & Passes
expect(module.api).toHaveBeenCalledTimes(1) // Passes
console.log(module.api.mock.calls) // [[123]]
console.log(module.api.mock.results) // [{ type: 'return', value: undefined }]
})
.mockRestore()
는 모의 함수의 모든 상태를 초기화하고, 모의 함수 구현을 원래의 함수 구현으로 복원(restore)합니다.
단, jest.spyOn()
을 사용한 경우만 복원할 수 있습니다.
.mockRestore() = .mockReset() + Restore
const module = {
api: value => value + 'DEF'
}
test('mockRestore', () => {
jest.spyOn(module, 'api').mockImplementation(() => 'Fake value..') // Only `jest.spyOn()`
expect(module.api('ABC')).toBe('Fake value..') // 호출됨 & Passes
expect(module.api).toHaveBeenCalledTimes(1) // Passes
module.api.mockRestore() // 모의 함수 초기화 및 구현 부를 복원합니다.
expect(module.api('ABC')).toBe('ABCDEF') // 호출됨 & Passes
expect(module.api).toHaveBeenCalledTimes(1) // Passes
})
https://jestjs.io/docs/en/getting-started
https://vue-test-utils.vuejs.org/guides/
https://medium.com/@syalot005006/%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%EC%9D%98-%EC%8B%A4%EC%88%98-%EA%B3%84%EC%82%B0-%EC%98%A4%EB%A5%98-a72ec3326b50
https://martinfowler.com/bliki/TestDouble.html