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

2,761 阅读17分钟

前言

为了担心有的小伙伴太长不看,我分成了上下两篇(不知道下篇能不能写完)

如果有小伙伴认认真真的跟着上篇实现了一遍代码的话,你会发现,这些逻辑都是纯 js 的,暂时还都没有涉及到 css,甚至我到目前为止都没有写过 css,也没有刷新过浏览器,通过测试就知道了逻辑是否正确(这也是用 TDD 后为什么会增加开发效率的原因)。当然了当所有的 js 逻辑都搞定后,我们需要在一点点的写 style,在调整对应的 html 结构。style 这部分是不值得测试的。

需求分析

和上篇一样,我们先把剩下的需求列出来。其实我是直接按照 elementUI 的 api 直接 copy 过来的(逃)。

list

  1. onClick 点击 Notification 时的回调函数
  2. duration 显示时间, 毫秒。设为 0 则不会自动关闭
  3. 显示的位置

以上就是这个组件的核心需求点了。剩下的需求任务交给你吧!

功能实现

onClick 点击 Notification 时的回调函数

这个很简单 直接上测试

测试

    it("onClick --> 点击 Notification 时的回调函数,点击 Notification 应该触发回调函数", () => {
      const onClick = jest.fn();
      const wrapper = wrapNotify({ onClick });
      const selector = ".wp-notification";
      wrapper.find(selector).trigger("click");
      expect(onClick).toBeCalledTimes(1);
    });

代码实现

// Notification.vue

<template>
  <div class="wp-notification" @click="onClickHandler">
    <div class="wp-notification__title">
      {{ title }}
    </div>
    
    ……
    
    export default {
      props: {
        onClick: {
          type: Function,
          default: () => {}
         }
    }
    
    ……
    
      methods: {
        onClickHandler() {
          this.onClick();
        }
  }
// index.js

function updateProps(notification, options) {
    setProp(notification, "title", options.title);
    setProp(notification, "message", options.message);
    setProp(notification, "showClose", options.showClose);
    setProp(notification, "onClose", options.onClose);
    setProp(notification, "onClick", options.onClick);
}

执行 npm run test:unit

[Vue warn]: Error in v-on handler: "TypeError: this.onClick is not a function"

我们执行完 npm run test:unit 之后,vue 抱怨了。让我们想想这是因为什么

噢,如果我们通过 notify() 函数传入参数的话,那么组件的 props 的默认值就被破坏了。所以我们还需要给 defaultOptions 添加默认值

// index.js

function createDefaultOptions() {
  return {
    showClose: true,
    onClick: () => {},
    onClose: () => {}
  };
}

重构

大家应该可以发现在 updateProps() 函数内,重复了那么多,有重复了那么我们就需要重构!干掉重构我们才能得到胜利 -__-

function updateProps(notification, options) {
  const props = ["title", "message", "showClose", "onClose", "onClick"];
  props.forEach(key => {
    setProp(notification, key, options[key]);
  });
}

后续我们只需要给这个 props 后面加参数就好了。

代码重构完后赶紧跑下测试(重要!)

除了上面的重复信息后,其实还有一处就是我们需要在 createDefaultOptions() 里面定义 options 的默认值,然后还得在 Notification.vue 内也定义默认值。让我们想想怎么能只利用 Notification.vue 里面定义得默认值就好

function updateProps(notification, options) {
  const props = ["title", "message", "showClose", "onClose", "onClick"];
  props.forEach(key => {
    const hasKey = key in options;
    if (hasKey) {
      setProp(notification, key, options[key]);
    }
  });
}

还是在 updateProps() 内做文章,当我们发现要处理的 key 在 options 内不存在的话,那么我们就不再设置了,这样就不会破坏掉最初再 Notification.vue 内设置的默认值了。

所以我们之前设置默认 options 的逻辑也就没用啦,删除掉!

// 统统删除掉
function mergeOptions();
function createDefaultOptions();

这里多说一嘴,我看到过好多项目,有的代码没有用了,程序员直接就注释掉了,而不是选择删除,这样会给后续的可读性带来很大的影响,后面的程序员不知道你为什么要注释,能不能删除。现在都 9102 年了,如果你想恢复之前的代码直接再 git 上找回来不就好了。不会 git?我不负责

再次整体浏览下代码,嗯 发现还算工整,好我们继续~~

重构完别忘记跑下测试!!!

duration 显示时间: 毫秒。设为 0 则不会自动关闭

嗯,这个需求可以拆分成两个测试

  1. 大于 0 时,到时间自动关闭
  2. 等于 0 时,不会自动关闭

大于 0 秒时,到时间自动关闭

测试
    jest.useFakeTimers();
    ……
    describe("duration	显示时间", () => {
      it("大于 0 时,到时间自动关闭", () => {
        const duration = 1000;
        wrapNotify({ duration });
        const body = document.querySelector("body");
        expect(body.querySelector(".wp-notification")).toBeTruthy();
        jest.runAllTimers();
        expect(body.querySelector(".wp-notification")).toBeFalsy();
      });
    });

这里我们需要借助 jest 对 time 的 mock 来验证,因为单元测试要的就是快,我们不可能去等待一个真实的延迟时间。

基于 jest 的文档,先使用 jest.useFakeTimers(), 然后再使用 jest.runAllTimers(); 来快速的让 setTimeout 触发。触发前验证组件是存在的,触发后验证组件是不存在的。

实现逻辑
// index.js
function updateProps(notification, options) {
  const props = [    ……    "duration"  ];
  
  setDuration(notification.duration, notification);
}


function setDuration(duration, notification) {
  setTimeout(() => {
    const parent = notification.$el.parentNode;
    if (parent) {
      parent.removeChild(notification.$el);
    }
    notification.$destroy()
  }, options.duration);
}
// Notification.vue

  props: {
    ……
    duration: {
      type:Number,
      default: 4500
    }
  }

同之前的添加属性逻辑一样,只不过这里需要特殊处理一下 duration 的逻辑。我们再 setDuration() 内使用 setTimeout 来实现延迟删除的逻辑。

重构

当我们戴上重构的帽子的时候,发现 setTimeout 里面的一堆逻辑其实就是为了删除。那为什么不把它提取成一个函数呢?

function setDuration(options, notification) {
  setTimeout(() => {
    deleteNotification(notification);
  }, options.duration);
}

function deleteNotification(notification) {
  const parent = notification.$el.parentNode;
  if (parent) {
    parent.removeChild(notification.$el);
  }
  notification.$destroy();
}

代码重构完后赶紧跑下测试(重要!)

等于 0 时,不会自动关闭

测试
      it("等于 0 时,不会自动关闭", () => {
        const duration = 0;
        wrapNotify({ duration });
        const body = document.querySelector("body");
        expect(body.querySelector(".wp-notification")).toBeTruthy();
        jest.runAllTimers();
        expect(body.querySelector(".wp-notification")).toBeTruthy();
      });
逻辑实现

这里的逻辑实现就很简单了

// index.js
function setDuration(duration, notification) {
  if (duration === 0) return;
  setTimeout(() => {
    deleteNotification(notification);
  }, duration);
}
重构

逻辑实现完我们就需要戴上重构的帽子!

可以看到上面的两个测试已经有了跟明显的重复了

 describe("duration	显示时间", () => {
      it("大于 0 时,到时间自动关闭", () => {
        const duration = 1000;
        wrapNotify({ duration });
        const body = document.querySelector("body");
        expect(body.querySelector(".wp-notification")).toBeTruthy();
        jest.runAllTimers();
        expect(body.querySelector(".wp-notification")).toBeFalsy();
      });

      it("等于 0 时,不会自动关闭", () => {
        const duration = 0;
        wrapNotify({ duration });
        const body = document.querySelector("body");
        expect(body.querySelector(".wp-notification")).toBeTruthy();
        jest.runAllTimers();
        expect(body.querySelector(".wp-notification")).toBeTruthy();
      });
    });

我们需要把它们重复的逻辑提取到一个函数内

describe("duration	显示时间", () => {
      let body;
      function handleDuration(duration) {
        wrapNotify({ duration });
        body = document.querySelector("body");
        expect(body.querySelector(".wp-notification")).toBeTruthy();
        jest.runAllTimers();
      }

      it("大于 0 时,到时间自动关闭", () => {
        handleDuration(1000);
        expect(body.querySelector(".wp-notification")).toBeFalsy();
      });

      it("等于 0 时,不会自动关闭", () => {
        handleDuration(0);
        expect(body.querySelector(".wp-notification")).toBeTruthy();
      });
    });

别忘记跑下测试哟~~

通过 duration 自动关闭的弹窗应该调用 onClose

这个需求点是我刚刚想出来的,我们之前只实现了点击关闭按钮时,才调用 onClose。但是当通过 duration 关闭时,也应该会调用 onClose。

测试
    it("通过设置 duration 关闭时也会调用 onClose", () => {
        const onClose = jest.fn();
        wrapNotify({ onClose, duration: 1000 });
        jest.runAllTimers();
        expect(onClose).toBeCalledTimes(1);
      });
代码实现
// index.js
function setDuration(duration, notification) {
  if (duration === 0) return;
  setTimeout(() => {
    // 新加逻辑
    notification.onClose();
    deleteNotification(notification);
  }, duration);
}

我们只需要再 setDuration() 内调用 onClose 即可。因为之前已经设置了它的默认值为一个函数(再 Notification.vue 内),所以我们这里也不必判断 onClose 是否存在。

显示的位置

我们先只处理默认显示的坐标,elementUI 里面是默认再右上侧出现的。

还有一个逻辑是当同时显示多个 Notification 时,是如何管理坐标的。

测试

    describe("显示的坐标", () => {
      it("第一个显示的组件位置默认是 top: 50px, right:10px ", () => {
        const wrapper = wrapNotify();
        expect(wrapper.vm.position).toEqual({
          top: "50px",
          right: "10px"
        });
      });

因为 vue 就是 MVVM 的框架,所以这里我们只需要对数据 position 做断言即可 (model => view)

逻辑实现

// index.js
export function notify(options = {}) {
  ……
  updatePosition(notification);
  return notification;
}

function updatePosition(notification) {
  notification.position = {
    top: "50px",
    right: "10px"
  };
}
// Notification.vue
// 新增 data.position
  data(){
    return {
      position:{
          top: "",
          right: ""
      },
    }
  }
 
// 新增 style
  <div class="wp-notification" :style="position" @click="onClickHandler">

好了,这样测试就能通过了。但是其实这样写并不能满足我们多个组件显示时位置的需求。没关系, TDD 就是这样,当你一口气想不出来逻辑时,就可以通过这样一小步一小步的来实现。再《测试驱动开发》中这种方法叫做三角法。我们继续

测试

      it("同时显示两个组件时,第二个组件的位置是 top: 125px, right:10px", () => {
        
        wrapNotify();
        const wrapper2 = wrapNotify();
        expect(wrapper2.vm.position).toEqual({
          top: "125px",
          right: "10px"
        });
      });

先假设同时显示两个组件时,第二个组件的位置是 top: 125px, right:10px,虽然我们知道正确的逻辑应该是:第一个组件的位置 + 第一个组件的高度 + 间隔距离。但是不着急,我们先默认组件的高度是定死的。先简单实现,最后再改为正确的逻辑。这里其实也体现了功能拆分的思想,把一个任务拆分成小的简单的、然后逐一击破。

代码实现

const notificationList = [];
export function notify(options = {}) {
  ……
  notificationList.push(notification);
  updateProps(notification, options);
  updatePosition(notification);
  return notification;
}

function updatePosition() {
  const interval = 25;
  const initTop = 50;
  const elementHeight = 50;

  notificationList.forEach((element, index) => {
    const top = initTop + (elementHeight + interval) * index;
    element.position.top = `${top}px`;
    element.position.right = `10px`;
  });
}

如何处理多个组件显示呢,我们这里的策略是通过数组来存储之前创建的所有组件,然后再 updatePosition() 内基于之前创建的个数来处理 top 值。

跑下测试~ 跑不过,提示如下

  ● Notification › notify() › 显示的坐标 › 第一个显示的组件位置默认是 top: 50px, right:10px 

    expect(received).toEqual(expected) // deep equality

    - Expected
    + Received

      Object {
        "right": "10px",
    -   "top": "50px",
    +   "top": "725px",
      }
  ● Notification › notify() › 显示的坐标 › 同时显示两个组件时,第二个组件的位置是 top: 125px, right:10px

    expect(received).toEqual(expected) // deep equality

    - Expected
    + Received

      Object {
        "right": "10px",
    -   "top": "125px",
    +   "top": "875px",
      }

两个测试竟然都失败了。给出的提示是 top 竟然一个是 725px ,另外一个是 875px 。这是为什么呢???

分析一下,造成这个结果的原因只能有一个,notificationList 在我们测试显示坐标的时候,长度绝对不是 0 个,那想一想为什么它的长度不为零呢?

因为它的作用域是在全局的。我们之前的测试创建出来的组件都被数组添加进去了。但是并没有删除释放掉。所以在上面的测试中它的长度不为零。好了,我们已经发现造成这个结果的问题了,其实发现问题出在哪里,就已经解决一大半了。之后我们只需要每次跑测试之前都清空掉 notificationList 即可。

那问题又来了,怎么清空它呢?因为是 esmoudle ,我们并没有导出 notificationList 呀,所以我们在测试类里面也没有办法对它的长度赋值为零。那我们需要导出这个数组嘛?没有意义呀,导出就破坏了封装呀,怎么办?

针对这个问题其实有相对应的 babel 插件解决 -- babel-plugin-rewire

按照文档我们处理下测试逻辑

引入 rewire

npm install babel-core babel-plugin-rewire
// babel.config.js
module.exports = {
  presets: ["@vue/cli-plugin-babel/preset"],
  // 新加
  plugins: ["rewire"]
};
// Notification.spec.js
import { notify, __RewireAPI__ as Main } from "../index";

describe("Notification", () => {
  beforeEach(() => {
    Main.__Rewire__("notificationList", []);
  });
  ……

首先先安装,然后再 babel.config.js 内配置好插件,接着我们再测试类里面处理逻辑:再 beforeEach() 钩子函数内,清空掉 notificationList ,这样我们就把每一个测试之间的依赖解开了。现在已经可以通过测试啦~

测试

  it("创建得组件都消失后,新创建的组件的位置应该是起始位置", () => {
        wrapNotify();
        jest.runAllTimers();
        const wrapper2 = wrapNotify();
        expect(wrapper2.vm.position).toEqual({
          top: "50px",
          right: "10px"
        });
      });

这个测试是为了当第一个组件消失之后,再新建一个组件时,位置是否是正确的(正确的位置应该回到起始位)。

先通过 wrapNotify() 显示出一个组件,然后利用 jest.runAllTimers() 触发组件移除的逻辑,接着我们再次创建组件,并检查它的位置

逻辑实现
// Notification.vue
  ……
  data(){
    return {
      position:{
        top:"",
        right:""
      },
    }
  },
  ……
  computed: {
    styleInfo(){
      return Object.assign({},this.position)
    }
  },
  ……
  <div class="wp-notification" :style="styleInfo" @click="onClickHandler">

我们这里利用计算属性,当 position 被重新赋值后触发更新 style。

// index.js

let countId = 0;
function createNotification(el) {
  const NotificationClass = Vue.extend(Notification);
  const notification = new NotificationClass({ el });
  notification.id = countId++;
  return notification;
}

function deleteNotification(notification) {
  const parent = notification.$el.parentNode;
  if (parent) {
    parent.removeChild(notification.$el);
  }
  removeById(notification.id);
  notification.$destroy();
}

function removeById(id) {
  notificationList = notificationList.filter(v => v.id !== id);
}

为了满足上面的测试,我们应该再组件被删除的时候从 notificationList 内删除掉。再调用 deleteNotification() 时删除掉就最好不过啦。但是我们需要知道我们删除的是哪个组件。所以我们给它加了一个唯一标识 id。这样删除组件的时候基于 id 即可啦。

测试

      it("创建两个组件,当第一个组件消失后,第二个组件得位置应该更新 -> 更新为第一个组件得位置", () => {
        wrapNotify({ duration: 1000 });
        const wrapper2 = wrapNotify({ duration: 3000 });
        jest.advanceTimersByTime(2000);
        expect(wrapper2.vm.position).toEqual({
          top: "50px",
          right: "10px"
        });
      });

我再详细的描述一下这个测试的目的,如果我们使用了 elementUI 里面的 notification 组件应该会知道,当我一口气点出多个 Notification 组件时,最早出现的组件会最早消失,当它消失后,后面的组件应该会顶上去。

首先我们创建出两个组件,让第一个组件消失的时间快一点(设置成了 1秒),第二个组件消失时间慢一点(设置成了 3秒),接着我们利用 jest.advanceTimersByTime(2000); 让计时器快速过去 2 秒。这时候第一个组件应该消失掉了。好,这时候测试报错了。正如我们期望的那样,第二个组件的位置没有变动。这里有一个特别重要的点,你需要知道你的测试什么时候应该失败, 什么时候应该正确。不能用巧合来编程!

逻辑实现

// index.js
function setDuration(duration, notification) {
  if (duration === 0) return;
  setTimeout(() => {
    notification.onClose();
    deleteNotification(notification);
    // 新加逻辑
    updatePosition();
  }, duration);
}

我们只需要再删除组件时调用 updatePosition() 即可。这得益于我们把每个功能都封装成了单独的函数,让现在复用起来很方便。

重构

到现在为止,我们已经把组件的坐标逻辑都驱动出来了。噢,对了,我们之前硬编码写死了组件的高度,那个还需要调整一下,我们先把这个需求记录下来放到需求 List 内,有时候我们再做某个需求的时候突然意识到我们可能还需要做点别的,别慌我们先把后续需要做的事情记录下来,等到我们完成现在的需求后再去解决。暂时先不要分心!先看看代码哪里需要重构了

看起来测试文件内(index.js) 有 3 处对初始值坐标的重复,我们先提取出来

    describe("显示的坐标", () => {
      const initPosition = () => {
        return {
          top: "50px",
          right: "10px"
        };
      };

      const expectEqualInitPosition = wrapper => {
        expect(wrapper.vm.position).toEqual(initPosition());
      };
      it("第一个显示的组件位置默认是 top: 50px, right:10px ", () => {
        const wrapper = wrapNotify();
        expectEqualInitPosition(wrapper);
      });

      it("同时显示两个组件时,第二个组件的位置是 top: 125px, right:10px", () => {
        wrapNotify();
        const wrapper2 = wrapNotify();
        expect(wrapper2.vm.position).toEqual({
          top: "125px",
          right: "10px"
        });
      });

      it("第一个组件消失后,新创建的组件的位置应该是起始位置", () => {
        wrapNotify();
        jest.runAllTimers();
        const wrapper2 = wrapNotify();
        expectEqualInitPosition(wrapper2);
      });

      it("第一个组件消失后,第二个组件的位置应该是更新为第一个组件的位置", () => {
        wrapNotify({ duration: 1000 });
        const wrapper2 = wrapNotify({ duration: 3000 });
        jest.advanceTimersByTime(2000);
        expectEqualInitPosition(wrapper2);
      });
    });

暂时看起来可读性还不错。

// index.js

export function notify(options = {}) {
  const container = createContainerAndAppendToView();
  const notification = createNotification(container);
  notificationList.push(notification);
  updateProps(notification, options);
  updatePosition(notification);
  return notification;
}

这里有个 notificationList.push(notification); 嗯,我不太喜欢,我认为可读性还是差了点。

// index.js

export function notify(options = {}) {
  ……
  addToList(notification);
  ……
}

function addToList(notification) {
  notificationList.push(notification);
}

这样看起来好多了,一眼看上去就知道干了啥。

重构完千万别忘记跑下测试!!!

测试

现在是时候回去收拾‘组件得高度’这个需求啦。还记得嘛,我们之前是再程序里面写死的高度。现在需要基于组件的高度来动态的获取。

之前的测试

      it("同时显示两个组件时,第二个组件的位置是 top: 125px, right:10px", () => {
        wrapNotify();
        const wrapper2 = wrapNotify();
        expect(wrapper2.vm.position).toEqual({
          top: "125px",
          right: "10px"
        });
      });

我们需要对这个测试进行重构

      it("同时显示两个组件时,第二个组件的位置是 -> 起始位置 + 第一个组件得高度 + 间隔", () => {
        const wrapper1 = wrapNotify();
        const wrapper2 = wrapNotify();
        const initTop = 50;
        const top = initTop + interval + wrapper1.vm.$el.offsetHeight;
        expect(wrapper2.vm.position).toEqual({
          top: `${top}px`,
          right: "10px"
        });
      });

重构后的测试不再硬编码写死第二个组件的值了。但是还是有些许不足,还记得嘛,间隔值 interval 和 initTop 值我们之前再 index.js 内定义过一次

// index.js

function updatePosition() {
  const interval = 25;
  const initTop = 50;
  ……
}

暂时也没有必要暴漏这两个变量的值,同上面一样,我们使用 Rewire 来解决这个问题。

更新我们的测试

      it("同时显示两个组件时,第二个组件的位置是 -> 起始位置 + 第一个组件得高度 + 间隔", () => {
        const wrapper1 = wrapNotify();
        const wrapper2 = wrapNotify();
        const interval = Main.__get__("interval");
        const initTop = Main.__get__("initTop");
        const top = initTop + interval + wrapper1.vm.$el.offsetHeight;
        expect(wrapper2.vm.position).toEqual({
          top: `${top}px`,
          right: "10px"
        });
      });

通过 Rewire 获取到 index.js 内没有暴漏出来的 interval 和 initTop。

逻辑实现

const interval = 25;
const initTop = 50

function updatePosition() {
  notificationList.forEach((element, index) => {
    const preElement = notificationList[index - 1];
    const preElementHeight = preElement ? preElement.$el.offsetHeight : 0;
    const top = initTop + (preElementHeight + interval) * index;
    element.position.top = `${top}px`;
    element.position.right = `10px`;
  });
}

把 interval 和 initTop 提到全局作用域内。

基于公式:起始位置 + 前一个组件的高度 + 间隔 算出后续组件的 top 值。

重构

逻辑实现通过测试后,又到了重构环节了。让我们看看哪里需要重构呢??

让我们先聚焦 updatePosition() 内,我认为这个函数内部逻辑再可读性上变差了。

function updatePosition() {
  const createPositionInfo = (element, index) => {
    const height = element ? element.$el.offsetHeight : 0;
    const top = initTop + (height + interval) * index;
    const right = 10;
    return {
      top: `${top}px`,
      right: `${right}px`
    };
  };

  notificationList.forEach((element, index) => {
    const positionInfo = createPositionInfo(element, index);
    element.position.top = positionInfo.top;
    element.position.right = positionInfo.right;
  });
}

我们把逻辑拆分出来一个 createPositionInfo() 函数用来获取要更新的 position 数据。这样我们再阅读代码的时候一眼就可以看出它的行为。因为 createPositionInfo() 是和 updatePosition() 紧密相关的,所以我选择让它成为一个内联函数,不过没有关系,如果将来需要变动的时候我们也可以很方便的提取出来。

但是这里其实还有一个问题,就是再 jsdom 坏境下并不会真正的去渲染元素,所以我们再测试里面获取元素的 offsetHeight 的时候会始终得到一个 0 。怎么办? 我们可以 mock 掉获取真实元素的高,给它一个假值。

先修改测试

      it("同时显示两个组件时,第二个组件的位置是 -> 起始位置 + 第一个组件得高度 + 间隔", () => {
        const interval = Main.__get__("interval");
        const initTop = Main.__get__("initTop");
        const elementHeightList = [50, 70];
        let index = 0;
        Main.__Rewire__("getHeightByElement", element => {
          return element ? elementHeightList[index++] : 0;
        });

        wrapNotify();
        const wrapper2 = wrapNotify();
        const top = initTop + interval + elementHeightList[0];
        expect(wrapper2.vm.position).toEqual({
          top: `${top}px`,
          right: "10px"
        });
      });

我们假设有个 getHeightByElement() 方法,它可以返回元素的高,它其实就是一个接缝,我们通过 mock 它的行为来达到测试的目的。

有个重要的点就是,我们需要假设每个组件的高度都是不一样的(如果都一样的话,那和我们之前写死的假值就没有区别了)

实现逻辑

function getHeightByElement(element) {
  return element ? element.$el.offsetHeight : 0;
}

function updatePosition() {
  const createPositionInfo = (element, index) => {
    const height = getHeightByElement(element);
    ……

这时候测试应该是失败了!原因再哪里??回头看看我们之前的 updatePosition() 逻辑吧,我们获取元素的高度时,直接用的当前的元素,正确的逻辑应该是使用上一个元素的高度。我们通过测试把 bug 找出来了!接着修改它

function updatePosition() {
  const createPositionInfo = (element, index) => {
    const height = getHeightByElement(element);
    const top = initTop + (height + interval) * index;
    const right = 10;
    return {
      top: `${top}px`,
      right: `${right}px`
    };
  };

  notificationList.forEach((element, index) => {
    // 新增逻辑
    const preElement = notificationList[index - 1];
    const positionInfo = createPositionInfo(preElement, index);
    element.position.top = positionInfo.top;
    element.position.right = positionInfo.right;
  });
}

我们通过 notificationList[index - 1] 获取到上一个组件。测试这时候应该会顺利的通过了!

重构完别忘记运行测试!!!

点击关闭按钮需要组件关闭

这个需求是我之前突然想到的,当初做点击关闭按钮需求的时候,我们只验证了关闭会调用 onClose 函数,但是我们没有验证组件是否被关闭了。当想到这个需求没处理的时候,我们就应该把它加到我们的需求 List 内,然后等到手头的需求完成了再回过头来处理它。

测试

      describe("点击关闭按钮", () => {
        it("组件应该被删除", () => {
          const wrapper = wrapNotify();
          const body = document.querySelector("body");
          const btnSelector = ".wp-notification__close-button";
          wrapper.find(btnSelector).trigger("click");
          expect(body.querySelector(".wp-notification")).toBeFalsy();
        });
      });

通过 toBeFalsy() 来验证组件还存在不存在即可

逻辑实现

// Notification.vue

  methods: {
    onCloseHandler() {
      this.onClose();
    +  this.$emit('close',this)
    },
// index.js
function createNotification(el) {
  const NotificationClass = Vue.extend(Notification);
  const notification = new NotificationClass({ el });
  + notification.$on("close", onCloseHandler);
  notification.id = countId++;
  return notification;
}
+ function onCloseHandler(notification) {
+  deleteNotification(notification);
+ }

通过监听组件 emit 发送 close 的事件来做删除的处理

噢,测试竟然没有通过,告诉我们 body 内还是有 notification 组件的。这是为什么呢???

原来我们一直忽略了一个逻辑:还记得我们做 duration 逻辑的时候嘛?当时有一个 setTimeout ,duration 到时之后才会触发删除组件的逻辑,但是我们再之前的测试里只创建了组件但是没有做清除的逻辑,这就导致了我们上面测试的失败。

describe("Notification", () => 
  + afterEach(() => {
  +    jest.runAllTimers();
  + })

再每一个测试调用完成之后都调用 jest.runAllTimers() 让 setTimeout 及时触发,这样我们就把每个测试创建出来的组件再测试后顺利的删除掉了。

通过上面的教训我们应该能认识到一个测试的生命周期有多重要。一个测试完成后一定要销毁,不然就会导致后续的测试失败!!!

重构

测试通过后,让我们继续看看哪些地方是需要重构的

      describe("点击关闭按钮", () => {
        it("调用 onClose", () => {
          const onClose = jest.fn();
          const wrapper = wrapNotify({ onClose });
          const btnSelector = ".wp-notification__close-button";
          wrapper.find(btnSelector).trigger("click");
          expect(onClose).toBeCalledTimes(1);
        });

        it("组件应该被删除", () => {
          const wrapper = wrapNotify();
          const body = document.querySelector("body");
          const btnSelector = ".wp-notification__close-button";
          wrapper.find(btnSelector).trigger("click");
          expect(body.querySelector(".wp-notification")).toBeFalsy();
        });
      });

我们可以发现两处重复:

  1. 获取关闭按钮的逻辑
  2. 检测组件是否存在(已经有好几处测试逻辑通过 body 来检测组件是否存在了)

获取关闭按钮的逻辑

      function clickCloseBtn(wrapper) {
        const btnSelector = ".wp-notification__close-button";
        wrapper.find(btnSelector).trigger("click");
      }

检测组件是否存在于视图中

    function checkIsExistInView() {
      const body = document.querySelector("body");
      return expect(body.querySelector(".wp-notification"));
    }

接着我们替换所有检测组件是否存在于视图中的逻辑

    // 检测是否存在
      checkIsExistInView().toBeTruthy();
      // 或者 检测是否不存在
      checkIsExistInView().toBeFalsy();

点击关闭按钮-只会调用 onClose 一次

我们最初写的测试是: 调用 onClose 但是我们从上一个测试得知组件最后会执行 settimeout 内部的逻辑。

function setDuration(duration, notification) {
  if (duration === 0) return;
  setTimeout(() => {
    notification.onClose();
    deleteNotification(notification);
    updatePosition();
  }, duration);
}

这里又调用了一次 onClose() ,这里有可能会调用 2 次 onClose(),为了验证这个 bug 我们重新改动下测试

测试

        it("调用 onClose", () => {
          const onClose = jest.fn();
          const wrapper = wrapNotify({ onClose });
          clickCloseBtn(wrapper);
          expect(onClose).toBeCalledTimes(1);
        });

重构为

        it("只会调用 onClose 一次", () => {
          const onClose = jest.fn();
          const wrapper = wrapNotify({ onClose });
          clickCloseBtn(wrapper);
          expect(onClose).toBeCalledTimes(1);
          // 组件销毁后
          jest.runAllTimers();
          expect(onClose).toBeCalledTimes(1);
        });

使用 jest.runAllTimers() 来触发 setTimeout 内部的逻辑执行。

果然这时候单侧已经通不过了。

逻辑实现

function setDuration(duration, notification) {
  if (duration === 0) return;
  setTimeout(() => {
  + if (isDeleted(notification)) return;
    notification.onClose();
    deleteNotification(notification);
    updatePosition();
  }, duration);
}

如果组件被删除了,那么我们就不执行下面的逻辑即可,这样就避免了当 settimeout() 执行时重复的调用 onClose() 了

function isDeleted(notification) {
  return !notificationList.some(n => n.id === notification.id);
}

我们之前的逻辑是组件删除的时候会从 notificationList 内删除,所以我们这里检测 list 内还有没有对应的 id 即可。

重构

// Notification.vue
  methods: {
    onCloseHandler() {
      this.onClose();
      this.$emit('close',this)
    },

我们再点击关闭按钮的时候调用了 onClose() ,但是我想调整一下,把调用 onClose() 的逻辑放到 index.js 内。

// Notification.vue

  methods: {
    onCloseHandler() {
      this.$emit('close',this)
    },
// index.js
function onCloseHandler(notification) {
  notification.onClose();
  deleteNotification(notification);
}

重构完跑下测试~~~

噢,测试失败了: 点击关闭按钮,应该调用传入的 onClose

但是我们想一想,调用组件的逻辑都是通过 notify(), 所以我认为这里删除掉这个测试也无所谓。

点击关闭按钮后需要更新坐标

这个需求也是我再做上一个测试的时候突然意识到的,关闭按钮后需要更新坐标。现在我们从 list 内取出来,搞定它

测试

      describe("创建两个组件,当第一个组件消失后,第二个组件得位置应该更新 -> 更新为第一个组件得位置", () => {
        it("通过点击关闭按钮消失", () => {
          const wrapper1 = wrapNotify();
          const wrapper2 = wrapNotify();
          clickCloseBtn(wrapper1);
          expectEqualInitPosition(wrapper2);
        });

        it("通过触发 settimeout 消失", () => {
          wrapNotify({ duration: 1000 });
          const wrapper2 = wrapNotify({ duration: 3000 });
          jest.advanceTimersByTime(2000);
          expectEqualInitPosition(wrapper2);
        });
      });

让我们先回顾一下,之前我们写的测试只验证了组件通过 settimeout 触发删除后验证的测试。其实点击关闭按钮和触发 settimeout 应该是一样的逻辑。

逻辑实现

// index.js
function onCloseHandler(notification) {
  notification.onClose();
  deleteNotification(notification);
  + updatePosition();
}

好了,现在只需要再点击关闭按钮后调用 updatePosition() 更新下位置即可。

重构

function onCloseHandler(notification) {
  notification.onClose();
  deleteNotification(notification);
  updatePosition();
}
function setDuration(duration, notification) {
  if (duration === 0) return;
  setTimeout(() => {
    if (isDeleted(notification)) return;
    notification.onClose();
    deleteNotification(notification);
    updatePosition();
  }, duration);
}

可以看到,我们再两个地方都共同调用了 notification.onClose()、deleteNotification()、updatePosition() 这三个函数。本着不能重复的原则,我们再对其封装一层

function handleDelete(notification) {
  if (isDeleted(notification)) return;
  notification.onClose();
  deleteNotification(notification);
  updatePosition();
}

然后替换 onCloseHandler 和 setDuration 内的逻辑

function setDuration(duration, notification) {
  if (duration === 0) return;
  setTimeout(() => {
    handleDelete(notification);
  }, duration);
}
function onCloseHandler(notification) {
  handleDelete(notification);
}

ps:有时候起个好名字真的好难~

别忘记跑下测试~~~

快照

我们的组件核心逻辑基本已经搞定,现在是时候加一下快照了 Snapshot Testing

  it("快照", () => {
    const wrapper = shallowMount(Notification);
    expect(wrapper).toMatchSnapshot();
  });

很简单,只需要两行代码,接着 jest 会再 __test__/snapshots 下生成一个文件 Notification.spec.js.snap

__test__/__snapshots__/Notification.spec.js.snap
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Notification 快照 1`] = `
<div class="wp-notification">
  <div class="wp-notification__title">

  </div>
  <div class="wp-notification__message">

  </div> <button class="wp-notification__close-button"></button>
</div>
`;

可以看到它把组件当前的状态存成了字符串的形式,jest 称之为快照(还是挺形象的)。

这样当我们改动组件破坏了快照时,就会报错提醒我们。

删除没有用的测试

当测试不在具有意义的时候我们需要删除它,记住,测试和产品代码一样,也是需要维护和迭代的

有了快照之后我们就可以删除下面的这个测试了

  it("应该有类名为 wp-notification 得 div", () => {
    const wrapper = shallowMount(Notification);
    const result = wrapper.contains(".wp-notification");
    expect(result).toBe(true);
  });

删除直接对组件做的测试

我们调用组件的时候都是通过 notify() 这个函数入口来调用,不会通过组件直接调用。所以我们最初对组件进行的测试也可以删除掉

  describe("props", () => {
    it("title - 可以通过 title 设置标题", () => {

    it("message - 可以通过 message 设置说明文字", () => {

    it("showClose - 控制显示按钮", () => {
  });

总结

为什么写这篇文章

当初再学习 TDD 时查遍了全网的资料,基本再前端实施 TDD 的教程基本没有,有的也只是点到为止,只举几个简单的 demo ,基本满足不了日常的工作场景。所以我就再想要不要写一篇,当初定的目标是写一个组件库,把每个组件都写出来,但是这篇文章写完后我发现写一个组件都太长了。基本太长了你们也不会看。之后会考虑要不要录制成视频。

这篇文章写了好久,基本我每天都会完成一两个小的需求。一个多星期下来竟然这么长了。其实也不算是教程,基本是我个人的开发日记,遇到了什么问题,怎么解决的。我认为这个过程比起最终的结果是更有价值的。所以花费了一个多星期去完成这篇文章。希望可以帮助到大家。

TDD 和传统方式对比

传统方式

就传统的开发方式而言,我们再开发的过程中会频繁的刷新浏览器,再 chrome 里面打断点调试代码。基本流程是:

写代码 -> 刷新浏览器 -> 看看视图 | 看看 console.log 出来的值

上面这个流程会一直重复,我相信大家都深有体会

TDD

我们通过测试来驱动,写代码只是为了测试能通过。我们把整体的需求拆分成一个一个的小任务,然后逐个击破。

写测试 -> 运行测试(red) -> 写代码让测试通过(green) -> 重构

就我自己的感觉来讲成本在于一个习惯问题,还有一个是写测试的成本。有很多小伙伴基本不会写测试,所以也就造成了 TDD 很难实施的错觉了。按照我自己实践来讲,先学习怎么写测试,再学习怎么重构。基本就可以入门 TDD 。有了测试的保障我们就不用一遍一遍的去调试了,并且全都是自动化的。保证自己的代码质量,减少bug,提高开发效率。远离 996 指日可待。

多说一嘴,上面的那么多测试,别看写起来文字挺长,其实通过一个只需要 5 - 20 分钟。

学习参考

最后再推荐几个学习链接

  1. Vue 应用单元测试的策略与实践 01 - 前言和目标
  2. TDD(测试驱动开发)是否已死? - 李小波的回答 - 知乎

吕立青老哥的 vue 单元测试系列文章可以让你轻松入手如何写测试,之后还会和极客学院合作推出前端 TDD 训练营,感兴趣的同学可以关注下

github

仓库代码 传送门

最后求个 star ~~

上集

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

后记

之后有时间的话会通过录制视频的方式来分享了。用文字的话有些地方很难去表达。

立个 flag 把 TDD 布道到前端领域 -_-


  • 这是我们团队的开源项目 element3
  • 一个支持 vue3 的前端组件库