# Tutorial - Todo App
์ด๋ฒ ํํ ๋ฆฌ์ผ์์๋ ๊ฐ๋จํ Todo App์ ๋ง๋ค๋ฉด์ ๊ฐ ์ปดํฌ๋ํธ์ ์ ๋ ํ ์คํธ(Unit Test)๋ฅผ ์์ฑํด๋ณด๊ฒ ์ต๋๋ค. ์ปดํฌ๋ํธ๋ฅผ ๊ตฌํํ๊ณ ํด๋น ์ปดํฌ๋ํธ์ ํ ์คํธ ์ฝ๋๋ฅผ ์์ฑํ๋ ์์๋ก ์์ ํฉ๋๋ค. ์ด ํํ ๋ฆฌ์ผ์ ํ์ตํ์๋ ๋ถ๋ค์ ์ํด ๋จ๊ณ๋ณ๋ก ์ฝ๋๋ฅผ ์ ๊ณตํฉ๋๋ค. ์ต๋ํ ๋จผ์ ๋ฐ๋ผ ํด๋ณด์๊ณ ์๋๋ ๋ถ๋ถ๋ง ์ฐธ๊ณ ํ๋ ์ฉ๋๋ก ์ฌ์ฉํด ์ฃผ์ธ์.
# ํ๋ก์ ํธ ์ ์
ํ๋ก์ ํธ ์ ์ ์์๋ ๋ค์๊ณผ ๊ฐ์ผ๋ฉฐ ์ต์ข ์ฝ๋๋ ์ฌ๊ธฐ์ (opens new window) ํ์ธํ์ค ์ ์์ต๋๋ค.
- vue cli ์ต์ ๋ฒ์ ์ค์น
npm install -g @vue/cli
- ํ๋ก์ ํธ ์์ฑ
vue create todo-app-test
ํ๋ก์ ํธ ์์ฑ ์ ๋งค๋ด์ผ ์ ํ์ ๋งํฌ (opens new window)๋ฅผ ์ฐธ๊ณ ํด ์ฃผ์ธ์.
- eslint์
env
์ต์ ์์ฑ์jest: true
์ถ๊ฐ
module.exports = {
root: true,
env: {
node: true,
jest: true, // jest api๋ค์ ์ฌ์ฉํ ๋ ์๋ฌ ํ์๊ฐ ๋์ง ์๊ฒ ํด์ค๋๋ค.
},
//...
}
@types/jest
๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ค์น
@types/jest
๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ jest api๋ค์ ์๋์์ฑ์ ์ ๊ณตํฉ๋๋ค.
npm install @types/jest -D
- jest.config.js์
testMatch
์ค์ ์ถ๊ฐ
ํ ์คํธํด์ผ ํ๋ ์ปดํฌ๋ํธ์ ํ ์คํธ ์ฝ๋๊ฐ ํ ํด๋ ๋ด์ ์กด์ฌํ๋ฉด ์ฐพ์ ๋ ํธ๋ฆฌํฉ๋๋ค. ๊ทธ๋ฌ๋ฏ๋ก @vue/cli-plugin-unit-jest (opens new window)์ testMatch ์ค์ ๊ฐ์ ์ถ๊ฐํฉ๋๋ค. ๊ธฐ๋ณธ์ ์ธ jest ์ค์ ๋ค์ ์ญํ ๋ค์ ์ฌ๊ธฐ (opens new window)๋ฅผ ์ฐธ๊ณ ํ์ธ์.
module.exports = {
preset: "@vue/cli-plugin-unit-jest",
testMatch: ["**/src/**/*.(test|spec).js"], // src ํด๋ ๋ด์ ํ์ผ ์ด๋ฆ์ spec์ด๋ test๊ฐ ํฌํจ๋ผ ์๋ค๋ฉด ํ
์คํธ๋ฅผ ์ํํฉ๋๋ค.
};
- ํ ์คํธ ์ฝ๋ ์คํํด๋ณด๊ธฐ
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์ ๊ตฌํํด๋ณด๊ฒ ์ต๋๋ค. ์ฐ๋ฆฌ๊ฐ ๊ตฌํํด์ผ ๋๋ ๊ธฐ๋ฅ๋ค์ ๋ค์๊ณผ ๊ฐ์ผ๋ฉฐ ์ฐจ๋ก๋๋ก ๊ตฌํํฉ๋๋ค.
- ํ ์ผ ์ถ๊ฐํ๊ธฐ
- ํ ์ผ ์ฒดํฌํ๊ธฐ
- ํ ์ผ ์ญ์ ํ๊ธฐ
# ํ ์ผ ์ถ๊ฐํ๊ธฐ
ํ ์ผ ์ถ๊ฐํ๊ธฐ์ ๊ตฌํ ์์๋ ๋ค์๊ณผ ๊ฐ์ผ๋ฉฐ ์ต์ข ์ฝ๋๋ ์ฌ๊ธฐ์ (opens new window) ํ์ธํ์ค ์ ์์ต๋๋ค.
- UI ๊ตฌํ
์๋์ ๋นจ๊ฐ ๋ฐ์ค๋ก ํ์๋ ๋ถ๋ถ์ UI๋ฅผ ๊ตฌํํฉ๋๋ค.
<!-- src/App.vue -->
<template>
<div>
<h1>Todo App</h1>
<div>
<label for="todo-control">ํ ์ผ ์์ฑ</label>
<div>
<input
id="todo-control"
type="text"
placeholder="ํ ์ผ์ ์์ฑํด์ฃผ์ธ์"
/>
<button type="button">์ถ๊ฐํ๊ธฐ</button>
</div>
</div>
</div>
</template>
- 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()).toMatch("Todo App");
});
it("renders label, input", () => {
const wrapper = shallowMount(App);
// 'ํ ์ผ ์์ฑ'์ด๋ผ๋ ํ
์คํธ๊ฐ ํ๋ฉด์ ์ถ๋ ฅ๋ฉ๋๋ค.
expect(wrapper.find("label").text()).toMatch("ํ ์ผ ์์ฑ");
// ํ ์ผ์ ์์ฑํ ์ ์๋ 'control'์ฐฝ ์ด ํ๋ฉด์ ์ถ๋ ฅ๋ฉ๋๋ค.
expect(wrapper.find("input").attributes("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").attributes("for")).toMatch(TODO_CONTROL);
expect(wrapper.find("input").attributes("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)
๊ทธ๋ฆฌ๊ณ ๋์ค์ ํด๋น ์ปดํฌ๋ํธ์ ์ญํ ์ ํ์
ํ๊ณ ์ถ์ ๋ ๊ด์ฌ์ฌ์ ๋ถ๋ฆฌ(separation of concerns, SoC) (opens new window)๋ฅผ ์ํด๋์๋ค๋ฉด ํ
์คํธ ์ฝ๋๋ง์ผ๋ก๋ ํ์
์ด ๊ฐ๋ฅํฉ๋๋ค. ์ด ๋ถ๋ถ์ ํํ ๋ฆฌ์ผ ๋ง์ง๋ง ๋ถ๋ถ์์ ๋ค๋ค๋ณด๊ฒ ์ต๋๋ค.
- ๊ธฐ๋ฅ ๊ตฌํ - ์ธํ ํ๊ทธ์ ํ ์ผ ์์ฑ ์ data์ ํ ์ผ ํ ์คํธ ๊ฐ ๋ฃ๊ธฐ
<!-- 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">์ถ๊ฐํ๊ธฐ</button>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
text: "",
};
},
methods: {
handleInput(event) {
this.text = event.target.value;
},
},
};
</script>
v-model์ ์ฌ์ฉํ์ง ์์ ์ด์ ๋ ํ์ฌ ์์ ์์๋ IME ์
๋ ฅ(ํ๊ตญ์ด, ์ผ๋ณธ์ด, ์ค๊ตญ์ด)์ ๋ํด์ ํ๊ณ์ ์ด ์๊ธฐ ๋๋ฌธ์
๋๋ค. ์์ธํ ๋ด์ฉ์ ์ด ๋งํฌ (opens new window)๋ฅผ ์ฐธ๊ณ ํด์ฃผ์ธ์.
- ๊ธฐ๋ฅ ๊ตฌํ - ํ ์คํธ ์ฝ๋ ์์ฑ
// 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
ํจ์๊ฐ ์คํ๋๊ณ data
์ text
๊ฐ์ด ๋ณ๊ฒฝ๋๋์ง ํ
์คํธํฉ๋๋ค. ๊ทธ๋ฆฌ๊ณ ์ด๋ฒคํธ ํธ๋ฆฌ๊ฑฐ๋ ๋น๋๊ธฐ๋ก ๋์ํ๊ธฐ ๋๋ฌธ์ async, await
๋ฌธ๋ฒ์ ์ฌ์ฉํ์ฌ ์คํ ์์๋ฅผ ๋ณด์ฅํด์ฃผ์ด์ผ ํฉ๋๋ค.
- ๊ธฐ๋ฅ ๊ตฌํ - "์ถ๊ฐํ๊ธฐ" ๋ฒํผ์ ๋๋ฅด๋ฉด ํ ์ผ ์ถ๊ฐ
<!-- 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="handleClickAddTodo">์ถ๊ฐํ๊ธฐ</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(event) {
this.text = event.target.value;
},
handleClickAddTodo() {
// ํ ์ผ ์ถ๊ฐ
this.todos.push({
id: this.newId,
text: this.text,
});
this.newId += 1;
// ์ธํ ๊ฐ ์ด๊ธฐํ
this.text = "";
},
},
};
</script>
- ๊ธฐ๋ฅ ๊ตฌํ - ํ ์คํธ ์ฝ๋ ์์ฑ
// src/App.test.js
it("adds todo when listens '์ถ๊ฐํ๊ธฐ' click event", async () => {
const wrapper = shallowMount(App);
wrapper.find("input").setValue("์๋ฌด๊ฒ๋ ์ํ๊ธฐ");
await wrapper.find("button").trigger("click");
expect(wrapper.find("li").text()).toContain("์๋ฌด๊ฒ๋ ์ํ๊ธฐ");
});
์ธํ ํ๊ทธ์ ํ ์ผ์ ํ์ดํํ๊ณ "์ถ๊ฐํ๊ธฐ" ๋ฒํผ์ด ํด๋ฆญ ๋์ ๋ ํ์ดํํ ํ ์ผ์ด ํ๋ฉด์ ์ถ๋ ฅ ๋๋์ง ํ ์คํธํฉ๋๋ค.
โ Tutorial - User Interaction API โ