開篇:上一篇我們學習基本的單元測試基礎知識和入門實例。但是,如果我們要測試的方法依賴於一個外部資源,如文件系統、數據庫、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); }
②定義抽取出的類

public class FileExtensionManager : IExtensionManager { public bool IsValid(string fileName) { bool result = false; // 讀取文件 return result; } }
Step2.然后我們從一個已知的類FileExtensionManager抽取出一個接口IExtensionManager。

public interface IExtensionManager { bool IsValid(string fileName); }
Step3.創建一個實現IExtensionManager接口的簡單存根代碼作為可替換的底層實現。

public class AlwaysValidFakeExtensionManager : IExtensionManager { public bool IsValid(string fileName) { return true; } }
於是,IsValidLogFileName方法就可以進行重構了:

public bool IsValidLogFileName(string fileName) { IExtensionManager manager = new FileExtensionManager(); return manager.IsValid(fileName); }
但是,這里被測試方法還是對具體類進行直接調用,我們必須想辦法讓測試方法調用偽對象而不是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); } }
其次,再添加新的測試代碼:

[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; } } }
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); } }
其次,新增一個測試方法,改為屬性注入方式:

[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); }
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(); } }
其次,在改造測試項目(位於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; } }
最后,改造測試代碼,這里我們創建的是新派生類而非被測試類的實例,配置這個新實例的公共字段,設置成我們在測試中創建的存根實例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); }
二、交互測試-模擬對象
工作單元可能有三種最終結果,目前為止,我們編寫過的測試只針對前兩種:返回值和改變系統狀態。現在,我們來了解如何測試第三種最終結果-調用第三方對象。
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; } }
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)); } } }
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); }
可以看出,這里的測試代碼中我們是對模擬對象進行斷言,而非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); } }
可以看出,這里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; } }
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; } }
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; } } }
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("someone@qq.com", "can't log", ex.Message)); } } } }
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("someone@qq.com", "can't log", "fake exception"); // 用預期對象同時斷言所有屬性 Assert.AreEqual(expectedEmail, mockEmail.email); }
總結:每個測試應該只測試一件事情,測試中應該也最多只有一個模擬對象。一個測試只能指定工作單元三種最終結果中的一個,不然的話天下大亂。
三、隔離(模擬)框架
3.1 為何使用隔離框架
對於復雜的交互場景,可能手工編寫模擬對象和存根就會變得很不方便,因此,我們可以借助隔離框架來幫我們在運行時自動生成存根和模擬對象。
一個隔離框架是一套可編程的API,使用這套API創建偽對象比手工編寫容易得多,快得多,而且簡潔得多。
隔離框架的主要功能就在於幫我們生成動態偽對象,動態偽對象是運行時創建的任何存根或者模擬對象,它的創建不需要手工編寫代碼(硬編碼)。
3.2 關於NSubstitute隔離框架
Nsubstitute是一個開源的框架,源碼是C#實現的。你可以在這里獲得它的源碼:https://github.com/nsubstitute/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); }
現在我們看看如何使用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"); }
需要注意的是:
(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("strict.txt")); }
如果我們不想關心方法的參數,即無論參數是什么,方法應該總是返回一個價值,這樣的話測試會更容易維護,因此我們可以借助NSub的參數匹配器:

[Test] public void Returns_ByDefault_WorksForAnyArgument() { IFileNameRules fakeRules = Substitute.For<IFileNameRules>(); // 強制方法返回假值 fakeRules.IsValidLogFileName(Arg.Any<string>()).Returns(true); Assert.IsTrue(fakeRules.IsValidLogFileName("anything.txt")); }
Arg.Any<Type>稱為參數匹配器,在隔離框架中被廣泛使用,控制參數處理。
如果我們需要模擬一個異常,也可以借助NSub來解決:

[Test] public void Returns_ArgAny_Throws() { IFileNameRules fakeRules = Substitute.For<IFileNameRules>(); fakeRules.When(x => x.IsValidLogFileName(Arg.Any<string>())). Do(context => { throw new Exception("fake exception"); }); Assert.Throws<Exception>(() => fakeRules.IsValidLogFileName("anything")); }
這里,使用了Assert.Throws驗證被測試方法確實拋出了一個異常。When和Do兩個方法顧名思義代表了什么時候發生了什么事,發生了事之后要觸發其他什么事。需要注意的是,這里When方法必須使用Lambda表達式。
3.5 同時使用模擬對象和存根
這里我們在一個場景中結合使用兩種類型的偽對象:一個用作存根,另一個用作模擬對象。
繼續前面的一個例子,LogAnalyzer要使用一個MailServer類和一個WebService類,這次需求有變化:如果日志對象拋出異常,LogAnalyzer需要通知Web服務,如下圖所示:
我們需要確保的是:如果日志對象拋出異常,LogAnalyzer會把這個問題通知WebService。下面是被測試類的代碼:

public interface IWebService { void Write(string message); } public class LogAnalyzerNew { private ILogger _logger; private IWebService _webService; public LogAnalyzerNew(ILogger logger, IWebService webService) { _logger = logger; _webService = webService; } public int MinNameLength { get; set; } public void Analyze(string fileName) { if (fileName.Length < MinNameLength) { try { _logger.LogError(string.Format("Filename too short : {0}", fileName)); } catch (Exception ex) { _webService.Write("Error From Logger : " + ex.Message); } } } }
現在我們借助NSubstitute進行測試:

[Test] public void Analyze_LoggerThrows_CallsWebService() { var mockWebService = Substitute.For<IWebService>(); var stubLogger = Substitute.For<ILogger>(); // 無論輸入什么都拋出異常 stubLogger.When(logger => logger.LogError(Arg.Any<string>())) .Do(info => { throw new Exception("fake exception"); }); var analyzer = new LogAnalyzerNew(stubLogger, mockWebService); analyzer.MinNameLength = 10; analyzer.Analyze("short.txt"); //驗證在測試中調用了Web Service的模擬對象,調用參數字符串包含 "fake exception" mockWebService.Received().Write(Arg.Is<string>(s => s.Contains("fake exception"))); }
這里我們不需要手工實現偽對象,但是代碼的可讀性已經變差了,因為有一堆Lambda表達式,不過它也幫我們避免了在測試中使用方法名字符串。
四、小結
本篇我們學習了單元測試的核心技術:存根、模擬對象以及隔離框架。使用存根可以幫助我們破除依賴,模擬對象與存根的區別主要在於存根不會導致測試失敗,而模擬對象則可以。要辨別你是否使用了存根,最簡單的方法是:存根永遠不會導致測試失敗,測試總是對被測試類進行斷言。使用隔離框架,測試代碼會更加易讀、易維護,重點是可以幫助我們節省不少時間編寫模擬對象和存根。
參考資料
(1)Roy Osherove 著,金迎 譯,《單元測試的藝術(第2版)》
(2)匠心十年,《NSubsititue完全手冊》
(3)張善友,《單元測試模擬框架:NSubstitute》