【單元測試的藝術】Chap 7: 測試階層和組織


目錄

  • PART I: 入門
    • Chap 1: 單元測試基礎
    • Chap 2: 第一個單元測試
  • PART II: 核心技術
    • Chap 3: 透過虛設常式解決依賴問題
    • Chap 4: 使用模擬物件驗證互動
    • Chap 5: 隔離(模擬)框架
    • Chap 6: 深入了解隔離框架
  • PART III: 測試程式碼
    • Chap 7: 測試階層和組織
    • Chap 8: 好的單元測試的支柱
    • Chap 9: 在組織中導入單元測試
    • Chap 10: 遺留程式碼
    • Chap 11: 設計與可測試性

前言

這一章介紹測試的模式和指導原則,幫助你重新打造測試的樣子和執行方式,進而改變測試與產品測試和其他測試的互動方式。

測試存放的位置:取決於它們在何處執行以及由誰執行。
測試執行的兩種情況:

  1. 測試作為自動化建置過程中的一部分來執行
  2. 由開發人員在他們自己的機器執行測試

自動化建置流程非常重要,因此接下來我們將詳盡討論。


一、執行自動化測試的自動化建置

如果你計畫讓團隊更敏捷,那就要完成下列的任務:

  • 對程式碼進行小的修改
  • 執行所有的測試,確保既有的功能沒有被破壞
  • 確保程式碼依然能夠很好地被整合,能與原本你所依賴的專案相容且執行無誤
  • 建立程式碼交付套件(deliverable package),一鍵自動部署

你可能需要幾類的建置設定(build configuration)和建置腳本(build script)。

建置腳本:

  • 一小段程式碼,和你的程式碼一起放在版本控制裡面
  • 可以分辨目前的版本

持續整合伺服器(continuous integration server)的建置腳本會呼叫這些腳本。

如果你要自動手動整合程式碼,需要做以下工作:(你可以使用自動化建置腳本和持續整合伺服器來自動化這些工作)

  • 在版本庫中取得所有最新版本的原始碼
  • 在自己的機器上編譯全部程式碼
  • 在自己的機器上執行所有測試
  • 修復所有的問題
  • 簽入你的原始碼

建置流程包含一組建置腳本、自動觸發器和一個伺服器,還可能包含一些建置代理(執行建置工作)。另外,整個團隊的共識也很重要。


1. 建置腳本結構

建構腳本包含:

  • 持續整合(Continuous Integration, CI)建置腳本
  • 每日建置腳本
  • 部署建置腳本

每個腳本完成一個功能,較容易維護,也易進行整合。

部署建置腳本的本質是一種交付機制(delivery mechanism)。由持續整合伺服器觸發執行,可能是一個簡單的 xcopy 檔案到遠端伺服器中,也可能是很複雜的任務,如部署到幾百個伺服器,重新初始化 Azure 或 EC3 實體,並整合資料庫。


2. 觸發建置和整合

持續整合(Continous Integration, CI)是指讓自動化建置與整合流程持續地進行。例如:讓某個建置腳本在每次有程式碼簽入時運行,或是每隔 45 分鐘執行一次,也可以在另一個建置腳本結束時運行。

持續整合伺服器的功能:

  • 按照指定事件觸發建置腳本
  • 提供建置腳本上下文及資料
  • 提供建置歷史和分析指標結果
  • 提供目前所有啟用與非啟用建置的狀態

二、依據速度和種類對應的測試內分類

  • 將單元測試和整合測試分開:因為開發人員可能沒有足夠的時間完成測試,
  • 綠色安全區域:只包含單元測試,如果有任何失敗,代表程式碼有問題,而不是其他潛在因素導致,也就是說,讓開發者專注於真正重要的事情。

三、確保測試程式是進行版本控管

測試程式必須被納入版本控管的一部分。將測試程式放在原始碼版控樹(source control tree)中,自動化建置流程就總是可以對產品執行正確版本的測試。


四、將測試類別的位置與被測試程式相對應

我們希望可以輕鬆達到以下目標:

  • 找到一個專案中所有相關的測試
  • 找到一個類別中所有相關的測試
  • 找到一個方法中所以相關的測試

完成上列目標的方式:

  1. 將測試對應到專案
    • 如:加上後綴 .UnitTests 命名
  2. 把測試對應到類別
    • 給每個被測試類別建立一個測試類別(one-test-class-per-class)
      • 如:LogAnalyzer.UnitTests
    • 給每個被測試的複雜方法建立一個單獨的測試類別(one-test-class-per-feature)
      • 假設 LoginManager 有個方法 ChangePassword,這個方法的測試案例特別多,可以建立兩個測試類別 LoginManagerTestsChangePassword、LoginManagerTests。
  3. 將測試對應到明確的工作單元入口
    • 可以在命名時,包含被測試工作單元的入口方法名稱,如 ChangePassword_scenario_expectedbehavior。

五、注入橫切面關注點

如果程式中存在像 DateTime 這樣的橫切面關注點,使用它們的地方會非常多,如果把它們設計成可注入的,對應的程式碼會非常好測試,卻同時難以閱讀和維護。

例如:應用程式適用目前時間寫 log

    public static class TimeLogger
    {
        public static string CreateMessage(string info)
        {
            return DateTime.Now.ToShortDateString() + " " + info;
        }
    }

為了讓程式碼更好測試,除了使用一個 ITimerProvider 介面,也可以建立名叫 SystemTime 的自訂類別,在所有產品程式中都使用這個類別,而不是建立標準的內建類別 DateTime。

使用 SystemTime 類別

    public static class TimeLogger
    {
        public static string CreateMessage(string info)
        {
            return SystemTime.Now.ToShortDateString() + " " + info; // 產品程式碼使用 SystemTime
        }
    }
    public class SystemTime
    {
        private static DateTime _date;
        public static void Set(DateTime custom)
        {
            _date = custom; // SystemTime 允許修改目前時間
        }
        public static void Reset()
        {
            _date = DateTime.MinValue; // 也可以重置目前時間
        }
        public static DateTime Now // 如果有設定時間,SystemTime 就回傳假時間,如果沒有設定,就回傳真實時間
        {
            get 
            {
                if (_date != DateTime.MinValue)
                {
                    return _date;
                }
                return DateTime.Now;
            }
        }
    }

SystemTime 類別提供了一個特殊方法來修改系統中目前的時間。有了這樣的程式碼,要測試產品程式是否正確使用了目前時間,就會非常容易了?

在測試中使用 SystemTime

    [TestFixture[
    public class TimeLoggerTests
    {
        [Test]
        public void SettingSystem_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] 方法,確保不會改變其他測試的時間值。

但你還需要考慮 culture 屬性(如 en-US 相對 en-GB)可能會改變輸出字串的格式,可以在測試上加入 NUnit 的 CultureInfoAttribute 特性,強制測試在指定的 culture 底下運行。


六、為應用程式建立測試 API

在開始替程式撰寫測試之後,或早或晚,我們會進行程式碼的重構,建立輔助(utility)方法,輔助類別以及其他很多基礎設計。

下列是可能會進行的工作:

  • 在測試類別中使用繼承,讓程式碼可重用
  • 建立測輔助類別和方法
  • 把 API 介紹給開發人員

接下來,我們來依次討論。


1. 使用繼承類別繼承模式

  • 重用輔助方法和工廠方法
  • 在不同類別上執行同一組測試
  • 使用共同的 setup 和 teardown 程式碼
  • 從肌底類別繼承而來的子類提供一個測試指引,方便開發人員撰寫測試

測試類別繼承的三種模式:

  1. 抽象測試基礎結構別
  2. 測試類別模板
  3. 抽象測試驅動類別

使用以上三種模式時用到的重構技術:

  1. 重構類別階層
  2. 使用泛型

A.抽象測試基礎結構類別模式

這個模式建立一個抽象的測試類別。

下面介紹一個例子,在兩個測試類別中重用 setup 方法。所有的測試都需要應用程式預設的 logger 來完成,將 log 內容存放到記憶體中,不產生 log 實體檔案。(也就是說,所有的測試都需要解除對 logger 的依賴。)

接下來的程式碼會完成以下幾個類別:

  • LogAnalyzer 類別和方法:需要測試的類別和方法
  • LoggingFacility 類別:使用 logger,測試也需要覆寫 logger
  • ConfigurationManager 類別:也使用了 LoggingFacility,同樣需要測試
  • LogAnalyzerTests 類別和方法:測試類別和方法最初的內容
  • ConfigurationManagerTests 類別:測試 ConfigurationManager 的測試類別
在測試類別中沒有遵循 DRY 原則的樣子
    public class LogAnalyzer // 在這個類別內部使用了 LoggingFacility
    {
        public void Analyze(string fileName)
        {
            if (fileName.Length < 8)
            {
                LoggingFacility.Log("Filename too short:" + fileName);
            }
            // 把其他的方法內容寫在這裡
        }
    }

    public class ConfigurationManager // 另一個在內部使用 LoggingFacility 的類別
    {
        public bool IsConfigured(string configName)
        {
            LoggingFacility.Log("checking " + configName);
            return result;
        }
    }

    public static class LoggingFacility
    {
        public static void Log(string text)
        {
            logger.Log(text);
        }
        private static ILogger logger;
        public static ILogger Logger
        {
            get { return logger; }
            set { logger = value; }
        }
    }

    [TestFixture]
    public class LogAnalyzerTests
    {
        [Test]
        public void Analyze_EmptyFile_ThrowsException()
        {
            LogAnalyzer la = new LogAnalyzer();
            la.Analyze("myemptyfile.txt");
            // 測試程式的其他內容
        }
        [TearDown]
        public void teardown()
        {
            // 在測試之間需要重置靜態資源
            LoggingFacility.Logger = null;
        }
    }

    [TestFixture]
    public class ConfigurationManagerTests
    {
        [Test]
        public void Analyze_EmptyFile_ThrowsException()
        {
            Configuration cm = new ConfigurationManager();
            bool configured = cm.IsConfigured("something");
            // 測試方法的其他內容
        }

        [TearDown]
        public void teardown()
        {
            // 在測試之間需要重置靜態資源
            LoggingFacility.Logger = null;
        }
    }

程式中有兩個類別使用了 LogFacility 類別:LogAnalyzer 和 ConfigurationManager。這兩個類別都需要測試。

要重構這段程式碼,一個方法是抽取一個新的輔助方法,在測試類別中重用,消除重複的程式碼。

考慮到衍生子類的可讀性,不要在基底類別中包含了共用的 [SetUp] 方法。我們可以使用 FakeTheLogger() 的輔助方法,如下所示:

一種重構方式
    [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 ConfigurationManagerTests: BaseTestsClass
    {
        [Test]
        public void Analyze_EmptyFile_ThrowsException()
        {
            FakeTheLogger(); // 呼叫基底類別的輔助方法
            ConfigurationManager cm = new ConfigurationManager();
            bool configured = cm.IsConfigured("something");
            // 測試程式其他內容
        }
    }

    [TestFixture]
    public class LogAnalyzerTests: BaseTestClass
    {
        [Test]
        public void Analyze_EmptyFile_ThrowsException()
        {
            FakeTheLogger(); // 呼叫基底類別的輔助方法
            LogAnalyzer la = new LogAnalyzer();
            la.Analyze("myemptyfile.txt");
        }
    }

直得注意的是,測試中的類別繼承深度不要超過一層


B. 測試模板類別模式

測試模板類別是一個抽象類別,包含一組抽象測試方法,衍生類別必須實作這些抽象方法。

StandardStringParser 測試類別大致結構
    [TextFixture]
    public class StandardStringParserTests
    {
        private StandardStringParser GetParser(string input) // (1) 定義解析器的工廠方法
        {
            return new StandardStringParser(input);
        }

        [Test]
        public void GetStringVersionFromHeader_SingleDigit_Found()
        {
            string input = "header;version=1;\n";
            StandardStringParser parser = GetParser(input) // (2) 使用工廠方法
            string versionFromHeader = parser.GetStringVersionFromHeader();
            Assert.AreEqual("1", versionFromHeader);
        }

        [Test]
        public void GetStringVersionFromHeader_WithMinorVersion_Found()
        {
            string input = "header;version1.1;\n";
            StandardStringParser parser = GetParser(input); // (2) 使用工廠方法
            // 測試其餘內容
        }

        [Test]
        public void GetStringVersionFromHeader_WithRevision_Found()
        {
            string input = "header;version=1.1.1;\n";
            StandardStringParser parser = GetParser(input);
            // 測試程式其餘內容
        }
    }

程式中使用了輔助方法 (1) GetParser(),避免所有測試都得自己建議需要使用的 (2) 解析器物件


C. 基底類別還能做更多事情嗎?

抽象驅動類別模式,是前一個方式的進階模式,在基底類別中實作了測試方法,並提供抽象方法供子類實作。

這個模式的重點在於:你不是實際在測試一個類別,而是測試產品程式的一個介面或基底類別


D. 重構測試類別階層

大部分開發者在開始撰寫測試時,都不會考慮繼承模式。

如果要重構測試類別之步驟:

  1. 重構:抽取基底別(superclass)
    • 建立個基底類別(BaseXXXTests)
    • 把工廠方法(如 GetParser)移到基底類別中
    • 把所有測試方法移到基底類別中
    • 抽取期望的輸出,放到基底類別的公開欄位
    • 抽取測試的輸入,放到抽象方法或衍生子類別需要建立的屬性
  2. 重構:使用工廠方法,回傳介面
  3. 重構:找到測試方法中所有使用實際類別的地方,替換成使用這些類別的介面
  4. 在衍生子類中,完成抽象工廠方法,回傳實際類別

E. 使用 .NET 泛型來設計測試階層

可以在基底類別中使用泛型,衍生子類就不需要覆寫任何方法,只需宣告測試型別。

使用 .NET 泛型來完成測試案例的階層
    // 使用泛型來完成同樣需求
    public abstract class GenericParserTests<T> where T: IStringParser // (1) 定義泛型約束條件
    {
        protected abstract string GetInputHeaderSingleDigit();
        protected T GetParser(string input) // (2) 取得泛型型別的實體,而非介面
        {
            return (T) Activator.CreateInstance(typeof(T), input); // (3) 回傳泛型型別的實體
        }

        [Test]
        public void GetStringVersionFromHeader_SingleDigit_Found()
        {
            string input = GetInputHeaderSingleDigit();
            T parser = GetParser(input);
            bool result = parser.HasCorrectHeader();
            Assert.IsFalse(result);
        }
        // 其他測試
    }

    // 繼承泛型基底類別的一個範例
    [TestFixture]
    public class StandardParserGenericTests: GenericParserTests<StandardStringParser> // (4) 繼承自泛型基底類別
    {
        protocted override string GetInputHeaderSingleDigit()
        {
            return "Header; 1"; // (5) 依據目前被測試類別的型別來回傳自訂的輸入
        }
    }

2. 建立測試輔助類別和方法

你可能會有類似這些的輔助類別:

  • 建立複雜物件或測試常用的物件的工廠方法
  • 系統初始化方法
  • 物件設定方法
  • 設定或讀取外部資源的方法,如讀取資料庫、設定檔案和測試輸入值的檔案
  • 特別的驗證輔助方法,對某些複雜或需要重複驗證的東西進行驗證

輔助方法:

  • 特別的驗證輔助類別,包含所有自訂的驗證方法
  • 特別的工廠方法,包含所有工廠方法
  • 特別的設定類別或是資料庫設定類別,包含整合測試風格的操作

3. 把你的 API 介紹給開發人員

  • 讓團隊的兩個成員結對寫測試程式
  • 準備輕量的說明文件或速查表
    • API 輔助類別名字使用一套已知的前綴或後綴
    • 用一個特殊的工具來解析 API 的名字和位置
    • 自動化這個文件的產生過程,作為自動化建置流程的一部分
  • 在團隊會議中討論 API 的變更
  • 新成員加入時和他們一起走過一次說明文件
  • 進行測試程式審查時,確保測試程式達到可讀性、可維護性和正確性的標準,確保測試在需要的地方使用了正確的 API

小結

我們來回顧一下:

  • 無論何種測試、怎麼測試,請將測試自動化
  • 把整合和單元測試分開,替團隊建立綠色安全區域
  • 按照專案和種類來組織測試(單元 vs 整合、慢 vs 快 etc)
  • 如果測試階層降低了可讀性,改用輔助類別和工具類別
  • 把你的 API 介紹給團隊成員

最近愛上一部法國電影《Portrait of a lady on fire》,是我這輩子看過最美麗、情感、抑制、豐富、震撼的電影,一部能當場把你胖揍一頓直接死亡又讓你從灰燼中復活、重新真正活著的電影,所以呢最後我們來用法文來結束這一回合吧!Au revoir~

#Unit Test #單元測試的藝術
也許你是單元測試甚至 TDD 愛好者,也許你著墨過一點單元測試,也許你是個剛開始寫程式的新手,不論你是誰,你都必須讀讀這本「單元測試的藝術」。 這是一本由 Roy Osherove 撰寫,針對靜態程式語言最經典的單元測試書籍。這個系列文會慢聊這本單元測試聖經,感受超層級的藝術。






Related Posts

利用 Elm 製作 Chrome Extension

利用 Elm 製作 Chrome Extension

ArvinH
七天學會 swift - 解析 XML Day6

七天學會 swift - 解析 XML Day6

eric236431
用 Node.js 快速打造 RESTful API

用 Node.js 快速打造 RESTful API

huli


Comments