前言

欸,各位工程師朋友好啊!今天 Hikari 我要來跟大家聊一個,在開發流程中很常聽到,但又常常讓人「不知道怎麼寫」、「寫了覺得沒用」的議題:**單元測試 (Unit Test)**!

很多人對單元測試可能抱持著各種想法:

  • 「我們專案沒時間寫單元測試啦!」
  • 「寫了單元測試,結果每次改程式碼都要改測試,超煩的!」
  • 「不知道要測什麼,就隨便測一下,應付 CI/CD 而已。」

這些心聲我完全能理解!單元測試的確是個雙面刃,寫得好能讓你信心滿滿、快速迭代;寫不好則變成沈重的負擔,每次重構都像在拆地雷。其實,問題的核心往往在於我們對「單元測試的邊界」不夠清晰,搞不清楚它到底該測什麼、不該測什麼。

今天,我們就來好好聊聊,一個有效的單元測試,到底應該關注哪些點?又該避開哪些坑? 希望能幫助大家更有效率地運用單元測試,讓它成為你開發路上的好幫手,而不是絆腳石!

單元測試,到底要測什麼?

首先,我們得搞清楚「單元」是什麼?在大多數情況下,一個「單元」指的是你程式碼中最小的、可獨立測試的邏輯片段,通常是一個函式 (Function) 或一個方法 (Method)。重點在於「單一職責 (Single Responsibility)」和「隔離 (Isolation)」。

釐清了「單元」後,我們可以把單元測試的目標聚焦在以下幾個方面:

1. 核心業務邏輯的正確性

這是單元測試最主要的目標。任何重要的商業規則、計算邏輯、演算法,都應該透過單元測試來驗證其正確性。例如:

  • 計算邏輯:折扣計算、稅金計算、分數排名。
  • 資料轉換:輸入字串解析成物件、日期格式轉換。
  • 條件判斷:使用者權限檢查、訂單狀態判斷、商品庫存檢查。

舉個 C# 的例子:

1
2
3
4
5
6
7
8
9
10
11
12
public class OrderService
{
public decimal CalculateTotalPrice(decimal unitPrice, int quantity, decimal discountRate)
{
if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity));
if (discountRate < 0 || discountRate > 1) throw new ArgumentOutOfRangeException(nameof(discountRate));

decimal subtotal = unitPrice * quantity;
decimal discountAmount = subtotal * discountRate;
return subtotal - discountAmount;
}
}

這個 CalculateTotalPrice 方法的單元測試,就應該驗證不同單價、數量、折扣率組合下的總價是否正確。

2. 邊界條件 (Edge Cases)

程式碼最容易出錯的地方之一就是邊界條件。你的函式對於「極端值」、「無效輸入」或「空值」的處理是否正確?例如:

  • 空值/空集合:輸入 null 或空字串 ""、空列表 []。
  • 零或負數:數量為零、價格為負數。
  • 最大/最小值:整數的最大值、字串的最大長度。

延續上面的例子,我們應該測試 quantity 為 0 或負數時,是否會拋出預期的例外;discountRate 超出 0 到 1 範圍時,是否也處理得當。

3. 錯誤處理流程 (Error Handling)

當你的程式碼預期會發生某些錯誤時,單元測試應該確保這些錯誤被正確地捕捉、處理或拋出。這包括:

  • 無效輸入:輸入不符合預期格式時,是否拋出 FormatException 或 ArgumentException。
  • 依賴失敗:模擬內部依賴發生錯誤時,你的單元是否能正確回應。

4. 回歸測試的基礎

單元測試提供了最強大的回歸保護網。當你修改了程式碼,或是重構了一段邏輯時,執行現有的單元測試可以立刻告訴你,你的改動是否意外地破壞了原有的功能。這能極大地增加你對程式碼變更的信心。

單元測試,什麼「不」該測?

理解了該測什麼之後,同樣重要的是知道什麼不該測。把精力浪費在不該測的東西上,只會讓你的測試變得又慢又脆弱,最終成為負擔。

1. 外部依賴 (External Dependencies)

這是單元測試最大的陷阱! 外部依賴包括:

  • **資料庫 (Database)**:SQL Server, MySQL, MongoDB 等。
  • **網路請求 (Network Calls)**:呼叫外部 API、微服務。
  • **檔案系統 (File System)**:讀寫檔案。
  • **時間日期 (Current Date/Time)**:DateTime.Now 或 Date.now()。

為什麼不該測?

  • 不穩定:資料庫可能連線失敗、網路可能延遲或斷線、檔案可能不存在。
  • 太慢:這些操作通常很耗時,會拖慢測試執行速度,導致開發者不愛跑測試。
  • 難以隔離:依賴外部資源會讓測試結果受到環境影響,無法保證在任何地方執行都一致。

那怎麼辦?
使用 Mocking (模擬) 或 **Stubbing (替身)**。簡單來說,就是用一個假的、可控制的物件來替換掉真實的外部依賴。這樣你的單元測試就只關注你自己的程式碼邏輯,而不用擔心外部服務的狀態。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 假設有一個介面定義了外部資料存取
public interface IUserRepository
{
User GetUserById(int id);
}

public class UserService
{
private readonly IUserRepository _userRepository;

public UserService(IUserRepository userRepository)
{
_userRepository = userRepository;
}

public string GetUserName(int userId)
{
var user = _userRepository.GetUserById(userId);
return user?.Name ?? "Guest";
}
}

// 單元測試時,我們不會真的去連資料庫,而是 Mock IUserRepository
// 使用 Moq 這樣的 Mocking Framework
[Test]
public void GetUserName_ShouldReturnUserName_WhenUserExists()
{
// Arrange
var mockRepo = new Mock<IUserRepository>();
mockRepo.Setup(repo => repo.GetUserById(1)).Returns(new User { Id = 1, Name = "Hikari" });
var service = new UserService(mockRepo.Object);

// Act
var userName = service.GetUserName(1);

// Assert
Assert.AreEqual("Hikari", userName);
}

[Test]
public void GetUserName_ShouldReturnGuest_WhenUserDoesNotExist()
{
// Arrange
var mockRepo = new Mock<IUserRepository>();
mockRepo.Setup(repo => repo.GetUserById(2)).Returns((User)null!); // Mock 回傳 null
var service = new UserService(mockRepo.Object);

// Act
var userName = service.GetUserName(2);

// Assert
Assert.AreEqual("Guest", userName);
}

上面這個例子,我們透過 Mock<IUserRepository>() 建立了一個假的 IUserRepository。當 GetUserById(1) 被呼叫時,它會直接回傳一個我們預設好的 User 物件,而不是真的去查資料庫。這樣測試就非常快速且穩定。

2. 框架或函式庫本身

你所使用的程式語言、框架 (例如 ASP.NET Core, Spring Boot, React) 或是第三方函式庫 (例如 Lodash, Moment.js),它們通常都有自己嚴謹的測試。你不需要重新測試這些已經被驗證過的東西。你的重點應該放在你用這些工具組合起來的「獨特邏輯」。

例如,你不需要測試 List<T>.Add() 方法是否能成功加入元素,因為那是 .NET 框架的責任。你需要測試的是你利用 List<T> 實現的某個特定業務邏輯。

3. 程式碼的實作細節 (Implementation Details)

這是一個很常見的誤區。單元測試應該測試**單元的行為 (Behavior),而不是它的內部實作 (Implementation)**。不要測試私有方法、內部狀態,除非這些私有方法或內部狀態是達成公開行為的必要環節。

為什麼不該測?

  • 脆弱的測試:當你重構程式碼,改變了內部實作(例如把一個方法拆成兩個私有方法,或改變變數名稱),但其公開行為不變時,如果你的測試是針對實作細節的,這些測試就會「壞掉」。這會導致你不敢重構,或是重構的成本變高。
  • 失去彈性:測試綁定實作細節,會讓程式碼很難修改。

專注於「當我給這個函式 A 輸入,它應該輸出 B」,而不是「這個函式會先呼叫私有方法 X,然後再呼叫私有方法 Y」。

4. 瑣碎的 Getter/Setter 或純粹的資料傳遞

如果你的類別只是簡單的資料容器 (POCO / DTO),僅包含 get 和 set 屬性,沒有任何業務邏輯,那為這些屬性寫單元測試的投資報酬率通常很低。它們幾乎不可能出錯。把時間花在測試那些真正有邏輯判斷、計算和複雜流程的程式碼上會更有價值。

講到這,什麼才是好的單元測試?

一個好的單元測試,通常會遵循 F.I.R.S.T 原則:

  • **Fast (快速)**:測試應該跑得很快,這樣開發者才會願意頻繁執行。
  • **Independent (獨立)**:每個測試案例都應該獨立於其他測試,執行順序不影響結果。
  • **Repeatable (可重複)**:在任何環境下重複執行,結果都應該一致。
  • **Self-Validating (自我驗證)**:測試結果應該是明確的 Pass 或 Fail,不需要人工判斷。
  • **Timely (及時)**:在寫程式碼之前或之後「立刻」寫,以確保測試與程式碼同步。

同時,要記得:關注行為,而非實作。你的單元測試應該像黑箱一樣,只關心輸入和輸出,而不去窺探盒子裡面的運作方式。這會讓你的測試更堅固,更能承受重構。

小結

單元測試是個強大的工具,但它終究是工具,不是目的。寫單元測試的目的,是為了提高程式碼品質、增加開發信心、降低 Bug 機率,並且加速迭代。

釐清「單元」的邊界,知道哪些該測、哪些不該測,是寫出高效單元測試的關鍵。把時間花在測試核心邏輯、邊界條件和錯誤處理上,並利用 Mocking/Stubbing 來隔離外部依賴,你會發現單元測試不再是負擔,而是你開發流程中不可或缺的加速器。

希望這篇文章能幫助大家對單元測試有更清晰的認識!如果大家有什麼單元測試的實戰心得或踩雷經驗,也歡迎在底下留言跟我分享喔!我們下次見!