使用 TDD 开发组件 --- Notification (上)

4,246 阅读9分钟

前言

本系列是使用 TDD 开发组件库,尝试把 TDD 在前端落地。本系列属于开发日记,包含了我在开发组件过程中的思考和对 TDD 的实施,不会详细的介绍 TDD 是什么,怎么使用。如果你需要了解 TDD 是什么的话,请去看 《测试驱动开发》。要开发的组件全部参照 elementUI,没有任何的开发计划,属于想写哪个组件就写哪个组件。ps 谨慎入坑,本文很长

组件描述

elementUI 关于这个组件详细的文档 传送门

需求分析

首先我们先做的第一件事就是先分析下需求,看看这个组件都有什么功能。把功能都列出来

List

  1. 可以设置弹窗的标题
  2. 可以设置弹窗的内容
  3. 可以设置弹窗是否显示关闭按钮
  4. 点击关闭按钮后,可以关闭弹窗
  5. 可以设置关闭后的回调函数
  6. 可以通过函数调用显示组件

好了,我们第一期的需求暂时是上面的几个。

这个需求列表会不断地扩充,先从简单的功能入手是 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;
}
  1. 我们创建一个 setProp 明确的写出这个操作是更新 prop 的。这里体现了代码的可读性。
  2. 我们把设置属性的操作都放到 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 的前端组件库