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

前言

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

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

需求分析

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

list

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

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

功能实现

onClick 点击 Notification 时的回调函数

这个很简单 直接上测试git

测试

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 抱怨了。让咱们想一想这是由于什么github

噢,若是咱们经过 notify() 函数传入参数的话,那么组件的 props 的默认值就被破坏了。因此咱们还须要给 defaultOptions 添加默认值chrome

// index.js

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

重构

你们应该能够发如今 updateProps() 函数内,重复了那么多,有重复了那么咱们就须要重构!干掉重构咱们才能获得胜利 -__-npm

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 布道到前端领域 -_-

相关文章
相关标签/搜索