Vue中的TDD与单元测试
一、什么是TDD
1.TDD开发流程
- 编写测试用例
- 运行测试,测试用例无法通过测试
- 编写代码,使测试用例通过测试
- 优化代码,完成开发
- 重复上述步骤
2.TDD的优势
- 长期减少回归bug
- 代码质量更好(阻止,可维护性)
- 测试覆盖率高
- 错误测试代码不容易出现
二、Vue环境中配置Jest
安装vue脚手架工具:
vue create jest-vue
安装成功之后项目目录如下:
不同版本项目的jest配置文件可能不太一样,目录也可能大同小异,不过影响不大。
运行npm run serve:
输入npm run test:unit
按a:
三、vue-test-utils的配置和使用
新建一个分支:
对没用的文件资源进行清理(这个可自行清理)。
去test/unit文件夹下:
修改一下文件夹名称和文件名字:
此时需要修改一下jest.config.js配置文件:
我们可以看看HelloWorld.test.js中内容:
注意:在刚开始安装vue脚手架时候,因为选择了单元测试,所以自带了vue-test-utils组件。
关于vue-test-utils中的具体细节可以查看官网。
我们给HelloWorld组件加一个类名:
然后在HelloWorld.test.js中增加测试代码:
四、使用TDD的方式开发Header组件
新建dev2分支:
假如我们想要做一个todolist组件。
我们做一些准备,项目目录如下:
接下了我们要进行TDD开发:
先要实现TodoList中的header。现在TodoList文件夹下新建components文件夹。然后新建Header.vue组件:
TDD的思想是先写测试用例后开发。先新加Header.js文件:
我们先分析header组件,然后编写测试用例。
首先Header应该有个input框:
import { shallowMount } from "@vue/test-utils";
import Header from "../../components/Header.vue";
it("Header 包含 input框:", () => {
const wrapper = shallowMount(Header);
const input = wrapper.find('[data-test="input"]');
expect(input.exists()).toBe(true);
});
这时候运行测试脚本肯定不通过。因为还没开始开发:
修改Header.vue代码:
这时候再运行就通过了。
以此类推,我们可以编写一堆关于Header组件的测试用例:
import { shallowMount } from "@vue/test-utils";
import Header from "../../components/Header.vue";
it("Header 包含 input框:", () => {
const wrapper = shallowMount(Header);
const input = wrapper.find('[data-test="input"]');
expect(input.exists()).toBe(true);
});
it("Header input框初始值内容为空", () => {
const wrapper = shallowMount(Header);
const inputValue = wrapper.vm.$data.inputValue;
expect(inputValue).toBe("");
});
it("Header input框值发生变化,数据应该跟着变", () => {
const wrapper = shallowMount(Header);
const input = wrapper.find('[data-test="input"]');
input.setValue("TEST");
const inputValue = wrapper.vm.$data.inputValue;
expect(inputValue).toBe("TEST");
});
it("Header input框输入回车,无内容时无反应", () => {
const wrapper = shallowMount(Header);
const input = wrapper.find('[data-test="input"]');
input.setValue("");
input.trigger("keyup.enter");
expect(wrapper.emitted().add).toBeFalsy();
});
it("Header input框输入回车,有内容时向外触发事件", () => {
const wrapper = shallowMount(Header);
const input = wrapper.find('[data-test="input"]');
input.setValue("TEST");
input.trigger("keyup.enter");
expect(wrapper.emitted().add).toBeTruthy();
});
it("Header input框输入回车,有内容时向外触发事件,同时清空inputValue", () => {
const wrapper = shallowMount(Header);
const input = wrapper.find('[data-test="input"]');
input.setValue("TEST");
input.trigger("keyup.enter");
expect(wrapper.emitted().add).toBeTruthy();
expect(wrapper.vm.$data.inputValue).toBe("");
});
然后依照Header的测试用例去修改Header组件:
<template>
<div>
<input data-test="input" v-model="inputValue" @keyup.enter="addTodoItem" />
</div>
</template>
<script>
export default {
name: "Header",
data() {
return {
inputValue: "",
};
},
methods: {
addTodoItem() {
if (this.inputValue) {
this.$emit("add", this.inputValue);
this.inputValue = "";
}
},
},
};
</script>
<style scoped lang="stylus"></style>
这就是TDD(测试驱动开发)。
我们接着对Todolist写测试用例:
import { shallowMount } from "@vue/test-utils";
import TodoList from "../../TodoList.vue";
import Header from "../../components/Header.vue";
it("TodoList 初始化时候,undoList应该为空", () => {
const wrapper = shallowMount(TodoList);
const undoList = wrapper.vm.$data.undoList;
expect(undoList).toEqual([]);
});
it("TodoList 监听到Header的add事件时候,会增加一个条目", () => {
const testContent = "test";
const wrapper = shallowMount(TodoList);
const header = wrapper.findComponent(Header);
header.vm.$emit("add", testContent);
const undoList = wrapper.vm.$data.undoList;
expect(undoList).toEqual([testContent]);
});
按照todolist测试用例来写todolist组件:
<template>
<div class="hello">
<Header @add="addUndoItem" />
<ul>
<li v-for="item in undoList" :key="item">{{ item }}</li>
</ul>
</div>
</template>
<script>
import Header from "./components/Header.vue";
export default {
name: "TodoList",
components: { Header },
data() {
return {
undoList: [],
};
},
methods: {
addUndoItem(inputValue) {
this.undoList.push(inputValue);
},
},
};
</script>
<style scoped lang="stylus"></style>
五、Header快照测试
先修改一下Header组件的样式:
Header.vue:
<template>
<div class="header">
<div class="header-content">
TodoList
<input
class="header-input"
data-test="input"
v-model="inputValue"
@keyup.enter="addTodoItem"
placeholder="TodoItem"
/>
</div>
</div>
</template>
<script>
export default {
name: "Header",
data() {
return {
inputValue: "",
};
},
methods: {
addTodoItem() {
if (this.inputValue) {
this.$emit("add", this.inputValue);
this.inputValue = "";
}
},
},
};
</script>
<style scoped lang="stylus">
.header{
line-height:60px;
height:60px;
background: #333;
}
.header-content{
width 600px;
margin:0 auto;
color:#fff;
font-size:24px;
}
.header-input{
float:right;
width 360px;
margin-top:16px;
line-height:24px;
color:#333;
outline:none;
text-indent:10px;
}
</style>
接下来我们来测试一下Header的样式快照:
it("Header 样式是否修改:", () => {
const wrapper = shallowMount(Header);
expect(wrapper).toMatchSnapshot();
});
运行npm run test:unit会生成该组件的样式快照:
这时候如果我们修改header组件的样式,测试会报错:
如果接受UI样式的变化,可以按u键。
六、通用代码提取
我们发现有些代码是重复的:
我们可以把它提取到一个公共的文件夹中:
七、实现undolist
先编写undoList测试用例
import { shallowMount } from "@vue/test-utils";
import { findTestWrapper } from "../../../../utils/testUtils";
import UndoList from "../../components/UndoList.vue";
it("UndoList 参数为[],count的值应该为0, 且列表无内容", () => {
const wrapper = shallowMount(UndoList, {
propsData: { list: [] },
});
const countElem = findTestWrapper(wrapper, "count");
const listItems = findTestWrapper(wrapper, "item");
expect(countElem.at(0).text()).toEqual("0");
expect(listItems.length).toEqual(0);
});
it("UndoList 参数为[1,2,3],count的值应该为3, 且列表有内容,且存在删除按钮", () => {
const wrapper = shallowMount(UndoList, {
propsData: { list: [1, 2, 3] },
});
const countElem = findTestWrapper(wrapper, "count");
const listItems = findTestWrapper(wrapper, "item");
const deleteBtns = findTestWrapper(wrapper, "delete-btn");
expect(countElem.at(0).text()).toEqual("3");
expect(listItems.length).toEqual(3);
expect(deleteBtns.length).toEqual(3);
});
it("UndoList 删除按钮被点击时候,向外触发删除时间", () => {
const wrapper = shallowMount(UndoList, {
propsData: { list: [1, 2, 3] },
});
const deleteBtn = findTestWrapper(wrapper, "delete-btn").at(1);
deleteBtn.trigger("click");
expect(wrapper.emitted().delete).toBeTruthy();
expect(wrapper.emitted().delete[0][0]).toBe(1);
});
按照undolist测试用例编写组件:
<template>
<div>
<div data-test="count">{{ list.length }}</div>
<ul>
<li v-for="(item, index) in list" :key="index" data-test="item">
{{ item }}
<span
data-test="delete-btn"
@click="
() => {
handleDelete(index);
}
"
>-</span
>
</li>
</ul>
</div>
</template>
<script>
export default {
name: "UndoList",
props: ["list"],
methods: {
handleDelete(index) {
this.$emit("delete", index);
},
},
};
</script>
<style scoped lang="stylus"></style>
继续写TodoList测试用例:
import { shallowMount } from "@vue/test-utils";
import TodoList from "../../TodoList.vue";
import Header from "../../components/Header.vue";
import Undolist from "../../components/UndoList.vue";
it("TodoList 初始化时候,undoList应该为空", () => {
const wrapper = shallowMount(TodoList);
const undoList = wrapper.vm.$data.undoList;
expect(undoList).toEqual([]);
});
it("TodoList 监听到Header的add事件时候,会增加一个条目", () => {
const testContent = "test";
const wrapper = shallowMount(TodoList);
const header = wrapper.findComponent(Header);
header.vm.$emit("add", testContent);
const undoList = wrapper.vm.$data.undoList;
expect(undoList).toEqual([testContent]);
});
it("TodoList 调用UndoList,应该传递list函数", () => {
const wrapper = shallowMount(TodoList);
const undoList = wrapper.findComponent(Undolist);
const list = undoList.props("list");
expect(list).toBeTruthy();
});
it("TodoList 调用handleItemDelete时候,UndoList列表内容会减少一个", () => {
const wrapper = shallowMount(TodoList);
wrapper.setData({
undoList: [1, 2, 3],
});
wrapper.vm.handleItemDelete(1);
expect(wrapper.vm.$data.undoList).toEqual([1, 3]);
});
修改todoList组件:
<template>
<div class="hello">
<Header @add="addUndoItem" />
<UndoList :list="undoList" @delete="handleItemDelete" />
</div>
</template>
<script>
import Header from "./components/Header.vue";
import UndoList from "./components/UndoList.vue";
export default {
name: "TodoList",
components: { Header, UndoList },
data() {
return {
undoList: [],
};
},
methods: {
addUndoItem(inputValue) {
this.undoList.push(inputValue);
},
handleItemDelete(index) {
this.undoList.splice(index, 1);
},
},
};
</script>
<style scoped lang="stylus"></style>
我们来看下面一个问题:
上面这个测试本来一个是todolist的单元测试,但它实际上确实一个集成测试,因为它有Header组件的交互。这涉及到两个模块的联动。所以我们需要进行改造:
it("TodoList 监听到Header的add事件时候,会增加一个条目", () => {
// const testContent = "test";
// const wrapper = shallowMount(TodoList);
// const header = wrapper.findComponent(Header);
// header.vm.$emit("add", testContent);
// const undoList = wrapper.vm.$data.undoList;
// expect(undoList).toEqual([testContent]);
const wrapper = shallowMount(TodoList);
wrapper.setData({
undoList: [1, 2, 3],
});
wrapper.vm.addUndoItem(5);
expect(wrapper.vm.$data.undoList).toEqual([1, 2, 3, 5]);
});
我们接下来实现todolist的编辑功能。同时优化一下代码。
八、完整代码
我们可以先看看项目的目录结构:
Header.js:
import { shallowMount } from "@vue/test-utils";
import { findTestWrapper } from "../../../../utils/testUtils";
import Header from "../../components/Header.vue";
describe("Header 组件", () => {
it("样式快照:", () => {
const wrapper = shallowMount(Header);
expect(wrapper).toMatchSnapshot();
});
it("包含 input框:", () => {
const wrapper = shallowMount(Header);
const input = findTestWrapper(wrapper, "input");
expect(input.exists()).toBe(true);
});
it("input框初始值内容为空", () => {
const wrapper = shallowMount(Header);
const inputValue = wrapper.vm.$data.inputValue;
expect(inputValue).toBe("");
});
it("input框值发生变化,数据应该跟着变", () => {
const wrapper = shallowMount(Header);
const input = findTestWrapper(wrapper, "input");
input.setValue("TEST");
const inputValue = wrapper.vm.$data.inputValue;
expect(inputValue).toBe("TEST");
});
it("input框输入回车,无内容时无反应", () => {
const wrapper = shallowMount(Header);
const input = findTestWrapper(wrapper, "input");
input.setValue("");
input.trigger("keyup.enter");
expect(wrapper.emitted().add).toBeFalsy();
});
it("input框输入回车,有内容时向外触发事件", () => {
const wrapper = shallowMount(Header);
const input = findTestWrapper(wrapper, "input");
input.setValue("TEST");
input.trigger("keyup.enter");
expect(wrapper.emitted().add).toBeTruthy();
});
it("input框输入回车,有内容时向外触发事件,同时清空inputValue", () => {
const wrapper = shallowMount(Header);
const input = findTestWrapper(wrapper, "input");
input.setValue("TEST");
input.trigger("keyup.enter");
expect(wrapper.emitted().add).toBeTruthy();
expect(wrapper.vm.$data.inputValue).toBe("");
});
});
Header.vue:
<template>
<div class="header">
<div class="header-content">
TodoList
<input
class="header-input"
data-test="input"
v-model="inputValue"
@keyup.enter="addTodoItem"
placeholder="ADD TodoItem"
/>
</div>
</div>
</template>
<script>
export default {
name: "Header",
data() {
return {
inputValue: "",
};
},
methods: {
addTodoItem() {
if (this.inputValue) {
this.$emit("add", this.inputValue);
this.inputValue = "";
}
},
},
};
</script>
<style scoped lang="stylus">
.header{
line-height:60px;
height:60px;
background: #333;
}
.header-content{
width 600px;
margin:0 auto;
color:#fff;
font-size:24px;
}
.header-input{
float:right;
width 360px;
margin-top:16px;
line-height:24px;
color:#333;
outline:none;
text-indent:10px;
}
</style>
UndoList.js:
import { shallowMount } from "@vue/test-utils";
import { findTestWrapper } from "../../../../utils/testUtils";
import UndoList from "../../components/Undolist.vue";
describe("UndoList 组件", () => {
it("参数为[],count的值应该为0, 且列表无内容", () => {
const wrapper = shallowMount(UndoList, {
propsData: { list: [] },
});
const countElem = findTestWrapper(wrapper, "count");
const listItems = findTestWrapper(wrapper, "item");
expect(countElem.at(0).text()).toEqual("0");
expect(listItems.length).toEqual(0);
});
it("参数为[1,2,3],count的值应该为3, 且列表有内容,且存在删除按钮", () => {
const wrapper = shallowMount(UndoList, {
propsData: {
list: [
{ status: "div", value: 1 },
{ status: "div", value: 2 },
{ status: "div", value: 3 },
],
},
});
const countElem = findTestWrapper(wrapper, "count");
const listItems = findTestWrapper(wrapper, "item");
const deleteBtns = findTestWrapper(wrapper, "delete-btn");
expect(countElem.at(0).text()).toEqual("3");
expect(listItems.length).toEqual(3);
expect(deleteBtns.length).toEqual(3);
});
it("删除按钮被点击时候,向外触发删除事件", () => {
const wrapper = shallowMount(UndoList, {
propsData: {
list: [
{ status: "div", value: 1 },
{ status: "div", value: 2 },
{ status: "div", value: 3 },
],
},
});
const deleteBtn = findTestWrapper(wrapper, "delete-btn").at(1);
deleteBtn.trigger("click");
expect(wrapper.emitted().delete).toBeTruthy();
expect(wrapper.emitted().delete[0][0]).toBe(1);
});
it("列表项被点击时候,向外触发status事件", () => {
const wrapper = shallowMount(UndoList, {
propsData: {
list: [
{ status: "div", value: 1 },
{ status: "div", value: 2 },
{ status: "div", value: 3 },
],
},
});
const currentBtn = findTestWrapper(wrapper, "item").at(1);
currentBtn.trigger("click");
expect(wrapper.emitted().status).toBeTruthy();
expect(wrapper.emitted().status[0][0]).toBe(1);
});
it("列表项显示一个输入框,两个正常列表内容", () => {
const wrapper = shallowMount(UndoList, {
propsData: {
list: [
{ status: "div", value: 1 },
{ status: "input", value: 2 },
{ status: "div", value: 3 },
],
},
});
const input = findTestWrapper(wrapper, "input");
expect(input.at(0).element.value).toBe("2");
expect(input.length).toBe(1);
});
it("input失去焦点时候,向外触发reset事件", () => {
const wrapper = shallowMount(UndoList, {
propsData: {
list: [
{ status: "div", value: 1 },
{ status: "input", value: 2 },
{ status: "div", value: 3 },
],
},
});
const inputElem = findTestWrapper(wrapper, "input").at(0);
inputElem.trigger("blur");
expect(wrapper.emitted().reset).toBeTruthy();
});
it("input变化时候,向外触发change事件", () => {
const wrapper = shallowMount(UndoList, {
propsData: {
list: [
{ status: "div", value: 1 },
{ status: "input", value: 123 },
{ status: "div", value: 3 },
],
},
});
const inputElem = findTestWrapper(wrapper, "input").at(0);
inputElem.trigger("change");
expect(wrapper.emitted().change).toBeTruthy();
console.log(wrapper.emitted().change);
expect(wrapper.emitted().change[0][0]).toEqual({
value: "123",
index: 1,
});
});
});
UndoList.vue:
<template>
<div class="undolist">
<div class="title">
正在进行
<span data-test="count" class="count">{{ list.length }}</span>
</div>
<ul class="list">
<li
v-for="(item, index) in list"
:key="index"
data-test="item"
@click="() => changeStatus(index)"
class="item"
>
<input
data-test="input"
:value="item.value"
v-if="item.status === 'input'"
@blur="handleInputBlur"
@change="(e) => handleInputChange(e.target.value, index)"
/>
<span v-else> {{ item.value }}</span>
<span
data-test="delete-btn"
@click="
() => {
handleDelete(index);
}
"
class="delete"
>-</span
>
</li>
</ul>
</div>
</template>
<script>
export default {
name: "UndoList",
props: ["list"],
methods: {
handleDelete(index) {
this.$emit("delete", index);
},
changeStatus(index) {
this.$emit("status", index);
},
handleInputBlur() {
this.$emit("reset");
},
handleInputChange(value, index) {
this.$emit("change", {
value,
index,
});
},
},
};
</script>
<style scoped lang="stylus">
.undolist{
width :600px;
margin: 0 auto;
}
.title{
margin:10px 0px;
line-height:30px;
font-size:24px;
font-weight:bold;
}
.count{
margin-top:5px;
float:right;
display:block;
width:20px;
height:20px;
line-height:20px;
text-align:center;
background:#e6e6e6;
border-radius:10px;
font-size:12px;
color:#000;
}
.list{
list-style-type:none;
}
.item{
margin-bottom:10px
line-height:32px;
border-radius:3px;
border-left:5px solid #629A9A;
font-size:14px;
background:#fff;
text-indent:10px;
}
.delete{
margin-top:5px;
float:right;
display:block;
width:20px;
height:20px;
line-height:20px;
text-align:center;
background:#e6e6e6;
border-radius:10px;
font-size:12px;
color:#000;
text-indent:0px;
margin-right:10px;
cursor:pointer
}
</style>
TodoList.js:
import { shallowMount } from "@vue/test-utils";
import TodoList from "../../TodoList.vue";
import Header from "../../components/Header.vue";
import Undolist from "../../components/UndoList.vue";
describe("TodoList 组件", () => {
it("初始化时候,undoList应该为空", () => {
const wrapper = shallowMount(TodoList);
const undoList = wrapper.vm.$data.undoList;
expect(undoList).toEqual([]);
});
it("监听到Header的add事件时候,会增加一个条目", () => {
// const testContent = "test";
// const wrapper = shallowMount(TodoList);
// const header = wrapper.findComponent(Header);
// header.vm.$emit("add", testContent);
// const undoList = wrapper.vm.$data.undoList;
// expect(undoList).toEqual([testContent]);
const wrapper = shallowMount(TodoList);
wrapper.setData({
undoList: [
{ status: "div", value: 1 },
{ status: "div", value: 2 },
{ status: "div", value: 3 },
],
});
wrapper.vm.addUndoItem(5);
expect(wrapper.vm.$data.undoList).toEqual([
{ status: "div", value: 1 },
{ status: "div", value: 2 },
{ status: "div", value: 3 },
{ status: "div", value: 5 },
]);
});
it("调用UndoList,应该传递list函数", () => {
const wrapper = shallowMount(TodoList);
const undoList = wrapper.findComponent(Undolist);
const list = undoList.props("list");
expect(list).toBeTruthy();
});
it("调用handleItemDelete时候,UndoList列表内容会减少一个", () => {
const wrapper = shallowMount(TodoList);
wrapper.setData({
undoList: [
{ status: "div", value: 1 },
{ status: "div", value: 2 },
{ status: "div", value: 3 },
],
});
wrapper.vm.handleItemDelete(1);
expect(wrapper.vm.$data.undoList).toEqual([
{ status: "div", value: 1 },
{ status: "div", value: 3 },
]);
});
it("changeStatus被调用时候,UndoList列表内容变化", () => {
const wrapper = shallowMount(TodoList);
wrapper.setData({
undoList: [
{ status: "div", value: 1 },
{ status: "div", value: 2 },
{ status: "div", value: 3 },
],
});
wrapper.vm.changeStatus(1);
expect(wrapper.vm.$data.undoList).toEqual([
{ status: "div", value: 1 },
{ status: "input", value: 2 },
{ status: "div", value: 3 },
]);
});
it("resetStatus被调用时候,UndoList列表内容变化", () => {
const wrapper = shallowMount(TodoList);
wrapper.setData({
undoList: [
{ status: "div", value: 1 },
{ status: "input", value: 2 },
{ status: "div", value: 3 },
],
});
wrapper.vm.resetStatus();
expect(wrapper.vm.$data.undoList).toEqual([
{ status: "div", value: 1 },
{ status: "div", value: 2 },
{ status: "div", value: 3 },
]);
});
it("changeItemValue被调用时候,UndoList列表内容变化", () => {
const wrapper = shallowMount(TodoList);
wrapper.setData({
undoList: [
{ status: "div", value: 1 },
{ status: "input", value: 2 },
{ status: "div", value: 3 },
],
});
wrapper.vm.changeItemValue({ value: "444", index: 1 });
expect(wrapper.vm.$data.undoList).toEqual([
{ status: "div", value: 1 },
{ status: "input", value: "444" },
{ status: "div", value: 3 },
]);
});
});
TodoList.vue:
<template>
<div class="hello">
<Header @add="addUndoItem" />
<UndoList
:list="undoList"
@delete="handleItemDelete"
@status="changeStatus"
@reset="resetStatus"
@change="changeItemValue"
/>
</div>
</template>
<script>
import Header from "./components/Header.vue";
import UndoList from "./components/UndoList.vue";
export default {
name: "TodoList",
components: { Header, UndoList },
data() {
return {
undoList: [],
};
},
methods: {
addUndoItem(inputValue) {
this.undoList.push({
status: "div",
value: inputValue,
});
},
handleItemDelete(index) {
this.undoList.splice(index, 1);
},
changeStatus(index) {
let newList = [];
this.undoList.forEach((item, itemIndex) => {
if (itemIndex === index) {
newList.push({
status: "input",
value: item.value,
});
} else {
newList.push({
status: "div",
value: item.value,
});
}
});
this.undoList = newList;
},
resetStatus() {
let newList = [];
this.undoList.forEach((item, itemIndex) => {
newList.push({
status: "div",
value: item.value,
});
});
this.undoList = newList;
},
changeItemValue(obj) {
this.undoList[obj.index].value = obj.value;
},
},
};
</script>
<style scoped lang="stylus"></style>
这样子一个简单的todolist组件就完成了:
我们从上面的代码会发现,开发业务组件时候使用单元测试结合TDD方法并不好。每次修改时候都要先修改测试用例再修改组件,耦合度太高。对于纯函数,类似工具库的开发可以使用单元测试+TDD方法。
九、CodeCoverage代码覆盖率
打开vue-test-utils教程文档https://v1.test-utils.vuejs.org/zh/installation/#用-jest-测试单文件组件,复制以下代码:
修改去掉js,复制到下面文件:
增加脚本:
运行npm run test:cov
会生成一个coverage文件夹:
打开index.html网页:
十、总结
ps:TDD和单元测试不是一个概念。二者可以结合使用,TDD也可以和集成测试结合。
如果我们开发函数库,可以用单元测试结合TDD。但如果是开发业务组件,那就不适合用单元测试+TDD了。