TDD 與持續重構 (2) : IPO 模型

上一篇文章 記錄了我從 91 的 TDD 與持續重構 課堂中,對於 TDD 新的體悟,接下來想分享的,是我自己走在 TDD 的路上,找到的一盞引路燈:IPO 模型。
TDD 為甚麼難?
TDD 嘛,測試驅動開發,紅燈綠燈重構。大約半年前的我,沒有看書,沒有人盯著我動手,只是稍微聽別人講述過概念後,我就開始嘗試用 TDD 開發——工作上的開發。
就憑著一股不知道哪裡來,但絕對不是梁靜茹給的勇氣,跌跌撞撞的開發了半年,遇到許多痛點,大約都是一個現象:我的單元測試很長很長,光是 Given 變數、Substitute.Returns() 就有個 10 行,驗證區塊少說也有 5 行,一個單元測試超級胖——對,我還沾沾自喜地以為這就是單元測試。
這個現象追根究柢就兩個原因:
- 程式設計的不夠好:一個物件擁有太多職責,所以才會需要預先設定一堆內容、驗證一堆狀態和回傳值
- 我沒有能力把步伐切小
小步快跑是 TDD 一個很重要的觀念,小步指的是:在紅燈到綠燈的階段,改動越少越好。如何把你的步伐變小?透過設計良好的測試情境,而這正是我認為 TDD 中最困難的地方:設計一個情境,小到剛好你可以完成某一個邏輯或要做的事情。
舉例來說
假設要設計一個貨幣計算機,可以輸入台幣和日幣的金額,回傳加總後台幣的金額。大一點的步伐可能會像這樣:
[Test]
public void twd_add_yen()
{
// 1. 建立台幣
// 2. 建立日圓
// 3. 換匯
// 4. 相加
}一個測試案例完成一個產品,還有測試保護,真棒!
但是想想看,實際上工作的需求通常不會這麼簡單,如果功能很大,沒有能力把他切小,就會產生許多的龐大測試,寫到最後要馬就是放棄 TDD,要馬就是像我一樣,去上 91 的課,學習到如何利用 IPO 模型幫助我們把步伐切小,設計出更適合的情境。
IPO 模型
IPO 是 input-process-output 的縮寫。Input 和 Output 是使用情境,使用者投入某種東西,期望得到某種結果;而 Process 就是我們的產品正在進行的事情。使用的時候通常會拿一張紙,畫分成三等份,左中右分別對應 input-process-output,拿著 Post-it,把想到的內容貼在對應的位置。
很抽象嗎?我也覺得。第一次聽到 IPO 是在前一堂課 針對遺留代碼加入單元測試的藝術 ,相信 AS A 工程師,input / output 的概念很好理解,但 Process 呢?哪些東西算 Process?要怎麼形容這些東西?
貨幣計算機
接下來,我想以剛剛的貨幣計算機為例,示範一下如何用 IPO 模型,幫助我產出 TDD 的情境。
首先是 Input。這裡應該會有兩個貨幣物件,一個是台幣,一個是日幣:

差不多就這樣。現在,讓我們也決定 Output,也就是回傳一個台幣的金額:

太棒了!剩下最後一個部分。讓我們先回顧一下需求:一個貨幣計算機,可以輸入台幣和日幣的金額,進行加減,回傳換算成台幣後的金額。
所以,Process 應該要放入甚麼?
首先,這些功能所在的類別必須被先建立,呼叫方法也是,接下來才會有後續的邏輯。當然,這個計算機裡最重要的就是加法了。最後,為了加總不同的幣別,還需要有換匯的功能對吧?
不過,換匯這個名詞太抽象,在這裡,我們現在只需要把日圓轉成新台幣,所以我們把它定義的明確一點:

暫時想不到還漏掉了甚麼。到目前為止,一個基本的 IPO 模型就建立完成了。那麼,我們要如何利用這個模型設計測試情境呢?
情境設計
回顧一下剛剛那個步伐有點大的貨幣計算機情境:
[Test]
public void twd_add_yen()
{
// 1. 建立台幣 - twd
// 2. 建立日圓 - yen
// 3. 換匯 - yen exchange to twd
// 4. 相加 - plus
}對應到我們最後完成的 IPO 模型,這個情境直接把所有的事情都完成了。
重新設計
TDD 的精神之一:小步快跑,所以接下來,讓我們試著把這些藏在 IPO 模型裡的代辦事項,轉化成小一點的測試情境吧。
首先,最重要的當然是有 class 和 API,所以我的第一個測試情境會選擇:
[return zero]
Input: 0, 0
Output: 0「那台幣和日幣在哪裡?」
TDD 的精神之一:紅燈、綠燈、重構,這時候就可以派上用場了。假設我們根據第一個測試情境寫出來的測試如下:
[Test]
public void get_zero()
{
var currencyCalculator = new CurrencyCalculator();
var sumAsTwd = currencyCalculator.SumAsTwd(0, 0);
Assert.AreEqual(0, sumAsTwd);
}
public class CurrencyCalculator()
{
public int SumAsTwd(int twd, int yen)
{
// 無恥一點,測試要求的是 0,我們就 Hardcode 0
return 0;
}
}通過之後,我們可以透過重構,把 currencyCalculator.Sum(0, 0) 的兩個參數,變成兩個 class:台幣和日幣。
比起原本的步伐,雖然只少做了兩件事情(相加和換匯),但我認為,台幣和日幣因為是方法的簽章,拆分到其他情境中再時做,會減少他的完整性;如果像《Kent Beck的測試驅動開發》一書一樣,支援多種貨幣,那我認同先做台幣、再做日幣會是比較好的選擇。
那麼,現在我們剩下這些事情要做:

下一個情境(看起來也會是加法的最後一個情境),是把日圓換成台幣,回傳相加的結果——畢竟兩個不同的幣別要相加,一定需要經過換匯的處理,這兩者我認為是不可拆分的,沒有辦法再切成更小的步伐:
[get sum of amount]
Input: 1, 300
Output: 1 + 10可以注意到,在 Output 的地方,不是寫 11 ,而是寫 1 + 10,是因為這樣能比較清楚的表達意圖:得到的金額是透過相加而來的。
[Test]
public void get_sum_of_amount()
{
var currencyCalculator = new CurrencyCalculator();
var sumAsTwd = currencyCalculator.SumAsTwd(new Twd(1), new Yen(300));
Assert.AreEqual(1 + 10, sumAsTwd);
}
public class CurrencyCalculator()
{
public int SumAsTwd(Twd twd, Yen yen)
{
// 實作 ExchangeToTwd 的部分
return twd.Amount + Yen.ExchangeToTwd().Amount;
}
}我們所有的代辦事項就完成了!
總結一下,我們剛剛透過分析需求,轉化成 IPO 模型的方式,定義出我們在 TDD 時,需要處理的待辦事項清單,根據這些清單,來決定我們如何設計測試情境,並且透過重構,來補足一些必要且不可拆分的內容。
這是我在課程中,覺得最有價值的地方,透過 91 的範例引導,讓我們可以跟著 91 的腳步一起練習這個思考的過程,而這也是我認為在 TDD 的實作當中,最困難的一個部分:Think First。
最後還是要再強調一下,這也算是我上完課的練習,比起 91 還有很多不足的地方,如果你認同我的想法,請你務必要去聽聽看 91 的【TDD 與 持續重構】課程;如果你反對我的結論,我覺得你也可以帶著你的想法,和 91、和在場的夥伴們碰撞看看,沒有誰對誰錯,那些碰撞後的產物,也許會讓你有更豐富的收穫。
不論你認同或是反對,都歡迎留下你的想法讓我知道,謝謝閱讀到這邊的你,我們下一篇文章見 👋🏻