뷰 테스트 코드 시작하기

뷰 테스트 유틸 라이브러리를 이용하여 간단한 테스트 코드를 작성해보겠다.

뷰 컴포넌트 테스트 코드 예시

아래의 코드를 가지고 npm t를 실행한다.

<!-- HelloWorld.vue -->
<template>
  <div>Hello {{ message }}</div>
</template>

<script>
export default {
  data() {
    return {
  	  message: 'Vue!'
    }
  }
}
</script>
// helloworld.test.js
import Vue from 'vue';
import HelloWorld from './HelloWorld.vue';

test('HelloWorld Component', () => {
  const cmp = new Vue(HelloWorld).$mount();
  expect(cmp.message).toBe('Vue!');
});

코드가 실행되면 HelloWorld 컴포넌트가 정상적으로 로딩되면서 테스트가 통과된다.

TIP

HelloWorld 컴포넌트 파일 경로 주의

뷰 테스트 유틸 API를 이용한 컴포넌트 테스트

앞에서 살펴본 테스트 코드의 번거러운 점은 컴포넌트의 그능을 테스트하기 위해 매번 뷰 인스턴스를 생성하고 $mount() API를 호출해야 한다는 점이다.

다행히도 뷰 테스트 유틸 라이브러리에서 이 반복 작업을 편하게 해주는 API를 제공한다.
바로 shallowMount()mount() 이다.
먼저 shallowMount() API를 사용해보자.

앞에서 살펴본 코드에 shallowMount() API를 적용해보면

// helloworld.test.js
import { shallowMount } from '@vue/test-utils';
import HelloWorld from './HelloWorld.vue';

test('HelloWorld Component', () => {
  const wrapper = shallowMount(HelloWorld);
  expect(wrapper.vm.message).toBe('Vue!');
});

뷰 테스트 라이브러리에서 shallowMount() API를 불러온 후 HelloWorld 컴포넌트의 message 속성을 테스트 하였다.
앞서 살펴본 코드와 차이나는 부분은 아래와 같다.

// 뷰 테스트 유틸 API를 사용하지 않은 경우
const cmp = new Vue(HelloWorld).$mount();
// 뷰 테스트 유틸 API를 사용한 경우
const wrapper = shallowMount(HelloWorld);

new Vue().$mount()로 접근하던 형식을 mount()로 간소화 하였다.

컴포넌트 테스팅 관련 API

앞에서 살펴본 shallowMount() API 이외에 mount() API도 테스트 할 때 자주 사용된다.
shallowMount()는 지정된 컴포넌트의 내용만 테스트할 때 사용하고, mount()는 해당 컴포넌트에 등록된 하위 컴포넌트의 내용들까지 확인할 때 사용한다.

Tutorial - Todo App

이번 튜토리얼에서는 간단한 Todo App을 만들면서 각 컴포넌트의 유닛 테스트(Unit Test)를 작성해보겠다.
커모넌트를 구현하고 핻아 컴포넌트의 테스트 코드를 작성하는 순서로 작업한다.

프로젝트 셋업

  1. vue cli 최신 버전 설치
npm install -g @vue/cli
  1. 프로젝트 생서
vue create todo-app-test
  1. eslint의 env 옵션 속성에 jest: true 추가
module.exports = {
  root: true,
  env: {
    node: true, 
    jest: true, // jest api들을 사용할 때 에러 표시가 나지 않게 해준다.
  },
  // ...
}
  1. @types/jest 라이브러리 설치
    @types/jest 라이브러리는 jest api들의 자동완성을 제공한다.
npm install @types/jest -D
  1. jest.config.js에 testMatch 설정 추가
    테스트해야 하는 컴포넌트와 테스트 코드가 한 폴더 내에 존재하면 찾을 때 편리하다.
    그러므로 @vue/cli-plugin-unit-jest의 testMatch 설정값을 추가한다.
module.exports = {
  preset: "@vue/cli-plugin-unit-jest",
  testMatch: ["**/src/**/*.(test|spec).js"], // src 폴더 내의 파일 이름에 spec이나 test가 포함돼 있다면 테스트를 수행한다.
}
  1. 테스트 코드 실행해보기
    App 컴포넌트를 제외한 모든 컴포넌트를 삭제한다.
    그리고 아래와 같이 코드를 작성해 준다.
<!-- src/App.vue -->
<template>
  <div>
    <h1>Todo App</h1>
  </div>
</template>
// src/App.test.js
import { shallowMount } from "@vue/test-utils";

import App from "./App.vue";

describe("App", () => {
  it("renders title", () => {
    const wrapper = shallowMount(App);
    
    expect(wrapper.find(".h1").text()).toMatch("Todo App");
  });
});

이제 테스트 코드를 실행해 주면 된다.

npm run test:unit

터미널 창에 아래와 같이 출력 된다면 프로젝트 셋업이 정상적으로 완료된 것이다.

> vue-cli-service test:unit

PASS  src/App.test.js
  App
    ✓ renders title (21ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.403s
Ran all test suites.

TIP

// package.json
{
  //...
  "scripts": {
  	// ...
    "test:unit": "vue-cli-service test:unit --watchAll"
  }
}

package.json에 test:unit 스크립트에 --watchAll 옵션을 추가해주자.
테스트가 추가되거나 수정되면 자동으로 다시 실행시켜준다.

프로젝트 시작

프로젝트 준비가 끝났으니 Todo App을 구현해보겠다.
우리가 구현해야 되는 기능들은 아래와 같으며 차례대로 구현한다.

  1. 할 일 추가하기
  2. 할 일 체크하기
  3. 할 일 삭제하기

할 일 추가하기

할 일 추가하기 구현 순서는 다음과 같다.

1. UI 구현

아래와 빨간 박스로 표시된 부분의 UI를 구현한다.

<!-- src/App.vue -->
<template>
  <div>
    <h1>Todo App</h1>
    <div>
      <label for="todo-control">할 일 작성</labe>
      <div>
        <input
        	:id="todo-control"
            type= "text"
            placeholder="할 일을 작성해주세요"
        />
        <button type="button">추가하기</button>
      </div>
    </div>
  </div>
</template>

2. UI 구현 - 테스트 코드 작성

UI 구현 코드에서 작성해야 되는 테스트 코드는 다음과 같다.

  • "할 일 작성"이라는 텍스트가 화면에 출려된다.
  • 할 일을 작성할 수 있는 인풋 태그가 화면에 출력된다.
  • "추가하기"라는 버튼이 화면에 출력된다.

한 가지 알아두셔야 할 것은 테스트 코드는 작성하는 사람에 따라 얼마든지 변경될 수 있다.
테스트 코드를 얼마나 세세하게 작성하냐에 따라서 코드의 안정성이 달라진다.
즉 테스트 코그를 작성한다고 해서 모든 에러를 방지 할 수없는게 아니다.
그러므로 조금이라도 더 세세하게 테스트를 작성해보는걸 권장한다.

// src/App.test.vue
import { shallowMount } from "@vue/test-utils";

import App from "./App.vue";

describe("App", () => {
  it("renders title", () => {
    const wrapper = shallowMount(App);
    
    expect(wrapper.find("h1").text()).toBe("Todo App");
  });
  
  it("renders label, input", () => {
    const wrapper = shallowMount(App);
    
    // '할 일 작성'이라는 텍스트가 화면에 출력된다.
    expect(wrapper.find("label").text()).toMatch("할 일 작성");
    
    // 할 일을 작성할 수 있는 'control'창 이 화면에 출력된다.
    expect(wrapper.find("input").attrbutes("placeholder")).toMatch("할 일을 작성해주세요");
  });
});

it("renders button", () => {
  const wrapper = shallowMount(App);
  
  // '추가하기'라는 버튼이 화면에 출력된다.
  expect(wrapper.find("button").text()).toMatch("추가하기");
});

막상 테스트 코드를 작성을 하고 보니 한가지 아쉬운 게 있다.
인풋 태그와 레이블이 연결된 지도 확인해보고 싶으므로 테스트를 하나 더 추가해보겠다.

it("connects label and input", () => {
  const wrapper = shallowMount(App);
  const TODO_CONTROL = 'todo-control';
  
  expect(wrapper.find("label").attrbutes("for")).toMatch(TODO_CONTROL);
  expect(wrapper.find("input").attrbutes("id")).toMatch(TODO_CONTROL);
});

이런 식으로 학습하시면서 아쉬운 부분들이 생긴다면 테스트를 추가해보자.

그리고 위의 테스트 코드에서 describe, it의 첫 번째 인수를 합쳐보면 "App renders label, input", "App renders button"으로 문장이 만들어지는 것을 볼 수 있다.
"render"는 "App"이 단수이기 때문에 s를 붙여서 "renders"로 작성하는 것이다.
이런 식으로 문법에 맞게 작성해주고 테스트 코드를 작성할때는 항상 말이 되게 작성하는것을 권장한다.
이렇게 작성하게 되면 테스트를 실행했을 때도 아래와 같이 수월하게 읽을 수 있다.

PASS  src/App.test.js
App
  ✓ renders title (21ms)
  ✓ renders label, input (5ms)
  ✓ connects label and input (3ms)

그리고 나중에 해당 컴포넌트의 역할을 파악하고 싶을 때 관심사의 분리(sparation of concerns, SoC)를 잘해 놓았다면 테스트 코드만으로도 파악이 가능하다.
이 부분은 튜토리얼 마지막 부분에서 다뤄보겠다.

3. 기능 구현 - 인풋 태그에 할 일 작성 시 data에 할 일 텍스트 값 넣기

<!-- src/App.vue -->
<template>
  <div>
    <h1>Todo App</h1>
    <div>
      <label for="todo-cotrol">할 일 작성</label>
      <div>
        <input 
          id="todo-control"
          type="text"
          placeholder="할 일을 작성해주세요"
          :value="text"
          @input="handleInput"
        />
        <button type="button">추가하기</button>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
  	  text: "",
    }
  },
  methods() {
  	handleInput(event) {
  	  this.text = event.target.value;
    },
  },
};
</script>

v-model을 사용하지 않은 이유는 현재 시점에서 IME 입력(한국어, 일본어, 중국어)에 대해서 한계점이 있기 때문이다.

참고

https://joshua1988.github.io/web-development/vuejs/v-model-usage/#%EA%B7%B8%EB%9F%BC-v-model%EC%9D%B4-%EB%8D%94-%ED%8E%B8%ED%95%98%EB%8B%88%EA%B9%8C-%EC%9D%B4%EA%B1%B0-%EC%93%B0%EB%A9%B4-%EB%90%98%EB%8A%94%EA%B1%B0%EC%A3%A0

4. 기능 구현 - 테스트 코드 작성

// src/App.test.js
it("changes input value when listens input event", async () => {
  const wrapper = shallowMount(App);
  
  // setValue는 아래 두 코드의 축약 api 이다.
  await wrapper.find("input").setValue("아무것도 안하기");
  // wrapper.find("input").element.value = "아무것도 안하기";
  // wrapper.find("input").trigger("input");
  
  expect(wrapper.vm.text).toMatch("아무것도 안하기");
});

vue-test-utils 라이브러리에선 input이벤트를 trigger시 event.target.value를 직접적으로 변경할 수 없다.
그래서 input의 value값을 변경한 뒤 input이벤트를 trigger해야 한다.
input이벤트를 trigger하면 handleInput 함수가 실행되고 datatext값이 변경되었는지 테스트한다.
그리고 이벤트 트리거는 비동기로 동작하기 때문에 async, await 문법을 사용하여 실행 순서를 보장해주어야 한다.

5. 기능 구현 - "추가하기" 버튼을 누르면 할 일 추가

<!-- src/App.vue -->
<template>
  <div>
    <h1>Todo App</h1>
    <div>
      <label for="todo-control">할 일 작성</label>
      <div>
        <input 
          id="todo-control"
          type="text"
          placeholder="할 일을 작성해주세요"
          :value="text"
          @input="handleInput"
        />
        <button type="button" @click="handleClickAppTodo">추가하기</button>
      </div>
    </div>
    <ul>
      <li v-for="todo in todos" :key="todo.id">
        {{ todo.text }}
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  data() {
  	return {
  	  text: "",
 	  newId: 0,
  	  todos: [],
    }
  },
  methods: {
  	handleInput(text) {
  	  this.text = event.text.value;
  	},
    handleClickAddTodo() {
  	  // 할 일 추가
  	  this.todos.push({
  	    id: this.newId,
  		text: this.text,
  	  });
  	  this.newId += 1;
  	  
  	  // 인풋 값 초기화
  	  this.text = "";
    },
  },
};
</script>

6. 기능 구현 - 테스트 코드 작성

// src/App.test.js
it("adds todo when listens '추가하기' click event", async () => {
  const wrapper = shallowMount(App);
  
  wrapper.find("input").setValue("아무것도 안하기");
  wait wrapper.find("button").trigger("click");
  
  expect(wrapper.find("li").text()).toContain("아무것도 안하기");
});

인풋 태그에 할 일을 타이핑하고 "추가하기" 버튼이 클릭 됐을 때 타이핑한 할 일이 화면에 출력 되는지 테스트한다.

자주 사용하는 API 목록

단위 테스트 케이스 작성을 위해 자주 사용하는 API 목록이다.

Shallow Rendering

테스트 할 컴포넌트의 기능만 테스트하고 하위 컴포넌트와는 분리해주는 테스트 API

import { shallowMount } from '@vue/test-utils';
import Component from './component';

describe('Component', () => {
  test('is a Vue instance', () => {
    const wrapper = shallowMount(Component);
    expect(wrapper.isVueInstance()).toBeTruthy();
  })
})

mount a component without rendering its child components

Mount

테스트할 컴포넌트의 하위 컴포넌트의 동작까지 함께 테스트 하는 API

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

test('testWorld Component', () => {
  const wrapper = mount(HelloWorld);
  expect(wrapper.vm.message).toBe('Vue!');
})

Snapshot Testing

스냅샷(Snapshot Testing) 테스팅을 하기 위해 먼저 아래 환경을 구성한다.

npm install --save-dev jest-serializer-vue
// package.json
{
  // ...
  "jest": {
    // ...
    // serializer for snapshots
    "snapshotSerializers": [
      "jest-serializer-vue"
    ]
  }
}

그리고 코드는 다음과 같다.

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

describe('Hello World Component', () => {
  test('[Snapshot Testing] renders hello world message', () => {
  	const { vm } = mount(HelloWorld);
    expect(vm.$el).toMatchSnapshot();
  })
})

테스트 커버리지(범위)

테스팅의 목적

  1. 코드를 변경할 때 두려워하지 않기 위해
  2. 코드 품질 보장
  3. 도큐먼트로써 역할

Comfidence to change / Removal of Fear + High Code Quality + Well-documented Code = Deeloper Happiness

뷰 컴포넌트 테스트

뷰 컴포넌트 테스트 코드를 작성할 때 고민할 지점들은 다음과 같다.

  • 컴포넌트의 입력 값: props, user interaction, lifecycle methods
  • 컴포넌트의 출력 값: events, rendered, output, connection with children

테스트 할 필요가 없는 지점들

  • 컴포넌트의 구체적인 로직 (비즈니스 로직)
  • 프레임워크 자체의 기능들 (prop rendering, prop, validtion 등)

뷰 컴포넌트 테스트 기법

  • Interation 테스트: 특정 컴포넌트에 종속된 하위 컴포넌트까지 모두 컴포넌트의 테스트 범위로 간주. mount() API 사용.
    특정 기능의 전체 흐름을 모두 테스트 케이스로 작성.
  • Shallow 테스트: 특정 컴포넌트에 등록된 하위 컴포넌트는 신경쓰지 않고 해당 컴포넌트의 기능만 테스트.
    shallowMount() API 사용. 특정 기능의 흐름을 잘게 분할해서 테스트 케이스로 작성.
profile
Always happy coding 😊

0개의 댓글