HEROPY
Tech
Jest와 Vue Test Utils(VTU)로 Vue 컴포넌트 단위(Unit) 테스트
{{ scrollPercentage }}%

Jest와 Vue Test Utils(VTU)로 Vue 컴포넌트 단위(Unit) 테스트

jestvue-test-utilsunit test

개요

Vue Test Utils(VTU)는 Vue.js 환경에서 단위 테스트를 하기 위한 공식(Official) 라이브러리입니다.
Jest는 페이스북에서 만든 테스트 프레임워크로 Vue Test Utils에서 권장하는 테스트 러너입니다.
두 가지 오픈 소스를 이용해 Vue 애플리케이션의 테스트를 진행합니다.

이 글에서는 Jest 24버전 이상을 기준으로 설명합니다.
Jest 23버전 이하를 사용하는 경우 Babel 등의 설치 및 설정이 다릅니다.

NPM Trends

npmtrends.com

단위 테스트

단위(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)
})

테스트를 동작시키면 다음과 같이 인수가 문자일 경우에서 테스트가 실패합니다.

Test failed

위 테스트를 성공시키기 위해서 addOne 함수를 수정(리팩토링)해 봅시다.
다음과 같이 parseFloat을 사용해 문자 데이터인 경우 숫자 데이터로 변환되도록 수정합니다.

exports.addOne = function (a) {
  return parseFloat(a) + 1
}

다시 테스트를 실행하면 다음과 같이 테스트가 통과합니다.

이런 과정을 통해서 우리가 작성한 코드의 신뢰도를 향상할 수 있고,
새로운 로직을 추가하거나 수정할 때도 테스트 통과를 기준으로 문제 발생의 부담이 줄일 수 있습니다.

Test passed

환경 설정

Jest CLI를 사용하기 위해서 전역으로 설치할 수 있습니다.

$ npm i -g jest

Vue CLI로 빠른 환경 설정

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 install options

Unit Testing과 Jest 설정에 주의하세요!

Vue CLI install options unit testing

Check the features needed for your project - Unit Testing 선택

Vue CLI로 설치하면, 이미 준비된 테스트 샘플(E.g. tests/unit/example.spec.js)이 있어서 바로 테스트할 수 있습니다.
(필요치 않으면 삭제하세요)

Vue CLI scripts in package.json

package.json
$ 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-jestVue 파일을 Jest가 실행할 수 있는 자바스크립트로 컴파일합니다.
jest-serializer-vue저장된 Jest Snapshot을 VueJS에 맞게 개선하기 위해 사용합니다.
babel-jestJS/JSX 파일을 Jest가 실행할 수 있는 자바스크립트로 컴파일합니다.
babel-core@bridgeBabel 6버전과의 호환을 위해 설치합니다. 관련 이슈가 있습니다.

비교적 최신 NuxtJS나 Vue CLI를 사용한다면 이미 내부에 @babel/core@babel/preset-env가 포함되어 있습니다.
자신의 프로젝트를 확인하여 다음 모듈의 추가 설치 여부를 결정하세요.

모듈설명
@babel/coreBabel 7버전입니다.
@babel/preset-envBabel의 지원 스펙을 지정합니다.
$ npm i -D @babel/core @babel/preset-env

Jest 구성 옵션

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": [
      // ...
    ],
    // ...
  }
}

Babel 구성 옵션

.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 testnpm t를 통해 동작시킬 수 있습니다.

{
  "scripts": {
    "test": "jest"
  }
}

혹은 --watch Jest CLI 옵션을 사용할 수 있습니다.
이는 파일이 변경되었는지 확인하고 변경된 파일과 관련된 테스트만 다시 실행할 수 있어 편리합니다.

{
  "scripts": {
    "test": "jest --watch"
  }
}
$ npm test
# or
$ npm t
# or
$ jest --watch

개선 사항

Vuetify

Vuetify UI 라이브러리를 사용하는 경우, 테스트 통과 여부와 상관없이 다음과 같은 에러가 발생할 수 있습니다.

When using Vuetify in tests

관련 이슈는 아직 오픈되어 있으니 지속적인 확인을 권장하며,
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> 루트 컴포넌트에 연결되는 컴포넌트를 사용하는 경우,
테스트에서는 루트 컴포넌트를 찾을 수 없기 때문에 다음과 같은 경고 메시지를 볼 수 있습니다.

When using Vuetify in tests

따라서 위에서 살펴본 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

Watchman은 페이스북에서 만든 파일 변경을 감시하는 서비스입니다.
Jest의 --watch 혹은 --watchAll CLI 옵션과 관련해 문제가 발생하는 경우 설치합니다.

설치에 대한 내용은 Watchman Installation을 참고하세요.
Homebrew를 사용한다면 다음과 같이 간단하게 설치할 수 있습니다.

$ brew update
$ brew install watchman

no-undef 에러

테스트 코드에서 Jest의 describetest 같은 전역(Global) 메소드나 오브젝트를 사용하면 ESLint에서 에러(no-undef)가 발생할 수 있습니다.
ESLint 구성 옵션에서 env.jest = true로 설정하면 해결할 수 있습니다.

ESLint error no-undef

ESLint ‘no-undef’ 에러

ESLint error no-undef

.eslintrc.js 파일

Unresolved~ 경고

만약 Jest API의 사전 정의를 활성화해야 하는 경우, IDE 옵션을 설정하지 않아도 Jest의 타입 선언(DefinitelyTyped)을 설치하면 간단하게 해결할 수 있습니다.

$ npm i -D @types/jest

Unresolved function or method

@types/jest 설치 전

Unresolved function or method

@types/jest 설치 후

regeneratorRuntime~ 에러

테스트에서 비동기 코드를 작성할 때 다음과 같은 에러가 발생할 수 있습니다.
이 에러는 다양한 이유가 있지만, 많은 경우 간단하게 @babel/polyfill을 포함하는 것으로 해결할 수 있습니다.

regeneratorRuntime error

$ npm i -D @babel/polyfill

테스트에 직접 포함하거나 jest.setup.js 파일에 포함하면 됩니다.
jest.setup.js 설정은 ‘’개선 사항 > Vuetify’ 파트를 참고하세요.

import '@babel/polyfill'

jsdom 업그레이드

Jest 25.1.0버전부터 내부에서 사용하는 jsdom이 v11에서 v15로 업그레이드되었습니다.
하지만 이전 버전의 Jest를 사용하거나 jsdom의 최신 버전(권장 사항, 현재 이 글을 쓰는 시점에서 v16)을 사용하려면 다음 모듈과 함께 Jest 구성 옵션을 추가하세요

모듈설명
jest-environment-jsdom-sixteenjsdom 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)

Jest를 통해 테스트의 적용 범위(coverage) 보고서를 확인할 수 있습니다.
이 보고서는 작성한 테스트 코드를 통해 테스트 대상(Vue 컴포넌트 등)의 코드가 실행된 범위를 확인합니다.

collectCoverage

보고서를 확인하려면 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

Jest coverage

모든 적용 범위 100%

테스트 코드를 잘 살펴보면 실제 테스트한 코드가 없지만,
테스트 결과를 보면 Stmts, Branch 등 모든 항목이 적용 범위 100%로 표시됩니다.

각 항목은 다음 표와 같으며,
백분율(%)은 전체 코드 중 실행(호출)된 코드의 비율입니다.

항목설명
Statements(Stmts)각 구문의 실행을 확인
BranchesIf, Switch 같은 조건문의 각 분기 실행을 확인
Functions(Funcs)각 함수 호출을 확인
Lines각 라인의 실행을 확인(Statements와 비슷)
Uncovered Line테스트를 통해 실행되지 않은 코드 라인을 표시

모든 항목이 100%인 이유는, 컴포넌트 마운트(shallowMount)를 통해서 reversedMsg와 연결된 모든 코드가 실행되었기 때문입니다.
컴포넌트에서 다음과 같이 일부 코드를 주석 처리 후 다시 테스트를 진행합니다.
이제 테스트 대상의 어느 부분이 작성한 테스트를 통해 실행되지 않았는지 확인할 수 있습니다.

Jest coverage

Jest coverage

coverageReporters

만약 보고서를 좀 더 자세하게 확인하고 싶다면, Jest 구성 옵션 coverageReporters을 통해 보고서 양식을 HTML 문서로 변경할 수 있습니다.
다음과 같이 구성 옵션을 수정하고 테스트를 실행합니다.

module.exports = {
  // ...
  collectCoverage: true,
  coverageReporters: ['html']
}
$ npm t

더 이상 터미널에서는 보고서가 표시되지 않습니다.
지정한 옵션을 통해 루트 경로에 coverage라는 새로운 디렉터리가 생성되었으며,
내부의 index.html 파일을 실행해 보고서를 확인할 수 있습니다.

변경 사항이 발생하면 ‘새로고침’을 누르세요.

Jest Coverage directory for HTML coverage reporter

Jest Coverage directory for HTML coverage reporter

HTML 범위 보고서.gif

coverageThreshold

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
    }
  }
}

Jest coverage threshold

첫 번째 컴포넌트 테스트

환경 설정에 맞게 테스트가 정상 동작하는지 확인하기 위해 매우 간단한 테스트용 코드를 작성해 보겠습니다.

이 글에서의 기본 프로젝트는 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__ 디렉터리를 테스트할 코드와 같은 경로에 생성할 것을 권장합니다.

First test directories

다음과 같이 테스트를 작성합니다.

// 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

다음과 같은 에러가 발생해야 합니다.
이제 무엇을 수정해야 테스트가 통과할지 명확해졌습니다.

First test fails

잘못된 받은 값(received)과 기댓값(expected)에 의해서도 테스트가 통과할 수 있어서,
의도적인 실패를 통해 테스트 신뢰도를 높일 수 있습니다.
특히 테스트가 복잡해지는 경우 더욱 조심해야 합니다.

Jest

전역 멤버(Globals)

describe는 테스트의 범위를 설정하고,
test는 단위 테스트를 설정합니다.

describe('테스트 범위', () => {
  test('단위 테스트 1', () => {})
  test('단위 테스트 2', () => {})
})

Hooks

Jest는 beforeAll, afterAll, beforeEach, afterEach의 4가지 전역 함수를 제공하며, 이를 통해 테스트 코드 전후를 제어할 수 있습니다.

beforeAllafterAll은 선언된 describe 범위 안에서 전후 동작하며,
beforeEachafterEach는 선언된 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!')
  })
})

Jest Hooks

중첩(Nesting)

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!')
    })
  })
})

only, skip

describetest에 사용할 수 있는 onlyskip 멤버가 있습니다.
일부 테스트만 실행하고 싶을 때는 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', () => {})
  })
})

Jest Global Only

only 멤버로 일부 테스트만 실행합니다.
describe('Vue Component', () => {
  describe.skip('Computed', () => {
    test('1', () => {})
    test('2', () => {})
  })
  describe('Created', () => {
    test('1', () => {})
    test.skip('2', () => {})
  })
  describe('Methods', () => {
    test('1', () => {})
    test('2', () => {})
  })
})

Jest Global Skip

skip 멤버로 일부 테스트를 중단합니다.

Matchers

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(속성경로, 값?)받은 값에 속성경로 존재 또는 속성경로에 해당 값이 존재하는지 확인

toBe

가장 기본적인 Matcher로, 받은 값과 기댓값이 같은지 비교합니다.
원시 데이터(Primitives)를 비교하는 용도로 사용합니다.

const user = {
  name: 'Neo',
  age: 85
}
test('Name', () => {
  expect(user.name).toBe('Neo')
})
test('Age', () => {
  expect(user.age).toBe(85)
})

toEqual과 toStrictEqual

toEqualtoStrictEqual은 객체나 배열 데이터의 모든 속성(요소)을 재귀적으로 비교하며, 이를 깊은(Deep) 동등 비교라고 합니다.
여기서 ‘깊은(Deep)’은 ‘데이터 안으로 들어가서 모두 꼼꼼하게’ 정도로 의역할 수 있습니다.

우선, toBe를 사용하는 다음 예제는 실패합니다.

const user = {
  name: 'Neo',
  age: 85
}
test('User', () => {
  expect(user).toBe({
    name: 'Neo',
    age: 85
  })
})

Jest failed toBe Matcher

실패한 테스트를 통해 ‘toBe’가 아닌 ‘toStrictEqual’ Matcher를 사용해야 한다는 것을 알 수 있습니다.

다음과 같이 toBetoStrictEqual로 변경하면, 테스트가 통과합니다.

// ...
test('User', () => {
  // Passes
  expect(user).toStrictEqual({
    name: 'Neo',
    age: 85
  })
})

toEqualtoStrictEqual은 비슷하지만, 다음과 같은 일부 엄격함의 차이가 있습니다.
실패하는 테스트만 // 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
})

toBeCloseTo

자바스크립트에서 10진수를 2진수로 변환하게 되면 0.1이나 0.2 소수점 숫자 같은 경우 무한 소수가 발생하게 됩니다.
따라서 toBe를 사용하는 다음 테스트는 실패하게 됩니다.

test('Floating point numbers', () => {
  expect(0.1 + 0.2).toBe(0.3)
})

Floating point numbers operation

toBeCloseTo로 변경하면, 테스트가 통과합니다.

test('Floating point numbers', () => {
  expect(0.1 + 0.2).toBeCloseTo(0.3) // Passes
})

toContain과 toContainEqual

두 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

함수가 호출될 때 에러가 발생하는지 확인합니다.
주의할 점은 에러를 던지는 함수를 별도의 함수로 한 번 랩핑해 실행하거나, 호출 없이 함수 자체를 받은 값 인수로 사용해야 테스트 자체의 에러로 인식되지 않습니다.

다음 예제를 통해 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()
})

toHaveBeenCalled

테스트에서 함수가 호출된 적이 있는지 확인합니다.
중요한 것은 해당 함수가 감시(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

toHaveReturnedtoHaveBeenCalled와 비슷하지만, 함수의 호출뿐만 아니라 반환 값이 있어야 합니다.
함수는 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
})

toHaveReturnedWith

함수가 호출되고 어떤 값을 반환했는지 확인합니다.
함수가 여러 번 호출되고 각각 반환 값이 다른 경우, 기댓값이 모든 호출 중 하나의 반환과 일치하면 테스트는 통과합니다.

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
})

Jest toHaveReturnedWith

toHaveProperty

객체에서 찾고자 하는 속성의 존재 여부와 그 값을 확인할 수 있습니다.
속성의 경로(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 Test Utils(VTU)

Mountings

테스트를 위해 Vue 컴포넌트를 연결 및 렌더링(Mounting)하는 다음 함수들이 있습니다.

함수반환특징
mount()Wrapper마운트(하위 컴포넌트 렌더링)
shallowMount()Wrapper얕은 마운트(하위 컴포넌트 스텁)
render()Promise문자열로 렌더링하고 Cheerio 객체(Promise)를 반환
renderToString()PromiseHTML로 렌더링

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 vs shallowMount

테스트에서 컴포넌트를 연결 및 렌더링하는 mountshallowMount 함수가 있습니다.
이 함수들은 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')
  })
})

매우 비슷하지만, mountshallowMount는 큰 차이점이 있습니다.
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())
  })
})

다음과 같은 결과를 볼 수 있습니다.

Vue test utils shallowMount vs mount

mount는 하위 컴포넌트도 같이 렌더링했기 때문에 <span>Child!</span>가 출력되었습니다.

shallowMount는 하위 컴포넌트를 Stub(스텁) 컴포넌트(<child-comp-stub></child-comp-stub>)로 대체하여 실제 작동하는 것처럼 보이게 합니다.
이는 하위 컴포넌트를 테스트에 포함하지 않아 독립적인 테스트가 가능합니다.
특히 테스트 컴포넌트가 많은 하위 컴포넌트를 가지고 있는 경우 유용합니다.

Mounting options

테스트에서 컴포넌트를 마운트하면서 다음과 같은 추가 옵션을 지정할 수 있습니다.

shallowMount(컴포넌트, 옵션)
옵션설명타입기본값
📎context오직 Functional components에 전달할 context를 지정Object
📎slots컴포넌트의 Slots에 삽입할 객체를 지정Object
📎scopedSlots컴포넌트의 Scoped Slots에 삽입할 객체를 지정Object
📎stubs스텁할 하위 컴포넌트들을 지정Object / Array
📎mocks모의 객체나 모의 함수를 지정Object
📎localVuecreateLocalVue로 생성된 Vue의 로컬 복사본을 지정createLocalVue()
📎attachTowindow나 DOM-Events 등을 사용할 때, 마운트할 컴포넌트로 대체될 요소의 선택자를 지정HTMLElement / Stringnull
📎attachToDocument(Deprecated) window나 DOM-Events 등을 사용할 때 지정
대신 attachTo 옵션 사용을 권장
Booleanfalse
📎attrs부모 컴포넌트에게 받을 $attrs 객체를 지정Object
📎propsData컴포넌트가 마운트될 때 전달할 Props를 지정Object
📎listeners부모 컴포넌트에게 받을 $listeners 객체를 지정Object
📎parentComponent부모 컴포넌트를 지정Object
📎provideinject에서 사용될 provide 객체를 지정Object

createLocalVue

테스트에서 사용할 로컬 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')

Wrappers

테스트에서 mountshallowMount로 컴포넌트를 마운트하면 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 전용입니다!

멤버설명구분
📎.vmVue 인스턴스를 반환(읽기 전용)
📎.element루트 요소를 반환(읽기 전용)
📎.wrappers찾은 결과의 모든 Wrapper의 배열을 반환(읽기 전용)WrapperArray
📎.length배열 길이를 반환(읽기 전용)WrapperArray
📎.at(숫자)Zero based 배열 인덱싱(음수는 뒤에서부터), Wrapper를 반환WrapperArray
📎.attributes(속성?)루트 요소의 HTML 속성 객체 혹은 속성값을 반환
📎.classes(클래스?)루트 요소의 HTML 클래스 속성값의 배열 혹은 속성값의 유무 반환
📎.contains(선택자 | 컴포넌트)특정 CSS선택자 혹은 컴포넌트가 포함되어 있는지 확인Wrapper / WrapperArray
📎.destroy()Vue 인스턴스를 제거(beforeDestroydestroyed 라이프사이클 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

테스트 더블(Test double)

테스트에 대해서 학습하다 보면 Mock, Spy, Stub 같은 난해한 용어들이 나오기 시작하는데,
이런 용어들을 일반적으로 테스트 더블(Test double)이라고 합니다.
영화 제작의 스턴트 대역(Stunt double)이 실제 배우를 대신하는 것처럼 테스트를 위해 실제 객체를 대신하는 것이죠.

각각 의미가 조금씩 다르고 설명만으로 정확한 의도를 파악하기 어렵지만,
모두 크게는 ‘테스트를 위한 가짜 객체‘ 정도로 정리할 수 있습니다.
일부 문서에서는 혼용되기도 합니다.

용어설명
더미(Dummy)실제로는 동작하지 않고 데이터를 채우기 위한 객체
스텁(Stub)실제로는 동작하지 않지만, 동작하는 것처럼 보이기 위해 준비된 값만을 반환하는 객체
모조품(Fake)실제로 동작하지만 프로덕트에는 적합하지 않은 객체
모의(Mock)실제로 동작하지만 준비된 값만을 반환하는 객체
스파이(Spy)호출 등 일부 정보를 기록해 다양한 상황을 감시하는 객체

모의 함수 만들기(Mocking)

테스트 환경에서는 실제 구현한 함수를 그대로 사용할 수 없는 경우가 많습니다.
네트워크 비용이 발생하는 외부 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()

위에서 살펴본 것과 같이 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에서, fetchTodocreated 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()

jest.spyOn()실제 함수를 모의 함수로 덮어쓰지 않고도 호출 감시를 목적으로 아주 유용하게 사용할 수 있습니다.

기존 예제에서 컴포넌트는 fetchTodocreated Hook에서 호출해 원하는 데이터를 가져옵니다.
Mounting 이후 fetchTodo가 호출되었는지 확인하기 위해 다음과 같이 jest.spyOn()을 사용합니다.
(fetchTodocreated 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
  })
})

Mock functions

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

.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

.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

.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

공지 이미지
이 내용을 72시간 동안 안 보고 싶어요!?