本章是全書的起點,從「什麼是單元測試」這個最基本的問題出發,逐步建立起一個精確的定義,並將單元測試與整合測試做出清楚的區隔。最後介紹測試驅動開發(TDD)的概念,以及成功實踐 TDD 所需的三項核心技能。

逐步定義單元測試#

傳統定義#

單元測試的概念可以追溯到 1970 年代的 Smalltalk 語言,由 Kent Beck 引入並推廣至各種程式語言。維基百科給出的經典定義是:

單元測試是一段程式碼(通常是一個方法),它呼叫另一段程式碼,然後檢查某些假設是否正確。如果假設被證明是錯誤的,測試就失敗了。

在這個定義中,unit 指的是一個方法或函式。但作者認為這個定義已經不夠精確。

Unit of Work(工作單元)#

作者重新定義了 unit 的含義——它不再僅僅是一個方法,而是一個 unit of work(工作單元)。

Unit of Work 是從呼叫系統中某個公開方法開始,到產生一個可觀察的結束結果為止的所有動作總和。工作單元的範圍可以小到一個方法,也可以大到橫跨多個類別。

工作單元有三種可觀察的結束結果

  • 回傳值:被呼叫的公開方法回傳一個非 void 的值
  • 狀態改變:系統在呼叫前後產生了可觀察的行為或狀態變化,且不需要探查私有狀態就能確認(例如:使用者登入後系統行為改變)
  • 第三方呼叫:對測試無法控制的外部系統發出呼叫,且該外部系統不回傳任何值(例如:呼叫第三方 logging 系統)
mindmap
  root((Unit of Work 結束結果))
    回傳值
      公開方法回傳非 void 的值
    狀態改變
      系統產生可觀察的行為變化
    第三方呼叫
      對外部系統發出呼叫

更新後的定義#

根據工作單元的概念,作者給出了演進後的定義:

單元測試是一段程式碼,它呼叫一個工作單元,並檢查該工作單元的某一個特定結束結果。如果對結束結果的假設被證明是錯的,測試就失敗了。單元測試的範圍可以小到一個方法,也可以大到多個類別。

好的單元測試的特質#

在定義「好的」單元測試之前,作者提出了一系列自我檢視問題。如果你對以下任何一題的答案是「否」,那你寫的可能不是單元測試,而是整合測試:

  • 能否在兩週或數月後重新執行測試並取得結果?
  • 團隊中的任何成員都能執行我寫的測試嗎?
  • 能否在幾分鐘內跑完所有單元測試?
  • 能否一鍵執行所有單元測試?
  • 能否在幾分鐘內寫出一個基本的測試?

好的單元測試應該具備以下特質:

  • 自動化且可重複執行
  • 容易實作
  • 明天仍然有意義(不會因時間改變而失效)
  • 任何人都能一鍵執行
  • 執行速度快
  • 結果一致(相同條件下每次結果相同)
  • 完全控制被測試的單元
  • 完全隔離(獨立於其他測試執行)
  • 失敗時能清楚指出問題所在

Figure 1.1: In classic testing, developers use a GUI to trigger actions on the class they want to test

整合測試#

什麼是整合測試#

作者將整合測試定義為:凡是不夠快速、不夠一致,且使用了被測試單元的一個或多個真實依賴的測試。

整合測試是在沒有完全控制所有依賴的情況下測試一個工作單元,它使用了真實的依賴項目,例如系統時間、真實檔案系統、真實資料庫、網路、執行緒、亂數產生器等。

簡單來說:整合測試使用真實依賴;單元測試則隔離工作單元與其依賴,使測試結果一致且可完全控制。

整合測試的缺點#

Figure 1.2: You can have many failure points in an integration test

將 1.2 節的自我檢視問題套用到整合測試上,可以看出幾個明顯問題:

問題說明
無法隨時重新執行程式碼不斷變動,若無法在修改後立即執行測試,就可能在不知情的情況下破壞既有功能。作者稱之為「意外引入的 bug」(accidental bugging)
無法讓團隊成員輕易執行需要特定環境設定(如資料庫連線字串),其他人不容易直接跑測試
執行速度慢使用真實資料庫或外部資源,速度遠比記憶體中的操作慢。當測試數量達到數百個時,每半秒都很關鍵
結果不一致例如測試依賴系統時間 DateTime.Now,每次執行的時間都不同,測試本質上就不一致
難以撰寫需要處理所有內部和外部依賴,撰寫和維護成本高
測試太多東西一次測試多個元件,失敗時難以定位問題根源,就像汽車拋錨時很難判斷是哪個子系統出問題

寫一個糟糕的單元測試毫無意義。如果你不自覺地寫出品質差的測試,不如不寫,以免日後在可維護性和時程上付出更大的代價。

Legacy Code(遺留程式碼)#

作者引用了 Michael Feathers 在 Working Effectively with Legacy Code 中的定義:

Legacy code 就是「沒有測試的程式碼」。

這個定義在閱讀本書時值得牢記——沒有測試保護的程式碼,修改時就像在未知的狀態中冒險。

什麼構成好的單元測試#

綜合以上討論,作者給出了單元測試的最終定義

單元測試是一段自動化的程式碼,它呼叫被測試的工作單元,然後檢查該單元某個結束結果的假設。單元測試幾乎都使用單元測試框架來撰寫。它容易撰寫、執行快速、值得信賴、可讀且可維護。只要產品程式碼沒有改變,測試結果就是一致的。

作者也修正了前一版書中「只針對控制流程程式碼測試」的觀點。沒有邏輯的程式碼(如 getter/setter)雖然不需要特別針對它寫測試,但它們會被工作單元使用,所以自然會被涵蓋到。

控制流程程式碼(Control flow code)是指包含邏輯的程式碼:if 陳述式、迴圈、switch/case、計算、或任何決策邏輯。

簡單的單元測試範例#

作者用一個不使用任何測試框架的範例,展示了最原始的單元測試寫法。假設有一個 SimpleParser 類別:

public class SimpleParser
{
    public int ParseAndSum(string numbers)
    {
        if (numbers.Length == 0)
        {
            return 0;
        }
        if (!numbers.Contains(","))
        {
            return int.Parse(numbers);
        }
        else
        {
            throw new InvalidOperationException(
                "I can only handle 0 or 1 numbers for now!");
        }
    }
}

最簡單的測試方式是寫一個 console 應用程式:

class SimpleParserTests
{
    public static void TestReturnsZeroWhenEmptyString()
    {
        try
        {
            SimpleParser p = new SimpleParser();
            int result = p.ParseAndSum(string.Empty);
            if (result != 0)
            {
                Console.WriteLine(
                    @"***SimpleParserTests.TestReturnsZeroWhenEmptyString:
                    Parse and sum should have returned 0 on an empty string");
            }
        }
        catch (Exception e)
        {
            Console.WriteLine(e);
        }
    }
}

Figure 1.3: The traditional way of writing unit tests

這種方式雖然可行,但非常 ad hoc。隨著測試數量增加,你會需要通用的 helper 方法來格式化錯誤訊息、處理 null 檢查等。這正是單元測試框架存在的意義——它們提供了這些基礎設施,讓測試更容易撰寫和維護(第 2 章將介紹如何使用框架)。

測試驅動開發(TDD)#

當你掌握了如何用測試框架撰寫結構化、可維護的測試後,下一個問題是:什麼時候寫測試?

傳統做法是在程式寫完之後補測試,但越來越多人選擇在撰寫產品程式碼之前先寫測試。這種方法稱為 Test-Driven Development(TDD),或稱 test-first development。

TDD 有許多不同的解讀。有人認為它是 test-first development,有人認為它是一種設計方法。在本書中,TDD 指的是 test-first development,設計在其中扮演次要角色。

TDD 的三個步驟#

  1. 撰寫一個失敗的測試:測試撰寫時假設產品程式碼已經完成,因此測試會失敗(證明缺少功能或存在 bug)
  2. 撰寫讓測試通過的產品程式碼:產品程式碼應盡可能簡單
  3. 重構程式碼:測試通過後,可以重構以提升可讀性、移除重複,然後繼續下一個測試
flowchart LR
    A["1. 撰寫<br/>失敗的測試"] --> B["2. 撰寫<br/>通過的程式碼"]
    B --> C[3. 重構]
    C --> A

Figure 1.4: Test-driven development — a bird's-eye view

TDD 的價值與風險#

正確執行 TDD 可以帶來顯著好處:

  • 提升程式碼品質
  • 減少 bug 數量
  • 提高對程式碼的信心
  • 縮短找到 bug 的時間
  • 改善程式設計
  • 「測試你的測試」——先看測試失敗再看它通過,等於驗證了測試本身的正確性

TDD 是一把雙面刃。執行不當可能導致專案延遲、浪費時間、降低動力和程式碼品質。TDD 本身不保證專案成功或測試的健壯性與可維護性。

TDD 成功的三項核心技能#

成功的 TDD 需要三種獨立的技能,作者建議一次專注學習一種,而非同時學習:

  1. 寫好測試的能力:知道如何撰寫可信賴、可讀、可維護的測試。這正是本書的核心主題
  2. Test-first 的能力:在產品程式碼之前先寫測試。這是大多數 TDD 書籍教的內容,但光會 test-first 不代表測試品質好。推薦 Kent Beck 的 Test-Driven Development: by Example
  3. 良好的設計能力:即使測試可讀且可維護,也不代表最終會得到好的系統設計。設計技能需要另外培養。推薦 Growing Object-Oriented Software, Guided by TestsClean Code
mindmap
  root((TDD 三項核心技能))
    寫好測試的能力
      可信賴、可讀、可維護
      本書核心主題
    Test-first 的能力
      先寫測試再寫程式碼
    良好的設計能力
      系統架構設計
      需另外培養

許多人試圖同時學習這三種技能,結果因為難度過高而放棄。建議採取漸進式學習——一次專注一種技能,降低挫折感,提高成功機率。

總結#

本章建立的核心概念:

  • 單元測試是一段自動化程式碼,呼叫工作單元並檢查某個結束結果,使用測試框架撰寫,容易寫、跑得快、結果一致、值得信賴
  • 整合測試使用真實依賴,難以撰寫和自動化、執行慢、需要環境設定。雖然整合測試也有其價值,但應與單元測試分開管理
  • TDD 是先寫測試再寫產品程式碼的開發方式,能確保高程式碼覆蓋率並「測試你的測試」,但需要正確執行才能發揮效益
  • 成功的 TDD 需要三項獨立技能:好的測試、test-first、好的設計,建議逐一學習