前言
本系列是使用 TDD 开发组件库,尝试把 TDD 在前端落地。本系列属于开发日记,包含了我在开发组件过程中的思考和对 TDD 的实施,不会详细的介绍 TDD 是什么,怎么使用。如果你需要了解 TDD 是什么的话,请去看 《测试驱动开发》。要开发的组件全部参照 elementUI,没有任何的开发计划,属于想写哪个组件就写哪个组件。ps 谨慎入坑,本文很长
组件描述
elementUI 关于这个组件详细的文档 传送门
需求分析
首先我们先做的第一件事就是先分析下需求,看看这个组件都有什么功能。把功能都列出来
List
- 可以设置弹窗的标题
- 可以设置弹窗的内容
- 可以设置弹窗是否显示关闭按钮
- 点击关闭按钮后,可以关闭弹窗
- 可以设置关闭后的回调函数
- 可以通过函数调用显示组件
好了,我们第一期的需求暂时是上面的几个。
这个需求列表会不断地扩充,先从简单的功能入手是 TDD 的一个技巧,随着需求不断地被实现,我们将会对功能有更深层次地理解,但是一开始我们并不需要考虑出所有的情况。
功能实现
会基于上面的功能 list 来一个一个的实现对应的功能
可以设置弹窗的标题
这个需求应该是最简单的,我们就从最简单的需求入手。先写下第一个测试
测试
describe("Notification", () => {
it("应该有类名为 wp-button 得 div", () => {
const wrapper = shallowMount(Notification);
const result = wrapper.contains(".wp-notification");
expect(result).toBe(true);
});
});
我们先基于测试驱动出来一个 class 为 wp-notification 的 div, 这里其实是存在质疑的,有没有必要通过测试来驱动出这么小的一个步骤,我这里的策略是先使用测试驱动出来,在最后的时候,使用快照功能,然后就可以把这个测试删除掉了。后续可以看到编写快照测试的逻辑。
写逻辑使其测试通过
逻辑实现
<template>
<div class="wp-notification">
</div>
</template>
接着写下第二个测试,这个测试就是真真正正的设置弹窗显示的标题了
测试
describe("props", () => {
it("title - 可以通过 title 设置标题", () => {
const wrapper = shallowMount(Notification, {
propsData: {
title: "test"
}
});
const titleContainer = wrapper.find(".wp-notification__title");
expect(titleContainer.text()).toBe("test");
});
});
首先我们通过设置属性 title 来控制显示的 title,接着我们断言有一个叫做 .wp-notification__title 的 div,它的 text 内容等于我们通过属性传入的值。
写逻辑使其测试通过
逻辑实现
<template>
<div class="wp-notification">
<div class="wp-notification__title">
{{title}}
</div>
</div>
</template>
export default {
props:{
title:{
type:String,
default:""
}
}
}
好了,我们接下来要如法炮制的把内容、关闭按钮、驱动出来。下面我会直接贴代码
设置弹窗显示的内容
测试
it("message - 可以通过 message 设置说明文字", () => {
const message = "这是一段说明文字";
const wrapper = shallowMount(Notification, {
propsData: {
message
}
});
const container = wrapper.find(".wp-notification__message");
expect(container.text()).toBe(message);
});
逻辑实现
<div class="wp-notification__message">
{{ message }}
</div
props: {
title: {
type: String,
default: ''
},
message: {
type: String,
default: ''
},
showClose: {
type: Boolean,
default: true
}
},
showClose - 可以设置弹窗是否显示关闭按钮
测试
it("showClose - 控制显示按钮", () => {
// 默认显示按钮
const wrapper = shallowMount(Notification);
const btnSelector = ".wp-notification__close-button";
expect(wrapper.contains(btnSelector)).toBe(true);
wrapper.setProps({
showClose: false
});
expect(wrapper.contains(btnSelector)).toBe(false);
});
逻辑实现
<button
v-if="showClose"
class="wp-notification__close-button"
></button>
props: {
title: {
type: String,
default: ''
},
message: {
type: String,
default: ''
},
showClose: {
type: Boolean,
default: true
}
},
可以设置关闭后的回调函数
测试
it("点击关闭按钮后,应该调用传入的 onClose ", () => {
const onClose = jest.fn();
const btnSelector = ".wp-notification__close-button";
const wrapper = shallowMount(Notification, {
propsData: {
onClose
}
});
wrapper.find(btnSelector).trigger("click");
expect(onClose).toBeCalledTimes(1);
});
我们期望的是点击关闭按钮的时候,会调用传入的 onClose 函数
逻辑实现
<button
v-if="showClose"
class="wp-notification__close-button"
@click="onCloseHandler"
></button>
我们先给 button 添加一个 click 处理
props: {
onClose: {
type: Function,
default: () => {}
}
},
在添加一个 onClose ,接着在点击关闭按钮后调用即可。
methods: {
onCloseHandler() {
this.onClose();
}
}
可以通过函数调用显示组件
测试
it("notify() 调用后会把 notification 添加到 body 内", () => {
notify();
const body = document.querySelector("body");
expect(body.querySelector(".wp-notification")).toBeTruthy();
})
我们检测 body 内部是否能查找到 notification 作为判断条件。
ps: jest 内置了 jsdom ,所以可以在测试得时候使用 document 等浏览器 api
逻辑实现
新建一个 index.js
// notification/index.js
import Notification from "./Notification.vue";
import Vue from "vue";
export function notify() {
const NotificationClass = Vue.extend(Notification);
const container = document.createElement("div");
document.querySelector("body").appendChild(container);
return new NotificationClass({
el: container
});
}
window.test = notify;
这时候会发现一个问题,我们创建得 Notification 不是通过 vue-test-utils 创建的,我们没有办法向上面一样通过 mound 创建一个 wrapper 来快速得验证组件得结果了。我们需要想办法依然借助 vue-test-utils 来快速验证由 notify() 创建出来得 notification 组件。
在查阅 vue-test-utils 时我发现了一个方法: createWrapper(), 通过这个我们就可以创建出来 wrapper 对象。
我们写个测试来测试一下: 通过 notify 设置组件得 title
测试
it("设置 title ", () => {
const notification = notify({ title: "test" });
const wrapper = createWrapper(notification);
const titleContainer = wrapper.find(".wp-notification__title");
expect(titleContainer.text()).toBe("test");
});
我们通过 createWrapper 创建 wrapper 对象,接着向我们之前测试 title 一样来测试结果
逻辑实现
import Notification from "./Notification.vue";
import Vue from "vue";
export function notify(options = {}) {
const container = document.createElement("div");
document.querySelector("body").appendChild(container);
return createNotification(container, options);
}
function createNotification(el, options) {
const NotificationClass = Vue.extend(Notification);
const notification = new NotificationClass({ el });
notification.title = options.title;
return notification;
}
重构
注意我把之前创建得 vue 组件得逻辑封装到了 createNotification 内了 (随着逻辑得增加要不断得重构,保持代码得可读性,TDD 得最后一个步骤就是重构)
这里我是硬编码让 options.title 赋值给 notification.title 得。
还有一种方式是通过 Object.assign() 的方式动态的赋值传过来的所有属性,但是缺点是代码阅读性很差,当我需要查看 title 属性哪里被赋值的时候,搜索代码根本就找不到。所以我这里放弃了这种动态的写法。
至此,我们这个测试也就通过了。
其中还有一个疑问,我们之前已经有测试来保障设置 title 是正确的逻辑了,这里还有必要再重写一遍嘛? 我给出的答案这里是需要的,因为通过 notify() 也是暴漏给用户的 api,我们需要验证其结果是否是正确的。只不过如果后面我们用不到通过组件的 props 来传值实现的话,那么我们可以删除掉之前的测试。我们需要保证测试的唯一性,不能重复。也就是说通过测试驱动出来的测试也是允许被删除掉的。
继续我们把 message showClose 的测试和实现都补齐
通过 notify 设置 message
测试
it("设置 message ", () => {
const message = "this is a message";
const wrapper = wrapNotify({ message });
const titleContainer = wrapper.find(".wp-notification__message");
expect(titleContainer.text()).toBe(message);
});
重构
解释下: wrapNotify(): 我们会发现每次都需要调用 createWrapper() 来创建出对应的 wrapper 对象,为了方便后续的调用,不如直接封装一个函数。
function wrapNotify(options) {
const notification = notify(options);
return createWrapper(notification);
}
这其实就是重构,每次写完了测试和逻辑之后,我们都需要停下来看一看,是不是需要重构了。要注意的是测试代码也是需要维护的,所以我们要保持代码的可读性、可维护性等。
逻辑实现
function createNotification(el, options) {
const NotificationClass = Vue.extend(Notification);
const notification = new NotificationClass({ el });
notification.title = options.title;
notification.message = options.message;
return notification;
}
通过 notify 设置 showClose
测试
it("设置 showClose", () => {
const wrapper = wrapNotify({ showClose: false });
const btnSelector = ".wp-notification__close-button";
expect(wrapper.contains(btnSelector)).toBe(false);
});
逻辑实现
function createNotification(el, options) {
const NotificationClass = Vue.extend(Notification);
const notification = new NotificationClass({ el });
notification.title = options.title;
notification.message = options.message;
notification.showClose = options.showClose;
return notification;
}
重构
好了,这时候我们需要停下来看看是否需要重构了。
测试部分的代码我认为暂时还好,可以不需要重构,但是我们看业务代码
// createNotification() 函数
notification.title = options.title;
notification.message = options.message;
notification.showClose = options.showClose;
当初我们是为了可读性才一个一个的写出来,但是这里随着需求逻辑的扩展,慢慢出现了坏的味道。我们需要重构它
function createNotification(el, options) {
const NotificationClass = Vue.extend(Notification);
const notification = new NotificationClass({ el });
updateProps(notification, options);
return notification;
}
function updateProps(notification, options) {
setProp("title", options.title);
setProp("message", options.message);
setProp("showClose", options.showClose);
}
function setProp(notification, key, val) {
notification[key] = val;
}
- 我们创建一个 setProp 明确的写出这个操作是更新 prop 的。这里体现了代码的可读性。
- 我们把设置属性的操作都放到 updateProps 内,让职责单一
这时候我们需要跑下单侧,看看这次的重构是否破坏了之前的逻辑(这很重要!)
我们再仔细地看看代码,又发现了一个问题,为什么我们需要再 createNotification() 里面更新属性呢,这样就违反了职责单一呀。
// index.js
export function notify(options = {}) {
const container = document.createElement("div");
document.querySelector("body").appendChild(container);
const notification = createNotification(container, options);
updateProps(notification, options);
return notification;
}
function createNotification(el) {
const NotificationClass = Vue.extend(Notification);
const notification = new NotificationClass({ el });
return notification;
}
重构后的代码,我们把 updateProps() 提到了 notify() 内,createNotification() 只负责创建组件就好了。
跑测试(重要!)
接着我们再看看代码还有没有要重构的部分了。
const container = document.createElement("div");
document.querySelector("body").appendChild(container);
嗯,我们又发现了,这其实可以放到一个函数中。
function createContainerAndAppendToView() {
const container = document.createElement("div");
document.querySelector("body").appendChild(container);
return container;
}
嗯,这样我们通过函数名就可以很明确的知道它的职责了。
在看看重构后的 notify()
export function notify(options = {}) {
const container = createContainerAndAppendToView();
const notification = createNotification(container);
updateProps(notification, options);
return notification;
}
跑下测试(重要!)
好了,暂时看起来代码结构还不错。我们又可以愉快的继续写下面的需求了
通过 notify 设置 onClose --> 关闭时的回调函数
测试
it("should onClose --> 关闭时的回调函数,关闭后应该调用回调函数", () => {
const onClose = jest.fn();
const wrapper = wrapNotify({ onClose });
const btnSelector = ".wp-notification__close-button";
wrapper.find(btnSelector).trigger("click");
expect(onClose).toBeCalledTimes(1);
});
逻辑实现
测试写完后,会报错,提示 btn 找不到,这是为什么呢??? 可以思考一下
首先我们要思考的是,什么会影响到 btn 找不到,只有一个影响点,那就是 options.showClose 这个属性,只有它为 false 的时候,按钮才不会显示。我们在 Notification.vue 内不是写了 showClose 的默认值为 true 嘛,为什么这里是 false 呢? 问题其实出在我们传给 notify 的 options, 在我们赋值 setProp 时,options 肯定是没有 showClose 的。所以我们需要给 options 一个默认值。
export function notify(options = {}) {
const container = createContainerAndAppendToView();
const notification = createNotification(container);
updateProps(notification, mergeOptions(options));
return notification;
}
function mergeOptions(options) {
return Object.assign({}, createDefaultOptions(), options);
}
function createDefaultOptions() {
return {
showClose: true
};
}
新增了 mergeOptions() 和 createDefaultOptions() 两个函数,这里特意说明一下为什么要用 createDefaultOptions 生成对象,而不是使用 const 直接在最外层定义一个配置对象。首先我们知道 const 是不能阻止修改对象内部的属性值得。每次都创建一个全新得对象,就是为了保证这个对象是不可变的(immutable)。
好了,补齐了上面的逻辑后,测试应该只会抱怨 onClose 没有并调用了。
function updateProps(notification, options) {
setProp(notification, "title", options.title);
setProp(notification, "message", options.message);
setProp(notification, "showClose", options.showClose);
// 新增
setProp(notification, "onClose", options.onClose);
}
下集
使用 TDD 开发组件 --- Notification (下)
github
仓库代码 传送门
最后求个 star ~~
- 这是我们团队的开源项目 element3
- 一个支持 vue3 的前端组件库