> ## Content Index
> Fetch the complete content index at: https://blog.anthea.day/llms.txt
> Use this file to discover other available public pages before exploring further.

# TDD 與持續重構 (2) ： IPO 模型
- URL: https://blog.anthea.day/learning-tdd-e8-88-87-e6-8c-81-e7-ba-8c-e9-87-8d-e6-a7-8b/
- Published: 2023-02-19T14:47:03.000Z
- Updated: 2026-11-07T18:57:52.000Z
- Author: Anthea / 芫荽
- Tags: #Migrated-1792241843742, #Import 2026-10-17 12:57, Reading, TDD 與持續重構

![https://cdn-images-1.medium.com/max/1000/0*MMzrqCrUuMluE2zZ.png](https://blog.anthea.day/content/images/2026/10/0-mmzrqcruumlue2zz.png)

圖片來源：www.odd-e.com

[上一篇文章](https://blog.anthea.day/learning-tdd-e8-88-87-e6-8c-81-e7-ba-8c-e9-87-8d-e6-a7-8b/) 記錄了我從 91 的 [TDD 與持續重構](https://tdd.best/courses/tdd-continuous-refactoring-202302/?ref=blog.anthea.day) 課堂中，對於 TDD 新的體悟，接下來想分享的，是我自己走在 TDD 的路上，找到的一盞引路燈：IPO 模型。

---

### TDD 為甚麼難？

TDD 嘛，測試驅動開發，紅燈綠燈重構。大約半年前的我，沒有看書，沒有人盯著我動手，只是稍微聽別人講述過概念後，我就開始嘗試用 TDD 開發——工作上的開發。

就憑著一股不知道哪裡來，但絕對不是梁靜茹給的勇氣，跌跌撞撞的開發了半年，遇到許多痛點，大約都是一個現象：我的單元測試很長很長，光是 Given 變數、`Substitute.Returns()` 就有個 10 行，驗證區塊少說也有 5 行，一個**單元測試**超級胖——對，我還沾沾自喜地以為這就是單元測試。

這個現象追根究柢就兩個原因：

1. 程式設計的不夠好：一個物件擁有太多職責，所以才會需要預先設定一堆內容、驗證一堆狀態和回傳值
2. **我沒有能力把步伐切小**

小步快跑是 TDD 一個很重要的觀念，小步指的是：在紅燈到綠燈的階段，改動越少越好。如何把你的步伐變小？透過設計良好的測試情境，而這正是我認為 TDD 中最困難的地方：設計一個情境，小到剛好你可以完成某一個邏輯或要做的事情。

#### 舉例來說

假設要設計一個貨幣計算機，可以輸入台幣和日幣的金額，回傳加總後台幣的金額。大一點的步伐可能會像這樣：

```csharp
[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 是在前一堂課 [針對遺留代碼加入單元測試的藝術](https://tdd.best/courses/unit-testing-gracefully-with-legacy-code-202301/?ref=blog.anthea.day) ，相信 AS A 工程師，input / output 的概念很好理解，但 Process 呢？哪些東西算 Process？要怎麼形容這些東西？

#### 貨幣計算機

接下來，我想以剛剛的貨幣計算機為例，示範一下如何用 IPO 模型，幫助我產出 TDD 的情境。

首先是 Input。這裡應該會有兩個貨幣物件，一個是台幣，一個是日幣：

![](https://blog.anthea.day/content/images/2026/10/1-bx_m4h1e4_jbtooalx5x4w.png)

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

![](https://blog.anthea.day/content/images/2026/10/1-ikmndyjjyw9c5awlhetadw.png)

太棒了！剩下最後一個部分。讓我們先回顧一下需求：一個貨幣計算機，可以輸入台幣和日幣的金額，進行加減，回傳換算成台幣後的金額。

所以，Process 應該要放入甚麼？

首先，這些功能所在的類別必須被先建立，呼叫方法也是，接下來才會有後續的邏輯。當然，這個計算機裡最重要的就是加法了。最後，為了加總不同的幣別，還需要有換匯的功能對吧？

不過，換匯這個名詞太抽象，在這裡，我們現在只需要把日圓轉成新台幣，所以我們把它定義的明確一點：

![](https://blog.anthea.day/content/images/2026/10/1-vm-aagumwuaumlxde7ob0g.png)

暫時想不到還漏掉了甚麼。到目前為止，一個基本的 IPO 模型就建立完成了。那麼，我們要如何利用這個模型設計測試情境呢？

---

### 情境設計

回顧一下剛剛那個步伐有點大的貨幣計算機情境：

```csharp
[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 的精神之一：紅燈、綠燈、重構，這時候就可以派上用場了。假設我們根據第一個測試情境寫出來的測試如下：

```csharp
[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的測試驅動開發》一書一樣，支援多種貨幣，那我認同先做台幣、再做日幣會是比較好的選擇。

那麼，現在我們剩下這些事情要做：

![](https://blog.anthea.day/content/images/2026/10/1-qzzpv193bifwoexutlkgna.png)

下一個情境（看起來也會是加法的最後一個情境），是把日圓換成台幣，回傳相加的結果——畢竟兩個不同的幣別要相加，一定需要經過換匯的處理，這兩者我認為是不可拆分的，沒有辦法再切成更小的步伐：

```
[get sum of amount] 
Input:  1, 300 
Output: 1 + 10
```

可以注意到，在 Output 的地方，不是寫 11 ，而是寫 1 + 10，是因為這樣能比較清楚的表達意圖：得到的金額是透過相加而來的。

```csharp
[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 與 持續重構】](https://tdd.best/courses/tdd-continuous-refactoring-202302/?ref=blog.anthea.day)課程；如果你反對我的結論，我覺得你也可以帶著你的想法，和 91、和在場的夥伴們碰撞看看，沒有誰對誰錯，那些碰撞後的產物，也許會讓你有更豐富的收穫。

不論你認同或是反對，都歡迎留下你的想法讓我知道，謝謝閱讀到這邊的你，我們下一篇文章見 👋🏻