Last Updated on 2019-09-08 by OneJar
本文同步刊載於 ALPHA Camp Blog。
「撰寫測試」已成為現代軟體開發的顯學。隨著軟體產品的規模越長越大,在不斷增加新功能、重構優化既有程式碼的過程,如何確保軟體既有功能不受影響,又能減少繁瑣的人工作業,靠的就是自動化測試。尤其當系統的業務邏輯龐大繁瑣,平時養成撰寫測試的好習慣更是保障軟體品質的關鍵。
開發團隊寫測試,通常有三種模式:
- 先寫測試再開發
- 開發完成再寫測試
- 無招勝有招 — — 不寫測試(誤)
本文的重點就是第一種模式,先寫測試再開發,也就是常聽到的 TDD(Test-Driven Development)。也許你還沒有寫測試的經驗,希望一窺何謂測試撰寫;又或者你已經有撰寫測試的經驗,但對於 TDD 模式感到陌生。
本文將介紹如何進行 TDD,並以一個簡單的題目,盡量用具體且易懂的方式,來示範傳統開發模式和 TDD 開發模式在流程和思維上的差異。
傳送門 (Table of Contents)
什麼是 TDD(Test-Driven Development)?
TDD(Test-Driven Development)是一種開發流程,中文是「測試驅動開發」。用一句白話形容,就是「先寫測試再開發」。先寫測試除了能確保測試程式的撰寫,還有一個好處:有助於在開發初期釐清程式介面如何設計。
程式介面,或是常說的 API 介面,是內部封裝細節和外部元件的溝通橋樑。在實作時,我們通常會希望程式介面維持穩定,越少改動越好。但在開發初期憑空定義出來的介面,常常在開發完成實際使用時才發現不好用,導致介面需要頻繁改動。
測試程式的作用是「模擬外部如何使用目標程式,驗證目標程式的行為是否符合預期」。換句話說,在寫測試時,會去了解目標程式如何被使用,比起憑空定義介面,更有助於在實作目標程式之前釐清適合的介面設計,減少後續變動的次數。
沒有程式怎麼寫測試? — — TDD 流程五步驟
你可能會有疑問:既然還沒有目標程式,那怎麼憑空寫測試?
下面這張圖很清楚地闡述 TDD 的運作,也就是所謂的「紅燈/綠燈/重構」循環(Red/Green/Refactor):
具體來說,TDD 流程可以分成五個步驟:
步驟一:選定一個功能,新增測試案例
- 重點在於思考希望怎麼去使用目標程式,定義出更容易呼叫的 API 介面。
- 這個步驟會寫好測試案例的程式,同時決定產品程式的 API 介面。
- 但尚未實作 API 實際內容。
步驟二:執行測試,得到 Failed(紅燈)
- 由於還沒撰寫 API 實際內容,執行測試的結果自然是 failed。
- 確保測試程式可執行,沒有語法錯誤等等。
步驟三:實作「夠用」的產品程式
- 這個階段力求快速實作出功能邏輯,用「最低限度」通過測試案例即可。
- 不求將程式碼優化一步到位。
步驟四:再次執行測試,得到 Passed(綠燈)
- 確保產品程式的功能邏輯已經正確地得到實作。
- 到此步驟,將完成一個可運作且正確的程式版本,包含產品程式和測試程式。
步驟五:重構程式
- 優化程式碼,包含產品程式和測試程式(測試程式也是專案需維護的一部份)。
- 提升程式的可讀性、可維護性、擴充性。
- 同時確保每次修改後,執行測試皆能通過。
每個功能重複上述步驟,就是 TDD 的開發流程。
實戰示範
接下來會用同一個具體的程式題目,實際示範傳統模式(先開發再寫程式)和 TDD 模式在開發和寫測試的流程差別。
範例所用的語言工具:
- Node.js
- 測試框架:Mocha
- 斷言套件:Expect
範例題目
改編自 Coding Dojo(Dojo 的中文為「道場」)一個小型程式題目:員工報表管理系統(Employee Report)。
公司有一份資料結構,儲存了每個員工的姓名、到職日、性別:
const employees = [
{ name: 'Max', onboardDate: '2008-05-04', gender: 'male' },
{ name: 'Sepp', onboardDate: '2018-12-17', gender: 'male' },
{ name: 'Nina', onboardDate: '2010-07-09', gender: 'female' },
{ name: 'Mike', onboardDate: '2005-01-01', gender: 'male' },
];
程式的目標是撰寫一個功能,可以撈出年資大於指定數字的資深員工清單。
傳統模式示範(先開發再寫程式)
直接開發
傳統模式就是初步規劃後,直接殺入開發,實作出一個 getReportBySeniority()
函數,可以接受「比較基準日」和「年資條件」兩個參數:
src/main/emp-report.js
const getReportBySeniority = (baseDate, year) => {
baseDate.setFullYear(baseDate.getFullYear() - year);
let i = 0, reportEmps = [];
for (i = 0; i < employees.length; i++) {
let emp = employees[i];
if (new Date(emp.onboardDate).getTime() <= baseDate.getTime()) {
reportEmps.push(emp);
}
}
return reportEmps;
}
module.exports = { getReportBySeniority };
嗯,除了「年資條件」,還接受「比較基準日」參數,開發者自我感覺 API 介面應該夠用了。
人工測試
開發完成後,通常會寫個簡單的程式來呼叫這個功能,確認程式執行結果如預期(也就是人工測試驗證):
index.js
const empReport = require('./src/main/emp-report');
let ret = empReport.getReportBySeniority(new Date(), 10);
console.log(ret);
執行結果:
$ node index.js
[ { name: 'Max', onboardDate: '2008-05-04', gender: 'male' },
{ name: 'Mike', onboardDate: '2005-01-01', gender: 'male' } ]
很好!成功撈出年資十年以上的員工報表,功能開發完成。
撰寫自動化測試
如果是不寫測試的狀況,到這邊大概就可以收工,準備進行下一個功能的開發。但我們是重視軟體品質的開發團隊,所以要繼續寫自動化測試,為 getReportBySeniority()
新增一個測試案例,並使用斷言庫(Assertion Library)檢驗回傳資料是否符合預期:
src/test/emp-report.test.js
const empReport = require('../main/emp-report');
const expect = require('expect');
describe('Get Report by Seniority', () => {
it('Get employees with seniority over 10 years', () => {
let baseDate = new Date(2019, 0, 1);
let ret = empReport.getReportBySeniority(baseDate, 10);
expect(ret).toHaveLength(2);
expect(ret[0]).toMatchObject({ name: 'Max', onboardDate: '2008-05-04' });
expect(ret[1]).toMatchObject({ name: 'Mike', onboardDate: '2005-01-01' });
});
});
執行測試結果:
完成測試程式,代表未來即使重構程式碼或增加新功能,都能透過自動化測試確保功能正確性,不用再像上面人工進行測試確認。
以上就是一個典型的傳統開發暨寫測試的流程。
TDD 模式示範
如果是 TDD 的模式呢?
步驟一:撰寫測試程式
先從測試案例切入:
src/test/emp-report.test.js
const empReport = require('../main/emp-report');
const expect = require('expect');
describe('Get Report by Seniority', () => {
it('Get employees with seniority over 10 years', () => {
// TODO
});
});
思考如果是外部元件,在不管實作細節的前提,會怎麼呼叫這個功能。以「撈出十年資深員工」的使用情境,可以定義出以下 API 介面:
describe('Get Report by Seniority', () => {
it('Get employees with seniority over 10 years', () => {
let baseDate = new Date(2019, 0, 1);
let ret = empReport.getReportBySeniority(baseDate, 10);
// TODO
});
});
這時候是站在使用者角度,容易聯想到有資深就有資淺,如果之後還想「撈出五年內的資淺員工」呢?
describe('Get Report by Seniority', () => {
it('Get employees with seniority over 10 years', () => {
let baseDate = new Date(2019, 0, 1);
let ret = empReport.getReportBySeniority(baseDate, 10);
// TODO
});
it('Get employees with seniority less than 5 years', () => {
// TODO
});
});
原本所設想的 API 介面顯然無法支援,至少需要再增加支援「年資大於」或「年資小於」的參數控制。最後完成的測試程式如下:
const empReport = require('../main/emp-report');
const expect = require('expect');
describe('Get Report by Seniority', () => {
it('Get employees with seniority over 10 years', () => {
let baseDate = new Date(2019, 0, 1);
let ret = empReport.getReportBySeniority(baseDate, 10, false);
expect(ret).toHaveLength(2);
expect(ret[0]).toMatchObject({ name: 'Max', onboardDate: '2008-05-04' });
expect(ret[1]).toMatchObject({ name: 'Mike', onboardDate: '2005-01-01' });
});
it('Get employees with seniority less than 5 years', () => {
let baseDate = new Date(2019, 0, 1);
let ret = empReport.getReportBySeniority(baseDate, 5, true);
expect(ret).toHaveLength(1);
expect(ret[0]).toMatchObject({ name: 'Sepp', onboardDate: '2018-12-17' });
});
});
要注意,這時候還沒有實作 API 的功能邏輯,只決定了介面:
src/main/emp-report.js
const employees = [
{ name: 'Max', onboardDate: '2008-05-04', gender: 'male' },
{ name: 'Sepp', onboardDate: '2018-12-17', gender: 'male' },
{ name: 'Nina', onboardDate: '2010-07-09', gender: 'female' },
{ name: 'Mike', onboardDate: '2005-01-01', gender: 'male' },
];
const getReportBySeniority = (baseDate, year, isLessThan) => {
// TODO
return null;
}
module.exports = { getReportBySeniority };
步驟二:執行紅燈測試
由於還沒實作 API 功能,理所當然得到紅燈:
步驟三:實作「夠用」的產品程式
實作 getReportBySeniority()
的內容。記得在這個步驟不要花太多力氣在雕琢程式碼,而是專注在功能邏輯的實作。
const getReportBySeniority = (baseDate, year, isLessThan) => {
baseDate.setFullYear(baseDate.getFullYear() - year);
let i = 0, reportEmps = [];
for (i = 0; i < employees.length; i++) {
let emp = employees[i];
let obTime = new Date(emp.onboardDate).getTime();
if ((isLessThan && obTime >= baseDate.getTime()) || (!isLessThan && obTime <= baseDate.getTime())) {
reportEmps.push(emp);
}
}
return reportEmps;
}
步驟四:執行綠燈測試
如果得到紅燈表示某處邏輯有錯誤,這個步驟必須修正到測試通過,確保功能邏輯的正確性。
至此,一個可運作且正確的程式版本已經完成,涵蓋產品程式和測試程式。
步驟五:重構
為了系統長遠發展的維護性,適當的重構是需要的。例如利用 Array.prototype.filter()
對 getReportBySeniority()
函數進行程式碼的簡化:
const getReportBySeniority = (baseDate, year, isLessThan) => {
baseDate.setFullYear(baseDate.getFullYear() - year);
let reportEmps = employees.filter(emp => {
let obTime = new Date(emp.onboardDate).getTime();
return isLessThan ? obTime >= baseDate.getTime() : obTime <= baseDate.getTime();
});
return reportEmps;
}
由於測試程式早已在前面步驟完成,可以放心重構,即使不慎改壞程式也可以透過自動化測試立即發現。
結語
經由上面的手把手示範,對於如何撰寫測試、如何用 TDD 模式開發,相信能有初步的概念,也感受到傳統模式和 TDD 流程在思維上的差異。
傳統模式容易遇到以下問題:
- 初期憑空設計介面,容易不適用,需要後續多次修改。
- 後期撰寫測試,容易專注於已實作功能的範疇,而少了對其他使用者情境的聯想。
- 容易因為專案時程或資源不足而犧牲測試程式的撰寫。
TDD 模式不僅在最初就確保測試程式的撰寫,相較於傳統模式,TDD 模式在一開始從使用方觀點切入,更容易在初期定義出更貼近使用方的介面。
理論上撰寫的測試案例越多,測試覆蓋率越高,代表對系統的信心度越高。然而專案時程和人力往往有限,而潛在的使用者情境案例可能無窮無盡。專案開發永遠是一個權衡(trade-off)的過程,在撰寫測試案例時,應該盡量選擇效益最大的測試案例撰寫,例如發生頻率最高的使用情境、已知一旦出錯將產生重大損失的案例等,然後在專案行有餘力時,持續補足其他 corner case,讓系統的品質確保更加穩固。
參考資料
- Test-driven development — Wikipedia
- 程式設計師升級必練內功:TDD Kata — ALPHA Camp blog
- 自動軟體測試、TDD 與 BDD — Yuren Ju — Medium
- [30天快速上手TDD]目錄與附錄 | In 91 — 點部落