事例紹介
TodoMVC が提供するオープンソースの Todos List タスク リストの例を使用して、 TDDを使用して Vue アプリケーションを開発する方法を学びましょう。
事例デモ効果:Vue.js・TodoMVC
基本的な機能は次のとおりです。
- タスクの内容を入力し、「Enter」をクリックすると、タスクがリストに追加されます。
- 単一またはすべてのタスクの完了ステータスを切り替えます
- タスクを削除する
- 未完了のタスクの数を表示する
- 完了したタスクをすべて削除する
- データフィルタリング
- タスクの内容を変更します(変更を保存するには Enter を押し、編集をキャンセルするには ESC を押します。内容が空の場合はタスクを削除します)。
ケース作成
コンポーネント ファイルを作成し、 src\components\TodoApp\index.vue
Github リポジトリのindex.htmlからページ コンテンツをコピーします。
<!-- src\components\TodoApp\index.vue -->
<template>
<section class="todoapp">
<header class="header">
<h1>todos</h1>
<input class="new-todo" placeholder="What needs to be done?" autofocus />
</header>
<section class="main">
<input id="toggle-all" class="toggle-all" type="checkbox" />
<label for="toggle-all">Mark all as complete</label>
<ul class="todo-list">
<!-- These are here just to show the structure of the list items -->
<!-- List items should get the class `editing` when editing and `completed` when marked as completed -->
<li class="completed">
<div class="view">
<input class="toggle" type="checkbox" checked />
<label>Taste JavaScript</label>
<button class="destroy"></button>
</div>
<input class="edit" value="Create a TodoMVC template" />
</li>
<li>
<div class="view">
<input class="toggle" type="checkbox" />
<label>Buy a unicorn</label>
<button class="destroy"></button>
</div>
<input class="edit" value="Rule the web" />
</li>
</ul>
</section>
<!-- This footer should be hidden by default and shown when there are todos -->
<footer class="footer">
<!-- This should be `0 items left` by default -->
<span class="todo-count"><strong>0</strong> item left</span>
<!-- Remove this if you don't implement routing -->
<ul class="filters">
<li>
<a class="selected" href="#/">All</a>
</li>
<li>
<a href="#/active">Active</a>
</li>
<li>
<a href="#/completed">Completed</a>
</li>
</ul>
<!-- Hidden if no completed items are left ↓ -->
<button class="clear-completed">Clear completed</button>
</footer>
</section>
</template>
App.vue にコンポーネントを導入します。
<template>
<div id="app">
<TodoApp />
</div>
</template>
<script>
import TodoApp from '@/components/TodoApp'
export default {
name: 'App',
components: { TodoApp }
}
</script>
package.jsonからスタイルの依存関係todomvc-app-css を見つけて、index.css をプロジェクトにコピーし、新しいsrc\style\index.css
ファイルを作成します。
で紹介されましたmain.js
:
// src\main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import './style/index.css'
Vue.config.productionTip = false
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
npm run serve
アプリケーションを起動して効果を確認してくださいhttp://localhost:8080/
コンポーネントユニットを分割する
次に、TDD(テスト駆動開発)手法に従ってアプリケーションを開発し、まず機能をコンポーネントに分割します。
Todoヘッダー
<!-- src\components\TodoApp\TodoHeader.vue -->
<template>
<header class="header">
<h1>todos</h1>
<input class="new-todo" placeholder="What needs to be done?" autofocus />
</header>
</template>
<script>
export default {
name: 'TodoHeader'
}
</script>
Todoフッター
<!-- src\components\TodoApp\TodoFooter.vue -->
<template>
<!-- This footer should be hidden by default and shown when there are todos -->
<footer class="footer">
<!-- This should be `0 items left` by default -->
<span class="todo-count"><strong>0</strong> item left</span>
<!-- Remove this if you don't implement routing -->
<ul class="filters">
<li>
<a class="selected" href="#/">All</a>
</li>
<li>
<a href="#/active">Active</a>
</li>
<li>
<a href="#/completed">Completed</a>
</li>
</ul>
<!-- Hidden if no completed items are left ↓ -->
<button class="clear-completed">Clear completed</button>
</footer>
</template>
<script>
export default {
name: 'TodoFooter'
}
</script>
TodoItem
<!-- src\components\TodoApp\TodoItem.vue -->
<template>
<li>
<!-- <li class="completed"> -->
<div class="view">
<input class="toggle" type="checkbox" />
<!-- <input class="toggle" type="checkbox" checked /> -->
<label>Taste JavaScript</label>
<button class="destroy"></button>
</div>
<input class="edit" value="Create a TodoMVC template" />
</li>
</template>
<script>
export default {
name: 'TodoItem'
}
</script>
TodoApp
<!-- src\components\TodoApp\index.vue -->
<template>
<section class="todoapp">
<TodoHeader />
<section class="main">
<input id="toggle-all" class="toggle-all" type="checkbox" />
<label for="toggle-all">Mark all as complete</label>
<ul class="todo-list">
<!-- These are here just to show the structure of the list items -->
<!-- List items should get the class `editing` when editing and `completed` when marked as completed -->
<TodoItem />
<TodoItem />
</ul>
</section>
<!-- This footer should be hidden by default and shown when there are todos -->
<TodoFooter />
</section>
</template>
<script>
import TodoHeader from './TodoHeader'
import TodoFooter from './TodoFooter'
import TodoItem from './TodoItem'
export default {
name: 'TodoApp',
components: { TodoHeader, TodoFooter, TodoItem }
}
</script>
TodoHeader コンポーネント
TodoHeader コンポーネントの機能は、テキスト ボックスにコンテンツを入力し、Enter キーを押すとカスタム イベントが送信され、カスタム イベントは文字列を渡し、最後にテキスト ボックスをクリアします。
テストケースを書く
まずテスト ケースを作成します。ファイルの場所には近接性の原則が推奨されます。
TodoApp コンポーネント フォルダーの下にテスト ケース フォルダーを作成します。フォルダーの名前は です__tests__
。Vue Test Utils はこのディレクトリ内のファイルを検索し、テスト ファイルとして実行します。
テスト ファイルには、テスト対象のコンポーネントにちなんだ名前を付けることが望ましいですTodoHeader.js
。
// src\components\TodoApp\__tests__\TodoHeader.js
import {
shallowMount } from '@vue/test-utils'
import TodoHeader from '@/components/TodoApp/TodoHeader'
describe('TodoHeader.vue', () => {
test('New todo', async () => {
const wrapper = shallowMount(TodoHeader)
// 可以给元素添加一个专门用于测试的 `data-testid`,方便测试的时候获取这个元素
const input = wrapper.findComponent('input[data-testid="new-todo"]')
const text = 'play'
// 文本框填入内容
// 操作视图也建议使用 await 等待一下
// 因为它可能会修改 vm 实例的状态,这样更稳妥一些
await input.setValue(text)
// 等待触发回车事件
await input.trigger('keyup.enter')
// 断言组件对外发送一个 new-todo 事件
expect(wrapper.emitted()['new-todo']).toBeTruthy()
// 断言事件发送的参数
expect(wrapper.emitted()['new-todo'][0][0]).toBe(text)
// 断言文本框已清空
expect(input.element.value).toBe('')
})
})
監視モードを使用してテストを実行します (もちろん失敗します)。
# 监视并运行全部测试文件
npm run test:unit -- --watch
# or 监视并运行指定测试文件
npm run test:unit -- TodoHeader.js --watch
コンポーネントの機能向上
次に、テスト ケースに従ってコンポーネントの機能を改善します。
<!-- src\components\TodoApp\TodoHeader.vue -->
<template>
<header class="header">
<h1>todos</h1>
<input
class="new-todo"
placeholder="What needs to be done?"
autofocus
data-testid="new-todo"
@keyup.enter="handleNewTodo"
/>
</header>
</template>
<script>
export default {
name: 'TodoHeader',
methods: {
handleNewTodo (e) {
const value = e.target.value.trim()
if (!value.length) {
return
}
this.$emit('new-todo', value)
e.target.value = ''
}
}
}
</script>
スタイルがデバッグされない限り、プロセス全体でこのコンポーネントの関数開発を完了するためにブラウザを開く必要はなく、ほとんどの場合、開発された関数は正しい必要があることがわかります。
コンポーネントの開発が完了し、テストに合格した後、コードを git に送信して、テストコマンドを再実行できますwatch
。このモードでは、ファイルが変更されていない場合、テストは行われません。
TodoApp コンポーネント
親コンポーネントとしての TodoApp は、TodoHeader によって送信されたカスタム イベントを受信し、関数を通じてそれを処理する必要があります。TodoApp は、タスク アイテムを追加できる配列を管理する必要があります。
テストケースを書く
// src\components\TodoApp\__tests__\TodoApp.js
import {
shallowMount } from '@vue/test-utils'
import TodoApp from '@/components/TodoApp'
import TodoItem from '@/components/TodoApp/TodoItem'
describe('TodoApp.vue', () => {
test('New todo', async () => {
const wrapper = shallowMount(TodoApp)
const text = 'play'
// 调用组件的方法,添加任务项
wrapper.vm.handleNewTodo(text)
// 期望管理的数组中包含刚添加的任务项
const todo = wrapper.vm.todos.find(t => t.text === text)
expect(todo).toBeTruthy()
})
test('Todo List', async () => {
const wrapper = shallowMount(TodoApp)
// 初始化默认数据,并等待视图更新
const todos = [
{
id: 1, text: 'eat', done: false },
{
id: 2, text: 'play', done: true },
{
id: 3, text: 'sleep', done: false }
]
await wrapper.setData({
todos
})
// 期望指定子组件被渲染了3个
expect(wrapper.findAllComponents(TodoItem).length).toBe(todos.length)
})
})
コンポーネントの機能向上
<!-- src\components\TodoApp\index.vue -->
<template>
<section class="todoapp">
<TodoHeader @new-todo="handleNewTodo" />
<section class="main">
<input id="toggle-all" class="toggle-all" type="checkbox" />
<label for="toggle-all">Mark all as complete</label>
<ul class="todo-list">
<!-- These are here just to show the structure of the list items -->
<!-- List items should get the class `editing` when editing and `completed` when marked as completed -->
<TodoItem v-for="todo in todos" :key="todo.id" />
</ul>
</section>
<!-- This footer should be hidden by default and shown when there are todos -->
<TodoFooter />
</section>
</template>
<script>
import TodoHeader from './TodoHeader'
import TodoFooter from './TodoFooter'
import TodoItem from './TodoItem'
export default {
name: 'TodoApp',
components: { TodoHeader, TodoFooter, TodoItem },
data () {
return {
todos: []
}
},
methods: {
handleNewTodo (text) {
const lastTodo = this.todos[this.todos.length - 1]
this.todos.push({
id: lastTodo ? lastTodo.id + 1 : 1,
text,
done: false
})
}
}
}
</script>
TodoItem コンポーネント
コンテンツ表示&処理完了ステータス
テストケースを書く
// src\components\TodoApp\__tests__\TodoItem.js
import {
shallowMount } from '@vue/test-utils'
import TodoItem from '@/components/TodoApp/TodoItem'
describe('TodoItem.vue', () => {
// 使用 vscode 注解声明 type 以使用类型提示
/** @type {import('@vue/test-utils').Wrapper} */
let wrapper = null
beforeEach(() => {
const todo = {
id: 1,
text: 'play',
done: true
}
wrapper = shallowMount(TodoItem, {
propsData: {
todo
}
})
})
test('text', () => {
// 断言文本内容
expect(wrapper.findComponent('[data-testid="todo-text"]').text()).toBe(wrapper.vm.todo.text)
})
test('done', async () => {
const done = wrapper.findComponent('[data-testid="todo-done"]')
const todoItem = wrapper.findComponent('[data-testid="todo-item"]')
// 断言完成状态
expect(done.element.checked).toBeTruthy()
// 断言 class - classes(获取 DOM 节点的 class 数组)
expect(todoItem.classes()).toContain('completed')
// 修改复选框状态,并等待视图更新
await done.setChecked(false)
// 断言 class
expect(todoItem.classes('completed')).toBeFalsy()
})
})
コンポーネントで、必須フィールドをTodoApp
subcomponent に渡します。そうしないと、テストでエラーが報告されます。TodoItem
prop
<TodoItem v-for="todo in todos" :key="todo.id" :todo="todo" />
コンポーネントの機能向上
<!-- src\components\TodoApp\TodoItem.vue -->
<template>
<li data-testid="todo-item" :class="{completed:todo.done}">
<div class="view">
<!-- eslint-disable-next-line vue/no-mutating-props -->
<input v-model="todo.done" data-testid="todo-done" class="toggle" type="checkbox" />
<label data-testid="todo-text">{
{ todo.text }}</label>
<button class="destroy"></button>
</div>
<input class="edit" value="Create a TodoMVC template" />
</li>
</template>
<script>
export default {
name: 'TodoItem',
props: {
todo: {
type: Object,
required: true
}
}
}
</script>
タスクを削除する
TodoItem は削除ボタンをクリックし、親コンポーネントに削除イベントを送信し、タスク アイテムの ID を親コンポーネントに渡します。親コンポーネントはメソッドを通じて削除操作を処理します。
テストケースを書く
test('delete todo', async () => {
const deleteBtn = wrapper.findComponent('[data-testid="delete"]')
await deleteBtn.trigger('click')
expect(wrapper.emitted()['delete-todo']).toBeTruthy()
expect(wrapper.emitted()['delete-todo'][0][0]).toBe(wrapper.vm.todo.id)
})
コンポーネントの機能向上
<button data-testid="delete" class="destroy" @click="$emit('delete-todo', todo.id)"></button>
TodoApp 削除アクション
テストケースを書く
// src\components\TodoApp\__tests__\TodoApp.js
import {
shallowMount } from '@vue/test-utils'
import TodoApp from '@/components/TodoApp'
import TodoItem from '@/components/TodoApp/TodoItem'
describe('TodoApp.vue', () => {
/** @type {import('@vue/test-utils')/Wrapper} */
let wrapper = null
beforeEach(async () => {
wrapper = shallowMount(TodoApp)
const todos = [
{
id: 1, text: 'eat', done: false },
{
id: 2, text: 'play', done: true },
{
id: 3, text: 'sleep', done: false }
]
// 初始化默认数据,并等待视图更新
await wrapper.setData({
todos
})
})
test('New todo', async () => {
const text = 'play'
// 调用组件的方法,添加任务项
wrapper.vm.handleNewTodo(text)
// 期望管理的数组中包含刚添加的任务项
const todo = wrapper.vm.todos.find(t => t.text === text)
expect(todo).toBeTruthy()
})
test('Todo List', async () => {
// 期望指定子组件被渲染了3个
expect(wrapper.findAllComponents(TodoItem).length).toBe(wrapper.vm.todos.length)
})
test('Delete Todo', async () => {
// 正向测试 传递一个真实的 id
await wrapper.vm.handleDeleteTodo(1)
expect(wrapper.vm.todos.length).toBe(2)
expect(wrapper.findAllComponents(TodoItem).length).toBe(2)
})
test('Delete Todo', async () => {
// 反向测试 传递要给不存在的 id
await wrapper.vm.handleDeleteTodo(123)
expect(wrapper.vm.todos.length).toBe(3)
expect(wrapper.findAllComponents(TodoItem).length).toBe(3)
})
})
コンポーネントの機能向上
<!-- src\components\TodoApp\index.vue -->
<template>
<section class="todoapp">
<TodoHeader @new-todo="handleNewTodo" />
<section class="main">
<input id="toggle-all" class="toggle-all" type="checkbox" />
<label for="toggle-all">Mark all as complete</label>
<ul class="todo-list">
<!-- These are here just to show the structure of the list items -->
<!-- List items should get the class `editing` when editing and `completed` when marked as completed -->
<TodoItem
v-for="todo in todos"
:key="todo.id"
:todo="todo"
@delete-todo="handleDeleteTodo"
/>
</ul>
</section>
<!-- This footer should be hidden by default and shown when there are todos -->
<TodoFooter />
</section>
</template>
<script>
import TodoHeader from './TodoHeader'
import TodoFooter from './TodoFooter'
import TodoItem from './TodoItem'
export default {
name: 'TodoApp',
components: { TodoHeader, TodoFooter, TodoItem },
data () {
return {
todos: []
}
},
methods: {
handleNewTodo (text) {
const lastTodo = this.todos[this.todos.length - 1]
this.todos.push({
id: lastTodo ? lastTodo.id + 1 : 1,
text,
done: false
})
},
handleDeleteTodo (todoId) {
const index = this.todos.findIndex(t => t.id === todoId)
if (index !== -1) {
this.todos.splice(index, 1)
}
}
}
}
</script>
TodoItem をダブルクリックして編集ステータスを取得します
テストケースを書く
test('edit todo style', async () => {
const label = wrapper.findComponent('[data-testid="todo-text"]')
const todoItem = wrapper.findComponent('[data-testid="todo-item"]')
const todoEdit = wrapper.findComponent('[data-testid="todo-edit"]')
// 触发双击事件
await label.trigger('dblclick')
// 断言 class
expect(todoItem.classes()).toContain('editing')
// 失去焦点
await todoEdit.trigger('blur')
expect(todoItem.classes('editing')).toBeFalsy()
})
コンポーネントの機能向上
<!-- src\components\TodoApp\TodoItem.vue -->
<template>
<li data-testid="todo-item" :class="{
completed: todo.done,
editing: isEditing
}">
<div class="view">
<!-- eslint-disable-next-line vue/no-mutating-props -->
<input v-model="todo.done" data-testid="todo-done" class="toggle" type="checkbox" />
<label data-testid="todo-text" @dblclick="isEditing=true">{
{ todo.text }}</label>
<button data-testid="delete" class="destroy" @click="$emit('delete-todo', todo.id)"></button>
</div>
<input
class="edit"
value="Create a TodoMVC template"
data-testid="todo-edit"
@blur="isEditing=false"
/>
</li>
</template>
<script>
export default {
name: 'TodoItem',
props: {
todo: {
type: Object,
required: true
}
},
data () {
return {
isEditing: false
}
}
}
</script>
TodoItem は自動的にフォーカスを取得します
カスタム コマンドを通じて自動的にフォーカスを取得します。
<!-- src\components\TodoApp\TodoItem.vue -->
<template>
<li data-testid="todo-item" :class="{
completed: todo.done,
editing: isEditing
}">
<div class="view">
<!-- eslint-disable-next-line vue/no-mutating-props -->
<input v-model="todo.done" data-testid="todo-done" class="toggle" type="checkbox" />
<label data-testid="todo-text" @dblclick="isEditing=true">{
{ todo.text }}</label>
<button data-testid="delete" class="destroy" @click="$emit('delete-todo', todo.id)"></button>
</div>
<input
v-focus="isEditing"
class="edit"
value="Create a TodoMVC template"
data-testid="todo-edit"
@blur="isEditing=false"
/>
</li>
</template>
<script>
export default {
name: 'TodoItem',
props: {
todo: {
type: Object,
required: true
}
},
directives: {
focus (element, binding) {
if (binding.value) {
element.focus()
}
}
},
data () {
return {
isEditing: false
}
}
}
</script>
TodoItem の変更を保存
テストケースを書く
test('save edit todo', async () => {
const label = wrapper.findComponent('[data-testid="todo-text"]')
const todoEdit = wrapper.findComponent('[data-testid="todo-edit"]')
// 触发双击事件
await label.trigger('dblclick')
// 编辑文本框中的内容展示
expect(todoEdit.element.value).toBe(wrapper.vm.todo.text)
// 修改文本框的值
const text = 'Hello'
await todoEdit.setValue(text)
// 触发回车保存事件
await todoEdit.trigger('keyup.enter')
// 断言是否对外发送一个自定义事件
expect(wrapper.emitted()['edit-todo']).toBeTruthy()
expect(wrapper.emitted()['edit-todo'][0][0]).toEqual({
id: wrapper.vm.todo.id,
text
})
// 断言编辑状态被取消
expect(wrapper.vm.isEditing).toBeFalsy()
})
コンポーネントの機能向上
// template
<input
v-focus="isEditing"
class="edit"
:value="todo.text"
data-testid="todo-edit"
@blur="isEditing=false"
@keyup.enter="handleEditTodo"
/>
// js
methods: {
handleEditTodo (e) {
this.$emit('edit-todo', {
id: this.todo.id,
text: e.target.value
})
// 取消编辑状态
this.isEditing = false
}
}
TodoApp は変更を保存します
テストケースを書く
test('Edit Todo', async () => {
const todo = {
id: 2, text: 'abc' }
// 修改任务
await wrapper.vm.handleEditTodo(todo)
expect(wrapper.vm.todos[1].text).toBe(todo.text)
// 内容为空时删除任务
todo.text = ''
await wrapper.vm.handleEditTodo(todo)
expect(wrapper.vm.todos.find(t => t.id === todo.id)).toBeFalsy()
})
コンポーネントの機能向上
// template
<TodoItem
v-for="todo in todos"
:key="todo.id"
:todo="todo"
@delete-todo="handleDeleteTodo"
@edit-todo="handleEditTodo"
/>
// js
handleEditTodo ({
id, text }) {
const todo = this.todos.find(t => t.id === id)
if (!todo) {
return
}
if (!text.trim().length) {
// 执行删除操作
return this.handleDeleteTodo(id)
}
// 执行修改操作
todo.text = text
}
TodoItem 編集のキャンセル
テストケースを書く
test('cancel edit todo', async () => {
const label = wrapper.findComponent('[data-testid="todo-text"]')
const todoEdit = wrapper.findComponent('[data-testid="todo-edit"]')
// 触发双击事件
await label.trigger('dblclick')
// 备份原内容
const text = wrapper.vm.todo.text
// 修改内容
await todoEdit.setValue('bbb')
// 触发 ESC 取消事件
await todoEdit.trigger('keyup.esc')
// 断言内容没有被修改
expect(wrapper.vm.todo.text).toBe(text)
// 断言编辑状态被取消
expect(wrapper.vm.isEditing).toBeFalsy()
})
コンポーネントの機能向上
// template
<input
v-focus="isEditing"
class="edit"
:value="todo.text"
data-testid="todo-edit"
@blur="isEditing=false"
@keyup.enter="handleEditTodo"
@keyup.esc="handleCancelEdit"
/>
// js
handleCancelEdit () {
this.isEditing = false
}