手把手一起 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
參數maxStars
Rating.vue
中的<li>
用v-for
改寫Rating.spec.js
再 Mount 時傳入propsData
yarn 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