# Unit Test ### 什麼是單元測試? 一個單元測試就是一段程式碼(通常是一個方法),這段程式呼叫了另一段程式碼,然後驗證某些假設的正確性。如果這些假設是錯誤的,單元測試就會失敗。一個單元可以是一個方法或函數。 ### 整合測試 **整合測試** 是對一個工作單元進行測試,而這個測試對被測試的單元並沒有完全的控制,而是使用該單元一個或多個真實依賴的相依物件,例如:時間、網路、資料庫、或檔案等等。 ### 優秀的單元測試 一個優秀的單元測試是一段自動化的程式碼,這段程式會呼叫被測試的工作單元,之後對這個單元的單一最終結果的某些假設或期望進行驗證。單元測試幾乎都是使用單元測試框架進行撰寫的。撰寫單元測試很容易,執行起來快速。單元測試可靠、易讀、並且很容易維護。**只要產品程式碼不發生變化,單元測試的執行結果是穩定一致的。** ### 命名 ```csharp= [Test] public void [UnitOfWorkName]_[Scenario]_[ExpectedBehavior] { // test code ... } ``` --- ### 專案介紹(LogAnalyzer) 完成一個產品,對公司的log檔案進行分析,在其中搜尋特定的情況與事件。 ### 第一個測試 一個單元測試通常包含三個行為: * **準備(Arrange)**:建立物件,進行必要設定等; * **操作(Act)**:執行動作; * **驗證(Assert)**:驗證事件符合預期。 ```csharp= public void IsValidFilename_BadExtension_ReturnFalse() { var logAn = new LogAnalyzer(); var result = logAn.IsValidLogFilename("FileWithBadExtension.foo"); Assert.AreEqual(false, result); } public class LogAnalyzer { public bool IsValidLogFilename(string filename) { if(!filename.EndsWith(".SLF")) { return false; } return true; } } ``` ### Assert(斷言) 類別 Assert類別是你的程式碼與NUnit測試框架中間的橋樑,用來確認在該假設下某個期望應該成立。 ### Setup NUnit 每次在執行前測試類別裡的任何一個測試方法之前,都會先呼叫[Setup]裡的發訪。 ```csharp= public class LogAnalyzerTests { private LogAnalyzer _logAnalyzer; [SetUp] public void SetUp() { _logAnalyzer = new LogAnalyzer(); } } public void IsValidFilename_BadExtension_ReturnFalse() { var result = _logAnalyzer.IsValidLogFilename("FileWithBadExtension.foo"); Assert.AreEqual(false, result); } ``` ### 驗證預期的例外 ```csharp= [Test] public void IsValidFilename_EmptyFilename_ThrowException() { var error = Assert.Catch<Exception>(() => _logAnalyzer.IsValidLogFilename("")); StringAssert.Contains("filename has to be provided", error.Message) } public class LogAnalyzer { public bool IsValidLogFilename(string filename) { if(string.IsNullOrEmpty(filename)) { throw new ArgumentException("filename has to be provided"); } if(!filename.EndsWith(".SLF")) { return false; } return true; } } ``` 沒用Assert.AreEqual來進行完整字串驗證,而採用StringAssert.Contains去驗證訊息包含你所期待出現的字串的原因是:隨著時間的變化,當程式碼加入越來越多的功能之後,字串內容經常會發生變化,使用StringAssert可以讓你更容易維護,驗證你所期待的資訊是否被包含在字串之中。 ### 狀態驗證 ```csharp= [Test] public void IsValidFilename_WhenCalled_ChangesWasLastFilenameValid() { var logAn = new LogAnalyzer(); logAn.IsValidFilename("badextension.foo"); Assert.AreEqual(false, logAn.WasLastFilenameValid); } public class LogAnalyzer { public bool WasLastFilenameValid { get; set;} public bool IsValidFilename(string filename) { WasLastFilenameValid = false; if(string.IsNullOrEmpty(filename)) { throw new ArgumentException("filename has to be provied") } if(!filename.EndsWith(".SLF")) { return false; } WasLastFilenameValid = true; return true; } } ``` --- ## 透過虛設常式(Stub)解決依賴問題 前面我們已經使用NUnit寫了第一個單元測試,同時也建立了幾個簡單的測試案例,在這些案例中,我們驗證了物件的回傳值。 接下來,我們討論更接近實務的例子,例如要測試物件依賴於另一個你無法控制或尚未實作的物件; Web服務、系統時間、或其他類的三方物件;在你的測試中無法決定回傳值,也無法控制行為的物件。 ### 虛設常式(Stub) SpaceX該如何確保太空人已經做好準備前往太空? 如果我們要對太空梭與太空人進行**整合測試**,那勢必得在太空中進行。這就是為什麼SpaceX建立整套模擬系統,模擬太空梭控制台的周遭環境,見此消除必須在外太控才能測試的外部依賴。  **虛設常式**是在系統中產生一個可控的替代物,來取代一個**外部相依的物件**。你可以在測試中,透過虛設常式來避免相依物件所造成的問題。 **抑制測試(test-inhibiting)** — 當程式碼依賴於某個外部資源,即使邏輯是完全正確的,但這種依賴仍可能造成測試失敗 #### 依賴 我們假設系統所支援的Log檔案格式設定是被存放在硬碟中 ```csharp= public bool IsValidFilename(string filename){ // read reference file // if the extension of the filename is in the list, return true. } ``` 這種設計的問題是,一旦測試依賴於檔案系統,這時所進行的其實就是整合測試。 ### 如何解決依賴問題? #### 加入抽象層 加入抽象層來封裝依賴行為,接者就可以在測試中模擬這個抽象層的實作內容 ```csharp= public bool IsValidLogFilename(string filename) { var manager = new FileExtensionManager(); return manager.IsValid(filename); } class FileExtensionManager { public bool IsValid(string filename) { // read reference file and do some validations here. } } ``` #### 擷取介面以便替換底層實作 **依賴反轉原則(Dependency inversion principle,DIP)** 是指一種特定的解耦(傳統的依賴關係建立在高層次上,而具體的策略設定則應用在低層次的模組上)形式,使得高層次的模組不依賴於低層次的模組的實現細節,依賴關係被顛倒(反轉),從而使得低層次模組依賴於高層次模組的需求抽象 ```csharp= public class FileExtensionManager : IExtensionManager { public bool IsValid(string filename) { // read reference file and so some validations here. } } public interface IExtensionManager { bool IsValid(string filename); } ``` #### 建立需虛設常式 ```csharp= public class FakeExtensionManager : IExtensionManager { public bool WillBeValid = false; public bool IsValid(string filename) { return WillBeValid; } } ``` **這個類別物件使用Fake字眼,說明這個物件類似另一個物件,他可能被當成模擬物件(mock)或虛設常式(stub)使用** #### 建構式注入 ```csharp= [Test] public void IsValidFilename_SupportedExtension_ReturnTrue() { var manager = new FakeExtensionManager(); manager.WillBeValid = true; var logAn = new LogAnalyzer(manager); var result = logAn.IsValidLogFilename("short.ext"); Assert.AreEqual(true, result); } public class LogAnalyzer { private IExtensionManager _manager; public LogAnalyzer(IExtensionManager manager) { _manager = manager; } public bool IsValidLogFilename(string filename) { return _manager.IsValid(filename); } } ``` #### 屬性注入 ```csharp= [Test] public void IsValidFilename_SupportedExtension_ReturnTrue() { var manager = new FakeExtensionManager(); manager.WillBeValid = true; var logAn = new LogAnalyzer(); logAn.ExtensionManager = manager; var result = logAn.IsValidLogFilename("short.ext"); Assert.AreEqual(true, result); } public class LogAnalyzer { private IExtensionManager _manager; public IExtensionManager ExtensionManager { get => _manager; set => _manager = value; }; public LogAnalyzer() { _manager = new FileExtensionManager(); } public bool IsValidLogFilename(string filename) { return _manager.IsValid(filename); } } ``` ### 重構 **A型重構**: 將具象類別抽象程介面或委派。 **B型重構**: 重構程式碼,以便將瑋費或介面的偽實作注入至目標物件中。 ### B型重構的幾種模式 * 從建構函式注入一個假物件 * 透過屬性注入假物件 * 工廠模式 ### 單元測試的三種型式 * 回傳值測試 * 系統狀態測試 * 相依元件互動測試 ### 互動測試 **互動測試**:如果一個特定的工作單元的最終結果,是呼叫另一個物件,就需要進行互動測試。 但是,被測試的物件不會有任何回傳值,或保存任何狀態,它根據一套複雜的業務邏輯,以正確地方式呼叫其它物件,而這個相依物件並不受你控制,或不屬於單元測試的一部份,那你該如何測試你的物標物件有正常地與其它物件互動呢? ### 基於值、狀態與互動測試 假設有一個灌溉系統,你對系統設定了一天要澆多少次水,每次要澆多少水量,你可以有兩種測試系統是否正常運行的方式: * 基於狀態的整合測試 * 土壤是否夠濕潤、樹是否健康、葉子是否青翠。也許很難測試,但如果可以,你就能知道系統是否正常 * 互動測試 * 測試水量流過是否符合預期,甚至更改時間(虛設常式)以便快速驗證。  ### 模擬物件 模擬物件是系統中的假物件,他可以拿來驗證被測試物件是否如預期呼叫這個假物件,因此來使得單元測試執行成功或失敗,通常每個測試物件裡面多只會有一個模擬物件。 // Put diagram to here ### 手刻模擬物件範例 ```csharp= public interface ILoggerService { void LogError(string message); } public class FakeLoggerService : ILoggerService { public string LastError; public void LogError(string message) { LastError = message; } } public class LogAnalyzer { private ILoggerService _loggerService; public LogAnalyzer(ILoggerService service) { _loggerService = service; } public void Analyze(string filename) { if(filename.Length < 8) { _loggerService.Logger("Filename too short") } } } [Test] public void Analyze_TooShortFileName_CallLoggerService() { var mockLogger = new FakeLoggerService(); var logAn = new LogAnalyzer(mcokLogger); var tooShortFileName = "abc.txt"; logAn.Analyze(tooShortFileName); StringAssert.Contains("Filename too short", mockLogger.LastError) } ``` --- ### 隔離框架 隔離框架是可以幫助寫程式的API,使用這套API來建立假物件會比手刻假物件要容易得多、快得多、簡潔許多。 ```csharp= public void Analyze_TooShortFilename_CallLogger() { var logger = Substitute.For<ILogger>(); var logAn = new LogAnalyzer(logger); logAn.Analyze("abc.txt"); logger.Received().LogError("Filename too short"); } ``` ### 模擬回傳值 ```csharp= [Test] public void IsValidLogFilename_ByDefault_ReturnTrue() { var manager = Substitute.For<IExtensionManager>(); manager.IsValid(Arg.Any<String>()).Return(true); var logAn = new LogAnalyzer(manager); var result = logAn.IsValidLogFilename("short.ext"); Assert.AreEqual(true, result); } ``` ### 使用隔離框架應避免的事 * 驗證錯誤的東西 * 驗證一個物件訂閱一個事件,不能保證功能是正確的。一個更好的方式是:你應該測試物件觸發事件時,發生一些有意義的事情。 * 一個測試中有多個模擬物件 * 過度指定測試 * 指定驗證被測試物件的純內部行為:測試的程式碼不屬於任何公開契約或介面 * 在需要虛設常式物件時,使用模擬物件 * 不必要的順序或過於精準的參數匹配 ###
×
Sign in
Email
Password
Forgot password
or
By clicking below, you agree to our
terms of service
.
Sign in via Facebook
Sign in via Twitter
Sign in via GitHub
Sign in via Dropbox
Sign in with Wallet
Wallet (
)
Connect another wallet
New to HackMD?
Sign up