TodoHeader组件
<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>
<style lang="scss" scoped>
</style>
- 创建测试文件 就近原则
- 创建
__test__文件\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);
const input = wrapper.find('input[data-testid="new-todo"]');
const text = 'zhangweihai';
// 模拟输入
await input.setValue(text);
// 模拟点击
await input.trigger('keyup.enter');
// 事件被触发
expect(wrapper.emitted()['new-todo']).toBeTruthy();
// 触发emit后 传出值为input输入值
expect(wrapper.emitted()['new-todo'][0][0]).toBe(text);
// 事件触发完成后输入框清空
expect(input.element.value).toBe('');
})
})
package.json中配置"test": "jest"
- 执行单个文件
npm run test src/components/TodoApp/__tests__/TodoHeader.js
- 上述测试文件中 模拟了用户输入,触发事件,清空输入框
- 可以通过vue工具查看验证
TodoApp组件
<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">
<TodoItem v-for="todo in todos" :key="todo.id" :todo="todo" />
</ul>
</section>
<TodoFooter />
</section>
</template>
<script>
import TodoHeader from './TodoHeader.vue';
import TodoFooter from './TodoFooter.vue';
import TodoItem from './TodoItem.vue';
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>
// TodoApp.js
import { shallowMount } from "@vue/test-utils";
import TodoApp from '@/components/TodoApp/index.vue';
import TodoItem from '@/components/TodoApp/TodoItem.vue'
describe('TodoApp', () => {
test('new todo', () => {
const wrapper = shallowMount(TodoApp);
const text = 'play';
// 执行handleNewTodo往todos中添加数据
wrapper.vm.handleNewTodo(text);
const todo = wrapper.vm.todos.find(item => item.text === text);
expect(todo.text).toBeTruthy();
})
// 测试列表展示
test('todo list', async () => {
const wrapper = shallowMount(TodoApp);
// 写法1
const todos = [
{id: 1, text: 'song', done: false},
{id: 2, text: 'play', done: true},
{id: 3, text: 'basketball', done: false},
]
await wrapper.setData({
todos
})
expect(wrapper.findAllComponents(TodoItem).length).toBe(todos.length);
// 写法2
/* wrapper.vm.todos = [
{id: 1, text: 'song', done: false},
{id: 2, text: 'play', done: true},
{id: 3, text: 'basketball', done: false},
]
await Vue.nextTick() */
})
})
TodoItem组件
<TodoItem v-for="todo in todos" :key="todo.id" :todo="todo" />
<template>
<!-- List items should get the class `editing` when editing and `completed` when marked as completed -->
<!-- 绑定class -->
<li data-testid="todo-item" :class="{completed: todo.done}">
<div class="view">
<!-- 绑定v-model -->
<input v-model="todo.done" data-testid="todo-done" class="toggle" type="checkbox" />
<!-- 绑定text -->
<label data-testid="todo-text">{{ todo.text }}</label>
<button class="destroy"></button>
</div>
<input class="edit" value="Rule the web" />
</li>
</template>
<script>
export default {
name: 'TodoItem',
props: {
todo: {
type: Object,
required: true
}
}
}
</script>
<style lang="scss" scoped>
</style>
// TodoItem.js
import { shallowMount } from "@vue/test-utils";
import TodoItem from '@/components/TodoApp/TodoItem.vue'
describe('TodoItem', () => {
test('todo-item show', () => {
const todo = {
id: 1,
text: 'play',
done: true,
}
const wrapper = shallowMount(TodoItem, {
propsData: {
todo
}
});
// 文本内容
expect(wrapper.find('[data-testid="todo-text"]').text()).toBe(todo.text);
// 选中状态
expect(wrapper.find('[data-testid="todo-done"]').element.checked).toBeTruthy();
// 选中新增class类名
expect(wrapper.find('[data-testid="todo-item"]').classes()).toContain('completed');
// 未选中状态
// expect(wrapper.find('[data-testid="todo-done"]').element.checked).toBeFalsy();
// expect(wrapper.find('[data-testid="todo-item"]').classes().length).toBe(0);
})
})
- 优化写法 测试分模块
import { shallowMount } from "@vue/test-utils";
import TodoItem from '@/components/TodoApp/TodoItem.vue'
describe('TodoItem.vue', () => {
// 优化
/** @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.find('[data-testid="todo-text"]').text()).toBe(wrapper.vm.todo.text);
});
test('done', async () => {
const done = wrapper.find('[data-testid="todo-done"]');
const todoItem = wrapper.find('[data-testid="todo-item"]');
// 选中状态
expect(done.element.checked).toBeTruthy();
// 选中新增class类名
expect(todoItem.classes()).toContain('completed');
// 更改选中状态
await done.setChecked(false);
expect(todoItem.classes('completed')).toBeFalsy();
})
})
- 有个细节问题 初始将
wrapper置为null后失去了智能提示 - 解决方案
/** @type {import('@vue/test-utils').Wrapper} */
let wrapper = null;
TodoItem.vue新增删除逻辑
<button data-testid="todo-del" class="destroy" @click="handleDelete(todo.id)"></button>
<script>
export default {
name: 'TodoItem',
props: {
todo: {
type: Object,
required: true
}
},
methods: {
handleDelete (id) {
this.$emit('delete-todo', id)
}
}
}
</script>
TodoItem.js验证
test('delete', async () => {
const delBtn = wrapper.find('button[data-testid="todo-del"]');
await delBtn.trigger('click');
expect(wrapper.emitted()['delete-todo']).toBeTruthy();
expect(wrapper.emitted()['delete-todo'][0][0]).toBe(wrapper.vm.todo.id);
})
- 同理 验证父组件删除逻辑
<TodoItem v-for="todo in todos" :key="todo.id" :todo="todo"
@delete-todo="handleDeleteTodo"
/>
export default {
methods: {
handleDeleteTodo (todoId) {
const index = this.todos.findIndex(item => item.id == todoId);
if (index != -1) {
this.todos.splice(index, 1);
}
}
}
}
// TodoApp.js
import { shallowMount } from "@vue/test-utils";
import TodoApp from '@/components/TodoApp/index.vue';
import TodoItem from '@/components/TodoApp/TodoItem.vue'
describe('TodoApp', () => {
/** @type {import('@vue/test-utils').Wrapper} */
let wrapper = null;
beforeEach(async () => {
wrapper = shallowMount(TodoApp);
const todos = [
{id: 1, text: 'song', done: false},
{id: 2, text: 'play', done: true},
{id: 3, text: 'basketball', done: false},
]
await wrapper.setData({
todos
})
})
test('new todo', () => {
const text = 'play';
// 执行handleNewTodo往todos中添加数据
wrapper.vm.handleNewTodo(text);
// 此时todos中新增了一条text数据
const todo = wrapper.vm.todos.find(item => item.text === text);
expect(todo.text).toBeTruthy();
})
// 测试列表展示
test('todo list', async () => {
expect(wrapper.findAllComponents(TodoItem).length).toBe(wrapper.vm.todos.length);
})
test('测试子传父删除功能', async () => {
// 执行handleDeleteTodo
await wrapper.vm.handleDeleteTodo(1);
expect(wrapper.vm.todos.length).toBe(2);
// dom更新是异步的 所以加上await
expect(wrapper.findAllComponents(TodoItem).length).toBe(2);
})
test('测试子传父删除功能--反向测试', async () => {
// 执行handleDeleteTodo
await wrapper.vm.handleDeleteTodo(111);
expect(wrapper.vm.todos.length).toBe(3);
// dom更新是异步的 所以加上await
expect(wrapper.findAllComponents(TodoItem).length).toBe(3);
})
})
TodoItem双击获得编辑状态
<template>
<!-- List items should get the class `editing` when editing and `completed` when marked as completed -->
<!-- 绑定class -->
<li
data-testid="todo-item"
:class="{
completed: todo.done,
editing: isEditing === true
}"
>
<div class="view">
<!-- 绑定v-model -->
<input v-model="todo.done" data-testid="todo-done" class="toggle" type="checkbox" />
<!-- 绑定text -->
<label
data-testid="todo-text"
@dblclick="isEditing = true"
>{{ todo.text }}</label>
<button data-testid="todo-del" class="destroy" @click="handleDelete(todo.id)"></button>
</div>
<input
v-focus="isEditing"
data-testid="todo-edit"
class="edit"
:value="todo.text"
@blur="isEditing = false"
/>
</li>
</template>
<script>
export default {
name: 'TodoItem',
props: {
todo: {
type: Object,
required: true
}
},
directives: {
// 自动获取焦点
focus (el, binding) {
if (binding.value) {
el.focus();
}
}
},
data () {
return {
isEditing: false
}
},
methods: {
handleDelete (id) {
this.$emit('delete-todo', id)
}
}
}
</script>
<style lang="scss" scoped>
</style>
TodoItem.js
test('双击编辑', async () => {
const todoItem = wrapper.find('li[data-testid="todo-item"]');
const label = wrapper.find('label[data-testid="todo-text"]');
const todoEdit = wrapper.find('input[data-testid="todo-edit"]');
// 双击
await label.trigger('dblclick');
// 新增类名editing
expect(todoItem.classes()).toContain('editing');
// input失去焦点
await todoEdit.trigger('blur');
// 删除类名
expect(todoItem.classes('editing')).toBeFalsy();
})
TodoItem回车保存 esc取消编辑
<input
v-focus="isEditing"
data-testid="todo-edit"
class="edit"
:value="todo.text"
@blur="isEditing = false"
@keyup.enter="handleEdit"
/>
<script>
export default {
name: 'TodoItem',
methods: {
handleEdit (e) {
this.$emit('edit-todo', {
id: this.todo.id,
text: e.target.value
});
// 取消编辑状态
this.isEditing = false;
}
}
}
</script>
test('保存编辑', async () => {
const label = wrapper.find('label[data-testid="todo-text"]');
const todoEdit = wrapper.find('input[data-testid="todo-edit"]');
// 双击
await label.trigger('dblclick');
// 编辑文本框中的内容展示
expect(todoEdit.element.value).toBe(wrapper.vm.todo.text);
// 修改文本框的值
const text = 'hello';
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();
});
- 子组件中触发
emit,所以我们需要完善父组件以及父组件测试文件
<TodoItem v-for="todo in todos" :key="todo.id" :todo="todo"
@delete-todo="handleDeleteTodo"
@edit-todo="handleEditTodo"
/>
<script>
export default {
name: 'TodoApp',
methods: {
handleEditTodo ({id, text}) {
const todo = this.todos.find(item => item.id == id);
if (!todo) {
return;
}
if (!text.trim().length) {
// 执行删除操作
this.handleDeleteTodo(id);
return;
}
// 执行修改
todo.text = text;
}
}
}
</script>
// TodoApp.js
test('测试子传父编辑功能', async () => {
const todo = {id: 2, text: 'jump'};
// 执行 handleEditTodo
await wrapper.vm.handleEditTodo(todo);
// todos数组第一项的text改为jump
expect(wrapper.vm.todos[1].text).toBe(todo.text);
// 反向测试 当todo.text清空时 删除当前项
todo.text = '';
await wrapper.vm.handleEditTodo(todo);
expect(wrapper.vm.todos.find(item => item.id === todo.id)).toBeFalsy();
})
TodoItem取消编辑
// TodoItem.vue
<input
v-focus="isEditing"
data-testid="todo-edit"
class="edit"
:value="todo.text"
@blur="isEditing = false"
@keyup.enter="handleEdit"
@keyup.esc="handleCancelEdit"
/>
handleCancelEdit () {
this.isEditing = false;
}
// TodoItem.js
test('取消编辑', async () => {
const label = wrapper.find('label[data-testid="todo-text"]');
const todoEdit = wrapper.find('input[data-testid="todo-edit"]');
// 双击
await label.trigger('dblclick');
// text备份
const text = wrapper.vm.todo.text;
// 设置值
todoEdit.setValue('随便任何输入内容');
// 触发取消
await todoEdit.trigger('keyup.esc');
// 验证字段没有被修改
expect(wrapper.vm.todo.text).toBe(text);
// 验证编辑状态被取消
expect(wrapper.vm.isEditing).toBeFalsy();
});
- 设置全选
// TodoApp/index.vue
<input
data-testid="toggle-all"
id="toggle-all"
class="toggle-all"
type="checkbox"
v-model="toggleAll"
/>
<script>
computed: {
toggleAll: {
get () {
// 是否全选
return this.todos.length && this.todos.every(item => item.done);
},
set (value) {
this.todos.forEach(item => {
item.done = value;
})
}
}
},
</script>
// TodoApp.js
test('测试全选', async () => {
const toggleAll = wrapper.find('input[data-testid="toggle-all"]');
// 设置全选
toggleAll.setChecked();
// 断言所有的子任务都被选中
wrapper.vm.todos.forEach(item => {
expect(item.done).toBeTruthy();
})
// 取消全选
toggleAll.setChecked(false);
wrapper.vm.todos.forEach(item => {
expect(item.done).toBeFalsy();
})
});
test('测试全选与子项联动', async () => {
const toggleAll = wrapper.find('input[data-testid="toggle-all"]');
// 让所有任务都变成选中状态
wrapper.vm.todos.forEach(item => {
item.done = true;
})
await Vue.nextTick();
// 断言toggleAll也被选中
expect(toggleAll.element.checked).toBeTruthy();
// 取消某个任务
wrapper.vm.todos[0].done = false;
// 断言toggleAll未被选中
await Vue.nextTick();
expect(toggleAll.element.checked).toBe(false);
// todos设置为空
wrapper.vm.todos = [];
// 断言toggleAll未被选中
await Vue.nextTick();
expect(toggleAll.element.checked).toBe(false);
});
TodoFooter.vue
- 父组件
<TodoFooter :todos="todos" />
// TodoFooter.vue
<template>
<footer class="footer">
<!-- This should be `0 items left` by default -->
<span class="todo-count"><strong data-testid="done-todos-count">{{ doneTodosCount }}</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',
props: {
todos: {
type: Object,
required: true
}
},
computed: {
doneTodosCount () {
return this.todos.lenght && this.todos.filter(todo => !todo.done).lenght;
}
}
}
</script>
<style lang="scss" scoped>
</style>
// TodoFooter.js
import { shallowMount } from "@vue/test-utils";
import TodoFooter from '@/components/TodoApp/TodoFooter.vue'
describe('TodoFooter', () => {
/** @type {import('@vue/test-utils').Wrapper} */
let wrapper = null;
beforeEach(() => {
const todos = [
{id: 1, text: 'song', done: false},
{id: 2, text: 'play', done: true},
{id: 3, text: 'basketball', done: false},
]
wrapper = shallowMount(TodoFooter, {
propsData: {
todos
}
});
});
test('测试剩余任务数量是否正确', () => {
// 未完成数量
const count = wrapper.vm.todos.filter(item => !item.done).length;
const countEl = wrapper.find('strong[data-testid="done-todos-count"]');
// 断言文本显示数字等于count
expect(Number.parseInt(countEl.text())).toBe(count);
})
})
- TodoFooter清除已完成任务的显示状态
<button
v-if="isClearCompletedShown"
data-testid="clear-completed"
class="clear-completed">
Clear completed
</button>
<script>
computed: {
isClearCompletedShown () {
return this.todos.length && this.todos.find(todo => todo.done);
}
}
</script>
// TodoFooter.js
test('清除按钮是否展示', async () => {
const clearBtn = wrapper.find('button[data-testid="clear-completed"]');
// 因为初始数据有已完成状态 则clearBtn显示 有done为true 则断言按钮是已渲染状态
expect(clearBtn.exists()).toBeTruthy();
// 清除所有任务的完成状态 断言clearBtn不存在
// 该写法有问题 不能直接修改propsData
/* wrapper.vm.todos.forEach(item => {
item.done = false;
});
await Vue.nextTick(); */
wrapper = shallowMount(TodoFooter, {
propsData: {
todos: [
{id: 1, text: 'song', done: false},
{id: 2, text: 'play', done: false},
{id: 3, text: 'basketball', done: false},
]
}
});
// 隐藏按钮
expect(wrapper.find('button[data-testid="clear-completed"]').exists()).toBeFalsy();
});
- 删除已完成任务
<TodoFooter :todos="todos"
@clear-completed="handleClearCompleted"
/>
<script>
methods: {
handleClearCompleted () {
// 清除所有已完成任务
for (let i = 0; i < this.todos.length; i++) {
if (this.todos[i].done) {
// delete
this.todos.splice(i, 1);
i--;
}
}
}
}
</script>
// TodoApp.js
test('清除已完成任务项', async () => {
wrapper.vm.handleClearCompleted();
await Vue.nextTick();
expect(wrapper.vm.todos).toEqual([
{id: 1, text: 'song', done: false},
{id: 3, text: 'basketball', done: false},
]);
});
TodoApp筛选数据- 实现思路
- 将路由导航到 / 断言 filterTodos = 所有的任务
- 将路由导航到 /active 断言 filterTodos = 所有的未完成任务
- 将路由导航到 /completed 断言 filterTodos = 所有的已完成任务
- 参考Vue Test Utils关于伪造
$route和$router的介绍
// TodoApp.js
beforeEach(async () => {
const $route = {
path: '/'
}
wrapper = shallowMount(TodoApp, {
mocks: {
$route
}
});
});
- 对项目进行改造,使用路由
// router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import TodoApp from '@/components/TodoApp/index.vue'
Vue.use(VueRouter)
const routes = [
{
path: '/',
component: TodoApp
},
{
path: '/active',
component: TodoApp
},
{
path: '/completed',
component: TodoApp
},
]
const router = new VueRouter({
routes
})
export default router
// index.vue
<TodoItem
v-for="todo in filterTodos"
:key="todo.id"
:todo="todo"
@delete-todo="handleDeleteTodo"
@edit-todo="handleEditTodo"
/>
<script>
computed: {
// 过滤数据
filterTodos () {
// 获取路由路径
// 根据路由路径过滤数据
const path = this.$route.path;
switch (path) {
// / 所有的任务列表
case '/':
return this.todos;
// /active 所有的未完成任务
case '/active':
return this.todos.filter((todo) => !todo.done);
// /completed 所有的已完成任务
case '/completed':
return this.todos.filter((todo) => todo.done);
default:
return this.todos;
}
},
}
</script>
// TodoApp.js
test('过滤数据', async () => {
// 将路由导航到 /
wrapper.vm.$route.path = '/';
await Vue.nextTick();
// 断言 filterTodos = 所有的任务
expect(wrapper.vm.filterTodos).toEqual([
{id: 1, text: 'song', done: false},
{id: 2, text: 'play', done: true},
{id: 3, text: 'basketball', done: false},
]);
// 将路由导航到 /active
wrapper.vm.$route.path = '/active';
await Vue.nextTick();
// 断言 filterTodos = 所有的未完成任务
expect(wrapper.vm.filterTodos).toEqual([
{id: 1, text: 'song', done: false},
{id: 3, text: 'basketball', done: false},
]);
// 将路由导航到 /completed
wrapper.vm.$route.path = '/completed';
await Vue.nextTick();
// 断言 filterTodos = 所有的已完成任务
expect(wrapper.vm.filterTodos).toEqual([
{id: 2, text: 'play', done: true},
]);
});
TodoFooter导航链接高亮
// TodoFooter.vue
<ul class="filters">
<li>
<!-- <a class="selected" href="#/">All</a> -->
<router-link exact to="/">All</router-link>
</li>
<li>
<!-- <a href="#/active">Active</a> -->
<router-link to="/active">Active</router-link>
</li>
<li>
<!-- <a href="#/completed">Completed</a> -->
<router-link to="/completed">Completed</router-link>
</li>
</ul>
TodoFooter.js
import { createLocalVue, mount } from "@vue/test-utils";
import TodoFooter from '@/components/TodoApp/TodoFooter.vue';
import VueRouter from 'vue-router';
// 创建局部Vue
const localVue = createLocalVue();
localVue.use(VueRouter);
const router = new VueRouter({
linkActiveClass: 'selected'
});
beforeEach(() => {
// 涉及到 样式断言 linkActiveClass: 'selected' 需要深渲染
wrapper = mount(TodoFooter, {
propsData: {
todos
},
localVue,
router
});
});
test('测试导航链接的激活状态', async () => {
// 找到所有导航链接
const links = wrapper.findAllComponents({
name: 'RouterLink'
});
router.push('/active');
await localVue.nextTick();
for (let i = 0; i < links.length; i++) {
// 被wrapper包装过后的组件
const link = links.at(i);
if (link.vm.to === '/active') {
expect(link.classes()).toEqual(["router-link-exact-active", "selected"]);
} else {
expect(link.classes('seleted')).toBeFalsy();
}
}
});
快照测试
- 当项目比较稳定的时候,加上快照测试,及时检测无意间的修改,真的想修改也得二次确认
// TodoHeader.js
test('快照测试-TodoHeader', () => {
expect(wrapper.html()).toMatchSnapshot();
});
- 生成对应的快照
- 生成测试覆盖率统计报告
// package.json
"scripts": {
"coverage": "vue-cli-service test:unit --coverage",
},