TDD五步驟 手把手帶你實戰 Test-Driven Development 範例

本文同步刊載於 ALPHA Camp Blog

「撰寫測試」已成為現代軟體開發的顯學。隨著軟體產品的規模越長越大,在不斷增加新功能、重構優化既有程式碼的過程,如何確保軟體既有功能不受影響,又能減少繁瑣的人工作業,靠的就是自動化測試。尤其當系統的業務邏輯龐大繁瑣,平時養成撰寫測試的好習慣更是保障軟體品質的關鍵。

開發團隊寫測試,通常有三種模式:

  1. 先寫測試再開發
  2. 開發完成再寫測試
  3. 無招勝有招 — — 不寫測試(誤)

本文的重點就是第一種模式,先寫測試再開發,也就是常聽到的 TDD(Test-Driven Development)。也許你還沒有寫測試的經驗,希望一窺何謂測試撰寫;又或者你已經有撰寫測試的經驗,但對於 TDD 模式感到陌生。

本文將介紹如何進行 TDD,並以一個簡單的題目,盡量用具體且易懂的方式,來示範傳統開發模式和 TDD 開發模式在流程和思維上的差異。

什麼是 TDD(Test-Driven Development)?

TDD(Test-Driven Development)是一種開發流程,中文是「測試驅動開發」。用一句白話形容,就是「先寫測試再開發」。先寫測試除了能確保測試程式的撰寫,還有一個好處:有助於在開發初期釐清程式介面如何設計

程式介面,或是常說的 API 介面,是內部封裝細節和外部元件的溝通橋樑。在實作時,我們通常會希望程式介面維持穩定,越少改動越好。但在開發初期憑空定義出來的介面,常常在開發完成實際使用時才發現不好用,導致介面需要頻繁改動。

測試程式的作用是「模擬外部如何使用目標程式,驗證目標程式的行為是否符合預期」。換句話說,在寫測試時,會去了解目標程式如何被使用,比起憑空定義介面,更有助於在實作目標程式之前釐清適合的介面設計,減少後續變動的次數。

沒有程式怎麼寫測試? — — TDD 流程五步驟

你可能會有疑問:既然還沒有目標程式,那怎麼憑空寫測試?

下面這張圖很清楚地闡述 TDD 的運作,也就是所謂的「紅燈/綠燈/重構」循環(Red/Green/Refactor)

(圖片來源:Wikipedia

具體來說,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,讓系統的品質確保更加穩固。

參考資料

發表留言