本章探討測試程式碼的組織方式,包含如何在自動化建置中執行測試、如何依速度與類型分類測試、測試與原始碼控制的關係、測試類別如何對應到產品程式碼,以及如何為應用程式建立可重用的測試 API。測試程式碼與產品程式碼一樣重要,如果放錯位置或缺乏良好的組織結構,辛苦撰寫的測試可能不會被執行,或者變得難以維護。

自動化建置執行自動化測試#

自動化建置流程(automated build process)是讓團隊更有生產力、更快獲得回饋的關鍵。一個完整的建置流程需要能夠做到:

  • 對程式碼進行小幅修改
  • 執行所有測試以確認沒有破壞既有功能
  • 確保程式碼仍能與其他依賴的專案良好整合
  • 一鍵建立可交付的部署套件並自動部署

建置流程是一個邏輯概念,涵蓋建置腳本(build scripts)、自動化觸發器(triggers)、建置伺服器(build server)、建置代理(build agents),以及團隊對於如何部署與整合程式碼的共識。

建置腳本的結構#

作者建議採用多個單一用途的建置腳本,以利於維護與保持建置流程的一致性。典型的建置腳本包含三種:

  • 持續整合(CI)建置腳本 – 至少會編譯目前原始碼(debug 模式)並執行所有單元測試。目標是在最短時間內提供最多資訊,讓開發者快速確認沒有破壞任何東西。
  • 夜間建置腳本(Nightly build) – 執行時間較長,涵蓋 CI 建置中被略過的任務,例如 release 模式編譯、執行所有較慢的測試、部署到測試環境等。每天至少執行一次。
  • 部署建置腳本(Deployment build) – 本質上是交付機制,由 CI 伺服器觸發,可以簡單到用 xcopy 複製到遠端伺服器,也可以複雜到部署到數百台伺服器或雲端環境。

觸發建置與持續整合#

持續整合(Continuous Integration) 就是讓自動化建置與整合流程持續運行。CI 伺服器的主要工作包含:

  • 根據特定事件(原始碼簽入、時間間隔、其他建置完成等)觸發建置腳本
  • 提供建置腳本所需的上下文與資料(版本、原始碼、其他建置的產出物等)
  • 提供建置歷史記錄與指標的概覽
  • 提供所有活躍與非活躍建置的目前狀態

一個建置配置(build configuration)包含要執行的命令、原始碼的快照、環境變數設定,以及可能來自先前建置的產出物。CI 伺服器通常會提供儀表板顯示建置狀態,並在建置失敗時通知相關人員。

作者建議將建置腳本的動作保持為版本控制的一部分,而非使用 CI 伺服器的內建功能建立任務,這樣可以確保任何版本的原始碼都能對應到正確的建置動作。

依速度與類型分類測試#

檢查測試的執行時間,區分哪些是整合測試、哪些是單元測試,然後將它們放在不同的地方。可以放在不同的資料夾和命名空間中,也可以放在完全獨立的測試專案中。

Figure 7.1: A simple folder structure for test projects

Figure 7.2: The unit testing and integration test projects have different namespaces

人為因素:為什麼要分離#

如果不分離單元測試與整合測試,會有很大的風險:人們不會經常執行測試。當開發者取得最新版本的原始碼後發現某些測試失敗,可能的原因包含:

  • 被測程式碼有 bug
  • 測試本身寫法有問題
  • 測試已經不再相關
  • 測試需要特定的環境配置才能執行

最後一點(配置問題)最為關鍵。將需要配置的整合測試混在單元測試中,就像一籃水果中混了壞蘋果,會讓所有測試看起來都不可信。開發者可能因此養成忽視測試失敗的習慣:「喔,那個測試有時候會失敗,沒關係的。」

安全綠區(Safe Green Zone)#

將整合測試與單元測試放在不同的位置,為團隊建立一個安全綠區

  • 安全綠區只包含單元測試
  • 開發者拿到最新程式碼後,可以執行該命名空間或資料夾中的所有測試
  • 測試應該全部通過(綠燈)
  • 如果安全綠區中的測試未通過,代表真正的問題,而非誤報的配置問題

這並不代表整合測試就不該全部通過。但因為整合測試本質上執行時間較長,開發者更可能頻繁執行單元測試,而將整合測試留到夜間建置中執行。安全綠區讓開發者在所有單元測試通過時,至少擁有部分的信心。

另外,為整合測試建立一個獨立的整合區域(integration zone),不僅是隔離執行較慢的測試,也是放置配置文件的地方,說明如何讓這些測試正確運作。

確保測試是原始碼控制的一部分#

測試程式碼必須存在於原始碼控制儲存庫中,就像正式的產品程式碼一樣。應該:

  • 將測試程式碼視為與產品程式碼同等重要
  • 測試應該是每個產品版本分支的一部分
  • 取得最新版本時,測試應該自動包含在內

因為單元測試與程式碼和 API 緊密相連,它們應該始終與所測試的程式碼版本保持一致。取得產品的 1.0.1 版本,同時也應該取得該版本的測試;1.0.2 版本的產品及其測試則會有所不同。將測試納入原始碼控制樹,也是讓自動化建置流程能夠正確執行對應版本測試的前提。

將測試類別對應到程式碼#

建立測試類別時,其結構與位置應該讓人能夠輕鬆做到以下三件事:

  • 看到一個專案,就能找到所有相關的測試
  • 看到一個類別,就能找到所有相關的測試
  • 看到一個方法,就能找到所有相關的測試

對應到專案#

為測試建立一個與被測專案同名的專案,在名稱末尾加上 .UnitTests。例如:

  • 產品專案:Osherove.MyLibrary
  • 單元測試專案:Osherove.MyLibrary.UnitTests
  • 整合測試專案:Osherove.MyLibrary.IntegrationTests

雖然看起來簡單,但這種做法直覺且有效,讓開發者能快速找到特定專案的所有測試。

對應到類別#

有兩種主要的對應方式:

每個類別一個測試類別 – 最簡單且最常見的模式。將被測類別的所有方法的測試放在一個大的測試類別中。測試類別的命名規則:取被測類別的名稱,加上 UnitTests 後綴。例如 LogAnalyzer 對應 LogAnalyzer.UnitTests

每個功能一個測試類別 – 當某個方法的測試案例太多,導致測試類別難以閱讀時,可以為該功能建立獨立的測試類別。例如 LoginManager 類別的 ChangePassword 方法測試太多時,可以拆分為:

  • LoginManagerTests – 包含其他所有測試
  • LoginManagerTestsChangePassword – 只包含 ChangePassword 方法的測試

測試的可讀性非常重要。你撰寫測試不僅是給電腦執行的,更是給人閱讀的。下一章會深入探討可讀性的面向。

對應到方法入口點#

為了讓所有針對特定工作單元的測試方法易於找到,應該給予測試方法有意義的名稱。可以使用第二章介紹的命名慣例:MethodName_Scenario_ExpectedBehavior。例如 ChangePassword_scenario_expectedbehavior

橫切關注點注入#

當處理橫切關注點(cross-cutting concerns)如時間管理、例外處理或日誌記錄時,在所有使用的地方都透過介面注入可能會讓程式碼變得難以閱讀且不易維護。

DateTime 為例,如果應用程式在很多地方使用目前時間(排程、日誌等),為每個使用點都建立 ITimeProvider 介面注入會非常繁瑣。作者建議一個更直接的做法:建立一個自訂的 SystemTime 類別。

public class SystemTime
{
    private static DateTime _date;

    public static void Set(DateTime custom)
    {
        _date = custom;
    }

    public static void Reset()
    {
        _date = DateTime.MinValue;
    }

    public static DateTime Now
    {
        get
        {
            if (_date != DateTime.MinValue)
            {
                return _date;
            }
            return DateTime.Now;
        }
    }
}

讓所有產品程式碼使用 SystemTime.Now 而非 DateTime.Now,這樣在測試中就可以透過 SystemTime.Set() 來控制時間:

[TestFixture]
public class TimeLoggerTests
{
    [Test]
    public void SettingSystemTime_Always_ChangesTime()
    {
        SystemTime.Set(new DateTime(2000, 1, 1));
        string output = TimeLogger.CreateMessage("a");
        StringAssert.Contains("01.01.2000", output);
    }

    [TearDown]
    public void afterEachTest()
    {
        SystemTime.Reset();
    }
}

這種做法的好處是不需要在應用程式中注入大量介面,只需要在測試類別的 [TearDown] 方法中重置時間即可。但這種技巧僅適用於系統中廣泛使用的橫切關注點,若過度使用反而會造成程式碼同樣難以閱讀。

要確保團隊中沒有人直接使用 DateTime,可以透過 code review,或者在轉換既有專案時,使用「尋找與取代」將所有 DateTime 替換為 SystemTime

為應用程式建立測試 API#

隨著測試越寫越多,你必然會需要重構測試、建立工具方法和工具類別。這些構件(在測試專案或被測程式碼中)的目的是提升可測試性和測試的可維護性。主要包含三個面向:

  • 在測試類別中使用繼承來達成程式碼重用與指引
  • 建立測試工具類別與方法
  • 讓開發者知道你的 API 存在

測試類別繼承模式#

在物件導向的測試程式碼中使用繼承,可以解決以下常見問題:

  • 重用工具方法與 factory 方法
  • 在不同的類別上執行相同的測試集合
  • 使用共用的 setup 或 teardown 程式碼
  • 為繼承基底類別的開發者提供測試指引

作者介紹了三種漸進式的繼承模式:

模式一:抽象測試基礎架構類別(Abstract Test Infrastructure Class)

建立一個抽象的基底測試類別,包含跨測試類別共用的基礎架構。例如,當多個測試類別都需要 fake 掉 LoggingFacility 的 logger 實作時,可以將這段邏輯抽取到基底類別:

[TestFixture]
public class BaseTestsClass
{
    public ILogger FakeTheLogger()
    {
        LoggingFacility.Logger = Substitute.For<ILogger>();
        return LoggingFacility.Logger;
    }

    [TearDown]
    public void teardown()
    {
        LoggingFacility.Logger = null;
    }
}

[TestFixture]
public class LogAnalyzerTests : BaseTestsClass
{
    [Test]
    public void Analyze_EmptyFile_ThrowsException()
    {
        FakeTheLogger();
        LogAnalyzer la = new LogAnalyzer();
        la.Analyze("myemptyfile.txt");
        //rest of test
    }
}

不要在基底類別中使用 [SetUp] 方法來自動初始化,因為這會降低衍生測試類別的可讀性。閱讀者必須跳到基底類別才能理解 setup 做了什麼。改用明確呼叫的工具方法(如 FakeTheLogger())會更清楚。此外,避免超過一層的繼承,否則測試會變得難以理解。

模式二:模板測試類別(Template Test Class)

Figure 7.3: A typical inheritance hierarchy that you'd like to test

當你有一組實作相同介面或繼承相同基底類別的類別時,模板測試類別模式非常有用。這個模式使用一個包含抽象測試方法的抽象類別,衍生類別必須實作這些測試。

BaseStringParser 的繼承體系為例,XMLStringParserIISLogStringParserStandardStringParser 都有相同的行為契約。模板測試基底類別定義了必須通過的抽象測試,衍生的測試類別只需覆寫 GetParser() factory 方法來提供正確的類別實例:

[TestFixture]
public abstract class TemplateStringParserTests
{
    public abstract void TestGetStringVersionFromHeader_SingleDigit_Found();
    public abstract void TestGetStringVersionFromHeader_WithMinorVersion_Found();
    public abstract void TestGetStringVersionFromHeader_WithRevision_Found();
}

[TestFixture]
public class XmlStringParserTests : TemplateStringParserTests
{
    protected IStringParser GetParser(string input)
    {
        return new XMLStringParser(input);
    }

    [Test]
    public override void TestGetStringVersionFromHeader_SingleDigit_Found()
    {
        IStringParser parser = GetParser("<Header>1</Header>");
        string versionFromHeader = parser.GetStringVersionFromHeader();
        Assert.AreEqual("1", versionFromHeader);
    }
    // ... 其他覆寫的測試方法
}

Figure 7.4: A template test pattern ensures developers don't forget important tests

這個模式的核心價值在於:作為架構師,你可以提供一份衍生類別必須實作的測試清單,確保開發者不會遺漏重要的測試。

模式三:抽象「填空」測試驅動類別(Abstract “Fill in the Blanks” Test Driver Class)

這個模式更進一步,將測試邏輯直接實作在基底類別中,並提供抽象的方法鉤子(hooks)讓衍生類別填入特定的輸入資料與預期輸出:

public abstract class FillInTheBlanksStringParserTests
{
    protected abstract IStringParser GetParser(string input);
    protected abstract string HeaderVersion_SingleDigit { get; }
    protected abstract string HeaderVersion_WithMinorVersion { get; }
    protected abstract string HeaderVersion_WithRevision { get; }

    public const string EXPECTED_SINGLE_DIGIT = "1";
    public const string EXPECTED_WITH_REVISION = "1.1.1";
    public const string EXPECTED_WITH_MINORVERSION = "1.1";

    [Test]
    public void GetStringVersionFromHeader_SingleDigit_Found()
    {
        string input = HeaderVersion_SingleDigit;
        IStringParser parser = GetParser(input);
        string versionFromHeader = parser.GetStringVersionFromHeader();
        Assert.AreEqual(EXPECTED_SINGLE_DIGIT, versionFromHeader);
    }
    // ... 其他測試方法(使用衍生類別提供的輸入)
}

衍生類別只需「填空」,不需要包含任何測試邏輯,所有測試都從基底類別繼承:

[TestFixture]
public class StandardStringParserTests : FillInTheBlanksStringParserTests
{
    protected override string HeaderVersion_SingleDigit
    { get { return string.Format("header\tversion={0}\t\n", EXPECTED_SINGLE_DIGIT); } }

    protected override string HeaderVersion_WithMinorVersion
    { get { return string.Format("header\tversion={0}\n", EXPECTED_WITH_MINORVERSION); } }

    protected override string HeaderVersion_WithRevision
    { get { return string.Format("header\tversion={0}\n", EXPECTED_WITH_REVISION); } }

    protected override IStringParser GetParser(string input)
    {
        return new StandardStringParser(input);
    }
}

Figure 7.5: A standard test class hierarchy implementation

重構為測試類別階層#

大多數開發者不會一開始就使用繼承模式撰寫測試,而是先正常撰寫,之後再重構。重構步驟:

  1. 提取超類別 – 建立 BaseXXXTests 基底類別,將 factory 方法移入,將所有測試移入,將預期輸出提取為公開欄位,將測試輸入提取為衍生類別實作的抽象方法或屬性
  2. 讓 factory 方法變成抽象的,並回傳介面
  3. 替換明確的類別型別為介面型別
  4. 在衍生類別中實作抽象 factory 方法,回傳明確的型別

也可以使用 .NET 泛型來實作測試階層,讓衍生類別只需宣告泛型參數的型別,而不需要覆寫 factory 方法。

classDiagram
    class BaseTestsClass {
        <<Abstract Infrastructure>>
        +FakeTheLogger() ILogger
        +teardown()
    }
    class TemplateTests {
        <<Template Test>>
        +TestSingleDigit()*
        +TestWithMinorVersion()*
    }
    class FillInTheBlanksTests {
        <<Fill in the Blanks>>
        #GetParser(input)* IStringParser
        #HeaderVersion* string
        +TestSingleDigit()
    }
    BaseTestsClass <|-- LogAnalyzerTests
    TemplateTests <|-- XmlStringParserTests
    FillInTheBlanksTests <|-- StandardStringParserTests

測試工具類別與方法#

隨著測試的增長,你可能會建立以下類型的工具方法:

方法類型說明
Factory 方法用於建立測試中經常需要的複雜物件
系統初始化方法在測試前設定系統狀態(例如替換 logging 機制為 stub)
物件配置方法設定物件的內部狀態(例如將客戶設為交易無效)
外部資源讀取方法從資料庫、設定檔、測試輸入檔中讀取資料
特殊 assert 工具方法封裝複雜的斷言邏輯(例如驗證系統日誌中 X、Y、Z 為 true 而 G 不是)

這些工具方法最終可能會被重構為獨立的工具類別:

  • 特殊 assert 工具類別
  • 特殊 factory 類別
  • 特殊配置類別或資料庫配置類別

讓開發者知道你的 API#

有了測試工具,還需要確保團隊成員知道並使用它們。作者建議的做法:

  • 兩人結對撰寫測試,讓熟悉 API 的人能教導另一人
  • 準備一份簡短的 cheat sheet(不超過幾頁),列出 API 的類型與位置
  • 在 API helper 的名稱上使用統一的前綴或後綴慣例
  • 自動化產生 API 文件,並納入建置流程
  • 在團隊會議中討論 API 的變更
  • 在新人入職時介紹這份文件
  • 進行測試 review(不只是 code review),確保測試的可讀性、可維護性與正確性,並確認有使用正確的 API

總結#

  • 無論採用什麼測試方式,都應使用自動化建置流程,盡可能頻繁地執行測試並持續交付
  • 整合測試與單元測試分離,讓團隊擁有一個所有測試都必須通過的安全綠區
  • 專案類型(單元測試 vs. 整合測試、快速 vs. 慢速)來組織測試,放在不同的目錄、資料夾或命名空間中
  • 使用測試類別階層將相同的測試集合套用到多個相關型別上(共享介面或基底類別的類別)
  • 如果測試類別階層讓測試變得難以閱讀(特別是基底類別中有共用的 setup 方法時),改用 helper 類別和工具類別
  • 讓團隊知道你的測試 API 的存在,否則會浪費時間和金錢在重複造輪子上