TDD(Test-Driven Development)測試驅動開發使用教學手冊


📚 目錄

一、前言

二、TDD 概念與原則

三、TDD 實踐步驟

四、TDD 開發環境與工具

五、撰寫良好測試的技巧

六、實作範例

七、TDD 在團隊開發中的應用

八、TDD 常見問題與最佳實踐

九、進階主題

十、附錄

總結

TDD 快速檢查清單

結語


一、前言

1.1 教學目的

本教學手冊旨在協助新進開發人員:

  • 理解 TDD 的核心概念與價值

  • 掌握 TDD 的實踐步驟與技巧

  • 建立良好的測試撰寫習慣

  • 在實際專案中應用 TDD 開發方法

  • 提升程式碼品質與可維護性

1.2 適用對象

  • 新進軟體工程師

  • 轉換至 TDD 開發方式的開發人員

  • 希望提升測試技能的程式設計師

  • 需要了解 TDD 實踐的技術主管

前置知識要求:

  • 基本程式設計能力(至少熟悉一種程式語言)

  • 了解物件導向程式設計基本概念

  • 具備版本控制系統(Git)基礎操作能力

1.3 預期學習成果

完成本教學手冊後,您將能夠:

✅ 說明 TDD 的核心原則與開發循環
✅ 獨立撰寫高品質的單元測試
✅ 運用 TDD 方式開發新功能
✅ 選擇適合的測試工具與框架
✅ 在團隊中推廣 TDD 實踐文化
✅ 辨識與避免常見的測試陷阱

1.4 教學手冊架構說明

本手冊採用循序漸進的學習路徑:

graph LR
    A[基礎概念] --> B[實踐步驟]
    B --> C[工具環境]
    C --> D[撰寫技巧]
    D --> E[實作範例]
    E --> F[團隊應用]
    F --> G[進階主題]

學習建議:

  • 建議按照章節順序閱讀

  • 每章結束後完成實作練習

  • 搭配範例專案進行實際操作

  • 與團隊成員討論分享經驗


二、TDD 概念與原則

2.1 什麼是 TDD(Test-Driven Development)

定義:
TDD(測試驅動開發)是一種軟體開發方法論,強調先寫測試,後寫實作。透過測試來驅動程式設計與開發流程。

核心理念:

“讓測試指引你的程式碼設計” - Kent Beck

TDD 的三個關鍵特徵:

  1. 測試優先(Test First)

    • 在撰寫功能程式碼前,先撰寫測試
    • 測試定義了預期行為與規格
  2. 小步前進(Baby Steps)

    • 每次只實作最小可用的功能
    • 持續進行小幅度的迭代
  3. 持續重構(Continuous Refactoring)

    • 在測試保護下安全地改善程式碼
    • 維持程式碼的簡潔與可維護性

TDD 的價值主張:

面向傳統開發TDD 開發
設計思考實作後才考慮測試透過測試思考設計
錯誤發現整合測試或上線後開發階段立即發現
重構信心擔心破壞既有功能測試保護下安心重構
文件化需額外撰寫文件測試即是活文件
程式碼品質依賴個人經驗可測試性促進好設計

2.2 TDD 的核心循環:Red → Green → Refactor

TDD 遵循一個簡單但強大的開發循環:

graph LR
    A[Red<br/>寫失敗的測試] --> B[Green<br/>讓測試通過]
    B --> C[Refactor<br/>重構程式碼]
    C --> A
    style A fill:#ffcccc
    style B fill:#ccffcc
    style C fill:#ccccff

```text

#### 🔴 Red(紅燈階段)- 撰寫失敗的測試

**目標:** 撰寫一個會失敗的測試,定義預期行為

**步驟:**

1. 思考要實作的功能需求

2. 撰寫測試案例描述預期行為

3. 執行測試,確認測試失敗(紅燈)

4. 確認失敗原因符合預期

**範例(Java):**

```java
@Test
public void testAddTwoNumbers() {
    Calculator calculator = new Calculator();
    int result = calculator.add(2, 3);
    assertEquals(5, result); // 此時會失敗,因為 add() 方法尚未實作
}

```text

**重點提醒:**

- ⚠️ 必須看到測試失敗才能進入下一階段

- ⚠️ 確認失敗訊息是你預期的原因

- ⚠️ 如果測試一開始就通過,可能是測試寫錯了


#### 🟢 Green(綠燈階段)- 撰寫最簡單的實作

**目標:** 用最簡單的方式讓測試通過

**步驟:**

1. 撰寫最少量的程式碼讓測試通過

2. 執行測試,確認測試通過(綠燈)

3. 不需要考慮完美設計,先求能動

**範例(Java):**

```java
public class Calculator {
    public int add(int a, int b) {
        return a + b; // 最簡單的實作
    }
}

```text

**重點提醒:**

- ✅ 只寫讓測試通過的程式碼,不多也不少

- ✅ 可以先用"假實作"或"硬編碼"快速通過測試

- ✅ 不要在這階段進行優化或重構


#### 🔵 Refactor(重構階段)- 改善程式碼品質

**目標:** 在測試保護下,改善程式碼設計

**步驟:**

1. 檢視程式碼是否有重複、冗餘或不清晰的地方

2. 進行重構改善程式碼品質

3. 持續執行測試確保功能正確

4. 重複重構直到滿意為止

**重構檢查清單:**

- [ ] 消除重複程式碼(DRY 原則)

- [ ] 改善命名清晰度

- [ ] 簡化複雜邏輯

- [ ] 提取共用方法或類別

- [ ] 確保符合 SOLID 原則


**重點提醒:**

- 🔄 重構時測試必須保持綠燈

- 🔄 一次只做一種重構動作

- 🔄 重構後立即執行測試驗證


### 2.3 TDD 與傳統開發流程的差異

#### 傳統開發流程


```mermaid
graph LR
    A[需求分析] --> B[設計]
    B --> C[實作]
    C --> D[測試]
    D --> E[除錯]
    E --> F[部署]

```text

**特點:**

- 測試在實作之後

- 容易產生難以測試的程式碼

- 錯誤發現較晚,修復成本高

- 重構風險大,容易破壞既有功能


#### TDD 開發流程


```mermaid
graph LR
    A[需求分析] --> B[撰寫測試]
    B --> C[實作]
    C --> D[重構]
    D --> B
    D --> E[部署]

```text

**特點:**

- 測試先於實作

- 自然產生可測試的程式碼

- 錯誤立即發現,修復成本低

- 重構有測試保護,風險小


#### 對比分析

| 面向 | 傳統開發 | TDD 開發 |
|------|---------|---------|
| **開發節奏** | 先寫功能後補測試 | 先寫測試後寫功能 |
| **測試覆蓋率** | 通常較低(20-50%) | 通常較高(80-100%) |
| **除錯時間** | 較多時間除錯 | 較少時間除錯 |
| **設計品質** | 依賴開發者經驗 | 測試強制好設計 |
| **文件化** | 需另外維護文件 | 測試即文件 |
| **重構信心** | 較低,怕改壞 | 較高,有測試保護 |
| **上手難度** | 較簡單 | 需要思維轉換 |
| **長期維護** | 容易腐化 | 較易維護 |

### 2.4 為什麼使用 TDD:好處與挑戰

#### ✅ TDD 的好處

**1. 提升程式碼品質**

- 強制思考程式介面與設計

- 自然產生低耦合、高內聚的程式碼

- 減少不必要的複雜度


**2. 提早發現錯誤**

- 在開發階段即時發現問題

- 降低除錯與修復成本

- 減少上線後的缺陷數量


**3. 安全重構**

- 測試作為安全網

- 重構時立即發現破壞

- 持續改善程式碼品質


**4. 活文件(Living Documentation)**

- 測試即是規格說明

- 測試展示使用方式

- 自動保持文件更新


**5. 加速開發速度**

- 減少除錯時間

- 降低回歸測試成本

- 新成員更容易理解程式碼


**6. 增強開發信心**

- 對程式碼有信心

- 敢於進行大幅修改

- 減少"害怕改壞"的心理


**實際數據參考:**

- 缺陷率降低 40-80%(IBM 研究)

- 測試覆蓋率提升至 80% 以上

- 長期維護成本降低 50%


#### ⚠️ TDD 的挑戰

**1. 學習曲線**

- 需要轉換開發思維

- 初期開發速度可能較慢

- 需要學習測試技巧與工具


**解決方案:**

- 從簡單專案開始練習

- 進行 Pair Programming

- 定期分享與檢討


**2. 初期時間投入**

- 撰寫測試需要額外時間

- 前期投資較大

- 短期內難以看到效益


**解決方案:**

- 著眼於長期價值

- 追蹤缺陷率與維護成本

- 建立團隊共識


**3. 測試維護成本**

- 需求變更時測試也需更新

- 測試程式碼也需要維護

- 不良的測試反而成為負擔


**解決方案:**

- 撰寫高品質測試

- 定期重構測試程式碼

- 刪除過時或無價值的測試


**4. 不適用的場景**

- UI 視覺調整

- 探索性開發(Spike)

- 效能調校

- 硬體整合測試


**解決方案:**

- 靈活運用,不是所有情況都需要 TDD

- 針對核心業務邏輯使用 TDD

- 其他部分採用事後測試


#### 💡 何時應該使用 TDD

**適合使用 TDD:**

- ✅ 核心業務邏輯開發

- ✅ 複雜演算法實作

- ✅ API 開發

- ✅ 工具函式庫

- ✅ 需要高可靠度的功能

- ✅ 需求明確的功能


**不一定需要 TDD:**

- ⚠️ 快速原型驗證

- ⚠️ UI 視覺調整

- ⚠️ 簡單的 CRUD 操作

- ⚠️ 一次性腳本

- ⚠️ 需求非常不明確的探索


### 2.5 單元測試 vs. 集成測試 vs. 系統測試

#### 測試金字塔(Test Pyramid)


```mermaid
graph TD
    A[UI/E2E Tests<br/>端對端測試<br/>數量少、速度慢、成本高]
    B[Integration Tests<br/>集成測試<br/>數量中、速度中、成本中]
    C[Unit Tests<br/>單元測試<br/>數量多、速度快、成本低]
    A --> B
    B --> C
    style C fill:#90EE90
    style B fill:#FFD700
    style A fill:#FF6347

```text

#### 🔵 單元測試(Unit Tests)

**定義:**  
測試程式碼中最小的可測試單元(通常是函式或方法)。

**特性:**

- 範圍小,只測試單一功能

- 執行速度快(毫秒級)

- 不依賴外部資源(資料庫、網路等)

- 使用 Mock/Stub 隔離依賴

- 數量最多(占 70-80%)


**範例:**

```java
@Test
public void testCalculateDiscount_WithVIPMember_ShouldGet20PercentOff() {
    // Arrange
    Product product = new Product("Laptop", 1000);
    Customer vipCustomer = new Customer("VIP");
    
    // Act
    double finalPrice = product.calculateDiscount(vipCustomer);
    
    // Assert
    assertEquals(800, finalPrice, 0.01);
}

```text

**適用時機:**

- 測試業務邏輯

- 測試演算法正確性

- 測試邊界條件

- 測試異常處理


#### 🟡 集成測試(Integration Tests)

**定義:**  
測試多個元件或模組之間的互動與整合。

**特性:**

- 範圍較大,測試元件間協作

- 執行速度中等(秒級)

- 可能依賴外部資源

- 使用測試資料庫或測試環境

- 數量中等(占 15-25%)


**範例:**

```java
@Test
public void testSaveOrder_ShouldPersistToDatabase() {
    // Arrange
    Order order = new Order("ORD-001", 1500);
    OrderRepository repository = new OrderRepository(testDatabase);
    
    // Act
    repository.save(order);
    
    // Assert
    Order savedOrder = repository.findById("ORD-001");
    assertNotNull(savedOrder);
    assertEquals(1500, savedOrder.getAmount());
}

```text

**適用時機:**

- 測試資料庫存取

- 測試 API 呼叫

- 測試訊息佇列

- 測試檔案讀寫


#### 🔴 系統測試/端對端測試(E2E Tests)

**定義:**  
從使用者角度測試整個系統流程。

**特性:**

- 範圍最大,測試完整流程

- 執行速度慢(分鐘級)

- 依賴完整環境

- 模擬真實使用者操作

- 數量最少(占 5-10%)


**範例:**

```java
@Test
public void testUserPurchaseFlow() {
    // 使用者登入
    loginPage.login("user@example.com", "password");
    
    // 搜尋商品
    searchPage.search("Laptop");
    
    // 加入購物車
    productPage.addToCart();
    
    // 結帳
    checkoutPage.checkout();
    
    // 驗證訂單建立
    assertTrue(orderPage.isOrderCreated());
}

```text

**適用時機:**

- 測試關鍵使用者流程

- 測試系統整合

- 測試跨系統互動

- 冒煙測試(Smoke Test)


#### 對比分析

| 特性 | 單元測試 | 集成測試 | E2E 測試 |
|------|---------|---------|---------|
| **測試範圍** | 單一函式/類別 | 多個元件 | 整個系統 |
| **執行速度** | 極快(<100ms) | 中等(1-10s) | 慢(10s-數分鐘) |
| **維護成本** | 低 | 中 | 高 |
| **失敗定位** | 容易 | 中等 | 困難 |
| **依賴性** | 無外部依賴 | 部分依賴 | 完全依賴 |
| **建議占比** | 70-80% | 15-25% | 5-10% |
| **TDD 適用** | ✅ 非常適合 | ⚠️ 部分適用 | ❌ 較不適用 |

#### 💡 實務建議

**1. 遵循測試金字塔原則**

- 大量單元測試建立快速反饋

- 適量集成測試確保整合正確

- 少量 E2E 測試驗證關鍵流程


**2. TDD 主要應用於單元測試**

- Red-Green-Refactor 循環適合單元測試

- 集成測試可在模組完成後補充

- E2E 測試在功能完整後撰寫


**3. 依據專案特性調整**

- API 專案可增加集成測試比例

- UI 密集專案可增加元件測試

- 業務邏輯複雜則加強單元測試


---

## 🎯 本章重點回顧

✅ TDD 是**先寫測試,後寫實作**的開發方法  
✅ 核心循環是 **Red → Green → Refactor**  
✅ TDD 提升程式碼品質、降低缺陷、加速長期開發  
✅ 需要投入學習時間,但長期效益顯著  
✅ 遵循**測試金字塔**原則,以單元測試為主  

---

## 📋 本章檢查清單

在進入下一章前,請確認您已經:


- [ ] 理解 TDD 的定義與核心理念

- [ ] 能說明 Red-Green-Refactor 三階段

- [ ] 了解 TDD 與傳統開發的差異

- [ ] 認識 TDD 的好處與挑戰

- [ ] 區分單元測試、集成測試、E2E 測試

- [ ] 理解測試金字塔的概念


---

## 📚 延伸閱讀


- 《Test Driven Development: By Example》 - Kent Beck

- 《Growing Object-Oriented Software, Guided by Tests》 - Steve Freeman & Nat Pryce

- Martin Fowler 的文章: [TestPyramid](https://martinfowler.com/bliki/TestPyramid.html)


---

**下一章:** [三、TDD 實踐步驟](#三tdd-實踐步驟)

---

## 三、TDD 實踐步驟

### 3.1 Step 1:撰寫失敗的測試(Red)

#### 🎯 目標

在開始實作功能前,先撰寫一個**明確定義預期行為**的測試案例。

#### 📝 實踐步驟

**步驟一:理解需求**

首先,確認你要實作的功能需求:


```markdown
需求範例:
「實作一個購物車系統,當使用者加入商品時,需要計算總金額」

```text

**步驟二:定義測試案例**

思考以下問題:

- ❓ 輸入是什麼?(參數、資料)

- ❓ 預期輸出是什麼?(回傳值、狀態變化)

- ❓ 有哪些邊界條件?

- ❓ 有哪些異常情況?


**步驟三:撰寫測試**


```java
// 範例:購物車測試 (Java + JUnit)
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class ShoppingCartTest {
    
    @Test
    public void testAddSingleItem_ShouldCalculateCorrectTotal() {
        // Arrange (準備測試資料)
        ShoppingCart cart = new ShoppingCart();
        Product laptop = new Product("Laptop", 30000);
        
        // Act (執行測試動作)
        cart.addItem(laptop, 1);
        
        // Assert (驗證結果)
        assertEquals(30000, cart.getTotal());
    }
}

```text

**步驟四:執行測試,確認失敗**


```bash
# 執行測試
mvn test

# 預期輸出(紅燈)
[ERROR] testAddSingleItem_ShouldCalculateCorrectTotal()
java.lang.NoSuchMethodError: ShoppingCart.addItem()

```text

#### ✅ 成功標準


- [ ] 測試程式碼可以編譯通過

- [ ] 測試執行結果為**失敗(紅燈)**

- [ ] 失敗原因是**因為功能尚未實作**

- [ ] 測試案例清楚描述預期行為

- [ ] 測試使用 AAA 模式(Arrange-Act-Assert)


#### ⚠️ 常見錯誤

**錯誤 1:測試一開始就通過**

```java
// ❌ 錯誤範例
@Test
public void testAddition() {
    assertEquals(5, 2 + 3); // 這不是在測試你的程式碼!
}

```text

**錯誤 2:測試過於複雜**

```java
// ❌ 錯誤範例:一次測試太多事情
@Test
public void testComplexScenario() {
    cart.addItem(item1, 2);
    cart.addItem(item2, 3);
    cart.applyDiscount(0.1);
    cart.checkout();
    cart.generateInvoice();
    // 太多行為混在一起!
}

```text

**正確做法:**

```java
// ✅ 正確範例:一次只測試一個行為
@Test
public void testAddMultipleItems_ShouldSumTotal() {
    ShoppingCart cart = new ShoppingCart();
    cart.addItem(new Product("A", 100), 2);
    cart.addItem(new Product("B", 200), 1);
    
    assertEquals(400, cart.getTotal());
}

```text

#### 💡 實務技巧

**技巧 1:使用描述性的測試名稱**


```java
// ❌ 不好的命名
@Test
public void test1() { }

@Test
public void testCart() { }

// ✅ 好的命名
@Test
public void testAddItem_WithNegativeQuantity_ShouldThrowException() { }

@Test
public void testCalculateTotal_WithEmptyCart_ShouldReturnZero() { }

```text

命名格式建議:

```text
test[方法名]_[測試條件]_[預期結果]

```text

**技巧 2:先寫最簡單的測試案例**

遵循**由簡入繁**的原則:


```java
// 第一個測試:最基本的情況
@Test
public void testAddSingleItem_ShouldUpdateTotal() { }

// 第二個測試:多個商品
@Test
public void testAddMultipleItems_ShouldSumTotal() { }

// 第三個測試:邊界條件
@Test
public void testAddItem_WithZeroQuantity_ShouldNotChangeTotal() { }

// 第四個測試:異常情況
@Test
public void testAddItem_WithNullProduct_ShouldThrowException() { }

```text

**技巧 3:使用測試資料建構器(Test Data Builder)**


```java
// 使用建構器簡化測試資料準備
public class ProductBuilder {
    private String name = "Default Product";
    private int price = 100;
    
    public ProductBuilder withName(String name) {
        this.name = name;
        return this;
    }
    
    public ProductBuilder withPrice(int price) {
        this.price = price;
        return this;
    }
    
    public Product build() {
        return new Product(name, price);
    }
}

// 測試中使用
@Test
public void testExpensiveProduct() {
    Product product = new ProductBuilder()
        .withName("MacBook Pro")
        .withPrice(80000)
        .build();
    
    cart.addItem(product, 1);
    assertEquals(80000, cart.getTotal());
}

```text

### 3.2 Step 2:撰寫最簡單的實作通過測試(Green)

#### 🎯 目標

用**最少量的程式碼**讓測試從紅燈變綠燈。

#### 📝 實踐步驟

**步驟一:建立最小實作**


```java
// 第一版:讓測試通過的最簡實作
public class ShoppingCart {
    private int total = 0;
    
    public void addItem(Product product, int quantity) {
        total = product.getPrice() * quantity;
    }
    
    public int getTotal() {
        return total;
    }
}

public class Product {
    private String name;
    private int price;
    
    public Product(String name, int price) {
        this.name = name;
        this.price = price;
    }
    
    public int getPrice() {
        return price;
    }
}

```text

**步驟二:執行測試,確認通過**


```bash
mvn test

# 預期輸出(綠燈)
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

```text

#### ⚠️ Green 階段的原則

**原則 1:只寫讓測試通過的程式碼**


```java
// ❌ 過度設計
public class ShoppingCart {
    private List<CartItem> items = new ArrayList<>();
    private DiscountStrategy discountStrategy;
    private TaxCalculator taxCalculator;
    // ... 太多不必要的設計
}

// ✅ 簡單實作
public class ShoppingCart {
    private int total = 0;
    
    public void addItem(Product product, int quantity) {
        total += product.getPrice() * quantity;
    }
}

```text

**原則 2:可以使用假實作(Fake It)**

當不確定最終設計時,先用最簡單的方式通過:


```java
// 第一個測試
@Test
public void testGetDiscount_ForVIP_ShouldReturn20Percent() {
    Customer vip = new Customer("VIP");
    assertEquals(0.2, vip.getDiscount(), 0.01);
}

// 假實作(硬編碼)
public class Customer {
    public double getDiscount() {
        return 0.2; // 先硬編碼通過測試
    }
}

// 當有第二個測試時,再改為真實邏輯
@Test
public void testGetDiscount_ForRegular_ShouldReturn0() {
    Customer regular = new Customer("REGULAR");
    assertEquals(0.0, regular.getDiscount(), 0.01);
}

// 現在需要真實實作
public class Customer {
    private String type;
    
    public Customer(String type) {
        this.type = type;
    }
    
    public double getDiscount() {
        if ("VIP".equals(type)) {
            return 0.2;
        }
        return 0.0;
    }
}

```text

**原則 3:三角測量法(Triangulation)**

透過多個測試案例,逐步逼近正確的實作:


```java
// 測試 1
@Test
public void testFizzBuzz_With3_ShouldReturnFizz() {
    assertEquals("Fizz", fizzBuzz(3));
}

// 第一版實作(假實作)
public String fizzBuzz(int number) {
    return "Fizz";
}

// 測試 2
@Test
public void testFizzBuzz_With5_ShouldReturnBuzz() {
    assertEquals("Buzz", fizzBuzz(5));
}

// 第二版實作
public String fizzBuzz(int number) {
    if (number % 3 == 0) return "Fizz";
    if (number % 5 == 0) return "Buzz";
    return String.valueOf(number);
}

// 測試 3
@Test
public void testFizzBuzz_With15_ShouldReturnFizzBuzz() {
    assertEquals("FizzBuzz", fizzBuzz(15));
}

// 第三版實作(完整邏輯)
public String fizzBuzz(int number) {
    if (number % 15 == 0) return "FizzBuzz";
    if (number % 3 == 0) return "Fizz";
    if (number % 5 == 0) return "Buzz";
    return String.valueOf(number);
}

```text

#### 💡 實務技巧

**技巧 1:快速迭代**


- ⏱️ 盡快讓測試變綠燈(目標:<5分鐘)

- ⏱️ 不要在 Green 階段進行重構

- ⏱️ 保持小步前進


**技巧 2:暫時跳過複雜邏輯**


```java
// 可以先用 TODO 標記待實作的複雜邏輯
public void processPayment(Payment payment) {
    // TODO: 實作複雜的支付邏輯
    if (payment.getAmount() > 0) {
        // 簡單版本先通過測試
    }
}

```text

**技巧 3:立即執行所有測試**


```bash
# 確保新實作不會破壞既有測試
mvn test

# 或使用 IDE 的快捷鍵
# IntelliJ: Ctrl+Shift+F10 (Windows) / Cmd+Shift+R (Mac)
# VS Code: Ctrl+; A (Windows) / Cmd+; A (Mac)

```text

### 3.3 Step 3:重構程式碼(Refactor)

#### 🎯 目標

在測試保護下,改善程式碼品質而不改變行為。

#### 📝 重構步驟

**步驟一:識別程式碼異味(Code Smells)**

檢查是否有以下問題:

- 重複程式碼(Duplicated Code)

- 過長方法(Long Method)

- 過長類別(Large Class)

- 過多參數(Long Parameter List)

- 魔術數字(Magic Numbers)

- 不清晰的命名(Unclear Naming)


**步驟二:選擇重構手法**


```java
// 重構前:有重複邏輯
public class ShoppingCart {
    public int calculateTotalForVIP(List<Product> products) {
        int total = 0;
        for (Product p : products) {
            total += p.getPrice();
        }
        return (int)(total * 0.8); // VIP 8折
    }
    
    public int calculateTotalForRegular(List<Product> products) {
        int total = 0;
        for (Product p : products) {
            total += p.getPrice();
        }
        return total;
    }
}

// 重構後:消除重複
public class ShoppingCart {
    public int calculateTotal(List<Product> products, double discount) {
        int total = products.stream()
            .mapToInt(Product::getPrice)
            .sum();
        return (int)(total * (1 - discount));
    }
}

```text

**步驟三:執行重構**


```mermaid
graph LR
    A[選擇重構目標] --> B[執行小幅重構]
    B --> C[執行測試]
    C --> D{測試通過?}
    D -->|是| E[提交變更]
    D -->|否| F[復原變更]
    F --> B
    E --> G{還需重構?}
    G -->|是| A
    G -->|否| H[完成]

```text

**步驟四:持續驗證**

每次重構後立即執行測試:


```bash
# 每次小改動後都要測試
git add .
mvn test && git commit -m "Refactor: extract method"

```text

#### 🔧 常用重構技巧

**技巧 1:提取方法(Extract Method)**


```java
// 重構前
public void processOrder(Order order) {
    // 驗證訂單
    if (order.getItems().isEmpty()) {
        throw new IllegalArgumentException("Empty order");
    }
    if (order.getTotal() < 0) {
        throw new IllegalArgumentException("Invalid total");
    }
    
    // 計算折扣
    double discount = 0;
    if (order.getCustomer().isVIP()) {
        discount = 0.2;
    }
    double finalPrice = order.getTotal() * (1 - discount);
    
    // 儲存訂單
    database.save(order);
}

// 重構後:提取獨立方法
public void processOrder(Order order) {
    validateOrder(order);
    double finalPrice = calculateFinalPrice(order);
    saveOrder(order);
}

private void validateOrder(Order order) {
    if (order.getItems().isEmpty()) {
        throw new IllegalArgumentException("Empty order");
    }
    if (order.getTotal() < 0) {
        throw new IllegalArgumentException("Invalid total");
    }
}

private double calculateFinalPrice(Order order) {
    double discount = order.getCustomer().isVIP() ? 0.2 : 0.0;
    return order.getTotal() * (1 - discount);
}

private void saveOrder(Order order) {
    database.save(order);
}

```text

**技巧 2:提取類別(Extract Class)**


```java
// 重構前:職責過多
public class Customer {
    private String name;
    private String email;
    private String phone;
    private String street;
    private String city;
    private String zipCode;
    
    // 太多欄位與職責
}

// 重構後:分離地址類別
public class Customer {
    private String name;
    private String email;
    private String phone;
    private Address address; // 提取為獨立類別
}

public class Address {
    private String street;
    private String city;
    private String zipCode;
    
    public String getFullAddress() {
        return street + ", " + city + " " + zipCode;
    }
}

```text

**技巧 3:以常數取代魔術數字**


```java
// 重構前
public double calculateDiscount(Customer customer) {
    if (customer.isVIP()) {
        return total * 0.8; // 魔術數字
    }
    return total * 0.95; // 魔術數字
}

// 重構後
private static final double VIP_DISCOUNT_RATE = 0.2;
private static final double REGULAR_DISCOUNT_RATE = 0.05;

public double calculateDiscount(Customer customer) {
    if (customer.isVIP()) {
        return total * (1 - VIP_DISCOUNT_RATE);
    }
    return total * (1 - REGULAR_DISCOUNT_RATE);
}

```text

**技巧 4:使用策略模式消除條件判斷**


```java
// 重構前
public double calculateShippingCost(String shippingType, double weight) {
    if ("STANDARD".equals(shippingType)) {
        return weight * 10;
    } else if ("EXPRESS".equals(shippingType)) {
        return weight * 20 + 50;
    } else if ("OVERNIGHT".equals(shippingType)) {
        return weight * 30 + 100;
    }
    return 0;
}

// 重構後:使用策略模式
public interface ShippingStrategy {
    double calculateCost(double weight);
}

public class StandardShipping implements ShippingStrategy {
    public double calculateCost(double weight) {
        return weight * 10;
    }
}

public class ExpressShipping implements ShippingStrategy {
    public double calculateCost(double weight) {
        return weight * 20 + 50;
    }
}

// 使用
public double calculateShippingCost(ShippingStrategy strategy, double weight) {
    return strategy.calculateCost(weight);
}

```text

#### ⚠️ 重構注意事項

**注意 1:一次只做一種重構**


```text
❌ 同時重構:改命名 + 提取方法 + 調整架構
✅ 循序漸進:先改命名 → 測試 → 提取方法 → 測試 → 調整架構 → 測試

```text

**注意 2:保持測試通過**


```mermaid
graph TD
    A[開始重構] --> B[小幅修改]
    B --> C[執行測試]
    C --> D{通過?}
    D -->|是| E[繼續下一步]
    D -->|否| F[立即復原]
    F --> B
    E --> G{完成?}
    G -->|否| B
    G -->|是| H[結束]

```text

**注意 3:不要同時修改行為與重構**


```java
// ❌ 錯誤:重構時加入新功能
public double calculateTotal() {
    // 重構:提取方法
    double subtotal = calculateSubtotal();
    
    // 同時加入新功能(稅金計算) - 不好!
    double tax = subtotal * 0.05;
    return subtotal + tax;
}

// ✅ 正確:先重構,再加新功能
// 步驟1:重構
public double calculateTotal() {
    return calculateSubtotal();
}

private double calculateSubtotal() {
    // 提取的邏輯
}

// 步驟2:寫新測試
@Test
public void testCalculateTotal_ShouldIncludeTax() {
    // 新功能的測試
}

// 步驟3:實作新功能
public double calculateTotal() {
    double subtotal = calculateSubtotal();
    double tax = calculateTax(subtotal);
    return subtotal + tax;
}

```text

#### 💡 重構最佳實踐

**實踐 1:使用版本控制**


```bash
# 每次重構後提交
git add .
git commit -m "Refactor: extract calculateDiscount method"

# 如果重構失敗可快速復原
git reset --hard HEAD

```text

**實踐 2:使用 IDE 重構工具**


```text
IntelliJ IDEA 常用快捷鍵:

- Ctrl+Alt+M: 提取方法

- Ctrl+Alt+V: 提取變數

- Ctrl+Alt+C: 提取常數

- Ctrl+Alt+F: 提取欄位

- F6: 移動類別/方法

- Shift+F6: 重新命名

```text

**實踐 3:測試也需要重構**


```java
// 測試程式碼也會有重複,需要重構
@Test
public void testVIPDiscount() {
    Customer vip = new Customer("John", "VIP");
    ShoppingCart cart = new ShoppingCart(vip);
    cart.addItem(new Product("A", 1000), 1);
    assertEquals(800, cart.getTotal());
}

@Test
public void testRegularCustomer() {
    Customer regular = new Customer("Jane", "REGULAR");
    ShoppingCart cart = new ShoppingCart(regular);
    cart.addItem(new Product("A", 1000), 1);
    assertEquals(1000, cart.getTotal());
}

// 重構測試:提取共用設定
private ShoppingCart createCartWith Product(Customer customer, int price) {
    ShoppingCart cart = new ShoppingCart(customer);
    cart.addItem(new Product("TestProduct", price), 1);
    return cart;
}

@Test
public void testVIPDiscount() {
    Customer vip = new Customer("John", "VIP");
    ShoppingCart cart = createCartWithProduct(vip, 1000);
    assertEquals(800, cart.getTotal());
}

@Test
public void testRegularCustomer() {
    Customer regular = new Customer("Jane", "REGULAR");
    ShoppingCart cart = createCartWithProduct(regular, 1000);
    assertEquals(1000, cart.getTotal());
}

```text

### 3.4 Step 4:重複循環與迭代開發

#### 🎯 目標

透過持續的 Red-Green-Refactor 循環,逐步完善功能。

#### 📝 迭代開發流程

**完整開發循環示範:**

讓我們用一個完整範例展示多次迭代:

**需求:實作一個訂單折扣計算系統**

- VIP 客戶享有 20% 折扣

- 訂單金額超過 1000 元再享 5% 折扣

- 折扣可累加


**第一次迭代:基本 VIP 折扣**


```java
// Iteration 1: Red - 寫測試
@Test
public void testVIPCustomer_ShouldGet20PercentDiscount() {
    Order order = new Order(new Customer("VIP"), 1000);
    assertEquals(800, order.getFinalPrice());
}

// Iteration 1: Green - 最簡實作
public class Order {
    private Customer customer;
    private double amount;
    
    public Order(Customer customer, double amount) {
        this.customer = customer;
        this.amount = amount;
    }
    
    public double getFinalPrice() {
        if (customer.isVIP()) {
            return amount * 0.8;
        }
        return amount;
    }
}

// Iteration 1: Refactor - 提取常數
private static final double VIP_DISCOUNT = 0.2;

public double getFinalPrice() {
    if (customer.isVIP()) {
        return amount * (1 - VIP_DISCOUNT);
    }
    return amount;
}

```text

**第二次迭代:一般客戶**


```java
// Iteration 2: Red - 新測試
@Test
public void testRegularCustomer_ShouldHaveNoDiscount() {
    Order order = new Order(new Customer("REGULAR"), 1000);
    assertEquals(1000, order.getFinalPrice());
}

// Iteration 2: Green - 程式碼已經支援,測試直接通過!

// Iteration 2: Refactor - 簡化邏輯
public double getFinalPrice() {
    double discount = customer.isVIP() ? VIP_DISCOUNT : 0;
    return amount * (1 - discount);
}

```text

**第三次迭代:高額訂單折扣**


```java
// Iteration 3: Red - 新測試
@Test
public void testLargeOrder_ShouldGetAdditional5PercentDiscount() {
    Order order = new Order(new Customer("REGULAR"), 1500);
    assertEquals(1425, order.getFinalPrice()); // 1500 * 0.95
}

// Iteration 3: Green - 加入邏輯
private static final double LARGE_ORDER_THRESHOLD = 1000;
private static final double LARGE_ORDER_DISCOUNT = 0.05;

public double getFinalPrice() {
    double discount = customer.isVIP() ? VIP_DISCOUNT : 0;
    
    if (amount > LARGE_ORDER_THRESHOLD) {
        discount += LARGE_ORDER_DISCOUNT;
    }
    
    return amount * (1 - discount);
}

// Iteration 3: Refactor - 提取方法
public double getFinalPrice() {
    double totalDiscount = calculateTotalDiscount();
    return amount * (1 - totalDiscount);
}

private double calculateTotalDiscount() {
    double discount = customer.isVIP() ? VIP_DISCOUNT : 0;
    
    if (amount > LARGE_ORDER_THRESHOLD) {
        discount += LARGE_ORDER_DISCOUNT;
    }
    
    return discount;
}

```text

**第四次迭代:VIP + 高額訂單組合**


```java
// Iteration 4: Red - 組合測試
@Test
public void testVIPWithLargeOrder_ShouldGetBothDiscounts() {
    Order order = new Order(new Customer("VIP"), 1500);
    // VIP 20% + 大額訂單 5% = 25% 折扣
    assertEquals(1125, order.getFinalPrice()); // 1500 * 0.75
}

// Iteration 4: Green - 程式碼已支援,測試通過!

// Iteration 4: Refactor - 進一步重構
public double getFinalPrice() {
    DiscountCalculator calculator = new DiscountCalculator(customer, amount);
    return calculator.calculateFinalPrice();
}

// 提取為獨立的折扣計算類別
public class DiscountCalculator {
    private static final double VIP_DISCOUNT = 0.2;
    private static final double LARGE_ORDER_DISCOUNT = 0.05;
    private static final double LARGE_ORDER_THRESHOLD = 1000;
    
    private Customer customer;
    private double amount;
    
    public DiscountCalculator(Customer customer, double amount) {
        this.customer = customer;
        this.amount = amount;
    }
    
    public double calculateFinalPrice() {
        double totalDiscount = calculateTotalDiscount();
        return amount * (1 - totalDiscount);
    }
    
    private double calculateTotalDiscount() {
        double discount = 0;
        
        if (customer.isVIP()) {
            discount += VIP_DISCOUNT;
        }
        
        if (amount > LARGE_ORDER_THRESHOLD) {
            discount += LARGE_ORDER_DISCOUNT;
        }
        
        return discount;
    }
}

```text

#### 📊 迭代進度追蹤

建議使用任務清單追蹤迭代進度:


```markdown
## 訂單折扣系統開發任務

### 已完成

- [x] VIP 客戶 20% 折扣

- [x] 一般客戶無折扣

- [x] 訂單金額 > 1000 享 5% 折扣

- [x] VIP + 大額訂單折扣累加


### 進行中

- [ ] 會員等級分級(金、銀、銅)


### 待辦

- [ ] 季節性促銷折扣

- [ ] 優惠券折扣

- [ ] 折扣上限設定

```text

#### 💡 迭代開發技巧

**技巧 1:保持迭代小而快**


```text
❌ 大迭代:一次實作完整的折扣系統(需要3小時)
✅ 小迭代:每個折扣類型獨立開發(每個15分鐘)

```text

**時間建議:**

- 每次迭代: 5-15 分鐘

- Red 階段: 1-3 分鐘

- Green 階段: 2-5 分鐘

- Refactor 階段: 2-7 分鐘


**技巧 2:優先實作最有價值的功能**


```mermaid
graph TD
    A[需求分析] --> B[功能排序]
    B --> C[選擇最高價值功能]
    C --> D[TDD 開發]
    D --> E[完成並驗收]
    E --> F{還有功能?}
    F -->|是| C
    F -->|否| G[完成]

```text

**技巧 3:使用測試清單(Test List)**

在開始前列出所有要測試的案例:


```markdown
## 購物車測試清單

### 基本功能

- [ ] 加入單一商品

- [ ] 加入多個不同商品

- [ ] 計算總金額


### 邊界條件

- [ ] 加入數量為 0 的商品

- [ ] 加入負數數量的商品

- [ ] 空購物車的總金額


### 異常處理

- [ ] 加入 null 商品

- [ ] 移除不存在的商品


### 進階功能

- [ ] 更新商品數量

- [ ] 清空購物車

- [ ] 套用折扣碼

```text

每完成一個測試就打勾,清楚掌握進度。

### 3.5 驗收標準(Definition of Done)與測試覆蓋率要求

#### 🎯 完成定義(Definition of Done)

每個功能開發完成前,必須滿足以下標準:

**基本標準:**


- [ ] ✅ 所有單元測試通過

- [ ] ✅ 測試覆蓋率達到標準(見下方說明)

- [ ] ✅ 程式碼通過靜態分析檢查

- [ ] ✅ 程式碼已重構,消除重複與異味

- [ ] ✅ 命名清晰,符合團隊規範

- [ ] ✅ 已提交版本控制並推送

- [ ] ✅ 程式碼已通過同儕審查(Code Review)


**進階標準:**


- [ ] ✅ 整合測試通過(如適用)

- [ ] ✅ 效能測試通過(如適用)

- [ ] ✅ 安全性檢查通過

- [ ] ✅ 文件已更新(API 文件、README 等)

- [ ] ✅ CI/CD Pipeline 執行成功


#### 📊 測試覆蓋率標準

**覆蓋率類型:**


1. **行覆蓋率(Line Coverage)**
   - 測試執行時經過的程式碼行數比例
   - **建議目標: 80% 以上**


2. **分支覆蓋率(Branch Coverage)**
   - 測試執行時經過的條件分支比例
   - **建議目標: 70% 以上**


3. **方法覆蓋率(Method Coverage)**
   - 被測試呼叫的方法比例
   - **建議目標: 90% 以上**

**覆蓋率工具:**


```xml
<!-- Maven: 使用 JaCoCo -->
<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.10</version>
    <executions>
        <execution>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
        <execution>
            <id>jacoco-check</id>
            <goals>
                <goal>check</goal>
            </goals>
            <configuration>
                <rules>
                    <rule>
                        <element>PACKAGE</element>
                        <limits>
                            <limit>
                                <counter>LINE</counter>
                                <value>COVEREDRATIO</value>
                                <minimum>0.80</minimum>
                            </limit>
                        </limits>
                    </rule>
                </rules>
            </configuration>
        </execution>
    </executions>
</plugin>

```text


```bash
# 執行並生成覆蓋率報告
mvn clean test jacoco:report

# 查看報告
open target/site/jacoco/index.html

```text

**覆蓋率報告範例:**


```text
-------------------------------------------------------
JaCoCo Coverage Report
-------------------------------------------------------
Package         Line Coverage   Branch Coverage
-------------------------------------------------------
com.example
  ├─ service    87.5% (35/40)  75.0% (6/8)
  ├─ model      95.0% (19/20)  N/A
  └─ util       82.3% (28/34)  80.0% (8/10)
-------------------------------------------------------
Total           88.3% (82/94)  77.8% (14/18)

```text

#### ⚠️ 覆蓋率的正確理解

**覆蓋率高 ≠ 測試品質好**


```java
// 範例:100% 覆蓋率但測試品質差
@Test
public void testCalculate() {
    Calculator calc = new Calculator();
    calc.add(2, 3);
    calc.subtract(5, 2);
    calc.multiply(3, 4);
    // 沒有任何 assert,但程式碼都執行了!
}

```text

**正確做法:**


```java
@Test
public void testAdd_ShouldReturnCorrectSum() {
    Calculator calc = new Calculator();
    int result = calc.add(2, 3);
    assertEquals(5, result); // 有驗證!
}

@Test
public void testSubtract_ShouldReturnCorrectDifference() {
    Calculator calc = new Calculator();
    int result = calc.subtract(5, 2);
    assertEquals(3, result);
}

```text

#### 💡 覆蓋率最佳實踐

**實踐 1:聚焦關鍵程式碼**


```text
✅ 高覆蓋率優先:

- 核心業務邏輯(100%)

- 複雜演算法(100%)

- 錯誤處理(90%+)


⚠️ 可較低覆蓋率:

- 簡單的 Getter/Setter

- 框架生成的程式碼

- UI 元件程式碼

```text

**實踐 2:設定覆蓋率門檻**


```yaml
# .gitlab-ci.yml 範例
test:
  script:
    - mvn clean test jacoco:report
  coverage: '/Total.*?([0-9]{1,3})%/'
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: target/site/jacoco/jacoco.xml
  # 設定最低覆蓋率要求
  allow_failure:
    exit_codes:
      - 1  # 如果覆蓋率低於門檻則失敗

```text

**實踐 3:定期檢視覆蓋率趨勢**


```mermaid
line
    title 測試覆蓋率趨勢
    x-axis [Week1, Week2, Week3, Week4, Week5, Week6]
    y-axis "Coverage %" 0 --> 100
    line [65, 70, 75, 78, 82, 85]

```text

#### 📋 檢查清單範本

將以下清單整合至開發流程:


```markdown
## 功能開發完成檢查清單

### 測試相關

- [ ] 所有測試案例通過(綠燈)

- [ ] 測試覆蓋率達到 80% 以上

- [ ] 包含邊界條件測試

- [ ] 包含異常處理測試

- [ ] 測試命名清晰易懂


### 程式碼品質

- [ ] 無重複程式碼

- [ ] 無程式碼異味(Code Smells)

- [ ] 命名清晰有意義

- [ ] 符合 SOLID 原則

- [ ] 通過靜態分析工具檢查


### 文件與審查

- [ ] 程式碼已提交並推送

- [ ] 通過 Code Review

- [ ] API 文件已更新

- [ ] Commit 訊息清晰


### CI/CD

- [ ] CI Pipeline 執行成功

- [ ] 無安全性漏洞警告

- [ ] 效能測試通過(如適用)


### 簽核

- [ ] 開發者簽核: ___________

- [ ] 審查者簽核: ___________

- [ ] 日期: ___________

```text

---

## 🎯 本章重點回顧

✅ Red 階段:撰寫失敗的測試,明確定義預期行為  
✅ Green 階段:用最簡單的方式讓測試通過  
✅ Refactor 階段:在測試保護下改善程式碼品質  
✅ 持續迭代:透過小步前進逐步完善功能  
✅ 設定明確的完成標準與覆蓋率目標  

---

## 📋 本章檢查清單

在進入下一章前,請確認您已經:


- [ ] 能夠撰寫描述性的測試案例

- [ ] 掌握 Red-Green-Refactor 三階段實踐

- [ ] 了解常用重構技巧

- [ ] 能夠進行小步迭代開發

- [ ] 理解測試覆蓋率的意義與限制

- [ ] 建立開發完成的檢查清單


---

**下一章:** [四、TDD 開發環境與工具](#四tdd-開發環境與工具)

---

## 四、TDD 開發環境與工具

### 4.1 測試框架介紹

#### 🎯 主流測試框架對比

不同程式語言有不同的測試框架,以下介紹常用的選擇:

| 語言 | 測試框架 | 特點 | 適用場景 |
|------|---------|------|---------|
| **Java** | JUnit 5 | 最流行,生態系完整 | 企業級應用 |
| | TestNG | 功能豐富,支援平行測試 | 大型測試套件 |
| **Python** | pytest | 簡潔易用,插件豐富 | 各種專案 |
| | unittest | Python 內建,標準庫 | 簡單專案 |
| **JavaScript** | Jest | All-in-one,零設定 | React/Node.js |
| | Mocha | 靈活可擴展 | 客製化需求 |
| **C#** | xUnit | 現代化,.NET Core 推薦 | .NET 應用 |
| | NUnit | 成熟穩定 | 傳統 .NET |
| **Go** | testing | 官方標準庫 | Go 專案 |
| **Ruby** | RSpec | BDD 風格 | Rails 應用 |

#### 📦 Java - JUnit 5

**安裝設定:**


```xml
<!-- pom.xml -->
<dependencies>
    <!-- JUnit 5 -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.0</version>
        <scope>test</scope>
    </dependency>
    
    <!-- Mockito (Mock 工具) -->
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-core</artifactId>
        <version>5.5.0</version>
        <scope>test</scope>
    </dependency>
</dependencies>

```text

**基本使用:**


```java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

class CalculatorTest {
    
    private Calculator calculator;
    
    @BeforeEach
    void setUp() {
        calculator = new Calculator();
    }
    
    @Test
    @DisplayName("測試加法:2 + 3 應該等於 5")
    void testAdd() {
        int result = calculator.add(2, 3);
        assertEquals(5, result);
    }
    
    @Test
    void testDivide_ByZero_ShouldThrowException() {
        assertThrows(ArithmeticException.class, () -> {
            calculator.divide(10, 0);
        });
    }
    
    @ParameterizedTest
    @ValueSource(ints = {1, 2, 3, 5, 8})
    void testIsPositive(int number) {
        assertTrue(calculator.isPositive(number));
    }
}

```text

**JUnit 5 主要註解:**


```java
@Test                  // 標記測試方法
@DisplayName("...")    // 測試顯示名稱
@BeforeEach           // 每個測試前執行
@AfterEach            // 每個測試後執行
@BeforeAll            // 所有測試前執行一次(static)
@AfterAll             // 所有測試後執行一次(static)
@Disabled             // 暫時停用測試
@RepeatedTest(5)      // 重複執行測試
@ParameterizedTest    // 參數化測試
@Timeout(5)           // 測試逾時限制(秒)

```text

#### 🐍 Python - pytest

**安裝:**


```bash
pip install pytest pytest-cov

```text

**基本使用:**


```python
# calculator.py
class Calculator:
    def add(self, a, b):
        return a + b
    
    def divide(self, a, b):
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return a / b

# test_calculator.py
import pytest
from calculator import Calculator

class TestCalculator:
    
    def setup_method(self):
        """每個測試前執行"""
        self.calc = Calculator()
    
    def test_add(self):
        """測試加法功能"""
        result = self.calc.add(2, 3)
        assert result == 5
    
    def test_divide_by_zero_should_raise_exception(self):
        """測試除以零應拋出例外"""
        with pytest.raises(ValueError, match="Cannot divide by zero"):
            self.calc.divide(10, 0)
    
    @pytest.mark.parametrize("a,b,expected", [
        (2, 3, 5),
        (0, 0, 0),
        (-1, 1, 0),
        (100, 200, 300),
    ])
    def test_add_with_multiple_inputs(self, a, b, expected):
        """參數化測試"""
        assert self.calc.add(a, b) == expected

```text

**執行測試:**


```bash
# 執行所有測試
pytest

# 顯示詳細輸出
pytest -v

# 生成覆蓋率報告
pytest --cov=calculator --cov-report=html

# 只執行特定測試
pytest test_calculator.py::TestCalculator::test_add

# 執行標記的測試
pytest -m slow

```text

**pytest 常用裝飾器:**


```python
@pytest.fixture          # 定義測試夾具(共用資源)
@pytest.mark.parametrize # 參數化測試
@pytest.mark.skip       # 跳過測試
@pytest.mark.skipif     # 條件式跳過
@pytest.mark.xfail      # 預期失敗
@pytest.mark.slow       # 自定義標記

```text

#### 🟨 JavaScript - Jest

**安裝:**


```bash
npm install --save-dev jest

```text

**設定 package.json:**


```json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "jest": {
    "testEnvironment": "node",
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80
      }
    }
  }
}

```text

**基本使用:**


```javascript
// calculator.js
class Calculator {
  add(a, b) {
    return a + b;
  }
  
  divide(a, b) {
    if (b === 0) {
      throw new Error('Cannot divide by zero');
    }
    return a / b;
  }
}

module.exports = Calculator;

// calculator.test.js
const Calculator = require('./calculator');

describe('Calculator', () => {
  let calculator;
  
  beforeEach(() => {
    calculator = new Calculator();
  });
  
  test('should add two numbers correctly', () => {
    const result = calculator.add(2, 3);
    expect(result).toBe(5);
  });
  
  test('should throw error when dividing by zero', () => {
    expect(() => {
      calculator.divide(10, 0);
    }).toThrow('Cannot divide by zero');
  });
  
  test.each([
    [1, 2, 3],
    [0, 0, 0],
    [-1, 1, 0],
  ])('add(%i, %i) should return %i', (a, b, expected) => {
    expect(calculator.add(a, b)).toBe(expected);
  });
});

```text

**Jest 常用 API:**


```javascript
// 斷言
expect(value).toBe(expected)              // 嚴格相等
expect(value).toEqual(expected)           // 深度相等
expect(value).toBeTruthy()                // 真值
expect(value).toBeFalsy()                 // 假值
expect(array).toContain(item)             // 包含元素
expect(fn).toThrow()                      // 拋出例外
expect(fn).toHaveBeenCalled()             // 被呼叫

// 生命週期
beforeAll(() => {})      // 所有測試前
beforeEach(() => {})     // 每個測試前
afterEach(() => {})      // 每個測試後
afterAll(() => {})       // 所有測試後

// 測試組織
describe('group', () => {})  // 測試群組
test('name', () => {})       // 測試案例
it('name', () => {})         // test 的別名

```text

### 4.2 IDE 與工具設定

#### 💻 IntelliJ IDEA 設定

**安裝必要插件:**


1. 開啟 Settings → Plugins

2. 搜尋並安裝:
   - JUnit
   - Code Coverage for Java
   - SonarLint (程式碼品質檢查)

**設定測試執行:**


```text

1. Run → Edit Configurations

2. Add New Configuration → JUnit

3. 設定:
   - Test kind: All in package
   - Package: com.example
   - VM options: -ea (啟用 assertions)
   - Working directory: $MODULE_WORKING_DIR$

```text

**快捷鍵設定:**


```text
Ctrl+Shift+F10 (Win) / Cmd+Shift+R (Mac)  - 執行當前測試
Ctrl+Shift+F9 (Win) / Cmd+Shift+D (Mac)   - Debug 當前測試
Alt+Insert (Win) / Cmd+N (Mac)            - 生成測試方法
Ctrl+Shift+T (Win) / Cmd+Shift+T (Mac)    - 在測試與實作間切換

```text

**即時測試執行(Infinitest):**


```xml
<!-- 安裝 Infinitest 插件 -->
Settings → Plugins → Marketplace → 搜尋 "Infinitest"

```text

設定後,每次儲存程式碼會自動執行相關測試。

#### 🔵 VS Code 設定

**安裝擴充套件:**


1. Java Extension Pack (Java 開發)

2. Test Runner for Java

3. Python Test Explorer (Python 開發)

4. Jest Runner (JavaScript 開發)

5. Coverage Gutters (顯示覆蓋率)

**settings.json 設定:**


```json
{
  "java.test.config": {
    "workingDirectory": "${workspaceFolder}"
  },
  "java.test.defaultConfig": "default",
  "python.testing.pytestEnabled": true,
  "python.testing.unittestEnabled": false,
  "coverage-gutters.coverageFileNames": [
    "coverage/lcov.info",
    "coverage/coverage.xml"
  ],
  "editor.codeActionsOnSave": {
    "source.organizeImports": true
  }
}

```text

**快捷鍵:**


```text
Ctrl+; A (Win) / Cmd+; A (Mac)  - 執行所有測試
Ctrl+; C (Win) / Cmd+; C (Mac)  - 執行當前測試
Ctrl+; L (Win) / Cmd+; L (Mac)  - 執行上次測試

```text

#### 🌙 Eclipse 設定

**安裝插件:**


1. Help → Eclipse Marketplace

2. 搜尋並安裝:
   - EclEmma (覆蓋率工具)
   - MoreUnit (測試輔助)

**執行測試:**


```text
右鍵點擊測試類別或方法
→ Run As → JUnit Test

```text

**快捷鍵:**


```text
Alt+Shift+X, T  - 執行 JUnit 測試
Alt+Shift+D, T  - Debug JUnit 測試
Ctrl+0          - 快速切換至測試

```text

### 4.3 持續整合(CI)與自動化測試

#### 🔄 為什麼需要 CI

**持續整合的價值:**


```mermaid
graph LR
    A[提交程式碼] --> B[自動執行測試]
    B --> C{測試通過?}
    C -->|是| D[合併程式碼]
    C -->|否| E[通知開發者]
    E --> F[修正問題]
    F --> A

```text

**好處:**

- ✅ 自動執行測試,減少人為疏失

- ✅ 及早發現整合問題

- ✅ 確保程式碼品質

- ✅ 自動生成測試報告

- ✅ 強制執行測試標準


#### 🦊 GitLab CI/CD 設定

**建立 .gitlab-ci.yml:**


```yaml
# .gitlab-ci.yml
image: maven:3.8-openjdk-17

stages:
  - test
  - report

variables:
  MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"

cache:
  paths:
    - .m2/repository
    - target/

test:
  stage: test
  script:
    - mvn clean test
  coverage: '/Total.*?([0-9]{1,3})%/'
  artifacts:
    when: always
    reports:
      junit:
        - target/surefire-reports/TEST-*.xml
    paths:
      - target/surefire-reports/
      - target/site/jacoco/
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "main"'

coverage:
  stage: report
  script:
    - mvn jacoco:report
  coverage: '/Total.*?([0-9]{1,3})%/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: target/site/jacoco/jacoco.xml
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

```text

**測試失敗通知設定:**


```yaml
# 加入通知階段
notify_failure:
  stage: .post
  script:
    - 'curl -X POST -H "Content-Type: application/json" 
       -d "{\"text\":\"Test Failed in ${CI_PROJECT_NAME}\"}" 
       $SLACK_WEBHOOK_URL'
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      when: on_failure

```text

#### 🐙 GitHub Actions 設定

**建立 .github/workflows/test.yml:**


```yaml
name: Run Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Set up JDK 17
      uses: actions/setup-java@v3
      with:
        java-version: '17'
        distribution: 'temurin'
        cache: maven
    
    - name: Run tests
      run: mvn clean test
    
    - name: Generate coverage report
      run: mvn jacoco:report
    
    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v3
      with:
        files: ./target/site/jacoco/jacoco.xml
        flags: unittests
        name: codecov-umbrella
    
    - name: Publish test results
      uses: EnricoMi/publish-unit-test-result-action@v2
      if: always()
      with:
        files: target/surefire-reports/*.xml
    
    - name: Comment PR with coverage
      uses: romeovs/lcov-reporter-action@v0.3.1
      if: github.event_name == 'pull_request'
      with:
        lcov-file: ./target/site/jacoco/jacoco.xml
        github-token: ${{ secrets.GITHUB_TOKEN }}

```text

**Python 專案範例:**


```yaml
name: Python Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ['3.8', '3.9', '3.10', '3.11']
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}
    
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install pytest pytest-cov
        pip install -r requirements.txt
    
    - name: Run tests with coverage
      run: |
        pytest --cov=. --cov-report=xml --cov-report=html
    
    - name: Upload coverage
      uses: codecov/codecov-action@v3
      with:
        files: ./coverage.xml

```text

#### 🚀 Jenkins 設定

**Jenkinsfile 範例:**


```groovy
pipeline {
    agent any
    
    tools {
        maven 'Maven 3.8'
        jdk 'JDK 17'
    }
    
    stages {
        stage('Checkout') {
            steps {
                checkout scm
            }
        }
        
        stage('Test') {
            steps {
                sh 'mvn clean test'
            }
        }
        
        stage('Coverage') {
            steps {
                sh 'mvn jacoco:report'
                jacoco(
                    execPattern: 'target/*.exec',
                    classPattern: 'target/classes',
                    sourcePattern: 'src/main/java',
                    inclusionPattern: '**/*.class'
                )
            }
        }
        
        stage('Quality Gate') {
            steps {
                script {
                    def coverage = sh(
                        script: 'mvn jacoco:check',
                        returnStatus: true
                    )
                    if (coverage != 0) {
                        error("Coverage below threshold!")
                    }
                }
            }
        }
    }
    
    post {
        always {
            junit 'target/surefire-reports/*.xml'
            publishHTML([
                reportDir: 'target/site/jacoco',
                reportFiles: 'index.html',
                reportName: 'Coverage Report'
            ])
        }
        failure {
            mail to: 'team@example.com',
                 subject: "Test Failed: ${env.JOB_NAME} - ${env.BUILD_NUMBER}",
                 body: "Check ${env.BUILD_URL}"
        }
    }
}

```text

### 4.4 測試覆蓋率工具

#### 📊 JaCoCo (Java)

**Maven 設定:**


```xml
<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.10</version>
    <executions>
        <execution>
            <id>prepare-agent</id>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
        <execution>
            <id>jacoco-check</id>
            <goals>
                <goal>check</goal>
            </goals>
            <configuration>
                <rules>
                    <rule>
                        <element>PACKAGE</element>
                        <limits>
                            <limit>
                                <counter>LINE</counter>
                                <value>COVEREDRATIO</value>
                                <minimum>0.80</minimum>
                            </limit>
                            <limit>
                                <counter>BRANCH</counter>
                                <value>COVEREDRATIO</value>
                                <minimum>0.70</minimum>
                            </limit>
                        </limits>
                    </rule>
                </rules>
            </configuration>
        </execution>
    </executions>
</plugin>

```text

**生成報告:**


```bash
mvn clean test jacoco:report

# 查看報告
open target/site/jacoco/index.html

```text

#### 🐍 Coverage.py (Python)

**安裝與使用:**


```bash
# 安裝
pip install coverage pytest-cov

# 執行測試並收集覆蓋率
coverage run -m pytest

# 生成報告
coverage report

# 生成 HTML 報告
coverage html
open htmlcov/index.html

# 與 pytest 整合
pytest --cov=mypackage --cov-report=html --cov-report=term

```text

**設定 .coveragerc:**


```ini
[run]
source = src/
omit = 
    */tests/*
    */migrations/*
    */__pycache__/*

[report]
precision = 2
exclude_lines =
    pragma: no cover
    def __repr__
    raise AssertionError
    raise NotImplementedError
    if __name__ == .__main__.:

[html]
directory = htmlcov

```text

#### 🟨 Istanbul/NYC (JavaScript)

**安裝:**


```bash
npm install --save-dev nyc

```text

**package.json 設定:**


```json
{
  "scripts": {
    "test": "jest",
    "test:coverage": "jest --coverage",
    "coverage": "nyc --reporter=html --reporter=text npm test"
  },
  "nyc": {
    "check-coverage": true,
    "lines": 80,
    "statements": 80,
    "functions": 80,
    "branches": 75,
    "include": [
      "src/**/*.js"
    ],
    "exclude": [
      "src/**/*.test.js",
      "src/**/*.spec.js"
    ]
  }
}

```text

**執行:**


```bash
npm run test:coverage
open coverage/index.html

```text

### 4.5 測試資料與 Mock 工具

#### 🎭 Mockito (Java)

**基本使用:**


```java
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;

public class OrderServiceTest {
    
    @Test
    public void testPlaceOrder_ShouldCallRepository() {
        // 建立 Mock 物件
        OrderRepository mockRepository = mock(OrderRepository.class);
        PaymentService mockPayment = mock(PaymentService.class);
        
        OrderService service = new OrderService(mockRepository, mockPayment);
        
        // 設定 Mock 行為
        when(mockPayment.process(any(Order.class))).thenReturn(true);
        when(mockRepository.save(any(Order.class))).thenReturn(new Order("123"));
        
        // 執行測試
        Order order = new Order("ORD-001", 1000);
        service.placeOrder(order);
        
        // 驗證互動
        verify(mockPayment).process(order);
        verify(mockRepository).save(order);
        verify(mockRepository, times(1)).save(any(Order.class));
    }
    
    @Test
    public void testGetOrder_WhenNotFound_ShouldThrowException() {
        OrderRepository mockRepository = mock(OrderRepository.class);
        
        // Mock 拋出例外
        when(mockRepository.findById("999"))
            .thenThrow(new OrderNotFoundException("Order not found"));
        
        OrderService service = new OrderService(mockRepository, null);
        
        assertThrows(OrderNotFoundException.class, () -> {
            service.getOrder("999");
        });
    }
}

```text

**進階技巧:**


```java
// 使用 @Mock 註解
@ExtendWith(MockitoExtension.class)
public class OrderServiceTest {
    
    @Mock
    private OrderRepository repository;
    
    @Mock
    private PaymentService payment;
    
    @InjectMocks  // 自動注入 Mock
    private OrderService service;
    
    @Test
    public void testWithAnnotations() {
        when(payment.process(any())).thenReturn(true);
        
        service.placeOrder(new Order("001", 1000));
        
        verify(repository).save(any());
    }
    
    // ArgumentCaptor: 捕捉參數
    @Test
    public void testCaptureArgument() {
        ArgumentCaptor<Order> captor = ArgumentCaptor.forClass(Order.class);
        
        service.placeOrder(new Order("001", 1500));
        
        verify(repository).save(captor.capture());
        Order captured = captor.getValue();
        assertEquals(1500, captured.getAmount());
    }
    
    // Spy: 部分 Mock
    @Test
    public void testSpyObject() {
        OrderService spy = spy(new OrderService(repository, payment));
        
        doReturn(true).when(spy).isValidOrder(any());
        
        spy.placeOrder(new Order("001", 1000));
    }
}

```text

#### 🐍 unittest.mock (Python)

**基本使用:**


```python
from unittest.mock import Mock, patch, MagicMock
import pytest

class TestOrderService:
    
    def test_place_order_should_call_repository(self):
        # 建立 Mock
        mock_repository = Mock()
        mock_payment = Mock()
        
        service = OrderService(mock_repository, mock_payment)
        
        # 設定回傳值
        mock_payment.process.return_value = True
        mock_repository.save.return_value = Order("123")
        
        # 執行
        order = Order("ORD-001", 1000)
        service.place_order(order)
        
        # 驗證呼叫
        mock_payment.process.assert_called_once_with(order)
        mock_repository.save.assert_called_once()
    
    @patch('order_service.PaymentService')
    @patch('order_service.OrderRepository')
    def test_with_patch_decorator(self, mock_repo_class, mock_payment_class):
        # 使用 @patch 裝飾器
        mock_repo = mock_repo_class.return_value
        mock_payment = mock_payment_class.return_value
        
        mock_payment.process.return_value = True
        
        service = OrderService(mock_repo, mock_payment)
        service.place_order(Order("001", 1000))
        
        assert mock_payment.process.called
    
    def test_mock_side_effect(self):
        # 設定副作用
        mock = Mock()
        mock.get_discount.side_effect = [0.1, 0.2, 0.3]
        
        assert mock.get_discount() == 0.1
        assert mock.get_discount() == 0.2
        assert mock.get_discount() == 0.3
    
    def test_mock_exception(self):
        mock_repo = Mock()
        mock_repo.find_by_id.side_effect = NotFoundException("Not found")
        
        service = OrderService(mock_repo, None)
        
        with pytest.raises(NotFoundException):
            service.get_order("999")

```text

#### 🟨 Jest Mock (JavaScript)


```javascript
// orderService.test.js
const OrderService = require('./orderService');

describe('OrderService', () => {
  let mockRepository;
  let mockPayment;
  let service;
  
  beforeEach(() => {
    // 建立 Mock 函式
    mockRepository = {
      save: jest.fn(),
      findById: jest.fn()
    };
    
    mockPayment = {
      process: jest.fn()
    };
    
    service = new OrderService(mockRepository, mockPayment);
  });
  
  test('should call payment and repository', async () => {
    // 設定 Mock 回傳值
    mockPayment.process.mockResolvedValue(true);
    mockRepository.save.mockResolvedValue({ id: '123' });
    
    const order = { id: 'ORD-001', amount: 1000 };
    await service.placeOrder(order);
    
    // 驗證呼叫
    expect(mockPayment.process).toHaveBeenCalledWith(order);
    expect(mockRepository.save).toHaveBeenCalledTimes(1);
  });
  
  test('should handle payment failure', async () => {
    // Mock 拋出錯誤
    mockPayment.process.mockRejectedValue(new Error('Payment failed'));
    
    const order = { id: 'ORD-001', amount: 1000 };
    
    await expect(service.placeOrder(order))
      .rejects
      .toThrow('Payment failed');
  });
  
  test('should mock implementation', () => {
    mockRepository.findById.mockImplementation((id) => {
      if (id === '123') {
        return { id: '123', amount: 1000 };
      }
      throw new Error('Not found');
    });
    
    const order = mockRepository.findById('123');
    expect(order.amount).toBe(1000);
    
    expect(() => mockRepository.findById('999')).toThrow('Not found');
  });
});

```text

#### 🎲 Faker - 測試資料生成

**Java - JavaFaker:**


```java
import com.github.javafaker.Faker;

public class TestDataBuilder {
    private static Faker faker = new Faker();
    
    public static Customer createRandomCustomer() {
        return new Customer(
            faker.name().fullName(),
            faker.internet().emailAddress(),
            faker.phoneNumber().phoneNumber(),
            faker.address().fullAddress()
        );
    }
    
    public static Product createRandomProduct() {
        return new Product(
            faker.commerce().productName(),
            faker.number().numberBetween(100, 10000)
        );
    }
}

// 使用
@Test
public void testWithRandomData() {
    Customer customer = TestDataBuilder.createRandomCustomer();
    Product product = TestDataBuilder.createRandomProduct();
    
    Order order = new Order(customer);
    order.addItem(product, 1);
    
    assertTrue(order.getTotal() > 0);
}

```text

**Python - Faker:**


```python
from faker import Faker

fake = Faker(['zh_TW'])  # 使用繁體中文

class TestDataBuilder:
    
    @staticmethod
    def create_customer():
        return Customer(
            name=fake.name(),
            email=fake.email(),
            phone=fake.phone_number(),
            address=fake.address()
        )
    
    @staticmethod
    def create_product():
        return Product(
            name=fake.catch_phrase(),
            price=fake.random_int(min=100, max=10000)
        )

# 使用
def test_with_random_data():
    customer = TestDataBuilder.create_customer()
    product = TestDataBuilder.create_product()
    
    order = Order(customer)
    order.add_item(product, 1)
    
    assert order.get_total() > 0

```text

---

## 🎯 本章重點回顧

✅ 選擇適合的測試框架(JUnit, pytest, Jest)  
✅ 設定 IDE 提升測試效率  
✅ 整合 CI/CD 實現自動化測試  
✅ 使用覆蓋率工具追蹤測試完整性  
✅ 善用 Mock 工具隔離依賴  

---

## 📋 本章檢查清單


- [ ] 已安裝測試框架並完成基本設定

- [ ] IDE 測試執行環境已設定完成

- [ ] CI/CD Pipeline 已整合測試流程

- [ ] 覆蓋率工具已設定並能正常生成報告

- [ ] 了解 Mock 工具的使用時機與方法

- [ ] 測試資料生成工具已就緒


---

**下一章:** [五、撰寫良好測試的技巧](#五撰寫良好測試的技巧)

---

## 五、撰寫良好測試的技巧

### 5.1 測試命名規範與可讀性

#### 🎯 為什麼測試命名很重要

好的測試名稱應該:

- 📖 清楚描述測試內容,無需閱讀程式碼

- 🎯 說明測試情境與預期結果

- 📚 作為活文件,讓團隊快速理解功能

- 🐛 測試失敗時能快速定位問題


#### 📝 命名模式

**模式一:方法名_測試情境_預期結果**


```java
// ✅ 清楚的命名
@Test
public void calculateDiscount_WithVIPCustomer_ShouldReturn20Percent() { }

@Test
public void processPayment_WhenBalanceInsufficient_ShouldThrowException() { }

@Test
public void addItem_WithNegativeQuantity_ShouldRejectAndReturnFalse() { }

// ❌ 不好的命名
@Test
public void test1() { }

@Test
public void testDiscount() { }

@Test
public void testPayment() { }

```text

**模式二:Given_When_Then (BDD 風格)**


```java
@Test
public void givenVIPCustomer_whenCalculateDiscount_thenReturn20Percent() {
    // Given (準備)
    Customer vip = new Customer("VIP");
    DiscountCalculator calculator = new DiscountCalculator();
    
    // When (執行)
    double discount = calculator.calculate(vip);
    
    // Then (驗證)
    assertEquals(0.20, discount, 0.01);
}

@Test
public void givenEmptyCart_whenCheckout_thenThrowEmptyCartException() {
    ShoppingCart cart = new ShoppingCart();
    
    assertThrows(EmptyCartException.class, () -> {
        cart.checkout();
    });
}

```text

**模式三:Should 模式(行為描述)**


```java
@Test
public void shouldCalculate20PercentDiscountForVIPCustomer() { }

@Test
public void shouldThrowExceptionWhenPaymentFails() { }

@Test
public void shouldReturnEmptyListWhenNoOrdersFound() { }

```text

#### 🌏 中英文命名對照

**使用 @DisplayName 提供中文說明:**


```java
@Test
@DisplayName("VIP 客戶應該獲得 20% 折扣")
public void calculateDiscount_WithVIPCustomer_ShouldReturn20Percent() {
    // 測試邏輯
}

@Test
@DisplayName("當購物車為空時,結帳應該拋出例外")
public void checkout_WithEmptyCart_ShouldThrowException() {
    // 測試邏輯
}

// Python pytest
def test_vip客戶應該獲得20%折扣():
    # 測試邏輯
    pass

// JavaScript Jest
test('VIP 客戶應該獲得 20% 折扣', () => {
  // 測試邏輯
});

```text

#### 📊 測試組織與群組


```java
@DisplayName("訂單服務測試")
class OrderServiceTest {
    
    @Nested
    @DisplayName("建立訂單")
    class CreateOrder {
        
        @Test
        @DisplayName("成功建立訂單")
        void shouldCreateOrderSuccessfully() { }
        
        @Test
        @DisplayName("訂單金額為負數時應拋出例外")
        void shouldThrowException_WhenAmountIsNegative() { }
    }
    
    @Nested
    @DisplayName("取消訂單")
    class CancelOrder {
        
        @Test
        @DisplayName("成功取消訂單")
        void shouldCancelOrderSuccessfully() { }
        
        @Test
        @DisplayName("已出貨訂單無法取消")
        void shouldNotCancelShippedOrder() { }
    }
}

```text

**測試執行結果:**


```text
OrderServiceTest
├─ CreateOrder
│  ├─ ✓ 成功建立訂單
│  └─ ✓ 訂單金額為負數時應拋出例外
└─ CancelOrder
   ├─ ✓ 成功取消訂單
   └─ ✓ 已出貨訂單無法取消

```text

### 5.2 安排測試結構(Arrange–Act–Assert 模式)

#### 🎯 AAA 模式介紹

**Arrange-Act-Assert (AAA)** 是撰寫測試的標準結構:


```text
Arrange (準備) → Act (執行) → Assert (驗證)

```text


```java
@Test
public void testTransferMoney() {
    // === Arrange (準備測試資料與環境) ===
    Account fromAccount = new Account("A001", 1000);
    Account toAccount = new Account("A002", 500);
    BankService service = new BankService();
    
    // === Act (執行要測試的動作) ===
    service.transfer(fromAccount, toAccount, 300);
    
    // === Assert (驗證結果) ===
    assertEquals(700, fromAccount.getBalance());
    assertEquals(800, toAccount.getBalance());
}

```text

#### 📝 Arrange (準備階段)

**目標:** 準備測試所需的資料、物件與環境

**最佳實踐:**


```java
@Test
public void testApplyDiscount() {
    // Arrange - 清楚區分準備階段
    // 1. 建立測試資料
    Customer vipCustomer = new Customer("John", CustomerType.VIP);
    Product product = new Product("Laptop", 30000);
    ShoppingCart cart = new ShoppingCart(vipCustomer);
    
    // 2. 設定初始狀態
    cart.addItem(product, 1);
    
    // 3. 準備相依物件(Mock)
    PaymentService mockPayment = mock(PaymentService.class);
    when(mockPayment.isAvailable()).thenReturn(true);
    
    OrderService service = new OrderService(mockPayment);
    
    // Act & Assert ...
}

```text

**使用 Builder 模式簡化準備:**


```java
// 測試資料建構器
public class CustomerBuilder {
    private String name = "Default Name";
    private CustomerType type = CustomerType.REGULAR;
    private int points = 0;
    
    public CustomerBuilder withName(String name) {
        this.name = name;
        return this;
    }
    
    public CustomerBuilder asVIP() {
        this.type = CustomerType.VIP;
        return this;
    }
    
    public CustomerBuilder withPoints(int points) {
        this.points = points;
        return this;
    }
    
    public Customer build() {
        return new Customer(name, type, points);
    }
}

// 使用
@Test
public void testVIPDiscount() {
    // Arrange - 使用 Builder 讓準備更清晰
    Customer vip = new CustomerBuilder()
        .withName("Alice")
        .asVIP()
        .withPoints(1000)
        .build();
    
    // Act
    double discount = vip.getDiscount();
    
    // Assert
    assertEquals(0.20, discount, 0.01);
}

```text

#### ⚡ Act (執行階段)

**目標:** 執行要測試的方法或動作

**最佳實踐:**


```java
@Test
public void testCheckout() {
    // Arrange
    ShoppingCart cart = createCartWithItems();
    
    // Act - 通常只有一行,清楚標示要測試的動作
    CheckoutResult result = cart.checkout();
    
    // Assert
    assertTrue(result.isSuccess());
}

```text

**處理例外的 Act:**


```java
@Test
public void testInvalidPayment_ShouldThrowException() {
    // Arrange
    PaymentService service = new PaymentService();
    Payment invalidPayment = new Payment(-100);
    
    // Act & Assert - 例外情況合併處理
    assertThrows(InvalidPaymentException.class, () -> {
        service.process(invalidPayment); // Act
    });
}

```text

**處理非同步的 Act:**


```java
@Test
public void testAsyncOperation() throws Exception {
    // Arrange
    AsyncService service = new AsyncService();
    
    // Act
    CompletableFuture<String> future = service.processAsync("data");
    String result = future.get(5, TimeUnit.SECONDS);
    
    // Assert
    assertEquals("processed: data", result);
}

```text

#### ✅ Assert (驗證階段)

**目標:** 驗證執行結果符合預期

**單一概念驗證:**


```java
// ✅ 好的做法 - 每個測試只驗證一個概念
@Test
public void testCalculateTotal_ShouldSumAllItemPrices() {
    ShoppingCart cart = new ShoppingCart();
    cart.addItem(new Product("A", 100), 2);
    cart.addItem(new Product("B", 200), 1);
    
    int total = cart.calculateTotal();
    
    assertEquals(400, total); // 只驗證總金額計算
}

@Test
public void testAddItem_ShouldIncreaseItemCount() {
    ShoppingCart cart = new ShoppingCart();
    
    cart.addItem(new Product("A", 100), 1);
    
    assertEquals(1, cart.getItemCount()); // 只驗證數量增加
}

// ❌ 不好的做法 - 在一個測試中驗證太多事情
@Test
public void testShoppingCart() {
    ShoppingCart cart = new ShoppingCart();
    cart.addItem(new Product("A", 100), 2);
    
    assertEquals(2, cart.getItemCount());         // 驗證數量
    assertEquals(200, cart.calculateTotal());     // 驗證總額
    assertTrue(cart.hasItem("A"));                // 驗證包含商品
    assertEquals("A", cart.getItems().get(0).getName()); // 驗證商品名稱
    // 太多驗證!測試失敗時難以定位問題
}

```text

**使用適當的斷言方法:**


```java
// 數值比較
assertEquals(expected, actual);
assertEquals(expected, actual, delta); // 浮點數比較
assertNotEquals(unexpected, actual);

// 布林值
assertTrue(condition);
assertFalse(condition);

// Null 檢查
assertNull(object);
assertNotNull(object);

// 物件比較
assertSame(expected, actual);        // 同一物件
assertNotSame(unexpected, actual);

// 陣列/集合
assertArrayEquals(expectedArray, actualArray);
assertIterableEquals(expectedList, actualList);

// 例外
assertThrows(ExceptionClass.class, () -> {
    // 會拋出例外的程式碼
});

// 綜合驗證
assertAll(
    () -> assertEquals(expected1, actual1),
    () -> assertEquals(expected2, actual2),
    () -> assertTrue(condition)
);

```text

**清晰的錯誤訊息:**


```java
// ❌ 沒有訊息
assertEquals(800, cart.getTotal());

// ✅ 提供清晰的錯誤訊息
assertEquals(800, cart.getTotal(), 
    "VIP 客戶購買 1000 元商品應獲得 20% 折扣,總額應為 800");

// ✅ 使用 assertAll 提供完整資訊
assertAll("訂單驗證",
    () -> assertEquals(3, order.getItemCount(), "商品數量不正確"),
    () -> assertEquals(1500, order.getTotal(), "訂單總額不正確"),
    () -> assertEquals("PENDING", order.getStatus(), "訂單狀態不正確")
);

```text

#### 💡 AAA 模式最佳實踐

**實踐 1:視覺上區隔三個階段**


```java
@Test
public void testTransferWithInsufficientBalance() {
    // Arrange
    Account from = new Account(100);
    Account to = new Account(200);
    BankService service = new BankService();
    
    // Act
    TransferResult result = service.transfer(from, to, 150);
    
    // Assert
    assertFalse(result.isSuccess());
    assertEquals("Insufficient balance", result.getMessage());
}

```text

**實踐 2:提取共用準備邏輯**


```java
public class OrderServiceTest {
    
    private OrderService service;
    private OrderRepository mockRepository;
    
    @BeforeEach
    public void setUp() {
        // 共用的 Arrange 邏輯
        mockRepository = mock(OrderRepository.class);
        service = new OrderService(mockRepository);
    }
    
    @Test
    public void testCreateOrder() {
        // Arrange - 只需準備此測試特有的資料
        Order order = new Order("001", 1000);
        when(mockRepository.save(any())).thenReturn(order);
        
        // Act
        Order created = service.createOrder(order);
        
        // Assert
        assertNotNull(created);
        assertEquals("001", created.getId());
    }
}

```text

**實踐 3:使用 Given-When-Then 註解**


```java
@Test
public void testApplyPromoCode() {
    // Given: VIP 客戶與有效的促銷碼
    Customer vip = new CustomerBuilder().asVIP().build();
    PromoCode code = new PromoCode("SUMMER2024", 0.15);
    Order order = new Order(vip, 1000);
    
    // When: 套用促銷碼
    order.applyPromoCode(code);
    
    // Then: 應該獲得 VIP 折扣 20% + 促銷碼 15% = 35% 折扣
    assertEquals(650, order.getFinalPrice());
}

```text

### 5.3 單一職責原則(Single Responsibility Principle in Tests)

#### 🎯 一個測試只測試一件事

**原則:** 每個測試方法應該只驗證一個行為或功能點。

**為什麼重要:**

- 🎯 測試失敗時能快速定位問題

- 📖 測試意圖更清晰

- 🔧 測試更容易維護

- 🚀 測試可獨立執行


**❌ 違反 SRP 的測試:**


```java
@Test
public void testUserRegistrationAndLogin() {
    // 測試了兩件事:註冊 + 登入
    UserService service = new UserService();
    
    // 測試註冊
    User user = service.register("john@example.com", "password123");
    assertNotNull(user);
    assertEquals("john@example.com", user.getEmail());
    
    // 測試登入
    boolean loginSuccess = service.login("john@example.com", "password123");
    assertTrue(loginSuccess);
    
    // 測試登入失敗
    boolean loginFail = service.login("john@example.com", "wrongpassword");
    assertFalse(loginFail);
    
    // 太多職責!
}

```text

**✅ 遵循 SRP 的測試:**


```java
@Test
public void testRegister_WithValidData_ShouldCreateUser() {
    // 只測試註冊功能
    UserService service = new UserService();
    
    User user = service.register("john@example.com", "password123");
    
    assertNotNull(user);
    assertEquals("john@example.com", user.getEmail());
}

@Test
public void testLogin_WithCorrectPassword_ShouldReturnTrue() {
    // 只測試成功登入
    UserService service = new UserService();
    service.register("john@example.com", "password123");
    
    boolean result = service.login("john@example.com", "password123");
    
    assertTrue(result);
}

@Test
public void testLogin_WithWrongPassword_ShouldReturnFalse() {
    // 只測試登入失敗
    UserService service = new UserService();
    service.register("john@example.com", "password123");
    
    boolean result = service.login("john@example.com", "wrongpassword");
    
    assertFalse(result);
}

```text

#### 🔍 識別多職責測試

**警訊:**

- 測試名稱包含"And"、"Or"

- 有多個 Act 階段

- 有多個不相關的 Assert

- 測試程式碼超過 15 行

- 註解說明"然後測試..."、"接著驗證..."


**重構範例:**


```java
// ❌ 多職責測試
@Test
public void testOrderProcessing() {
    Order order = new Order(1000);
    
    // 驗證訂單建立
    assertNotNull(order);
    
    // 加入商品
    order.addItem(new Product("A", 500), 2);
    assertEquals(1000, order.getTotal());
    
    // 套用折扣
    order.applyDiscount(0.1);
    assertEquals(900, order.getTotal());
    
    // 結帳
    order.checkout();
    assertEquals("PAID", order.getStatus());
}

// ✅ 拆分為多個單一職責測試
@Test
public void testAddItem_ShouldUpdateTotal() {
    Order order = new Order();
    
    order.addItem(new Product("A", 500), 2);
    
    assertEquals(1000, order.getTotal());
}

@Test
public void testApplyDiscount_ShouldReduceTotal() {
    Order order = new Order();
    order.addItem(new Product("A", 1000), 1);
    
    order.applyDiscount(0.1);
    
    assertEquals(900, order.getTotal());
}

@Test
public void testCheckout_ShouldChangeStatusToPaid() {
    Order order = new Order();
    order.addItem(new Product("A", 1000), 1);
    
    order.checkout();
    
    assertEquals("PAID", order.getStatus());
}

```text

### 5.4 使用 Mock、Stub、Fake、Spy 的正確時機

#### 🎭 測試替身(Test Doubles)概述


```mermaid
graph TD
    A[Test Doubles<br/>測試替身] --> B[Dummy<br/>虛擬物件]
    A --> C[Stub<br/>樁物件]
    A --> D[Mock<br/>模擬物件]
    A --> E[Spy<br/>間諜物件]
    A --> F[Fake<br/>假物件]

```text

#### 1️⃣ Dummy - 虛擬物件

**定義:** 用來填充參數,但不會被實際使用的物件。

**使用時機:** 方法需要參數但測試中不會用到該參數


```java
@Test
public void testSendEmail() {
    EmailService service = new EmailService();
    User dummyUser = null; // Dummy,不會被使用
    
    // 只測試郵件發送,不關心使用者物件
    service.sendEmail("test@example.com", "Subject", "Body", dummyUser);
    
    // 驗證郵件發送行為
}

```text

#### 2️⃣ Stub - 樁物件

**定義:** 提供預設回應的物件,用於提供測試所需的資料。

**使用時機:** 需要從相依物件取得特定回應


```java
// Stub 實作
public class StubPaymentService implements PaymentService {
    @Override
    public boolean process(Payment payment) {
        return true; // 總是回傳成功
    }
    
    @Override
    public PaymentStatus getStatus(String transactionId) {
        return new PaymentStatus("SUCCESS"); // 固定回應
    }
}

@Test
public void testOrderWithPaymentStub() {
    // Arrange
    PaymentService stubPayment = new StubPaymentService();
    OrderService service = new OrderService(stubPayment);
    Order order = new Order(1000);
    
    // Act
    boolean result = service.processOrder(order);
    
    // Assert
    assertTrue(result);
}

// 使用 Mockito 建立 Stub
@Test
public void testWithMockitoStub() {
    PaymentService stubPayment = mock(PaymentService.class);
    when(stubPayment.process(any())).thenReturn(true); // Stub 行為
    
    OrderService service = new OrderService(stubPayment);
    
    assertTrue(service.processOrder(new Order(1000)));
}

```text

#### 3️⃣ Mock - 模擬物件

**定義:** 可以驗證行為的物件,用於檢查方法是否被正確呼叫。

**使用時機:** 需要驗證互動行為(呼叫次數、參數等)


```java
@Test
public void testOrderShouldCallPaymentService() {
    // Arrange
    PaymentService mockPayment = mock(PaymentService.class);
    when(mockPayment.process(any())).thenReturn(true);
    
    OrderService service = new OrderService(mockPayment);
    Order order = new Order(1000);
    
    // Act
    service.processOrder(order);
    
    // Assert - 驗證互動行為
    verify(mockPayment).process(any(Payment.class)); // 驗證被呼叫
    verify(mockPayment, times(1)).process(any());    // 驗證呼叫次數
    verify(mockPayment, never()).refund(any());      // 驗證未被呼叫
}

@Test
public void testOrderShouldPassCorrectAmount() {
    PaymentService mockPayment = mock(PaymentService.class);
    OrderService service = new OrderService(mockPayment);
    
    service.processOrder(new Order(1500));
    
    // 使用 ArgumentCaptor 捕捉傳遞的參數
    ArgumentCaptor<Payment> captor = ArgumentCaptor.forClass(Payment.class);
    verify(mockPayment).process(captor.capture());
    
    Payment captured = captor.getValue();
    assertEquals(1500, captured.getAmount());
}

```text

**Stub vs Mock 的差異:**


```java
// Stub: 只提供回應,不驗證行為
@Test
public void testWithStub() {
    PaymentService stub = mock(PaymentService.class);
    when(stub.process(any())).thenReturn(true);
    
    service.processOrder(order);
    
    // 不關心 process 是否被呼叫
}

// Mock: 提供回應 + 驗證行為
@Test
public void testWithMock() {
    PaymentService mock = mock(PaymentService.class);
    when(mock.process(any())).thenReturn(true);
    
    service.processOrder(order);
    
    verify(mock).process(any()); // 驗證互動
}

```text

#### 4️⃣ Spy - 間諜物件

**定義:** 使用真實物件,但可以覆寫部分方法。

**使用時機:** 大部分使用真實行為,只需模擬少數方法


```java
@Test
public void testWithSpy() {
    // 使用真實物件建立 Spy
    OrderService realService = new OrderService(realRepository, realPayment);
    OrderService spyService = spy(realService);
    
    // 只覆寫特定方法
    doReturn(true).when(spyService).isValidOrder(any());
    
    // 其他方法使用真實實作
    spyService.processOrder(order);
    
    // 可以驗證真實方法的呼叫
    verify(spyService).sendConfirmationEmail(any());
}

@Test
public void testPartialMock() {
    List<String> list = new ArrayList<>();
    List<String> spyList = spy(list);
    
    // 真實行為
    spyList.add("item1");
    assertEquals(1, spyList.size()); // 真實的 size()
    
    // 覆寫特定方法
    when(spyList.size()).thenReturn(100);
    assertEquals(100, spyList.size()); // 使用模擬的 size()
}

```text

**⚠️ Spy 的陷阱:**


```java
// ❌ 錯誤:會真的呼叫方法
when(spyList.get(0)).thenReturn("mocked");

// ✅ 正確:使用 doReturn
doReturn("mocked").when(spyList).get(0);

```text

#### 5️⃣ Fake - 假物件

**定義:** 有簡化實作的可運作物件(如記憶體資料庫)。

**使用時機:** 真實實作太複雜或太慢,需要輕量級替代


```java
// Fake 實作:記憶體版本的 Repository
public class FakeOrderRepository implements OrderRepository {
    private Map<String, Order> storage = new HashMap<>();
    private int idCounter = 1;
    
    @Override
    public Order save(Order order) {
        if (order.getId() == null) {
            order.setId(String.valueOf(idCounter++));
        }
        storage.put(order.getId(), order);
        return order;
    }
    
    @Override
    public Order findById(String id) {
        Order order = storage.get(id);
        if (order == null) {
            throw new OrderNotFoundException();
        }
        return order;
    }
    
    @Override
    public List<Order> findAll() {
        return new ArrayList<>(storage.values());
    }
}

@Test
public void testWithFakeRepository() {
    // 使用 Fake 替代真實資料庫
    OrderRepository fakeRepo = new FakeOrderRepository();
    OrderService service = new OrderService(fakeRepo);
    
    Order order = new Order(1000);
    Order saved = service.createOrder(order);
    
    assertNotNull(saved.getId());
    assertEquals(order.getAmount(), saved.getAmount());
    
    // Fake 有真實的行為,可以查詢
    Order found = fakeRepo.findById(saved.getId());
    assertEquals(saved.getId(), found.getId());
}

```text

#### 🎯 選擇指南

| 類型 | 提供回應 | 驗證行為 | 有實作邏輯 | 使用時機 |
|------|---------|---------|-----------|---------|
| **Dummy** | ❌ | ❌ | ❌ | 填充參數 |
| **Stub** | ✅ | ❌ | ❌ | 提供測試資料 |
| **Mock** | ✅ | ✅ | ❌ | 驗證互動行為 |
| **Spy** | ✅ | ✅ | ✅(部分) | 部分模擬 |
| **Fake** | ✅ | ❌ | ✅(簡化) | 輕量級替代實作 |

**決策流程:**


```mermaid
graph TD
    A[需要測試替身] --> B{需要驗證<br/>呼叫行為?}
    B -->|是| C[使用 Mock]
    B -->|否| D{需要<br/>回傳資料?}
    D -->|是| E{需要複雜<br/>邏輯?}
    D -->|否| F[使用 Dummy]
    E -->|是| G[使用 Fake]
    E -->|否| H[使用 Stub]

```text

#### 💡 最佳實踐

**實踐 1:優先使用真實物件**


```java
// ✅ 優先使用真實物件
@Test
public void testOrderCalculation() {
    Order order = new Order(); // 真實物件
    order.addItem(new Product("A", 100), 2);
    
    assertEquals(200, order.getTotal());
}

// ⚠️ 只在必要時使用 Mock
@Test
public void testOrderWithExternalService() {
    PaymentService mockPayment = mock(PaymentService.class); // 外部服務才 Mock
    Order order = new Order(); // 內部邏輯用真實物件
    
    OrderService service = new OrderService(mockPayment);
    service.processOrder(order);
}

```text

**實踐 2:不要過度使用 Mock**


```java
// ❌ 過度 Mock
@Test
public void testBadExample() {
    Product mockProduct = mock(Product.class);
    when(mockProduct.getPrice()).thenReturn(100);
    when(mockProduct.getName()).thenReturn("Product");
    
    Customer mockCustomer = mock(Customer.class);
    when(mockCustomer.getName()).thenReturn("John");
    
    // 所有東西都 Mock,失去測試意義!
}

// ✅ 適度使用
@Test
public void testGoodExample() {
    Product product = new Product("Product", 100); // 真實物件
    Customer customer = new Customer("John");      // 真實物件
    
    PaymentService mockPayment = mock(PaymentService.class); // 只 Mock 外部依賴
    when(mockPayment.process(any())).thenReturn(true);
}

```text

**實踐 3:Mock 外部依賴**


```text
✅ 應該 Mock:

- 資料庫

- 外部 API

- 檔案系統

- 網路服務

- 時間/日期

- 隨機數生成器


❌ 不要 Mock:

- 值物件(Value Objects)

- 資料傳輸物件(DTOs)

- 簡單的業務邏輯

- 被測試的類別本身

```text

### 5.5 常見測試陷阱與避免方式

#### 🕳️ 陷阱 1:測試相依性

**問題:** 測試之間互相依賴,執行順序影響結果


```java
// ❌ 錯誤:測試間有相依性
public class UserServiceTest {
    private static User createdUser; // 共享狀態!
    
    @Test
    public void test1_CreateUser() {
        UserService service = new UserService();
        createdUser = service.create("john@example.com");
        assertNotNull(createdUser);
    }
    
    @Test
    public void test2_UpdateUser() {
        // 依賴 test1 的結果
        createdUser.setName("John Doe");
        service.update(createdUser);
    }
}

// ✅ 正確:每個測試獨立
public class UserServiceTest {
    
    @Test
    public void testCreateUser() {
        UserService service = new UserService();
        User user = service.create("john@example.com");
        assertNotNull(user);
    }
    
    @Test
    public void testUpdateUser() {
        UserService service = new UserService();
        // 自行準備測試資料,不依賴其他測試
        User user = service.create("john@example.com");
        user.setName("John Doe");
        
        User updated = service.update(user);
        assertEquals("John Doe", updated.getName());
    }
}

```text

**解決方案:**

- ✅ 每個測試使用 @BeforeEach 準備獨立資料

- ✅ 測試後清理資料(@AfterEach)

- ✅ 避免使用 static 共享狀態

- ✅ 測試應能以任意順序執行


#### 🕳️ 陷阱 2:測試實作細節而非行為

**問題:** 測試程式碼內部實作,而非外部行為


```java
// ❌ 錯誤:測試實作細節
@Test
public void testCalculateDiscount() {
    Order order = new Order(1000);
    
    // 測試內部欄位(實作細節)
    Field field = Order.class.getDeclaredField("discountRate");
    field.setAccessible(true);
    assertEquals(0.1, field.get(order));
    
    // 測試私有方法(實作細節)
    Method method = Order.class.getDeclaredMethod("calculateDiscountAmount");
    method.setAccessible(true);
    assertEquals(100, method.invoke(order));
}

// ✅ 正確:測試外部行為
@Test
public void testCalculateDiscount() {
    Order order = new Order(1000);
    order.applyDiscount(0.1);
    
    // 只測試公開 API 的行為
    assertEquals(900, order.getFinalPrice());
}

```text

**原則:**

- ✅ 只測試 public API

- ✅ 測試"做什麼",不測試"怎麼做"

- ❌ 不要使用反射存取私有成員

- ❌ 不要測試私有方法


#### 🕳️ 陷阱 3:脆弱的測試

**問題:** 測試對程式碼小改動過於敏感


```java
// ❌ 脆弱:硬編碼順序與格式
@Test
public void testGetUserList() {
    List<User> users = service.getAllUsers();
    
    assertEquals("John", users.get(0).getName());
    assertEquals("Jane", users.get(1).getName());
    assertEquals("Bob", users.get(2).getName());
    // 新增使用者或改變順序就會失敗
}

// ✅ 穩定:測試業務邏輯而非實作細節
@Test
public void testGetUserList_ShouldContainAllUsers() {
    List<User> users = service.getAllUsers();
    
    assertEquals(3, users.size());
    assertTrue(users.stream().anyMatch(u -> "John".equals(u.getName())));
    assertTrue(users.stream().anyMatch(u -> "Jane".equals(u.getName())));
    assertTrue(users.stream().anyMatch(u -> "Bob".equals(u.getName())));
}

```text

#### 🕳️ 陷阱 4:忽略測試邊界條件

**問題:** 只測試正常流程,忽略邊界與異常


```java
// ❌ 不完整:只測試正常情況
@Test
public void testDivide() {
    Calculator calc = new Calculator();
    assertEquals(5, calc.divide(10, 2));
}

// ✅ 完整:涵蓋邊界條件
@Test
public void testDivide_NormalCase() {
    Calculator calc = new Calculator();
    assertEquals(5, calc.divide(10, 2));
}

@Test
public void testDivide_ByZero_ShouldThrowException() {
    Calculator calc = new Calculator();
    assertThrows(ArithmeticException.class, () -> {
        calc.divide(10, 0);
    });
}

@Test
public void testDivide_WithNegativeNumbers() {
    Calculator calc = new Calculator();
    assertEquals(-5, calc.divide(-10, 2));
    assertEquals(-5, calc.divide(10, -2));
    assertEquals(5, calc.divide(-10, -2));
}

@Test
public void testDivide_WithZeroDividend() {
    Calculator calc = new Calculator();
    assertEquals(0, calc.divide(0, 5));
}

```text

**邊界條件檢查清單:**

- [ ] 空值(null)

- [ ] 空集合/字串

- [ ] 零值

- [ ] 負數

- [ ] 最大/最小值

- [ ] 邊界值(n-1, n, n+1)


#### 🕳️ 陷阱 5:測試程式碼重複

**問題:** 測試間有大量重複程式碼


```java
// ❌ 重複的測試準備
@Test
public void testVIPDiscount() {
    Customer customer = new Customer("John");
    customer.setType(CustomerType.VIP);
    customer.setPoints(1000);
    Product product = new Product("Laptop", 30000);
    ShoppingCart cart = new ShoppingCart(customer);
    cart.addItem(product, 1);
    
    assertEquals(24000, cart.getTotal());
}

@Test
public void testRegularCustomer() {
    Customer customer = new Customer("Jane");
    customer.setType(CustomerType.REGULAR);
    customer.setPoints(0);
    Product product = new Product("Laptop", 30000);
    ShoppingCart cart = new ShoppingCart(customer);
    cart.addItem(product, 1);
    
    assertEquals(30000, cart.getTotal());
}

// ✅ 提取共用邏輯
public class ShoppingCartTest {
    
    private Product laptop;
    
    @BeforeEach
    public void setUp() {
        laptop = new Product("Laptop", 30000);
    }
    
    private ShoppingCart createCartWithCustomer(CustomerType type) {
        Customer customer = new Customer("Test User");
        customer.setType(type);
        ShoppingCart cart = new ShoppingCart(customer);
        cart.addItem(laptop, 1);
        return cart;
    }
    
    @Test
    public void testVIPDiscount() {
        ShoppingCart cart = createCartWithCustomer(CustomerType.VIP);
        assertEquals(24000, cart.getTotal());
    }
    
    @Test
    public void testRegularCustomer() {
        ShoppingCart cart = createCartWithCustomer(CustomerType.REGULAR);
        assertEquals(30000, cart.getTotal());
    }
}

```text

#### 🕳️ 陷阱 6:沒有 Assert 的測試

**問題:** 測試沒有驗證任何結果


```java
// ❌ 無效測試:沒有 assert
@Test
public void testProcessOrder() {
    OrderService service = new OrderService();
    Order order = new Order(1000);
    service.processOrder(order); // 沒有驗證!
}

// ✅ 有效測試:明確驗證結果
@Test
public void testProcessOrder_ShouldChangeStatus() {
    OrderService service = new OrderService();
    Order order = new Order(1000);
    
    service.processOrder(order);
    
    assertEquals("PROCESSED", order.getStatus());
}

```text

#### 🕳️ 陷阱 7:Sleep/延遲測試

**問題:** 使用 Thread.sleep() 等待非同步操作


```java
// ❌ 不可靠:使用 sleep
@Test
public void testAsyncOperation() throws Exception {
    service.processAsync();
    Thread.sleep(1000); // 不可靠!
    assertTrue(service.isCompleted());
}

// ✅ 可靠:使用適當的同步機制
@Test
public void testAsyncOperation() throws Exception {
    CompletableFuture<Void> future = service.processAsync();
    future.get(5, TimeUnit.SECONDS); // 最多等待 5 秒
    assertTrue(service.isCompleted());
}

// ✅ 或使用 Awaitility 庫
@Test
public void testAsyncWithAwaitility() {
    service.processAsync();
    
    await().atMost(5, SECONDS)
           .until(() -> service.isCompleted());
}

```text

---

## 🎯 本章重點回顧

✅ 使用描述性測試命名,清楚表達測試意圖  
✅ 遵循 AAA 模式組織測試結構  
✅ 每個測試只驗證一個概念(SRP)  
✅ 適當選擇測試替身(Mock/Stub/Fake/Spy)  
✅ 避免常見測試陷阱,撰寫穩定可維護的測試  

---

## 📋 本章檢查清單


- [ ] 測試命名遵循一致的格式

- [ ] 測試使用 AAA 模式組織

- [ ] 每個測試只測試一件事

- [ ] 正確使用 Mock 和 Stub

- [ ] 測試涵蓋邊界條件

- [ ] 測試彼此獨立,無相依性

- [ ] 避免測試實作細節


---

**下一章:** [六、實作範例](#六實作範例)

---

## 六、實作範例

### 6.1 範例一:計算機(Calculator)類別的 TDD 實作

這是最經典的 TDD 入門範例,我們將從零開始實作一個計算機類別。

#### 🎯 需求分析

**功能需求:**

- 加法運算

- 減法運算

- 乘法運算

- 除法運算(需處理除以零的情況)


#### 📝 第一次迭代:加法功能

**步驟 1: Red - 撰寫失敗的測試**


```java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class CalculatorTest {
    
    @Test
    public void testAdd_TwoPositiveNumbers_ShouldReturnSum() {
        // Arrange
        Calculator calculator = new Calculator();
        
        // Act
        int result = calculator.add(2, 3);
        
        // Assert
        assertEquals(5, result);
    }
}

```text

**執行測試:**

```bash
# 測試失敗:Calculator 類別不存在
Error: Cannot resolve symbol 'Calculator'

```text

**步驟 2: Green - 實作最簡單的程式碼**


```java
public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
}

```text

**執行測試:**

```bash
# 測試通過 ✅
Tests run: 1, Failures: 0, Errors: 0

```text

**步驟 3: Refactor - 檢視程式碼**

目前程式碼已經很簡潔,不需要重構。繼續下一個測試案例。

#### 📝 第二次迭代:加法邊界條件

**Red - 加入更多測試案例**


```java
@Test
public void testAdd_WithNegativeNumbers_ShouldReturnCorrectSum() {
    Calculator calculator = new Calculator();
    
    assertEquals(-1, calculator.add(-2, 1));
    assertEquals(-5, calculator.add(-2, -3));
}

@Test
public void testAdd_WithZero_ShouldReturnOtherNumber() {
    Calculator calculator = new Calculator();
    
    assertEquals(5, calculator.add(0, 5));
    assertEquals(5, calculator.add(5, 0));
    assertEquals(0, calculator.add(0, 0));
}

@Test
public void testAdd_WithLargeNumbers_ShouldNotOverflow() {
    Calculator calculator = new Calculator();
    
    // 使用 long 避免溢位
    assertEquals(3000000000L, calculator.addLong(2000000000L, 1000000000L));
}

```text

**Green - 程式碼已支援,測試通過**

需要新增 `addLong` 方法:


```java
public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
    
    public long addLong(long a, long b) {
        return a + b;
    }
}

```text

**Refactor - 重構測試準備邏輯**


```java
public class CalculatorTest {
    
    private Calculator calculator;
    
    @BeforeEach
    public void setUp() {
        calculator = new Calculator();
    }
    
    @Test
    public void testAdd_TwoPositiveNumbers_ShouldReturnSum() {
        assertEquals(5, calculator.add(2, 3));
    }
    
    @Test
    public void testAdd_WithNegativeNumbers_ShouldReturnCorrectSum() {
        assertEquals(-1, calculator.add(-2, 1));
        assertEquals(-5, calculator.add(-2, -3));
    }
    
    // ... 其他測試
}

```text

#### 📝 第三次迭代:減法功能

**Red - 撰寫減法測試**


```java
@Test
public void testSubtract_TwoPositiveNumbers_ShouldReturnDifference() {
    assertEquals(2, calculator.subtract(5, 3));
}

@Test
public void testSubtract_ResultIsNegative_ShouldReturnNegativeNumber() {
    assertEquals(-2, calculator.subtract(3, 5));
}

```text

**Green - 實作減法**


```java
public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
    
    public int subtract(int a, int b) {
        return a - b;
    }
    
    public long addLong(long a, long b) {
        return a + b;
    }
}

```text

#### 📝 第四次迭代:乘法與除法

**Red - 撰寫測試**


```java
@Test
public void testMultiply_TwoPositiveNumbers_ShouldReturnProduct() {
    assertEquals(15, calculator.multiply(3, 5));
}

@Test
public void testMultiply_WithZero_ShouldReturnZero() {
    assertEquals(0, calculator.multiply(5, 0));
    assertEquals(0, calculator.multiply(0, 5));
}

@Test
public void testMultiply_WithNegativeNumber_ShouldReturnNegativeProduct() {
    assertEquals(-15, calculator.multiply(-3, 5));
    assertEquals(-15, calculator.multiply(3, -5));
    assertEquals(15, calculator.multiply(-3, -5));
}

@Test
public void testDivide_TwoPositiveNumbers_ShouldReturnQuotient() {
    assertEquals(3, calculator.divide(15, 5));
}

@Test
public void testDivide_WithRemainder_ShouldReturnIntegerPart() {
    assertEquals(3, calculator.divide(10, 3));
}

@Test
public void testDivide_ByZero_ShouldThrowArithmeticException() {
    assertThrows(ArithmeticException.class, () -> {
        calculator.divide(10, 0);
    });
}

```text

**Green - 實作乘法與除法**


```java
public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
    
    public int subtract(int a, int b) {
        return a - b;
    }
    
    public int multiply(int a, int b) {
        return a * b;
    }
    
    public int divide(int a, int b) {
        if (b == 0) {
            throw new ArithmeticException("Cannot divide by zero");
        }
        return a / b;
    }
    
    public long addLong(long a, long b) {
        return a + b;
    }
}

```text

**Refactor - 最終優化**


```java
public class Calculator {
    
    public int add(int a, int b) {
        return a + b;
    }
    
    public long addLong(long a, long b) {
        return a + b;
    }
    
    public int subtract(int a, int b) {
        return a - b;
    }
    
    public int multiply(int a, int b) {
        return a * b;
    }
    
    public int divide(int a, int b) {
        validateDivisor(b);
        return a / b;
    }
    
    public double divideAsDouble(int a, int b) {
        validateDivisor(b);
        return (double) a / b;
    }
    
    private void validateDivisor(int divisor) {
        if (divisor == 0) {
            throw new ArithmeticException("Cannot divide by zero");
        }
    }
}

```text

#### 📊 完整測試覆蓋率


```bash
mvn clean test jacoco:report

-------------------------------------------------------
Calculator Coverage Report
-------------------------------------------------------
Class           Line Coverage   Branch Coverage
-------------------------------------------------------
Calculator      100% (15/15)    100% (4/4)
-------------------------------------------------------

```text

#### 💡 範例總結

**學習重點:**

1. ✅ 從最簡單的測試案例開始

2. ✅ 每次只加入一個新功能

3. ✅ 測試涵蓋正常情況、邊界條件、異常情況

4. ✅ 透過重構提取共用邏輯(validateDivisor)

5. ✅ 測試先行,讓設計自然浮現

### 6.2 範例二:RESTful API 的 TDD 開發流程

這個範例展示如何使用 TDD 開發一個訂單管理 API。

#### 🎯 需求分析

**API 端點:**

- `POST /api/orders` - 建立訂單

- `GET /api/orders/{id}` - 查詢訂單

- `GET /api/orders` - 查詢所有訂單

- `PUT /api/orders/{id}/cancel` - 取消訂單


**訂單資料結構:**

```json
{
  "id": "ORD-001",
  "customerId": "CUST-001",
  "items": [
    {"productId": "P001", "quantity": 2, "price": 100}
  ],
  "totalAmount": 200,
  "status": "PENDING"
}

```text

#### 📝 第一次迭代:建立訂單 API

**使用技術棧:**

- Spring Boot

- JUnit 5

- MockMvc (測試 HTTP 層)

- Mockito (Mock 依賴)


**步驟 1: Red - 撰寫 API 測試**


```java
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(OrderController.class)
public class OrderControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private OrderService orderService;
    
    @Test
    public void testCreateOrder_WithValidData_ShouldReturn201Created() throws Exception {
        // Arrange
        String requestBody = """
            {
                "customerId": "CUST-001",
                "items": [
                    {"productId": "P001", "quantity": 2, "price": 100}
                ]
            }
            """;
        
        Order createdOrder = new Order("ORD-001", "CUST-001", 200, "PENDING");
        when(orderService.createOrder(any(OrderRequest.class)))
            .thenReturn(createdOrder);
        
        // Act & Assert
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").value("ORD-001"))
            .andExpect(jsonPath("$.customerId").value("CUST-001"))
            .andExpect(jsonPath("$.totalAmount").value(200))
            .andExpect(jsonPath("$.status").value("PENDING"));
    }
    
    @Test
    public void testCreateOrder_WithInvalidData_ShouldReturn400BadRequest() throws Exception {
        // Arrange
        String invalidRequest = """
            {
                "customerId": "",
                "items": []
            }
            """;
        
        // Act & Assert
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(invalidRequest))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.error").exists());
    }
}

```text

**步驟 2: Green - 實作 Controller**


```java
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    
    private final OrderService orderService;
    
    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }
    
    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(
            @Valid @RequestBody OrderRequest request) {
        
        Order order = orderService.createOrder(request);
        OrderResponse response = OrderResponse.from(order);
        
        return ResponseEntity
            .status(HttpStatus.CREATED)
            .body(response);
    }
}

```text

**DTO 類別:**


```java
public class OrderRequest {
    @NotBlank(message = "Customer ID is required")
    private String customerId;
    
    @NotEmpty(message = "Items cannot be empty")
    private List<OrderItemRequest> items;
    
    // Getters and Setters
}

public class OrderItemRequest {
    @NotBlank
    private String productId;
    
    @Min(1)
    private int quantity;
    
    @Min(0)
    private double price;
    
    // Getters and Setters
}

public record OrderResponse(
    String id,
    String customerId,
    double totalAmount,
    String status
) {
    public static OrderResponse from(Order order) {
        return new OrderResponse(
            order.getId(),
            order.getCustomerId(),
            order.getTotalAmount(),
            order.getStatus()
        );
    }
}

```text

**步驟 3: 實作 Service 層測試**


```java
@ExtendWith(MockitoExtension.class)
public class OrderServiceTest {
    
    @Mock
    private OrderRepository orderRepository;
    
    @Mock
    private InventoryService inventoryService;
    
    @InjectMocks
    private OrderService orderService;
    
    @Test
    public void testCreateOrder_ShouldGenerateOrderIdAndSave() {
        // Arrange
        OrderRequest request = createValidOrderRequest();
        Order expectedOrder = new Order("ORD-001", "CUST-001", 200, "PENDING");
        
        when(inventoryService.checkAvailability(anyString(), anyInt()))
            .thenReturn(true);
        when(orderRepository.save(any(Order.class)))
            .thenReturn(expectedOrder);
        
        // Act
        Order result = orderService.createOrder(request);
        
        // Assert
        assertNotNull(result.getId());
        assertEquals("CUST-001", result.getCustomerId());
        assertEquals(200, result.getTotalAmount());
        assertEquals("PENDING", result.getStatus());
        
        verify(orderRepository).save(any(Order.class));
    }
    
    @Test
    public void testCreateOrder_WhenInventoryInsufficient_ShouldThrowException() {
        // Arrange
        OrderRequest request = createValidOrderRequest();
        
        when(inventoryService.checkAvailability(anyString(), anyInt()))
            .thenReturn(false);
        
        // Act & Assert
        assertThrows(InsufficientInventoryException.class, () -> {
            orderService.createOrder(request);
        });
        
        verify(orderRepository, never()).save(any());
    }
    
    private OrderRequest createValidOrderRequest() {
        OrderRequest request = new OrderRequest();
        request.setCustomerId("CUST-001");
        
        OrderItemRequest item = new OrderItemRequest();
        item.setProductId("P001");
        item.setQuantity(2);
        item.setPrice(100);
        
        request.setItems(List.of(item));
        return request;
    }
}

```text

**Service 實作:**


```java
@Service
public class OrderService {
    
    private final OrderRepository orderRepository;
    private final InventoryService inventoryService;
    
    public OrderService(OrderRepository orderRepository, 
                       InventoryService inventoryService) {
        this.orderRepository = orderRepository;
        this.inventoryService = inventoryService;
    }
    
    @Transactional
    public Order createOrder(OrderRequest request) {
        // 驗證庫存
        validateInventory(request);
        
        // 計算總額
        double totalAmount = calculateTotalAmount(request.getItems());
        
        // 建立訂單
        Order order = new Order(
            generateOrderId(),
            request.getCustomerId(),
            totalAmount,
            "PENDING"
        );
        
        // 儲存訂單
        return orderRepository.save(order);
    }
    
    private void validateInventory(OrderRequest request) {
        for (OrderItemRequest item : request.getItems()) {
            boolean available = inventoryService.checkAvailability(
                item.getProductId(),
                item.getQuantity()
            );
            
            if (!available) {
                throw new InsufficientInventoryException(
                    "Product " + item.getProductId() + " is not available"
                );
            }
        }
    }
    
    private double calculateTotalAmount(List<OrderItemRequest> items) {
        return items.stream()
            .mapToDouble(item -> item.getPrice() * item.getQuantity())
            .sum();
    }
    
    private String generateOrderId() {
        return "ORD-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
    }
}

```text

#### 📝 第二次迭代:查詢訂單 API

**Red - 撰寫測試**


```java
@Test
public void testGetOrder_WithValidId_ShouldReturn200AndOrder() throws Exception {
    // Arrange
    Order order = new Order("ORD-001", "CUST-001", 200, "PENDING");
    when(orderService.getOrderById("ORD-001")).thenReturn(order);
    
    // Act & Assert
    mockMvc.perform(get("/api/orders/ORD-001"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.id").value("ORD-001"))
        .andExpect(jsonPath("$.customerId").value("CUST-001"));
}

@Test
public void testGetOrder_WithInvalidId_ShouldReturn404NotFound() throws Exception {
    // Arrange
    when(orderService.getOrderById("INVALID"))
        .thenThrow(new OrderNotFoundException("Order not found"));
    
    // Act & Assert
    mockMvc.perform(get("/api/orders/INVALID"))
        .andExpect(status().isNotFound())
        .andExpect(jsonPath("$.error").value("Order not found"));
}

@Test
public void testGetAllOrders_ShouldReturnOrderList() throws Exception {
    // Arrange
    List<Order> orders = List.of(
        new Order("ORD-001", "CUST-001", 200, "PENDING"),
        new Order("ORD-002", "CUST-002", 300, "COMPLETED")
    );
    when(orderService.getAllOrders()).thenReturn(orders);
    
    // Act & Assert
    mockMvc.perform(get("/api/orders"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$").isArray())
        .andExpect(jsonPath("$.length()").value(2))
        .andExpect(jsonPath("$[0].id").value("ORD-001"))
        .andExpect(jsonPath("$[1].id").value("ORD-002"));
}

```text

**Green - 實作**


```java
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    
    private final OrderService orderService;
    
    // ... constructor
    
    @GetMapping("/{id}")
    public ResponseEntity<OrderResponse> getOrder(@PathVariable String id) {
        Order order = orderService.getOrderById(id);
        return ResponseEntity.ok(OrderResponse.from(order));
    }
    
    @GetMapping
    public ResponseEntity<List<OrderResponse>> getAllOrders() {
        List<Order> orders = orderService.getAllOrders();
        List<OrderResponse> responses = orders.stream()
            .map(OrderResponse::from)
            .toList();
        return ResponseEntity.ok(responses);
    }
}

```text

**全域例外處理:**


```java
@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleOrderNotFound(
            OrderNotFoundException ex) {
        
        ErrorResponse error = new ErrorResponse(
            "NOT_FOUND",
            ex.getMessage()
        );
        
        return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(error);
    }
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationError(
            MethodArgumentNotValidException ex) {
        
        String message = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(FieldError::getDefaultMessage)
            .collect(Collectors.joining(", "));
        
        ErrorResponse error = new ErrorResponse(
            "VALIDATION_ERROR",
            message
        );
        
        return ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(error);
    }
}

public record ErrorResponse(String code, String message) {}

```text

#### 📝 第三次迭代:取消訂單 API

**Red - 撰寫測試**


```java
@Test
public void testCancelOrder_WithPendingOrder_ShouldReturn200() throws Exception {
    // Arrange
    Order cancelledOrder = new Order("ORD-001", "CUST-001", 200, "CANCELLED");
    when(orderService.cancelOrder("ORD-001")).thenReturn(cancelledOrder);
    
    // Act & Assert
    mockMvc.perform(put("/api/orders/ORD-001/cancel"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.status").value("CANCELLED"));
}

@Test
public void testCancelOrder_WithCompletedOrder_ShouldReturn400() throws Exception {
    // Arrange
    when(orderService.cancelOrder("ORD-001"))
        .thenThrow(new IllegalOrderStateException("Cannot cancel completed order"));
    
    // Act & Assert
    mockMvc.perform(put("/api/orders/ORD-001/cancel"))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.error").exists());
}

```text

**Green - 實作**


```java
@PutMapping("/{id}/cancel")
public ResponseEntity<OrderResponse> cancelOrder(@PathVariable String id) {
    Order order = orderService.cancelOrder(id);
    return ResponseEntity.ok(OrderResponse.from(order));
}

```text

**Service 層實作:**


```java
@Transactional
public Order cancelOrder(String orderId) {
    Order order = getOrderById(orderId);
    
    if (!order.canBeCancelled()) {
        throw new IllegalOrderStateException(
            "Order in status " + order.getStatus() + " cannot be cancelled"
        );
    }
    
    order.cancel();
    return orderRepository.save(order);
}

```text

**Order 領域模型:**


```java
@Entity
public class Order {
    @Id
    private String id;
    private String customerId;
    private double totalAmount;
    private String status;
    
    // ... constructors, getters, setters
    
    public boolean canBeCancelled() {
        return "PENDING".equals(status) || "CONFIRMED".equals(status);
    }
    
    public void cancel() {
        if (!canBeCancelled()) {
            throw new IllegalOrderStateException("Cannot cancel order");
        }
        this.status = "CANCELLED";
    }
}

```text

#### 🧪 整合測試


```java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Sql(scripts = "/test-data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "/cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
public class OrderIntegrationTest {
    
    @Autowired
    private TestRestTemplate restTemplate;
    
    @Test
    public void testCompleteOrderWorkflow() {
        // 1. 建立訂單
        OrderRequest request = createOrderRequest();
        ResponseEntity<OrderResponse> createResponse = restTemplate.postForEntity(
            "/api/orders",
            request,
            OrderResponse.class
        );
        
        assertEquals(HttpStatus.CREATED, createResponse.getStatusCode());
        String orderId = createResponse.getBody().id();
        assertNotNull(orderId);
        
        // 2. 查詢訂單
        ResponseEntity<OrderResponse> getResponse = restTemplate.getForEntity(
            "/api/orders/" + orderId,
            OrderResponse.class
        );
        
        assertEquals(HttpStatus.OK, getResponse.getStatusCode());
        assertEquals("PENDING", getResponse.getBody().status());
        
        // 3. 取消訂單
        ResponseEntity<OrderResponse> cancelResponse = restTemplate.exchange(
            "/api/orders/" + orderId + "/cancel",
            HttpMethod.PUT,
            null,
            OrderResponse.class
        );
        
        assertEquals(HttpStatus.OK, cancelResponse.getStatusCode());
        assertEquals("CANCELLED", cancelResponse.getBody().status());
    }
}

```text

#### 💡 範例總結

**學習重點:**

1. ✅ 由外而內測試(Controller → Service → Repository)

2. ✅ 使用 MockMvc 測試 HTTP 層

3. ✅ 使用 Mockito 隔離依賴

4. ✅ 測試涵蓋成功情境與錯誤處理

5. ✅ 整合測試驗證完整流程

### 6.3 範例三:資料庫操作(Repository)的 TDD 測試

這個範例展示如何測試資料存取層。

#### 🎯 需求分析

**Repository 功能:**

- 儲存客戶資料

- 根據 ID 查詢客戶

- 根據 Email 查詢客戶

- 查詢所有活躍客戶

- 更新客戶資料

- 軟刪除客戶


#### 📝 使用 H2 記憶體資料庫進行測試

**測試設定:**


```java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class CustomerRepositoryTest {
    
    @Autowired
    private CustomerRepository customerRepository;
    
    @Autowired
    private TestEntityManager entityManager;
    
    @BeforeEach
    public void setUp() {
        customerRepository.deleteAll();
    }
    
    // ... 測試方法
}

```text

**application-test.yml:**


```yaml
spring:
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true
  h2:
    console:
      enabled: true

```text

#### 📝 第一次迭代:基本 CRUD 操作

**Red - 撰寫測試**


```java
@Test
public void testSave_NewCustomer_ShouldGenerateIdAndSave() {
    // Arrange
    Customer customer = new Customer(
        null,
        "John Doe",
        "john@example.com",
        "0912345678",
        true
    );
    
    // Act
    Customer saved = customerRepository.save(customer);
    
    // Assert
    assertNotNull(saved.getId());
    assertEquals("John Doe", saved.getName());
    assertEquals("john@example.com", saved.getEmail());
    assertTrue(saved.isActive());
}

@Test
public void testFindById_WithExistingId_ShouldReturnCustomer() {
    // Arrange
    Customer customer = createAndSaveCustomer("John Doe", "john@example.com");
    
    // Act
    Optional<Customer> found = customerRepository.findById(customer.getId());
    
    // Assert
    assertTrue(found.isPresent());
    assertEquals("John Doe", found.get().getName());
}

@Test
public void testFindById_WithNonExistingId_ShouldReturnEmpty() {
    // Act
    Optional<Customer> found = customerRepository.findById(999L);
    
    // Assert
    assertFalse(found.isPresent());
}

```text

**Green - 實作 Entity 與 Repository**


```java
@Entity
@Table(name = "customers")
public class Customer {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private String name;
    
    @Column(nullable = false, unique = true)
    private String email;
    
    private String phone;
    
    @Column(nullable = false)
    private boolean active = true;
    
    @CreatedDate
    private LocalDateTime createdAt;
    
    @LastModifiedDate
    private LocalDateTime updatedAt;
    
    // Constructors, Getters, Setters
}

public interface CustomerRepository extends JpaRepository<Customer, Long> {
    // 基本方法由 JpaRepository 提供
}

```text

#### 📝 第二次迭代:自訂查詢方法

**Red - 撰寫測試**


```java
@Test
public void testFindByEmail_WithExistingEmail_ShouldReturnCustomer() {
    // Arrange
    Customer customer = createAndSaveCustomer("John Doe", "john@example.com");
    
    // Act
    Optional<Customer> found = customerRepository.findByEmail("john@example.com");
    
    // Assert
    assertTrue(found.isPresent());
    assertEquals("John Doe", found.get().getName());
}

@Test
public void testFindByEmail_WithNonExistingEmail_ShouldReturnEmpty() {
    // Act
    Optional<Customer> found = customerRepository.findByEmail("nonexist@example.com");
    
    // Assert
    assertFalse(found.isPresent());
}

@Test
public void testFindAllByActiveTrue_ShouldReturnOnlyActiveCustomers() {
    // Arrange
    Customer active1 = createAndSaveCustomer("John", "john@example.com");
    Customer active2 = createAndSaveCustomer("Jane", "jane@example.com");
    Customer inactive = createAndSaveCustomer("Bob", "bob@example.com");
    inactive.setActive(false);
    customerRepository.save(inactive);
    
    // Act
    List<Customer> activeCustomers = customerRepository.findAllByActiveTrue();
    
    // Assert
    assertEquals(2, activeCustomers.size());
    assertTrue(activeCustomers.stream()
        .allMatch(Customer::isActive));
}

@Test
public void testFindByNameContaining_ShouldReturnMatchingCustomers() {
    // Arrange
    createAndSaveCustomer("John Doe", "john@example.com");
    createAndSaveCustomer("Jane Doe", "jane@example.com");
    createAndSaveCustomer("Bob Smith", "bob@example.com");
    
    // Act
    List<Customer> results = customerRepository.findByNameContaining("Doe");
    
    // Assert
    assertEquals(2, results.size());
    assertTrue(results.stream()
        .allMatch(c -> c.getName().contains("Doe")));
}

```text

**Green - 實作查詢方法**


```java
public interface CustomerRepository extends JpaRepository<Customer, Long> {
    
    Optional<Customer> findByEmail(String email);
    
    List<Customer> findAllByActiveTrue();
    
    List<Customer> findByNameContaining(String name);
}

```text

#### 📝 第三次迭代:複雜查詢

**Red - 撰寫測試**


```java
@Test
public void testFindActiveCustomersWithRecentActivity_ShouldReturnCorrectList() {
    // Arrange
    LocalDateTime thirtyDaysAgo = LocalDateTime.now().minusDays(30);
    
    Customer recent = createAndSaveCustomer("Recent User", "recent@example.com");
    recent.setLastActivityDate(LocalDateTime.now().minusDays(5));
    customerRepository.save(recent);
    
    Customer old = createAndSaveCustomer("Old User", "old@example.com");
    old.setLastActivityDate(LocalDateTime.now().minusDays(60));
    customerRepository.save(old);
    
    Customer inactive = createAndSaveCustomer("Inactive User", "inactive@example.com");
    inactive.setActive(false);
    inactive.setLastActivityDate(LocalDateTime.now().minusDays(5));
    customerRepository.save(inactive);
    
    // Act
    List<Customer> results = customerRepository
        .findActiveCustomersWithRecentActivity(thirtyDaysAgo);
    
    // Assert
    assertEquals(1, results.size());
    assertEquals("Recent User", results.get(0).getName());
}

@Test
public void testCountByActiveTrue_ShouldReturnActiveCustomerCount() {
    // Arrange
    createAndSaveCustomer("User1", "user1@example.com");
    createAndSaveCustomer("User2", "user2@example.com");
    
    Customer inactive = createAndSaveCustomer("User3", "user3@example.com");
    inactive.setActive(false);
    customerRepository.save(inactive);
    
    // Act
    long count = customerRepository.countByActiveTrue();
    
    // Assert
    assertEquals(2, count);
}

```text

**Green - 使用 @Query 實作**


```java
public interface CustomerRepository extends JpaRepository<Customer, Long> {
    
    Optional<Customer> findByEmail(String email);
    
    List<Customer> findAllByActiveTrue();
    
    List<Customer> findByNameContaining(String name);
    
    @Query("SELECT c FROM Customer c WHERE c.active = true " +
           "AND c.lastActivityDate >= :since")
    List<Customer> findActiveCustomersWithRecentActivity(
        @Param("since") LocalDateTime since);
    
    long countByActiveTrue();
    
    @Query("SELECT c FROM Customer c WHERE c.email LIKE %:domain")
    List<Customer> findByEmailDomain(@Param("domain") String domain);
}

```text

#### 📝 第四次迭代:事務測試

**Red - 撰寫測試**


```java
@Test
public void testUpdateCustomerEmail_ShouldUpdateAndPersist() {
    // Arrange
    Customer customer = createAndSaveCustomer("John Doe", "old@example.com");
    Long customerId = customer.getId();
    
    // Act
    customer.setEmail("new@example.com");
    customerRepository.save(customer);
    entityManager.flush();
    entityManager.clear(); // 清除快取,強制重新查詢
    
    // Assert
    Customer updated = customerRepository.findById(customerId).orElseThrow();
    assertEquals("new@example.com", updated.getEmail());
}

@Test
public void testDeleteCustomer_ShouldRemoveFromDatabase() {
    // Arrange
    Customer customer = createAndSaveCustomer("John Doe", "john@example.com");
    Long customerId = customer.getId();
    
    // Act
    customerRepository.delete(customer);
    entityManager.flush();
    
    // Assert
    Optional<Customer> found = customerRepository.findById(customerId);
    assertFalse(found.isPresent());
}

@Test
public void testSoftDelete_ShouldSetActiveToFalse() {
    // Arrange
    Customer customer = createAndSaveCustomer("John Doe", "john@example.com");
    Long customerId = customer.getId();
    
    // Act
    customerRepository.softDelete(customerId);
    entityManager.flush();
    entityManager.clear();
    
    // Assert
    Customer found = customerRepository.findById(customerId).orElseThrow();
    assertFalse(found.isActive());
}

```text

**Green - 實作軟刪除**


```java
public interface CustomerRepository extends JpaRepository<Customer, Long> {
    
    // ... 其他方法
    
    @Modifying
    @Query("UPDATE Customer c SET c.active = false WHERE c.id = :id")
    void softDelete(@Param("id") Long id);
}

```text

**Service 層封裝:**


```java
@Service
public class CustomerService {
    
    private final CustomerRepository customerRepository;
    
    public CustomerService(CustomerRepository customerRepository) {
        this.customerRepository = customerRepository;
    }
    
    @Transactional
    public void softDeleteCustomer(Long customerId) {
        customerRepository.softDelete(customerId);
    }
}

```text

#### 💡 測試輔助方法


```java
@DataJpaTest
public class CustomerRepositoryTest {
    
    @Autowired
    private CustomerRepository customerRepository;
    
    @Autowired
    private TestEntityManager entityManager;
    
    // 輔助方法:建立並儲存客戶
    private Customer createAndSaveCustomer(String name, String email) {
        Customer customer = new Customer();
        customer.setName(name);
        customer.setEmail(email);
        customer.setPhone("0912345678");
        customer.setActive(true);
        customer.setLastActivityDate(LocalDateTime.now());
        return customerRepository.save(customer);
    }
    
    // 輔助方法:建立客戶建構器
    private CustomerBuilder aCustomer() {
        return new CustomerBuilder();
    }
    
    private static class CustomerBuilder {
        private String name = "Default Name";
        private String email = "default@example.com";
        private String phone = "0912345678";
        private boolean active = true;
        
        public CustomerBuilder withName(String name) {
            this.name = name;
            return this;
        }
        
        public CustomerBuilder withEmail(String email) {
            this.email = email;
            return this;
        }
        
        public CustomerBuilder inactive() {
            this.active = false;
            return this;
        }
        
        public Customer build() {
            Customer customer = new Customer();
            customer.setName(name);
            customer.setEmail(email);
            customer.setPhone(phone);
            customer.setActive(active);
            customer.setLastActivityDate(LocalDateTime.now());
            return customer;
        }
    }
    
    // 使用建構器的測試
    @Test
    public void testWithBuilder() {
        // Arrange
        Customer customer = aCustomer()
            .withName("John Doe")
            .withEmail("john@example.com")
            .inactive()
            .build();
        
        // Act
        Customer saved = customerRepository.save(customer);
        
        // Assert
        assertFalse(saved.isActive());
    }
}

```text

#### 💡 範例總結

**學習重點:**

1. ✅ 使用 @DataJpaTest 進行 Repository 測試

2. ✅ 使用 H2 記憶體資料庫加速測試

3. ✅ 使用 TestEntityManager 控制持久化上下文

4. ✅ 測試涵蓋基本 CRUD、自訂查詢、事務操作

5. ✅ 使用建構器模式簡化測試資料準備

---

## 🎯 本章重點回顧

✅ 計算機範例:展示基本 TDD 循環  
✅ RESTful API 範例:由外而內的測試策略  
✅ Repository 範例:資料存取層測試技巧  
✅ 每個範例都遵循 Red-Green-Refactor 循環  
✅ 實戰中的測試組織與輔助方法設計  

---

## 📋 本章檢查清單


- [ ] 理解如何從零開始 TDD 開發

- [ ] 掌握 HTTP 層測試技巧(MockMvc)

- [ ] 了解如何測試資料存取層

- [ ] 能夠撰寫測試輔助方法

- [ ] 理解不同層級的測試重點


---

**下一章:** [七、TDD 在團隊開發中的應用](#七tdd-在團隊開發中的應用)

---

## 七、TDD 在團隊開發中的應用

### 7.1 TDD 與敏捷開發(Agile、Scrum)的結合

#### 🎯 TDD 在 Scrum 流程中的定位


```mermaid
graph LR
    A[Sprint Planning] --> B[User Story]
    B --> C[編寫測試]
    C --> D[實作功能]
    D --> E[Code Review]
    E --> F[Sprint Review]
    F --> G[Sprint Retrospective]

```text

**Sprint Planning 階段:**

- 將 User Story 拆分為可測試的任務

- 定義 Acceptance Criteria(驗收標準)

- 估算包含測試撰寫的時間


**Daily Stand-up:**

- 報告:昨天寫了哪些測試,通過了哪些測試

- 問題:遇到難以測試的程式碼設計


**Sprint Review:**

- 展示測試覆蓋率報告

- 展示所有測試通過的 CI 結果


#### 💡 Definition of Done 包含 TDD 要求


```markdown
## Definition of Done


- [ ] 程式碼已完成

- [ ] 單元測試已撰寫且通過(覆蓋率 > 80%)

- [ ] 整合測試已通過

- [ ] Code Review 已完成

- [ ] CI/CD Pipeline 執行成功

- [ ] 文件已更新

- [ ] 已合併至主分支

```text

### 7.2 Pair Programming 與 TDD

#### 🤝 Ping-Pong Pairing

**流程:**


1. **開發者 A**: 撰寫失敗的測試(Red)

2. **開發者 B**: 撰寫讓測試通過的程式碼(Green)

3. **兩人一起**: 重構程式碼(Refactor)

4. **角色互換**: 開發者 B 撰寫下一個測試

**範例:**


```java
// 開發者 A 寫測試
@Test
public void testCalculateShippingCost_ForStandardDelivery() {
    Order order = new Order(1000);
    assertEquals(50, order.calculateShippingCost("STANDARD"));
}

// 開發者 B 實作
public class Order {
    private double amount;
    
    public double calculateShippingCost(String type) {
        if ("STANDARD".equals(type)) {
            return 50;
        }
        return 0;
    }
}

// 兩人一起重構
public class Order {
    private static final Map<String, Double> SHIPPING_COSTS = Map.of(
        "STANDARD", 50.0,
        "EXPRESS", 100.0
    );
    
    public double calculateShippingCost(String type) {
        return SHIPPING_COSTS.getOrDefault(type, 0.0);
    }
}

```text

#### 💡 Pair Programming 最佳實踐


- ⏰ 每 25 分鐘交換角色(Pomodoro 技巧)

- 💬 持續溝通思考過程

- 🎯 專注於當前的測試案例

- 📝 記錄待辦測試清單


### 7.3 Code Review 與測試審查重點

#### 🔍 測試程式碼審查檢查清單


```markdown
## 測試審查檢查清單

### 測試品質

- [ ] 測試命名清晰描述測試意圖

- [ ] 使用 AAA 模式組織測試

- [ ] 每個測試只驗證一個概念

- [ ] 測試彼此獨立,無相依性


### 測試覆蓋

- [ ] 涵蓋正常流程

- [ ] 涵蓋邊界條件

- [ ] 涵蓋異常情況

- [ ] 覆蓋率達到團隊標準(80%+)


### 測試可維護性

- [ ] 沒有重複的測試邏輯

- [ ] 使用測試輔助方法或 Builder

- [ ] Mock 使用適當(不過度)

- [ ] 測試資料清晰易懂


### 測試執行

- [ ] 測試執行速度合理(<5 秒)

- [ ] 測試在 CI 中穩定通過

- [ ] 沒有使用 Thread.sleep()

- [ ] 沒有依賴外部環境

```text

#### 💬 Code Review 對話範例

**情境:過度使用 Mock**


```java
// ❌ 待審查的程式碼
@Test
public void testCreateOrder() {
    Product mockProduct = mock(Product.class);
    when(mockProduct.getPrice()).thenReturn(100);
    when(mockProduct.getName()).thenReturn("Product");
    
    Customer mockCustomer = mock(Customer.class);
    when(mockCustomer.getName()).thenReturn("John");
    
    // 過度 Mock
}

// 💬 Code Review 意見
@Reviewer: "這個測試過度使用 Mock,Product 和 Customer 
是簡單的值物件,建議使用真實物件。"

// ✅ 改進後
@Test
public void testCreateOrder() {
    Product product = new Product("Product", 100);
    Customer customer = new Customer("John");
    
    // 更簡潔,更真實
}

```text

### 7.4 在 CI/CD Pipeline 中整合測試流程

#### 🔄 完整的 CI/CD 流程


```mermaid
graph TD
    A[Push Code] --> B[CI 建置]
    B --> C[執行測試]
    C --> D{測試通過?}
    D -->|否| E[通知開發者]
    D -->|是| F[程式碼檢查]
    F --> G[部署至 Staging]
    G --> H[E2E 測試]
    H --> I{驗證通過?}
    I -->|否| E
    I -->|是| J[部署至 Production]

📝 Jenkins Pipeline 範例

pipeline {
    agent any
    
    stages {
        stage('Checkout') {
            steps {
                checkout scm
            }
        }
        
        stage('Build') {
            steps {
                sh 'mvn clean compile'
            }
        }
        
        stage('Unit Tests') {
            steps {
                sh 'mvn test'
            }
            post {
                always {
                    junit 'target/surefire-reports/*.xml'
                    jacoco(
                        execPattern: 'target/*.exec',
                        classPattern: 'target/classes',
                        sourcePattern: 'src/main/java'
                    )
                }
            }
        }
        
        stage('Quality Gate') {
            steps {
                script {
                    def coverage = sh(
                        script: "mvn jacoco:check",
                        returnStatus: true
                    )
                    if (coverage != 0) {
                        error("Coverage below 80%")
                    }
                }
            }
        }
        
        stage('Integration Tests') {
            steps {
                sh 'mvn verify -P integration-tests'
            }
        }
        
        stage('Deploy to Staging') {
            when {
                branch 'develop'
            }
            steps {
                sh './deploy-staging.sh'
            }
        }
        
        stage('E2E Tests') {
            when {
                branch 'develop'
            }
            steps {
                sh 'npm run test:e2e'
            }
        }
    }
    
    post {
        failure {
            mail to: 'team@example.com',
                 subject: "Build Failed: ${env.JOB_NAME} - ${env.BUILD_NUMBER}",
                 body: "Check ${env.BUILD_URL}"
        }
        success {
            echo 'All tests passed!'
        }
    }
}

```text

### 7.5 建立團隊 TDD 實踐文化

#### 📚 TDD 推廣策略

**階段一:試點專案(1-2 個月)**

- 選擇小型新專案嘗試 TDD

- 指定 TDD 經驗豐富的成員帶領

- 定期分享經驗與困難


**階段二:知識分享(持續進行)**

- 每週 TDD 讀書會

- 內部 TDD 工作坊

- Code Kata 練習(如 Bowling Game, FizzBuzz)


**階段三:全面推廣(3-6 個月)**

- 將 TDD 納入 Definition of Done

- 建立團隊測試規範文件

- Code Review 強化測試品質檢查


**階段四:持續改進(持續)**

- 追蹤測試覆蓋率趨勢

- 定期檢視測試速度

- 分享成功案例


#### 🎓 團隊培訓計畫


```markdown
## TDD 培訓計畫(4 週)

### Week 1: 基礎概念

- TDD 原理與好處

- Red-Green-Refactor 循環

- 測試框架入門(JUnit/pytest)

- 實作:計算機類別


### Week 2: 進階技巧

- AAA 模式

- Mock 與 Stub

- 測試替身使用時機

- 實作:訂單服務


### Week 3: 實務應用

- API 測試

- 資料庫測試

- CI/CD 整合

- 實作:完整功能模組


### Week 4: 團隊實踐

- Pair Programming

- Code Review 技巧

- Legacy Code 重構

- 實際專案練習

```text

#### 💡 克服團隊阻力

**常見反對意見與應對:**

| 反對意見 | 應對策略 |
|---------|---------|
| "TDD 太慢" | 展示長期數據:除錯時間減少 50% |
| "寫測試很無聊" | 推廣 Pair Programming,讓測試撰寫更有趣 |
| "沒時間寫測試" | 強調測試即文件,減少後期維護成本 |
| "測試很難寫" | 提供培訓與 Pair Programming 支援 |
| "改既有程式碼太困難" | 先從新功能開始,逐步改善 |

---

## 八、TDD 常見問題與最佳實踐

### 8.1 常見誤區與修正方式

#### ❌ 誤區 1:先寫所有測試再寫實作

**錯誤做法:**

```java
// 一次寫完所有測試
testAdd()
testSubtract()
testMultiply()
testDivide()

// 然後一次實作所有功能

```text

**正確做法:**

```java
// 一次一個測試 + 實作循環
testAdd() → implement add() → refactor
testSubtract() → implement subtract() → refactor

```text

#### ❌ 誤區 2:測試覆蓋率 100% 就是好測試

**問題:**

- 覆蓋率高不代表測試品質好

- 可能有測試但沒有驗證(assert)


**正確觀念:**

- 關注關鍵業務邏輯的測試

- 確保每個測試都有明確驗證

- 測試應該能抓到真實的 bug


#### ❌ 誤區 3:所有東西都要 Mock

**錯誤:**

```java
Product mockProduct = mock(Product.class);
Customer mockCustomer = mock(Customer.class);
// 連簡單的值物件都 Mock

```text

**正確:**

```java
Product product = new Product("Item", 100); // 真實物件
Customer customer = new Customer("John");    // 真實物件
PaymentService mockPayment = mock(PaymentService.class); // 只 Mock 外部依賴

```text

#### ❌ 誤區 4:測試私有方法

**錯誤:**

```java
@Test
public void testPrivateMethod() {
    Method method = MyClass.class.getDeclaredMethod("privateMethod");
    method.setAccessible(true);
    // 不應該測試私有方法
}

```text

**正確:**

```java
@Test
public void testPublicBehavior() {
    MyClass obj = new MyClass();
    int result = obj.publicMethod(); // 透過公開 API 測試
    assertEquals(expected, result);
}

```text

### 8.2 測試覆蓋率與品質間的平衡

#### 📊 覆蓋率目標建議


```text
核心業務邏輯:    95-100%
服務層:          85-95%
控制器層:        80-90%
工具類別:        90-100%
配置類別:        50-70%

```text

#### 💡 不必追求 100% 覆蓋率的情況


- 簡單的 Getter/Setter

- 框架生成的程式碼

- 配置類別

- 第三方函式庫的封裝


#### ✅ 品質優於數量

**高品質測試的特徵:**

- 測試失敗時能快速定位問題

- 測試名稱清楚描述意圖

- 測試彼此獨立

- 測試執行速度快

- 測試能抓到真實 bug


### 8.3 與 Legacy Code 整合的策略

#### 🔧 漸進式重構策略

**步驟 1:建立特徵測試(Characterization Tests)**


```java
// 先為現有行為寫測試(即使不知道正確結果)
@Test
public void testLegacyBehavior() {
    LegacyClass legacy = new LegacyClass();
    int result = legacy.complexMethod(input);
    
    // 記錄當前行為(即使不確定是否正確)
    assertEquals(currentBehavior, result);
}

```text

**步驟 2:小步重構**


```java
// 提取小方法
public int complexMethod(Input input) {
    validateInput(input); // 提取出來的方法可以寫測試
    int result = calculate(input);
    return result;
}

@Test
public void testValidateInput() {
    // 可以單獨測試提取出來的方法
}

```text

**步驟 3:打破依賴**


```java
// Legacy Code(難以測試)
public class OrderService {
    public void process() {
        Database db = new Database(); // 緊耦合
        db.save(order);
    }
}

// 重構後(可測試)
public class OrderService {
    private final Database database;
    
    public OrderService(Database database) { // 依賴注入
        this.database = database;
    }
    
    public void process() {
        database.save(order);
    }
}

// 現在可以 Mock
@Test
public void testProcess() {
    Database mockDb = mock(Database.class);
    OrderService service = new OrderService(mockDb);
    service.process();
    verify(mockDb).save(any());
}

```text

### 8.4 大型專案中導入 TDD 的建議

#### 📋 導入計畫


**Phase 評估與準備(1 個月)**

- 評估現有程式碼測試覆蓋率

- 選擇測試框架與工具

- 建立 CI/CD Pipeline

- 制定測試規範



**Phase 培訓與試點(2-3 個月)**

- TDD 培訓課程

- 選擇 1-2 個模組試點

- 建立最佳實踐文件

- 定期回顧與改進



**Phase 逐步推廣(6-12 個月)**

- 新功能強制使用 TDD

- 修改既有程式碼時補充測試

- 定期檢視測試品質

- 分享成功案例



**Phase 持續優化(持續)**

- 優化測試執行速度

- 改進測試架構

- 追蹤品質指標

- 建立測試文化


#### 💡 實務建議


**DO (應該做):**



- ✅ 從新功能開始採用 TDD

- ✅ 設定合理的覆蓋率目標

- ✅ 投資測試基礎設施

- ✅ 定期培訓與分享

- ✅ 慶祝測試文化的成功



**DON'T (不要做):**



- ❌ 要求立即達到 100% 覆蓋率

- ❌ 在時程壓力下放棄測試

- ❌ 過度強調覆蓋率數字

- ❌ 忽視測試執行速度

- ❌ 缺乏管理層支持


### 8.5 實務經驗分享與成功案例

#### 📖 案例一:電商平台的 TDD 轉型

**背景:**

- 團隊規模:15 人

- 專案規模:50 萬行程式碼

- 原測試覆蓋率:15%


**實施策略:**

1. 新功能強制 TDD(覆蓋率 > 80%)

2. 修改既有功能時補充測試

3. 每週 Code Kata 練習

4. Pair Programming 推廣

**成果(6 個月後):**

- 測試覆蓋率提升至 65%

- 線上缺陷率降低 60%

- 開發信心大幅提升

- 重構變得更安全


#### 📖 案例二:金融系統的測試改善

**挑戰:**

- 高穩定性要求

- 複雜的業務規則

- Legacy Code 多


**解決方案:**

1. 為核心計算邏輯補充單元測試

2. 建立整合測試環境

3. 引入契約測試(Contract Testing)

4. 定期進行回歸測試

**成果:**

- 核心邏輯覆蓋率達 95%

- 上線前缺陷發現率提升 80%

- 發版信心提升


---

## 九、進階主題

### 9.1 BDD(行為驅動開發)與 TDD 的差異與結合

#### 🎯 BDD 簡介

**BDD (Behavior-Driven Development)** 強調用自然語言描述系統行為。

**Given-When-Then 格式:**


```gherkin
Feature: 購物車結帳
  
  Scenario: VIP 客戶結帳享有折扣
    Given 我是 VIP 客戶
    And 購物車有總價 1000 元的商品
    When 我進行結帳
    Then 我應該支付 800 元
    And 折扣為 200 元

```text

**使用 Cucumber 實作:**


```java
@Given("我是 VIP 客戶")
public void 我是VIP客戶() {
    customer = new Customer(CustomerType.VIP);
}

@Given("購物車有總價 {int} 元的商品")
public void 購物車有總價元的商品(int amount) {
    cart = new ShoppingCart(customer);
    cart.addItem(new Product("商品", amount), 1);
}

@When("我進行結帳")
public void 我進行結帳() {
    result = cart.checkout();
}

@Then("我應該支付 {int} 元")
public void 我應該支付元(int expectedAmount) {
    assertEquals(expectedAmount, result.getFinalAmount());
}

```text

#### 💡 TDD vs BDD

| 面向 | TDD | BDD |
|------|-----|-----|
| 焦點 | 程式碼正確性 | 業務行為 |
| 語言 | 程式碼 | 自然語言 |
| 對象 | 開發者 | 開發者+業務人員 |
| 範圍 | 單元測試為主 | 驗收測試為主 |

**結合使用:**

- 使用 BDD 描述驗收標準

- 使用 TDD 實作細節

- BDD 測試作為高層級規格

- TDD 測試作為底層實作驗證


### 9.2 Property-Based Testing

#### 🎲 概念介紹

傳統測試使用固定輸入,Property-Based Testing 使用隨機輸入驗證屬性。

**範例(使用 jqwik):**


```java
@Property
boolean absoluteValueIsAlwaysPositive(@ForAll int number) {
    return Math.abs(number) >= 0;
}

@Property
boolean reverseTwiceGivesOriginal(@ForAll List<Integer> list) {
    List<Integer> reversed = reverse(reverse(list));
    return list.equals(reversed);
}

@Property
boolean sortedListIsOrdered(@ForAll List<Integer> list) {
    List<Integer> sorted = list.stream()
        .sorted()
        .collect(Collectors.toList());
    
    for (int i = 0; i < sorted.size() - 1; i++) {
        if (sorted.get(i) > sorted.get(i + 1)) {
            return false;
        }
    }
    return true;
}

```text

### 9.3 測試驅動的設計(Test-Driven Design)

TDD 不僅是測試技術,更是設計工具:

**TDD 促進的設計原則:**

1. **單一職責原則(SRP)**: 測試驅動類別職責分離

2. **依賴反轉原則(DIP)**: 可測試性需要依賴注入

3. **介面隔離原則(ISP)**: Mock 需求推動介面設計

4. **開放封閉原則(OCP)**: 測試保護下安全擴展

**設計浮現範例:**


```java
// 第一版:簡單實作
public class OrderService {
    public void process(Order order) {
        Database.save(order);
    }
}

// 為了測試,重構為可注入
public class OrderService {
    private final OrderRepository repository;
    
    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
    
    public void process(Order order) {
        repository.save(order);
    }
}

// 測試驅動出介面設計
public interface OrderRepository {
    void save(Order order);
    Order findById(String id);
}

```text

### 9.4 自動化測試報告與品質儀表板

#### 📊 Allure 測試報告


```xml
<dependency>
    <groupId>io.qameta.allure</groupId>
    <artifactId>allure-junit5</artifactId>
    <version>2.24.0</version>
</dependency>

```text


```java
@Epic("訂單管理")
@Feature("訂單建立")
public class OrderCreationTest {
    
    @Test
    @Story("VIP 客戶訂單")
    @Severity(SeverityLevel.CRITICAL)
    @Description("測試 VIP 客戶建立訂單時自動套用折扣")
    public void testVIPOrderDiscount() {
        // 測試實作
    }
}

```text

#### 📈 SonarQube 品質儀表板

整合 SonarQube 追蹤:

- 程式碼覆蓋率

- 程式碼異味

- 技術債務

- 安全漏洞


---

## 十、附錄

### 10.1 推薦學習資源

#### 📚 經典書籍


1. **Test Driven Development: By Example** - Kent Beck
   - TDD 創始人的經典著作
   - 包含完整範例


2. **Growing Object-Oriented Software, Guided by Tests** - Steve Freeman & Nat Pryce
   - 進階 TDD 技巧
   - 大型系統的測試策略


3. **Working Effectively with Legacy Code** - Michael Feathers
   - 處理舊程式碼的測試策略


4. **xUnit Test Patterns** - Gerard Meszaros
   - 完整的測試模式手冊

#### 🌐 線上資源


- **Martin Fowler's Blog**: https://martinfowler.com

- **Uncle Bob's Blog**: http://blog.cleancoder.com

- **Test Double Blog**: https://blog.testdouble.com


#### 🎥 影片課程


- Test Driven Development - Pluralsight

- TDD with JUnit 5 - Udemy

- Clean Code - Uncle Bob


### 10.2 常用測試工具與框架清單

#### ☕ Java

| 工具/框架 | 用途 | 網址 |
|----------|------|------|
| JUnit 5 | 單元測試框架 | junit.org |
| Mockito | Mock 框架 | site.mockito.org |
| AssertJ | 流暢斷言庫 | assertj.github.io |
| TestContainers | 容器化整合測試 | testcontainers.org |
| JaCoCo | 覆蓋率工具 | jacoco.org |
| Awaitility | 非同步測試 | github.com/awaitility |
| WireMock | HTTP Mock 伺服器 | wiremock.org |
| Rest Assured | REST API 測試 | rest-assured.io |

#### 🐍 Python

| 工具/框架 | 用途 |
|----------|------|
| pytest | 測試框架 |
| unittest.mock | Mock 工具 |
| Coverage.py | 覆蓋率工具 |
| Faker | 測試資料生成 |
| pytest-asyncio | 非同步測試 |
| requests-mock | HTTP Mock |

#### 🟨 JavaScript/TypeScript

| 工具/框架 | 用途 |
|----------|------|
| Jest | 測試框架 |
| Mocha | 測試框架 |
| Chai | 斷言庫 |
| Sinon | Mock/Spy 工具 |
| Cypress | E2E 測試 |
| Testing Library | UI 元件測試 |

### 10.3 TDD 範本專案連結與練習題

#### 🏋️ Code Kata 練習


1. **Bowling Game Kata**
   - 實作保齡球計分系統
   - 學習處理複雜業務規則


2. **FizzBuzz Kata**
   - 經典入門練習
   - 學習基本 TDD 循環


3. **String Calculator Kata**
   - 字串計算機
   - 學習漸進式開發


4. **Roman Numerals Kata**
   - 阿拉伯數字轉羅馬數字
   - 學習演算法實作


5. **Bank Account Kata**
   - 銀行帳戶系統
   - 學習物件設計

#### 🔗 練習資源


- **Cyber-Dojo**: https://cyber-dojo.org

- **Codewars**: https://codewars.com

- **Exercism**: https://exercism.org


### 10.4 專有名詞中英對照表

| 中文 | 英文 | 說明 |
|------|------|------|
| 測試驅動開發 | Test-Driven Development (TDD) | 先寫測試後寫實作的開發方法 |
| 紅燈-綠燈-重構 | Red-Green-Refactor | TDD 的核心循環 |
| 單元測試 | Unit Test | 測試最小可測試單元 |
| 整合測試 | Integration Test | 測試多個元件的整合 |
| 端對端測試 | End-to-End (E2E) Test | 測試完整使用者流程 |
| 測試覆蓋率 | Test Coverage | 測試涵蓋的程式碼比例 |
| 模擬物件 | Mock Object | 用於驗證互動的測試替身 |
| 樁物件 | Stub | 提供固定回應的測試替身 |
| 假物件 | Fake Object | 有簡化實作的測試替身 |
| 間諜物件 | Spy Object | 部分模擬的測試替身 |
| 斷言 | Assertion | 驗證預期結果 |
| 重構 | Refactoring | 在不改變行為下改善程式碼結構 |
| 持續整合 | Continuous Integration (CI) | 自動化建置與測試 |
| 持續部署 | Continuous Deployment (CD) | 自動化部署 |
| 配對程式設計 | Pair Programming | 兩人協同編程 |
| 程式碼審查 | Code Review | 團隊檢視程式碼 |

---

## 🎓 總結

### ✨ TDD 的核心價值


1. **提升程式碼品質**: 強制思考設計,產生高品質程式碼

2. **建立安全網**: 測試保護重構,降低修改風險

3. **加速開發**: 減少除錯時間,長期效益顯著

4. **活文件**: 測試即規格,自動保持更新

5. **設計工具**: TDD 驅動良好的物件導向設計

### 📈 持續改進的建議


- 🎯 設定明確的覆蓋率目標

- 📊 定期檢視測試品質

- 🔄 持續優化測試執行速度

- 📚 不斷學習新的測試技巧

- 🤝 建立團隊測試文化


### 🚀 下一步行動


1. **立即開始**: 從下一個功能開始實踐 TDD

2. **持續練習**: 每週至少一次 Code Kata

3. **分享經驗**: 與團隊分享學習心得

4. **建立習慣**: 將 TDD 融入日常開發流程

---

## 📋 TDD 快速檢查清單

### 開始開發前


- [ ] 理解需求並能描述預期行為

- [ ] 準備好測試環境

- [ ] 列出要測試的案例清單


### Red 階段


- [ ] 撰寫描述性的測試名稱

- [ ] 使用 AAA 模式組織測試

- [ ] 確認測試執行失敗(紅燈)

- [ ] 失敗原因符合預期


### Green 階段


- [ ] 撰寫最簡單的實作

- [ ] 測試執行通過(綠燈)

- [ ] 所有既有測試仍然通過


### Refactor 階段


- [ ] 檢視程式碼異味

- [ ] 消除重複邏輯

- [ ] 改善命名與結構

- [ ] 測試持續保持綠燈


### 完成後


- [ ] 測試覆蓋率達標

- [ ] 程式碼通過 Code Review

- [ ] CI/CD Pipeline 執行成功

- [ ] 文件已更新


---

## 🙏 結語

TDD 不僅是一種測試技術,更是一種開發思維方式。透過持續的練習與實踐,您將體會到 TDD 帶來的價值:


- 💪 更有信心的重構

- 🐛 更少的 bug

- 📖 更好的程式碼文件

- 🎯 更清晰的設計

- 😊 更愉快的開發體驗


**記住**: TDD 是一段旅程,而非目的地。持續學習,持續改進,享受 TDD 帶來的樂趣!

---

**Happy Testing! 🧪✨**

---

*文件版本: 1.0*  
*最後更新: 2025年11月7日*  
*作者: Eric Cheng*