手把手一起 TDD

想了解更多 TDD,可以參考 TDD - 測試驅動開發,此篇是實作篇,手把手帶你體驗 TDD 💨💨
TDD Flow
再看一眼 TDD 流程,稍微有個印象,我們就繼續往下看 ~

Step 1. 列出需求清單
Step 2. 新增測試,測試 Fail
Step 3. 撰寫剛好能通過測試的程式
Step 4. 重構程式
範例 - Function
目標:Function - validatePassword
要開發一個密碼驗證的函式,會先請列一下密碼要被滿足的條件。
需求清單:
- Test 1 - 長度至少 8 碼
 - Test 2 - 至少含 1 個數字
 - Test 3 - 至少含 1 個英文字母(大寫/小寫)
 
Fixture:先準備好測試環境
// validate.js
/**
 * 密碼驗證
 * @param string $paramter ,ex: 'a12345678'
 * @return boolean 回傳結果 true 有效密碼, false 無效密碼
 * @example validatePassword('a12345678') => true
 */
const validatePassword = () => {};
export default validatePassword;
// validate.spec.js
describe("validatePassword", () => {
  // 1. 至少 8 碼
  // 2. 至少含 1 個數字
  // 3. 至少含 1 個英文字母(大寫/小寫)
});

TDD cycle
Red - [ Test 0 防呆 ]
在驗證的時候,基本都會有個防呆,只要值是 ""、undefined、null,永遠都會被視為 invalid value,在第一行就會被 return 了,程式不會再往下跑,所以我們可以把這當作最簡單的開局測試案例。
- 在 
validate.spec.js新增傳入空字串要返回false的測試。 yarn test=> fail test
// validate.spec.js
it("return false given an empty string", () => {
    expect(validatePassword("")).toBe(false);
});

Green - [ Test 0 防呆 ]
- 新增無腦解,呼叫 
validatePassword直接return false yarn test=> pass test
// validate.js
const validatePassword = () => {
  return false;
};

Refactor - [ Test 0 防呆 ]
Skip,目前還沒有重構必要。
Red - [ Test 1 長度至少 8 碼 ]
- 在 
validate.spec.js中,新增密碼長度要大於 8 碼的測試 yarn test=> fail test
ℹ️ 這邊第一個一定會過測試參數 a12345678,建議可以直接使用 PM 規格書提供的成功範例當做測試參數,永遠保證這個一定是合法密碼。
// validate.spec.js
it("at least 8 characters long", () => {
    expect(validatePassword("a12345678")).toBe(true);
});

Green - [ Test 1 長度至少 8 碼 ]
validate.js新增檢查參數大於 8 的判斷式yarn test=> pass test
// validate.js
const validatePassword = (password) => {
  if (password.length >= 8) {
    return true;
  }
  return false;
};

Refactor - [ Test 1 長度至少 8 碼 ]
Skip,目前還沒有重構必要,程式碼還算易懂。
Red - [ Test 2 至少含 1 個數字 ]
- 在 
validate.spec.js中,新增是否含數字的測試 yarn test=> fail test
// validate.spec.js
it("contains at least one number", () => {
    expect(validatePassword("ABCDEFGHIJ")).toBe(false);
});

Green - [ Test 2 至少含 1 個數字 ]
validate.js新增檢查數字的正則yarn test=> pass test
// validate.js
const validatePassword = (password) => {
  if (password.length >= 8 && /[0-9]/g.test(password)) {
    return true;
  }
  return false;
};
ℹ️ 正則測試方法可參考 MDN - regexObj.test(str)

Refactor - [ Test 2 至少含 1 個數字 ]
- 目前 
長度大於 8 碼和數字正則擠在同一個 if 判斷式裡,降低易讀性,可將兩個拆開,各用變數做代表做重構 yarn test=> pass test
// validate.js
const validatePassword = (password) => {
  const validLength = password.length >= 8;
  const containsNumber = /[0-9]/g.test(password);
  return validLength && containsNumber;
};

Red - [ Test 3 至少含 1 個英文字母(大寫/小寫) ]
- 在 
validate.spec.js中,新增是否含英文字母的測試 yarn test=> fail test
// validate.spec.js
it("contains at least one letter", () => {
    expect(validatePassword("123456789")).toBe(false);
});

Green - [ Test 3 至少含 1 個英文字母(大寫/小寫) ]
validate.js新增檢查英文字母的正則yarn test=> pass test
// validate.js
const validatePassword = (password) => {
  const validLength = password.length >= 8;
  const containsNumber = /[0-9]/g.test(password);
  const containsLetter = /[a-z]/g.test(password);
  return validLength && containsNumber && containsLetter;
};

因為英文字母這邊當初條件有列,要可接受大小寫,我們可以多增加幾個 assert,以確保需求條件都滿足。
- 在 
validate.spec.js中,新增內有含以小寫英文字母的參數的測試 yarn test=> pass test
目前程式可接受小寫英文字母輸入的,接著試另一個大寫英文字母的測試案例。
// validate.spec.js
it("contains at least one letter", () => {
    expect(validatePassword("123456789")).toBe(false);
    expect(validatePassword("a123456789")).toBe(true);
});

Red - [ Test 3 至少含 1 個英文字母(大寫/小寫) ]
- 在 
validate.spec.js中,新增內有含以大寫英文字母的參數的測試 yarn test=> fail test
這是測試居然失敗!這時候就會回頭檢視功能程式,是不是沒考慮到英文字母為大寫的情境。
// validate.spec.js
it("contains at least one letter", () => {
    expect(validatePassword("123456789")).toBe(false);
    expect(validatePassword("a123456789")).toBe(true);
    expect(validatePassword("A123456789")).toBe(true);
});

Green - [ Test 3 至少含 1 個英文字母(大寫/小寫) ]
validate.js補上檢查大寫英文字母的正則yarn test=> pass test
驗證通過後,以 TDD 流程開發的密碼驗證函式就完成了 👏
// validate.js
const containsLetter = /[aA-zZ]/g.test(password);

Final Code
// validate.js
const validatePassword = (password) => {
  const validLength = password.length >= 8;
  const containsNumber = /[0-9]/g.test(password);
  const containsLetter = /[aA-zZ]/g.test(password);
  return validLength && containsNumber && containsLetter;
};
export default validatePassword;
// validate.spec.js
import validatePassword from "./validate";
describe("validatePassword", () => {
  // 1. 至少 8 碼
  // 2. 至少含 1 個數字
  // 3. 至少含 1 個英文字母(大寫/小寫)
  it("return false given an empty string", () => {
    expect(validatePassword("")).toBe(false);
  });
  it("at least 8 characters long", () => {
    expect(validatePassword("a12345678")).toBe(true);
  });
  it("contains at least one number", () => {
    expect(validatePassword("ABCDEFGHIJ")).toBe(false);
  });
  it("contains at least one letter", () => {
    expect(validatePassword("123456789")).toBe(false);
    expect(validatePassword("a123456789")).toBe(true);
    expect(validatePassword("A123456789")).toBe(true);
  });
});

- 技術使用: vue-test-utils + Jest
 - 範例來源:TDD in JavaScript | Test Driven Development
 
範例 - Dom
Target Dom - rating stars
一個顯示星等的 component,下方有顯示其分數。
需求清單:
- Test 1 - 五個星星
 - Test 2 - 星星 active 數量
 - Test 3 - 顯示的數字
 

Fixture
準備好組件(Rating.vue)和測試檔案(Rating.spec.js),並寫一些測試的起手式 code。
wrapper:代表 Dom
beforeEach:每次測試時都會執行,可在此階段建立測試資料、Dom
afterEach:每次測試後都會執行,可在此階段移除測試資料、Dom上面方法都是 Jest 提供的,可以參考 Jest - Setup and Teardown。
// Rating.vue
<template>
  <div></div>
</template>
<script></script>
// Rating.spec.js
import { shallowMount } from "@vue/test-utils";
import Rating from "@/components/Rating.vue";
let wrapper = null;
// mount component before each test
beforeEach(() => {
  wrapper = shallowMount(Rating);
});
// destroy component after each test
afterEach(() => {
  wrapper.destroy();
});
describe("Rating", () => {
  it("renders the stars",() => {})
});

TDD cycle
Red - [ Test 1 五個星星 ]
- 在 
Rating.spec.js中,新增有 className.star數量要有五個的測試。 yarn test=> fail test
// Rating.spec.js
describe("Rating", () => {
  it("renders the stars", () => {
    const stars = wrapper.findAll(".star");
    expect(stars.length).toBe(5);
  });
});

Green - [ Test 1 五個星星 ]
- 新增無腦解 
<li class="star">*5 yarn test=> pass test
// Rating.vue
<template>
  <div>
    <ul>
      <li class="star" />
      <li class="star" />
      <li class="star" />
      <li class="star" />
      <li class="star" />
    </ul>
  </div>
</template>

Refactor - [ Test 1 五個星星 ]
如果今天需求改成要 10 顆星星呢?再複製五個 <li> 貼上?那實在太對不起工程師這職業了,所以我們要把這段 hard code 重構一下,讓最大星星數量由外面當作 props 傳進來。
Rating.vue新增props參數maxStarsRating.vue中的<li>用v-for改寫Rating.spec.js再 Mount 時傳入propsDatayarn test=> pass test
保持通過測試狀態!Awesome!第一個 TDD cycle 就完成了👏
接下來就再依照需求清單,進行下一個 Red-Green-Refactor。
// Rating.vue
<template>
  <div>
    <ul>
      <li v-for="star in maxStars" :key="star" class="star" />
    </ul>
  </div>
</template>
<script>
export default {
  props: {
    maxStars: {
      type: Number,
      default: 0
    }
  }
};
</script>
// Rating.spec.js
beforeEach(() => {
  wrapper = shallowMount(Rating, {
    propsData: {
      maxStars: 5
    }
  });
});

Red - [ Test 2 星星 active 數量 ]
- 在 
Rating.spec.js中,新增 3 個active星星數的測試 yarn test=> fail test
// Rating.spec.js
describe("Rating", () => {
  it("renders the stars", () => {
    const stars = wrapper.findAll(".star");
    expect(stars.length).toBe(5);
  });
  it("renders the active stars", () => {
    const active = wrapper.findAll(".star.active");
    expect(active.length).toBe(3);
  });
});

Green - [ Test 2 星星 active 數量 ]
- 在 
Rating.vue中,className 條件判斷寫star <= 3時,className 會自動新增active yarn test=> pass test
// Rating.vue
<template>
  <div>
    <ul>
      <li
        v-for="star in maxStars"
        :key="star"
        class="star"
        :class="{
          active: star <= 3
        }"
      />
    </ul>
  </div>
</template>

Refactor - [ Test 2 星星 active 數量 ]
active 星數,一般是由使用者或後端產生的,所以一樣要改成由外面用 props 方式傳入。
- 在 
Rating.vue、Rating.spec.js新增props參數initGrade - className 的條件判斷,改用參數
 yarn test=> pass test
// Rating.vue
<template>
  <div>
    <ul>
      <li
        v-for="star in maxStars"
        :key="star"
        class="star"
        :class="{
          active: star <= initGrade
        }"
      />
    </ul>
  </div>
</template>
<script>
export default {
  props: {
    maxStars: {
      type: Number,
      default: 0
    },
    initGrade: {
      type: Number,
      default: 0
    }
  }
};
</script>
// Rating.spec.js
beforeEach(() => {
  wrapper = shallowMount(Rating, {
    propsData: {
      maxStars: 5,
      initGrade: 3
    }
  });
});

Red - [Test 3 顯示的數字]
最後一個測試了,下方需顯示當前 active 星數和全部星數的資訊。
- 在 
Rating.spec.js中新增測試,條件為一個.summary的 dom,內容文字為前星數的資訊 yarn test=> fail test
// Rating.spec.js
describe("Rating", () => {
  it("renders the stars", () => {
    const stars = wrapper.findAll(".star");
    expect(stars.length).toBe(5);
  });
  it("renders the active stars", () => {
    const active = wrapper.findAll(".star.active");
    expect(active.length).toBe(3);
  });
  it("renders a summary", () => {
    const summary = wrapper.find(".summary");
    expect(summary.text()).toBe("2 of 5");
  });
});

[vue-test-utils]: find did not return .summary, cannot call text() on empty Wrapper[color=#ff0000]
意即在目前組件內找不到
.summary的 dom,無法呼叫 text() 方法。
Green - [ Test 3 顯示的數字 ]
- 先新增 
<div class="summary" /> yarn test=> fail test,雖然還沒通過測試,但這邊也可以發現 error message 已經改變了,可以找到.summary,只是文字訊息不對。
// Rating.vue
<template>
  <div>
    <ul>
      <li
        v-for="star in maxStars"
        :key="star"
        class="star"
        :class="{
          active: star <= initGrade
        }"
      />
    </ul>
    <div class="summary" />
  </div>
</template>

Expected: “2 of 5”
Received: “”[color=#ff0000]
- 在 
Rating.vue直接把文字寫跟測試案例一模一樣3 of 5 yarn test=> pass test
// Rating.vue
<template>
  <div>
    <ul>
      <li
        v-for="star in maxStars"
        :key="star"
        class="star"
        :class="{
          active: star <= initGrade
        }"
      />
    </ul>
    <div class="summary">3 of 5</div>
  </div>
</template>

Refactor - [ Test 3 顯示的數字 ]
.summary內的文字顯示改用props參數yarn test=> pass test
Bravo ! 整個 TDD 流程完成了!
// Rating.vue
<template>
  <div>
    <ul>
      <li
        v-for="star in maxStars"
        :key="star"
        class="star"
        :class="{
          active: star <= initGrade
        }"
      />
    </ul>
    <div class="summary">{{ initGrade }} of {{ maxStars }}</div>
  </div>
</template>

Final Code
// Rating.vue
<template>
  <div>
    <ul>
      <li
        v-for="star in maxStars"
        :key="star"
        class="star"
        :class="{
          active: star <= initGrade
        }"
      />
    </ul>
    <div class="summary">{{ initGrade }} of {{ maxStars }}</div>
  </div>
</template>
<script>
export default {
  props: {
    maxStars: {
      type: Number,
      default: 0
    },
    initGrade: {
      type: Number,
      default: 0
    }
  }
};
</script>
// Rating.spec.js
import { shallowMount } from "@vue/test-utils";
import Rating from "@/components/Rating.vue";
let wrapper = null;
// mount component before each test
beforeEach(() => {
  wrapper = shallowMount(Rating, {
    propsData: {
      maxStars: 5,
      initGrade: 3
    }
  });
});
// destroy component after each test
afterEach(() => {
  wrapper.destroy();
});
describe("Rating", () => {
  it("renders the stars", () => {
    const stars = wrapper.findAll(".star");
    expect(stars.length).toBe(5);
  });
  it("renders the active stars", () => {
    const active = wrapper.findAll(".star.active");
    expect(active.length).toBe(3);
  });
  it("renders a summary", () => {
    const summary = wrapper.find(".summary");
    expect(summary.text()).toBe("3 of 5");
  });
});

- 技術使用: vue-test-utils + Jest