目錄
- 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: 設計與可測試性
前言
在前一章節,我們學會了怎麼手刻模擬物件和虛設常式物件,也瞭解這麼做會帶來一些問題。本章,我們將探討如何優雅地解決這些問題的一些方案,這些方案將基於隔離框架(isolation framwork)。也就是能在執行時期建立和設定假物件的類別褲。這些物件稱為動態虛設常式物件(dynamic stub)和動態模擬物件(dynamic mock)。
一、為什麼要使用隔離框架
隔離框架的定義:一個隔離框架是一套可用來幫助寫程式的 API ,使用這套 API 來建立假物件,比手刻物件要容易得多、快得多、簡潔得多。
隔離框架如果設計得當,可以幫助開發人員解決經常得撰寫重複的程式碼,重複撰寫驗證的語法或難以驗證模擬物件互動的問題。如果設計地極為精妙,隔離框架可以讓測試持續使用很多年,開發人員不需要每次為了一丁點的產品程式碼改動,而得回頭修復被影響的測試程式。
使用隔離框架來建立與使用虛設‘池是物件和模擬物件有很多的優點,可以讓程式碼更為優雅,簡化複雜的測試,提高測試執行速度,並減少測試中的錯誤。
假設你有一個比較複雜的介面:
public interface IComplicatedInterface
{
void Method1(string a, string b, bool c, int x, object o);
void Method2(string b, bool c, int x, object o);
void Method3(bool c, int x, object o);
}
替這個介面手動建立虛設常式物件和模擬物件,可能要花不少時間,因為你需要存放每個方法的參數,如下列程式碼清單:
class MyTestableComplicatedInterface: IComplicatedInterface
{
// 手工撰寫笨拙的語句
public string meth1_a;
public stirng meth1_b, meth2_b;
public bool meth1_c, meth2_c, meth3_c;
public int meth1_x, meth2_x, meth3_x;
public int meth1_0, meth2_0, meth3_0;
public void Method1(string a, string b, bool c, int x, object o)
{
meth1_a = a;
meth1_b = b;
meth1_c = c;
meth1_x = x;
meth1_0 = 0;
}
public void Method2(string b, bool c, int x, object o)
{
meth2_b = b;
meth2_c = c;
meth2_x = x;
meth2_0 = o;
}
public void Method3(bool c, int x, object o)
{
meth3_c = c;
meth3_x = x;
meth3_0 = o;
}
}
手刻這個物件,不但費時也費力,而且還有個大問題:如果需要測試的是某個方法被呼叫多次,該怎麼辦?如果該方法需要一句傳入的參數來決定回傳值,或是要記住同一個方法的所以參數,又該怎麼辦?手刻的程式碼很快就會一團糟。
使用隔離框架來實作以上功能是,測試程式在撰寫時會變得簡單、可讀,且簡短得多。
二、動態產生假物件
動態假物件的定義:動態假物件是在執行時期建立的任何虛設常式物件或模擬物件,它的建立不需要手刻假物件的程式碼(寫死在假類別中)。
使用動態假物件,我們就不需要再手刻程式來實作介面或子類別了。
接下來,來簡單介紹一下隔離框架 NSubstitute。
1. NSubstitute(簡稱 NSub)
- 開源程式碼的隔離框架
- 免費下載,可透過 NuGet 安裝
- 語法簡單、執行速度快,API 的使用容易上手
- 支援 準備 - 執行 - 驗證 (Arrange - Act - Assert) 模型
- 理念:測試的準備階段建立和設定物件,接著對被測試產品執行動作,最後驗證在測試中是否正常的與假物件進行互動
- 有 Substitute 類別,用來在執行時期產生假物件
- For(type)的方法,有泛型和非泛型兩種,是使用產生假物件最常用的方法
- 測試執行時,這個方法會動態建立,並回傳參數所指定的類別或介面的一個假物件,不需額外再寫程式完成這個物件的內容
- 受限框架(constrained framework),它最適合替介面建立假物件
- 對於類別,NSub 無法替不可繼承(sealed)建立假物件,而對不可繼承的類別,NSub 也只能偽造類別中的虛擬方法
2. 用動態假物件來取代手刻假物件
我們來看一個手刻的假物件,它會檢查呼叫 log 紀錄訊息是否正確執行。
透過手刻假物件來進行驗證
[TestFixture]
class LogAnalyzerTests
{
[Test]
public void Analyze_TooShortFileName_CallLogger()
{
FakeLogger logger = new FakeLogger(); // 建立假物件
LogAnalyzer analyzer = new LogAnalyzer(logger);
analyzer.MinNameLength = 6;
analyzer.Analyze("a.ext");
StringAssert.Contains("too short", logger.LastError); // 把假物件當模擬物件來使用並驗證
}
}
class FakeLogger: ILogger
{
public string LastError;
public void LogError(string message)
{
LastError = message;
}
}
接著,我們會建立一個動態模擬物件,取代之前的測試方式。下面的程式碼清單呈現了使用 NSub 來模擬 ILogger,並且可以看到要驗證呼叫方法所傳入的參數,有多麼簡單!
使用 NSub 產生假物件
[Test]
public void Analyze_TooShortFileName_CallLogger()
{
ILogger logger = Substitute.For<ILogger>(); // 建立模擬物件,在測試最後進行驗證
LogAnalyzer analyzer = new LogAnalyzer(logger);
analyzer.MinNamaLength = 6;
analyzer.Analyze("a.txt");
logger.Received().LogError("Filename too short: a.txt"); // 使用 NSub API 來設定期望結果
}
簡單幾行程式碼,就免去了手刻模擬物件或虛設常式那堆麻煩事,因為 NSub 能自動產生假物件。這個 ILogger 的假物件是動態產生的,假物件時做了 Ilogger 的介面,但是沒有實作 ILogger 上的任何方法。
透過在 LogError() 前呼叫 Received(),我們在告訴 NSub 實際上是要詢問假物件這個方法是否有被呼叫過。如果該方法沒有被呼叫,測試的最後一行會拋出一個例外,這行程式碼告訴讀測試程式的人:「某個物件應該曾經被呼叫過一次,否則這個測試應該失敗。」
如果 LogError() 方法沒有被呼叫,你會在測試失敗的訊息中看到類似下面的錯誤訊息:
NSubstitute.Exceptions.ReceivedCallsException: Expected to receive a call matching:
LogError("FileName too short: a.txt")
Actuall received no matching calls.
瞭解如何把假物件當作模擬物件之後,接著我們來看看怎麼將它們看作虛設常式物件,提供被測試系統所需要的回傳值。
三、模擬回傳值
如果介面的方法不是回傳空值,那麼該如何完成介面的動態假物件回傳一個值呢?
從一個假物件回傳值
[Test]
public void Returns_ByDefault_WorksForHardCodedArgument()
{
IFileNameRules fakRules = Substitute.For<IFileNameRules>();
fakeRules.IsValidLogFileName("strict.txt").Returns(true); // 強制方法被呼叫時要回傳假的值
Assert.IsTrue(fakeRules.IsValidLogFileName("strict.txt"));
}
接下來,我們來試著在程式碼中使用參數匹配器(argument matcher),略過傳入的參數值:
[Test]
public void Returns_ByDefault_WorksForHardCodedArgument()
{
IFileNameRules fakeRules = Substitute.For<IFileNameRules>();
fakeRules.IsValidLogFileName(Arg.Any<String>()).Returns(true); // 忽略參數內容
Assert.IsTrue(fakeRules.IsValidLogFileName("anything.txt"));
}
留意一下是怎麼使用 Arg 類別的,這表示不論傳入的參數為何,我們都會回傳這個假值。Arg.Any<Type>
稱為參數匹配器,在隔離框架中被廣泛使用,用來控制參數的處理。
如果要拋出例外:
[Test]
public void Returns_ArgAny_Throws()
{
IFileNameRules fakeRules = Substitute.For<IFileNameRules>();
fakeRules.When(x => x.IsValidLogFileName(Arg.Any<string>())) // 這邊會使用到 lambda 表示式
.Do(context => { throw new Exception("fake exception"); });
Assert.Throws<Exception>(() => fakeRules.IsValidLogFileName("anything"));
}
在程式碼中,使用 Assert.Throw 是用來驗證被測試方法是否有拋出期望的例外。
- When 方法必須使用 Lambda 表示式:在 When 方法中,參數 x 代表你要模擬改變行為的假物件。
- Do 部分則使用了 CallInfo context 參數:在執行時,context 包含了呼叫的參數值。
學會了模擬回傳值和例外,我們來討論一下更實務面的需求。
1. 同時使用模擬物件和虛設常式物件
我們在一個情境裡面結合使用這兩種類型的假物件。兩個假物件,一個用來當虛設常式物件,一個用來當模擬物件。
使用虛設常式物件來取代 logger 並模擬拋出例外,而使用模擬物件當假的 web 服務,確認它是否有被正確地呼叫。整個測試就是 LogAnalyzer 類別與其他物件的互動測試。
你需要確保的是:如果 log 物件拋出了例外,LogAnalyzer2 會把這個問題通知給 WebService。
被測試方法以及使用手刻模擬物件和虛設常式物件的測試程式
[Test]
public void Analyze_LoggerThrows_CallsWebService() // 測試方法
{
FakeWebService mockWebService = new FakeWebService();
Fakelogger2 stubLogger = new FakeLogger2();
stubLogger.WillThrow = new Exception("fake exception");
var analyzer2 = new LogAnalyzer2(stubLogger, mockWebService)'
analyzer2.MinNameLength = 8;
string tooShortFileName = "abc.ext";
analyzer2.Analyze(tooShortFileName);
Assert.That(mockWebService.MessageToWebService, Is.StringContaining("fake exception"));
}
public class FakeWebService: IWebService // 用模擬物件來當作一個假的 web 服務
{
public string MessageToWebService;
public void Write(string message)
{
MessageToWebService = message;
}
}
public class FakeLogger2: ILogger // 用虛設常式物件來模擬 logger 拋出例外
{
public Exception WillThrow = null;
public string LoggerGotMessage = null;
public void LogError(string message)
{
LoggerGotMessage = message;
if (WillThrow != null)
{
throw WillThrow;
}
}
}
// ----- PRODUCION CODE
public class LogAnalyzer2
{
private ILogger _logger;
private IWebService _webService;
public LogAnalyzer2(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 e)
{
_webService.Write("Error From Logger: " + e);
}
}
}
}
public interface IWebService
{
void Write(string message);
}
下一段程式碼清單,則用 NSub 來取代上述的程式碼內容。
把前面的測試轉換成 NSubStitute 的寫法
[Test]
public void Analyze_LoggerThrows_CallWebService()
{
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 LogAnalyzer2(stubLogger, mockWebService);
analyzer.MinNameLength = 10;
analyzer.Analyze("Short.txt");
mockWebService.Received().Write(Arg.Is<string>(s => s.Contains("fake exception"))); // 確認 web 服務的模擬物件有被正確呼叫,而且傳入的字串參數包含了 fake exception 的內容
}
這個測試寫法的好處:不需要手刻假物件的內容。但要注意的是,這段程式的可讀性變差了。
- 參數匹配約束(argument-matching constraint)既可以用在測試準備的部分,進行虛設常式物件的設定,也可以用在驗證的部分,檢查在測試過程中是否正確地與模擬物件進行互動。
2. 驗證物件是帶著某些屬性的情況
如果你預期的參數是一個帶有 某個期望屬性值 的物件,像是在呼叫 WebService.Write 方法時,應傳入一個帶有 serverity 和 message 屬性的 ErrorInfo
物件,該怎麼做呢?
[Test]
public void Analyze_LoggerThrows_CallsWebServiceWithNSubObject()
{
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 Loganalyzer3(stubLogger, mockWebService);
analyzer.MinNameLength = 10;
analyzer.Analyze("Short.txt");
mockWebService.Received()
.Write(Arg.Is<ErrorInfo>(info => info.Severity == 1000 // 針對你期望的物件類型所使用的強型別參數匹配器
&& info.Message.Contains("fake exception"))); // 透過 C# 裡面簡單的 and 來建立你對物件更複雜的期望
}
要測試參數物件期望值,最簡單的方式是比較兩個物件。你可以建立一個帶有所有預期屬性的 expected 物件,和實際傳入的參數物件進行比較。如下列程式所示。
比較完整物件
[Test]
public void Analyze_LoggerThrows_CallsWebServiceWithNSubObjectCompare()
{
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 LogAnalyzer3(stubLogger, mockWebService);
analyzer.MinNameLength = 10;
analyze.Analyze("Short.txt");
var expected = new ErrorInfo(1000, "fake exception"); // 建立你期望接收到的物件
mockWebService.Received().Write(expected); // 驗證你是否得到了值相等的物件,本質上就是 assert.equals
}
只有滿足以下條件時,測試整個物件的方式才可行:
- 容易建立帶有預設值的物件
- 需要測試目標物件上的所有屬性
- 知道全部屬性的期望值
- 進行比較的兩個物件,實作了 Equals() 方法
四、測試事件相關的活動
事件是雙向的,你可以從兩個方向進行測試:
- 測試監聽事件的那一方
- 測試觸發事件的那一方
1. 測試事件監聽者
我們要解決的第一個測試情境就是:檢查一個物件是否有註冊到另一個物件的一個事件。
一個比較好的設計方式是:檢查監聽物件是否對發生的事情做出某種具體的反應,如果監聽者沒有註冊到這個事件,那它就不會採取任何可觀察到的公開行為。詳細的實現方式如下:
事件相關的程式碼,以及如何觸發事件
class Presenter
{
public readonly IView _view;
public Presenter(IView view)
{
_view = view;
this._view.Loaded += OnLoaded;
}
private void OnLoaded()
{
_view.Render("Hello World");
}
}
public interface IView
{
event Action Loaded;
void Render(string text);
}
// ----- TESTS
[TestFixture]
public class EventRelatedTests
{
[Test]
public void ctor_WhenViewIsLoaded_CallsViewRender()
{
var mockView = Substitute.For<IView>();
Presenter p = new Presenter(mockView);
mockView.Loaded += Raise.Event<Action>(); // 使用 NSubstitute 觸發事件
mockView.Received().Render(Arg.Is<string>(s => s.Contains("Hello World"))); // 驗證在測試中 view 物件的 Render 方法是否被呼叫
}
}
有幾點留意:
- 這個模擬物件同時也是個虛設常式物件
- 要觸發一個試驗,你需要在測試中註冊這個事件。這種作法是為了滿足編譯器的要求,因為事件相關的屬性編譯器會特別謹慎,編譯要求很嚴格。事件只能由宣告它們的類別或結構直接喝叫。
另一個情境有兩個相依物件:一個 log 物件,一個 View。下列程式碼確保了 Presenter 在虛設常式物件得到一個事件時寫入 log。
模擬一個事件,以及一個單獨的模擬物件
[Test]
public void ctor_WhenViewHasError_CallsLogger()
{
var stubView = Substitute.For<IView>();
var mockLogger = Substitute.For<Ilogger>();
Presenter p = new Presenter(stubView, mockLogger);
stubView.ErrorOccured += Raise.Event<Action<string>>("fake error"); // (1) 模擬錯誤發生
mockLogger.Received().LogError(Arg.Is<string>(s => s.Contains("fake error"))); // (2) 使用模擬物件驗證 log 有被正確呼叫
}
注意:你使用了 (1) 虛設常式物件觸發事件,一個 (2) 模擬物件驗證是否正確地呼叫了 log 服務。
接著,我們來看看驗證事件發生點有在正確的時機點觸發事件,而不是檢查測試監聽者的反應。
2. 測試事件是否觸發
測試事件的一個簡單方式,就是在測試方法內部使用一個匿名委派,手動註冊這個方法。
使用匿名委派來註冊一個事件
[Test]
public void EventFiringManual()
{
bool loadFired = false;
SomeView view = new SomeView();
view.Load += delegate { loadedFired = true };
view.DoSomethingThatEventuallyFiresThisEvent();
Assert.IsTrue(loadFired);
}
這個委派只記錄了這個事件是否被觸發。你也可以在委派中加入參數,用來記錄需要的值,以便後續進行驗證。
五、現有的 .NET 隔離框架
2012 隔離框架統計結果:
六、隔離框架的優缺點
隔離框架有以下顯著優點:
- 更容易驗證參數
- 更容易驗證一個方法被多次呼叫
- 更容易建立假物件
使用隔離框架時應避免的陷阱:
- 測試程式可讀性變差
- 驗證了錯誤的東西
- 一個測試中有多個模擬物件
- 過度指定的測試
七、小結
隔離框架很酷,但有一點很重要:盡量選擇測試回傳值系統狀態改變的測試(相對於互動測試),使用這兩種測試,可以減少對程式碼內部細部實作的假設。只有在非不得已才此用模擬物件,因為容易更難維護與閱讀。
如果有超過 5% 的測試使用模擬物件,而非虛設常式物件,你可能過度指定了。
在下一章節,我們將深入了解隔離框架,了解它們的設計,以及影響其能力的底層實作內容。