使用 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 佈道到前端領域 -_-

相關文章
相關標籤/搜索