目錄
- 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: 設計與可測試性
前言
這一章介紹測試的模式和指導原則,幫助你重新打造測試的樣子和執行方式,進而改變測試與產品測試和其他測試的互動方式。
測試存放的位置:取決於它們在何處執行以及由誰執行。
測試執行的兩種情況:
- 測試作為自動化建置過程中的一部分來執行
- 由開發人員在他們自己的機器執行測試
自動化建置流程非常重要,因此接下來我們將詳盡討論。
一、執行自動化測試的自動化建置
如果你計畫讓團隊更敏捷,那就要完成下列的任務:
- 對程式碼進行小的修改
- 執行所有的測試,確保既有的功能沒有被破壞
- 確保程式碼依然能夠很好地被整合,能與原本你所依賴的專案相容且執行無誤
- 建立程式碼交付套件(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)中,自動化建置流程就總是可以對產品執行正確版本的測試。
四、將測試類別的位置與被測試程式相對應
我們希望可以輕鬆達到以下目標:
- 找到一個專案中所有相關的測試
- 找到一個類別中所有相關的測試
- 找到一個方法中所以相關的測試
完成上列目標的方式:
- 將測試對應到專案
- 如:加上後綴 .UnitTests 命名
- 把測試對應到類別
- 給每個被測試類別建立一個測試類別(one-test-class-per-class)
- 如:LogAnalyzer.UnitTests
- 給每個被測試的複雜方法建立一個單獨的測試類別(one-test-class-per-feature)
- 假設 LoginManager 有個方法 ChangePassword,這個方法的測試案例特別多,可以建立兩個測試類別 LoginManagerTestsChangePassword、LoginManagerTests。
- 給每個被測試類別建立一個測試類別(one-test-class-per-class)
- 將測試對應到明確的工作單元入口
- 可以在命名時,包含被測試工作單元的入口方法名稱,如 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 程式碼
- 從肌底類別繼承而來的子類提供一個測試指引,方便開發人員撰寫測試
測試類別繼承的三種模式:
- 抽象測試基礎結構別
- 測試類別模板
- 抽象測試驅動類別
使用以上三種模式時用到的重構技術:
- 重構類別階層
- 使用泛型
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. 重構測試類別階層
大部分開發者在開始撰寫測試時,都不會考慮繼承模式。
如果要重構測試類別之步驟:
- 重構:抽取基底別(superclass)
- 建立個基底類別(BaseXXXTests)
- 把工廠方法(如 GetParser)移到基底類別中
- 把所有測試方法移到基底類別中
- 抽取期望的輸出,放到基底類別的公開欄位
- 抽取測試的輸入,放到抽象方法或衍生子類別需要建立的屬性
- 重構:使用工廠方法,回傳介面
- 重構:找到測試方法中所有使用實際類別的地方,替換成使用這些類別的介面
- 在衍生子類中,完成抽象工廠方法,回傳實際類別
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~