.NET單元測試的藝術-2.核心技術
開篇:上一篇我們學習基本的單元測試基礎知識和入門例項。但是,如果我們要測試的方法依賴於一個外部資源,如檔案系統、資料庫、Web服務或者其他難以控制的東西,那又該如何編寫測試呢?為了解決這些問題,我們需要建立測試存根、偽物件及模擬物件。這一篇中我們會開始接觸這些核心技術,藉助存根破除依賴,使用模擬物件進行互動測試,使用隔離框架支援適應未來和可用性的功能。
系列目錄:
1.入門
2.核心技術
3.測試程式碼
一、破除依賴-存根
1.1 為何使用存根?
當我們要測試的物件依賴另一個你無法控制(或者還未實現)的物件,這個物件可能是Web服務、系統時間、執行緒排程或者很多其他東西。
那麼重要的問題來了:你的測試程式碼不能控制這個依賴的物件向你的程式碼返回什麼值,也不能控制它的行為(例如你想摸你一個異常)。
因此,這種情況下你可以使用存根。
1.2 存根簡介
(1)外部依賴項
一個外部依賴項是系統中的一個物件,被測試程式碼與這個物件發生互動,但你不能控制這個物件。(常見的外部依賴項包括:檔案系統、執行緒、記憶體以及時間等)
(2)存根
一個存根(Stub)是對系統中存在的一個依賴項(或者協作者)的可控制的替代物。通過使用存根,你在測試程式碼時無需直接處理這個依賴項。
1.3 發現專案中的外部依賴
繼續上一篇中的LogAn案例,假設我們的IsValidLogFilename方法會首先讀取配置檔案,如果配置檔案說支援這個副檔名,就返回true:
public bool IsValidLogFileName(string fileName) { // 讀取配置檔案 // 如果配置檔案說支援這個副檔名,則返回true }
那麼問題來了:一旦測試依賴於檔案系統,我們進行的就是整合測試,會帶來所有與整合測試相關的問題—執行速度較慢,需要配置,一次測試多個內容等。
換句話說,儘管程式碼本身的邏輯是完全正確的,但是這種依賴可能導致測試失敗。
1.4 避免專案中的直接依賴
想要破除直接依賴,可以參考以下兩個步驟:
(1)找到被測試物件使用的外部介面或者API;
(2)把這個介面的底層實現替換成你能控制的東西;
對於我們的LogAn專案,我們要做到替代例項不會訪問檔案系統,這樣便破除了檔案系統的依賴性。因此,我們可以引入一個間接層來避免對檔案系統的直接依賴。訪問檔案系統的程式碼被隔離在一個FileExtensionManager類中,這個類之後將會被一個存根類替代,如下圖所示:
在上圖中,我們引入了存根 ExtensionManagerStub 破除依賴,現在我們得程式碼不應該知道也不會關心它使用的擴充套件管理器的內部實現。
1.5 重構程式碼提高可測試性
有兩類打破依賴的重構方法,二者相互依賴,他們被稱為A型和B型重構。
(1)A型 把具體類抽象成介面或委託;
下面我們實踐抽取介面將底層實現變為可替換的,繼續上述的IsValidLogFileName方法。
Step1.我們將和檔案系統打交道的程式碼分離到一個單獨的類中,以便將來在程式碼中替換帶對這個類的呼叫。
①使用抽取出的類
public bool IsValidLogFileName(string fileName) { FileExtensionManager manager = new FileExtensionManager(); return manager.IsValid(fileName); }View Code
②定義抽取出的類
public class FileExtensionManager : IExtensionManager { public bool IsValid(string fileName) { bool result = false; // 讀取檔案 return result; } }View Code
Step2.然後我們從一個已知的類FileExtensionManager抽取出一個介面IExtensionManager。
public interface IExtensionManager { bool IsValid(string fileName); }View Code
Step3.建立一個實現IExtensionManager介面的簡單存根程式碼作為可替換的底層實現。
public class AlwaysValidFakeExtensionManager : IExtensionManager { public bool IsValid(string fileName) { return true; } }View Code
於是,IsValidLogFileName方法就可以進行重構了:
public bool IsValidLogFileName(string fileName) { IExtensionManager manager = new FileExtensionManager(); return manager.IsValid(fileName); }View Code
但是,這裡被測試方法還是對具體類進行直接呼叫,我們必須想辦法讓測試方法呼叫偽物件而不是IExtensionManager的原本實現,於是我們想到了DI(依賴注入),這時就需要B型重構。
(2)B型 重構程式碼,從而能夠對其注入這種委託和介面的偽實現。
剛剛我們想到了依賴注入,依賴注入的主要表現形式就是建構函式注入與屬性注入,於是這裡我們主要來看看建構函式層次與屬性層次如何注入一個偽物件。
① 通過建構函式注入偽物件
根據上圖所示的流程,我們可以重構LogAnalyzer程式碼:
public class LogAnalyzer { private IExtensionManager manager; public LogAnalyzer(IExtensionManager manager) { this.manager = manager; } public bool IsValidLogFileName(string fileName) { return manager.IsValid(fileName); } }View Code
其次,再新增新的測試程式碼:
[TestFixture] public class LogAnalyzerTests { [Test] public void IsValidFileName_NameSupportExtension_ReturnsTrue() { // 準備一個返回true的存根 FakeExtensionManager myFakeManager = new FakeExtensionManager(); myFakeManager.WillBeValid = true; // 通過構造器注入傳入存根 LogAnalyzer analyzer = new LogAnalyzer(myFakeManager); bool result = analyzer.IsValidLogFileName("short.ext"); Assert.AreEqual(true, result); } // 定義一個最簡單的存根 internal class FakeExtensionManager : IExtensionManager { public bool WillBeValid = false; public bool IsValid(string fileName) { return WillBeValid; } } }View Code
Note:這裡將偽存根類和測試程式碼放在一個檔案裡,因為目前這個偽物件只在這個測試類內部使用。它比起手工實現的偽物件和測試程式碼放在不同檔案中,將它們放在一個檔案裡的話,定位、閱讀以及維護程式碼都要容易的多。
② 通過屬性設定注入偽物件
建構函式注入只是方法之一,屬性也經常用來實現依賴注入。
根據上圖所示的流程,我們可以重構LogAnalyzer類:
public class LogAnalyzer { private IExtensionManager manager; // 允許通過屬性設定依賴項 public IExtensionManager ExtensionManager { get { return manager; } set { manager = value; } } public LogAnalyzer() { this.manager = new FileExtensionManager(); } public bool IsValidLogFileName(string fileName) { return manager.IsValid(fileName); } }View Code
其次,新增一個測試方法,改為屬性注入方式:
[Test] public void IsValidFileName_SupportExtension_ReturnsTrue() { // 設定要使用的存根,確保其返回true FakeExtensionManager myFakeManager = new FakeExtensionManager(); myFakeManager.WillBeValid = true; // 建立analyzer,注入存根 LogAnalyzer log = new LogAnalyzer(); log.ExtensionManager = myFakeManager; bool result = log.IsValidLogFileName("short.ext"); Assert.AreEqual(true, result); }View Code
Note : 如果你想表明被測試類的某個依賴項是可選的,或者測試可以放心使用預設建立的這個依賴項例項,這時你就可以使用屬性注入。
1.6 抽取和重寫
抽取和重寫是一項強大的技術,可直接替換依賴項,實現起來快速乾淨,可以讓我們編寫更少的介面、更多的虛擬函式。
還是繼續上面的例子,首先改造被測試類(位於Manulife.LogAn),新增一個返回真實例項的虛工廠方法,正常在程式碼中使用工廠方法:
public class LogAnalyzerUsingFactoryMethod { public bool IsValidLogFileName(string fileName) { // use virtual method return GetManager().IsValid(fileName); } protected virtual IExtensionManager GetManager() { // hard code return new FileExtensionManager(); } }View Code
其次,在改造測試專案(位於Manulife.LogAn.UnitTests),建立一個新類,宣告這個新類繼承自被測試類,建立一個我們要替換的介面(IExtensionManager)型別的公共欄位(不需要屬性get和set方法):
public class TestableLogAnalyzer : LogAnalyzerUsingFactoryMethod { public IExtensionManager manager; public TestableLogAnalyzer(IExtensionManager manager) { this.manager = manager; } // 返回你指定的值 protected override IExtensionManager GetManager() { return this.manager; } }View Code
最後,改造測試程式碼,這裡我們建立的是新派生類而非被測試類的例項,配置這個新例項的公共欄位,設定成我們在測試中建立的存根例項FakeExtensionManager:
[Test] public void OverrideTest() { FakeExtensionManager stub = new FakeExtensionManager(); stub.WillBeValid = true; // 建立被測試類的派生類的例項 TestableLogAnalyzer logan = new TestableLogAnalyzer(stub); bool result = logan.IsValidLogFileName("stubfile.ext"); Assert.AreEqual(true, result); }View Code
二、互動測試-模擬物件
工作單元可能有三種最終結果,目前為止,我們編寫過的測試只針對前兩種:返回值和改變系統狀態。現在,我們來了解如何測試第三種最終結果-呼叫第三方物件。
2.1 模擬物件與存根的區別
模擬物件和存根之間的區別很小,但二者之間的區別非常微妙,但又很重要。二者最根本的區別在於:
存根不會導致測試失敗,而模擬物件可以。
下圖展示了存根和模擬物件之間的區別,可以看到測試會使用模擬物件驗證測試是否失敗。
2.2 第一個手工模擬物件
建立和使用模擬物件的方法與使用存根類似,只是模擬物件比存根多做一件事:它儲存通訊的歷史記錄,這些記錄之後用於預期(Expection)驗證。
假設我們的被測試專案LogAnalyzer需要和一個外部的Web Service互動,每次LogAnalyzer遇到一個過短的檔名,這個Web Service就會收到一個錯誤訊息。遺憾的是,要測試的這個Web Service還沒有完全實現。就算實現了,使用這個Web Service也會導致測試時間過長。
因此,我們需要重構設計,建立一個新的介面,之後用於這個介面建立模擬物件。這個介面只包括我們需要呼叫的Web Service方法。
Step1.抽取介面,被測試程式碼可以使用這個介面而不是直接呼叫Web Service。然後建立實現介面的模擬物件,它看起來十分像存根,但是它還儲存了一些狀態資訊,然後測試可以對這些資訊進行斷言,驗證模擬物件是否正確呼叫。
public interface IWebService { void LogError(string message); } public class FakeWebService : IWebService { public string LastError; public void LogError(string message) { this.LastError = message; } }View Code
Step2.在被測試類中使用依賴注入(這裡是建構函式注入)消費Web Service:
public class LogAnalyzer { private IWebService service; public LogAnalyzer(IWebService service) { this.service = service; } public void Analyze(string fileName) { if (fileName.Length < 8) { // 在產品程式碼中寫錯誤日誌 service.LogError(string.Format("Filename too short : {0}",fileName)); } } }View Code
Step3.使用模擬物件測試LogAnalyzer:
[Test] public void Analyze_TooShortFileName_CallsWebService() { FakeWebService mockService = new FakeWebService(); LogAnalyzer log = new LogAnalyzer(mockService); string tooShortFileName = "abc.ext"; log.Analyze(tooShortFileName); // 使用模擬物件進行斷言 StringAssert.Contains("Filename too short : abc.ext", mockService.LastError); }View Code
可以看出,這裡的測試程式碼中我們是對模擬物件進行斷言,而非LogAnalyzer類,因為我們測試的是LogAnalyzer和Web Service之間的互動。
2.3 同時使用模擬物件和存根
假設我們得LogAnalyzer不僅需要呼叫Web Service,而且如果Web Service丟擲一個錯誤,LogAnalyzer還需要把這個錯誤記錄在另一個外部依賴項裡,即把錯誤用電子郵件傳送給Web Service管理員,如下程式碼所示:
if (fileName.Length < 8) { try { // 在產品程式碼中寫錯誤日誌 service.LogError(string.Format("Filename too short : {0}", fileName)); } catch (Exception ex) { email.SendEmail("a", "subject", ex.Message); } }View Code
可以看出,這裡LogAnalyzer有兩個外部依賴項:Web Service和電子郵件服務。我們看到這段程式碼只包含呼叫外部物件的邏輯,沒有返回值,也沒有系統狀態的改變,那麼我們如何測試當Web Service丟擲異常時LogAnalyzer正確地呼叫了電子郵件服務呢?
我們可以在測試程式碼中使用存根替換Web Service來模擬異常,然後模擬郵件服務來檢查呼叫。測試的內容是LogAnalyzer與其他物件的互動。
Step1.抽取Email介面,封裝Email類
public interface IEmailService { void SendEmail(EmailInfo emailInfo); } public class EmailInfo { public string Body; public string To; public string Subject; public EmailInfo(string to, string subject, string body) { this.To = to; this.Subject = subject; this.Body = body; } public override bool Equals(object obj) { EmailInfo compared = obj as EmailInfo; return To == compared.To && Subject == compared.Subject && Body == compared.Body; } }View Code
Step2.封裝EmailInfo類,重寫Equals方法
public class EmailInfo { public string Body; public string To; public string Subject; public EmailInfo(string to, string subject, string body) { this.To = to; this.Subject = subject; this.Body = body; } public override bool Equals(object obj) { EmailInfo compared = obj as EmailInfo; return To == compared.To && Subject == compared.Subject && Body == compared.Body; } }View Code
Step3.建立FakeEmailService模擬物件,改造FakeWebService為存根
public class FakeEmailService : IEmailService { public EmailInfo email = null; public void SendEmail(EmailInfo emailInfo) { this.email = emailInfo; } } public class FakeWebService : IWebService { public Exception ToThrow; public void LogError(string message) { if (ToThrow != null) { throw ToThrow; } } }View Code
Step4.改造LogAnalyzer類適配兩個Service
public class LogAnalyzer { private IWebService webService; private IEmailService emailService; public LogAnalyzer(IWebService webService, IEmailService emailService) { this.webService = webService; this.emailService = emailService; } public void Analyze(string fileName) { if (fileName.Length < 8) { try { webService.LogError(string.Format("Filename too short : {0}", fileName)); } catch (Exception ex) { emailService.SendEmail(new EmailInfo("[email protected]", "can't log", ex.Message)); } } } }View Code
Step5.編寫測試程式碼,建立預期物件,並使用預期物件斷言所有的屬性
[Test] public void Analyze_WebServiceThrows_SendsEmail() { FakeWebService stubService = new FakeWebService(); stubService.ToThrow = new Exception("fake exception"); FakeEmailService mockEmail = new FakeEmailService(); LogAnalyzer log = new LogAnalyzer(stubService, mockEmail); string tooShortFileName = "abc.ext"; log.Analyze(tooShortFileName); // 建立預期物件 EmailInfo expectedEmail = new EmailInfo("[email protected]", "can't log", "fake exception"); // 用預期物件同時斷言所有屬性 Assert.AreEqual(expectedEmail, mockEmail.email); }View Code
總結:每個測試應該只測試一件事情,測試中應該也最多隻有一個模擬物件。一個測試只能指定工作單元三種最終結果中的一個,不然的話天下大亂。
三、隔離(模擬)框架
3.1 為何使用隔離框架
對於複雜的互動場景,可能手工編寫模擬物件和存根就會變得很不方便,因此,我們可以藉助隔離框架來幫我們在執行時自動生成存根和模擬物件。
一個隔離框架是一套可程式設計的API,使用這套API建立偽物件比手工編寫容易得多,快得多,而且簡潔得多。
隔離框架的主要功能就在於幫我們生成動態偽物件,動態偽物件是執行時建立的任何存根或者模擬物件,它的建立不需要手工編寫程式碼(硬編碼)。
3.2 關於NSubstitute隔離框架
NSubstitute 更注重替代(Substitute)概念。它的設計目標是提供一個優秀的測試替代的.NET模擬框架。它是一個模擬測試框架,用最簡潔的語法,使得我們能夠把更多的注意力放在測試工作,減輕我們的測試配置工作,以滿足我們的測試需求,幫助完成測試工作。它提供最經常需要使用的測試功能,且易於使用,語句更符合自然語言,可讀性更高。對於單元測試的新手或只專注於測試的開發人員,它具有簡單、友好的語法,使用更少的lambda表示式來編寫完美的測試程式。
NSubstitute 採用的是Arrange-Act-Assert測試模式,你只需要告訴它應該如何工作,然後斷言你所期望接收到的請求,就大功告成了。因為你有更重要的程式碼要編寫,而不是去考慮是需要一個Mock還是一個Stub。
在.NET專案中,我們仍然可以通過NuGet來安裝NSubsititute:
3.3 使用NSubstitute模擬物件
NSub是一個受限框架,它最適合為介面建立偽物件。我們繼續以前的例子,來看下面一段程式碼,它是一個手寫的偽物件FakeLogger,它會檢查日誌呼叫是否正確執行。此處我們沒有使用隔離框架。
public interface ILogger { void LogError(string message); } public class FakeLogger : ILogger { public string LastError; public void LogError(string message) { LastError = message; } } [Test] public void Analyze_TooShortFileName_CallLogger() { // 建立偽物件 FakeLogger logger = new FakeLogger(); MyLogAnalyzer analyzer = new Chapter5.MyLogAnalyzer(logger); analyzer.MinNameLength = 6; analyzer.Analyze("a.txt"); StringAssert.Contains("too short", logger.LastError); }View Code
現在我們看看如何使用NSub偽造一個物件,換句話說,之前我們手動寫的FakeLogger在這裡就不用再手動寫了:
[Test] public void Analyze_TooShortFileName_CallLogger() { // 建立模擬物件,用於測試結尾的斷言 ILogger logger = Substitute.For<ILogger>(); MyLogAnalyzer analyzer = new MyLogAnalyzer(logger); analyzer.MinNameLength = 6; analyzer.Analyze("a.txt"); // 使用NSub API設定預期字串 logger.Received().LogError("Filename too short : a.txt"); }View Code
需要注意的是:
(1)ILogger介面自身並沒有這個Received方法;
(2)NSub名稱空間提供了一個擴充套件方法Received,這個方法可以斷言在測試中呼叫了偽物件的某個方法;
(3)通過在LogError()前呼叫Received(),其實是NSub在詢問偽物件的這個方法是否呼叫過。
3.4 使用NSubstitute模擬值
如果介面的方法返回不為空,如何從實現介面的動態偽物件返回一個值呢?我們可以藉助NSub強制方法返回一個值:
[Test] public void Returns_ByDefault_WorksForHardCodeArgument() { IFileNameRules fakeRules = Substitute.For<IFileNameRules>(); // 強制方法返回假值 fakeRules.IsValidLogFileName("strict.txt").Returns(true); Assert.IsTrue(fakeRules.IsValidLogFileName("