--- tags: Unit Test, --- # 單元測試筆記 ## 單元測試的定義 > A unit test is a piece of a code (usually a method) that invokes another piece of code and checks the correctness of some assumptions afterward - 我自己的定義是有另外一個程式會幫你檢查你產品程式的邏輯是否錯誤. - 邏輯的定義是**某一個方法在某個情境之下 , 期望它採取的行為** - 所以對我來說 , 單元測試就是另外撰寫一個程式去檢查你產品程式內的方法在某一個情境之下 , 是否有採取我期望它執行的行為. - 當然 , 邏輯不會只有一個 , 產品程式的方法也不會只有一個 , 所以你會撰寫不只一個方法去檢查你產品程式的邏輯. ## 單元測試的好處 ? - 情境 1 : 當你接手一個超過十年的專案 , 負責實作該專案的人都已物去人非. 現在新的 PM 跟你說他想改一個很簡單的功能 , 並拍拍你肩說「加油呀」. 而你卻不知道該從何下手.... 此時若是此專案有單元測試...? - 撰寫單元測試就像在撰寫一份程式版規格書. 好的單元測試能幫助你快速了解那些不是你實作的功能. - 每一個單元測試方法都在驗證一個邏輯. 所以可以透過單元測試方法去知道產品方法在實作時的邏輯或者是意圖. 同時這個邏輯一定是來自於規格書. - 情境 2 : 當你為了新增一個功能而修改一個方法邏輯的時候 , 結果原本舊的功能就壞了 ... - 單元測試會幫你檢查程式邏輯 , 所以如果你做了修改後 , 導致某幾個單元測試不通過. 代表你這次的修改絕對有問題... - 因此你可以放心修改你的產品方法. 而不用緊張地在每做一個修改後 , 都人工去驗證是否有違反舊有的邏輯. 反正單元測試方法會幫你檢查 - 同理 , 你自然也可以放心重構你的方法. - 情境 3 : 當你完成一個大功能後 , 並且 push 後突然發現有你之前檢查沒發現的大 bug ... - 你不會在所有功能都完成後 , 才撰寫單元測試. 而是會透過撰寫單元測試來驗證你每一個邏輯的實作是否正確. 當驗證此邏輯正確後 , 才繼續實作下一步. - 當問題越早被發現 , 你需要修復的成本就會越低 - 情境 4 : 完成功能後 , 你發現你的程式是很難維護的 , 方法物件間各種強相依 ... - 寫單元測試的前提是產品方法是可以測試. 所以你必須想辦法讓你的程式碼是可維護的. - 通常可測試的程式碼解偶都會比較好. 也就可能會具備比較好的結構和可讀性. (例如若欲測試的方法強相依於某個類別 , 可能會導致你無法撰寫單元測試方法去測試它) ## 甚麼是好的單元測試 - Fast - 寫一個單元測試不需要太多的時間. - 一個單元測試方法執行完成所花費的時間非常少. (s) - Trustworthy - 若是單元測試不通過 , 則必定是產品程式哪裡寫錯了. 如果看到一個測試不通過 , 還要懷疑會不會是單元測試方法寫錯了 , 則這肯定不是一個好的測試. - Readable / Maintainable - 可以很容易看出這個單元測試想要檢測的邏輯 , 或者它是去執行檢查的方式為何. - Isolated - 單元測試應該是可以獨立執行的 , 不應該與任何事物相依. - Repeatable - 若產品程式沒有任何的修改 , 則單元測試檢測的結果也不應該有任何的改變. ## 定義名詞 - Mock (模擬物件) - 它可以拿來驗證被測試物件是否如預期般呼叫這個模擬物件 , 因此來判斷此單元測試結果成功或失敗 , 通常每個單元測試方法最多只有一個 mock 物件. - Mock 物件會去記錄自身被測試物件呼叫的執行狀況. e.g. 某個屬性是否僅被設定一次 !? - ![Mock](https://github.com/s0920832252/C_Sharp/blob/master/Files/ArtOfUnitTest/Mock.png?raw=true) - Sub (虛擬常式) - 可設定的替代物件 , 用來取代被測試物件相依的物件. - ![Sub](https://github.com/s0920832252/C_Sharp/blob/master/Files/ArtOfUnitTest/Sub.png?raw=true) - Fake (假物件) - 描述一個 Mock 物件或 Sub 物件. 若這個假物件是拿來驗證物件之間的互動(對它驗證) , 則它就是模擬物件 , 否則它就是虛擬常式物件. ## 撰寫單元測試的 GUILDLINE ### Naming your tests - 讓人能夠快速理解此單元測試所欲檢驗的邏輯是非常重要的. - 所以單元測試的名稱應該要由下列三項組成 - 在測甚麼 - 被測試的情境為何 - 期望待測試物件執行的行為 - ```C# // Good Example public void Add_SingleNumber_ReturnsSameNumber() { var stringCalculator = new StringCalculator(); var actual = stringCalculator.Add("0"); Assert.Equal(0, actual); } ``` ### Arranging your tests (3A原則) - 3A 原則是常見地撰寫單元測試的步驟 , 其有三個步驟如下 - Arrange : 將待測試物件設定成(初始化)想測試的環境. - Act : 執行動作 - Assert : 驗證是否如期望般執行. - 3A 原則可以幫助我們將想驗證的東西(動作) 從設定環境步驟以及驗證步驟區分開來 , 使得單元測試程式更具可讀性和維護性 - ```C# // Good Example public void Add_EmptyString_ReturnsZero() { // Arrange var stringCalculator = new StringCalculator(); ... // Act var actual = stringCalculator.Add(""); // Assert Assert.Equal(0, actual); } ``` ### Avoid logic in tests - 在單元測試方法內加入越多的程式邏輯 , 會導致此測試方法越容易出錯. 而這會導致我們不信任此測試方法的驗證結果. - 越多的程式邏輯 , 會導致單元測試方法所欲檢驗的商業邏輯或是意圖越不容易讓人理解. - ```C# //Bad Example public void Add_MultipleNumbers_ReturnsCorrectResults() { var stringCalculator = new StringCalculator(); var expected = 0; var testCases = new[] { "0,0,0", "0,1,2", "1,2,3" }; foreach (var test in testCases) { Assert.Equal(expected, stringCalculator.Add(test)); expected += 3; } } ``` ### Avoid multiple asserts - 一個單元測試應該只測試一個邏輯. - 若是檢測複數個邏輯 , 當前面某個邏輯驗證失敗 , 單元測試方法並不會繼續檢查後面的邏輯是否正確. 會導致我們很難一次知道問題出在哪裡. - 除非你驗證的邏輯是物件互動 , e.g. mock 物件的 A 方法被調用一次 , 且 B 方法被調用兩次. - ```C# // Bad Example public void Add_EdgeCases_ThrowsArgumentExceptions() { Assert.Throws<ArgumentException>(() => stringCalculator.Add(null)); Assert.Throws<ArgumentException>(() => stringCalculator.Add("a")); Assert.Throws<ArgumentException>(() => stringCalculator.Add("_")); Assert.Throws<ArgumentException>(() => stringCalculator.Add("#")); } ``` ### Validate private methods by unit testing public methods - 重申 : 單元測試是用來模擬 「**外部**」 如何使用測試目標物件 , 驗證其行為是否符合預期. - 外部使用者原本就不了解(也不需要) , 測試目標物件非 public 成員的行為. 所以單元測試既然是模擬外部使用端的動作 , 那當然只需要針對**測試目標物件 public 成員的行為**進行模擬與驗證. - ```C# // Production Code public string ParseLogLine(string input) { var sanitizedInput = TrimInput(input); return sanitizedInput; } private string TrimInput(string input) { return input.Trim(); } ``` ``` C# // Test Code public void ParseLogLine_ByDefault_ReturnsTrimmedResult() { var parser = new Parser(); var result = parser.ParseLogLine(" a "); Assert.Equals("a", result); } ``` ### Fake static references - 撰寫單元測試方法的一個原則是必須要能夠設定產品方法的情境. ( Arrange 步驟) - 可以考慮透過假物件 , 去設定情境 - 原則上盡可能使用真實的類別 , 如果不行 , 再使用 Stubs , 再不行 , 使用 Mocks. 過度使用 Fake , 是有可能導致測試失真的. ```C# // Product Code public interface IChecker { bool IsValid(string fileName); } public class LogAnalyzer { private readonly IChecker _checker; public LogAnalyzer(IChecker checker) { _checker = checker; } public bool IsValidLogFileName(string filePath) => _checker.IsValid(filePath); } ``` ```C# // Test Code private class FakeChecker : IChecker { public bool WillBeValid { get; set; } public bool IsValid(string fileName) => WillBeValid; } [Test] public void IsValidLogFileName_ValidLogName_ReturnTrue() { #region Arrange var fakeChecker = new FakeChecker() { WillBeValid = true }; var logAnalyzer = new LogAnalyzer(fakeChecker); #endregion #region Act var isValidLogFileName = logAnalyzer.IsValidLogFileName("test case"); #endregion #region Assert Assert.IsTrue(isValidLogFileName); #endregion } ``` - 透過 Create 一個 FakeChecker Class , 去控制 LogAnalyzer 呼叫 IsValidLogFileName 的結果. ## Act and Override - 實務上很常使用的技巧.:question: ### 以例子解釋 , 試圖為下列方法寫單元測試. - 情境 : 已經有很多人再使用 IsXms 這個方法了 , 所以禁止修改 IsXms 的傳入參數. ```C# // 請忽略 "為甚麼一開始不寫成靜態方法以及 DateTime 用參數的方式傳入" 這件事. // 我就是要這樣寫 , 咬我呀. public class Xmas { // 判斷今天是不是聖誕節 public bool IsXms() { var today = DateTime.Now; return today.ToString("MMdd") == "1225"; } } ``` ```C# [Test] public void IsXms_TodayIsXms_ReturnTrue() { #region Arrange var xmas = new Xmas(); #endregion #region Act var isXms = xmas.IsXms(); #endregion #region Assert Assert.IsTrue(isXms); #endregion } [Test] public void IsXms_TodayNotXms_ReturnFalse() { #region Arrange var xmas = new Xmas(); #endregion #region Act var isXms = xmas.IsXms(); #endregion #region Assert Assert.IsFalse(isXms); #endregion } ``` 上面兩個單元測試方法 , 永遠只能通過一個. 因為我們沒辦法控制 today 這個變數. ### 若透過 DI 的方式去控制 today ? ```C# public class Xmas { private readonly DateTime _today; public Xmas() { _today = DateTime.Now; } public Xmas(DateTime dateTime) { _today = dateTime; } public bool IsXms() { return _today.ToString("MMdd") == "1225"; } } ``` ```C# private readonly DateTime _xmsDay = new DateTime(2000, 12, 25); private readonly DateTime _notXmsDay = new DateTime(2000, 1, 1); [Test] public void IsXms_TodayIsXms_ReturnTrue() { #region Arrange var xmas = new Xmas(_xmsDay); #endregion #region Act var isXms = xmas.IsXms(); #endregion #region Assert Assert.IsTrue(isXms); #endregion } [Test] public void IsXms_TodayNotXms_ReturnFalse() { #region Arrange var xmas = new Xmas(_notXmsDay); #endregion #region Act var isXms = xmas.IsXms(); #endregion #region Assert Assert.IsFalse(isXms); #endregion } ``` 單元測試都通過了 ~~~~ 但會造成兩個問題 1. 程式邏輯被改變了 , DateTime 的產生時機由 IsXms 方法內移動到建構式. e.g. 實際使用上 , 若 new Xmas() 物件後 , 過了好幾天才呼叫 IsXms(). 會有錯誤. 2. 違反迪米特法則(最小知識原則) 僅是為了寫測試 , 而增加了 public 成員. 外界其實是不需要知道 DateTime 這個物件存在的. ### 使用 Extract and Override 1. 將依賴的部分抽出到 protected virtual 方法內. 2. 建立假 class 繼承原本的 class 3. 在假類別內 override protected 方法. 4. 測假類別 ```C# public class Xmas { public bool IsXms() { var today = GetToday(); return today.ToString("MMdd") == "1225"; } protected virtual DateTime GetToday() { return new DateTime(); } } ``` ```C# private readonly DateTime _xmsDay = new DateTime(2000, 12, 25); private readonly DateTime _notXmsDay = new DateTime(2000, 1, 1); private class FakeXmas : Xmas { public DateTime Today { get; set; } protected override DateTime GetToday() { return Today; } } [Test] public void IsXms_TodayIsXms_ReturnTrue() { #region Arrange var xmas = new FakeXmas() { Today = _xmsDay }; #endregion #region Act var isXms = xmas.IsXms(); #endregion #region Assert Assert.IsTrue(isXms); #endregion } [Test] public void IsXms_TodayNotXms_ReturnFalse() { #region Arrange var xmas = new FakeXmas() { Today = _notXmsDay }; #endregion #region Act var isXms = xmas.IsXms(); #endregion #region Assert Assert.IsFalse(isXms); #endregion } ``` ### 使用 Moq 對 Protected Method 做 override ```C# private readonly DateTime _xmsDay = new DateTime(2000, 12, 25); [Test] public void IsXms_TodayIsXms_ReturnTrue() { #region Arrange var mock = new Mock<Xmas>(); mock.Protected().Setup<DateTime>("GetToday").Returns(_xmsDay); var xmas = mock.Object; #endregion #region Act var isXms = xmas.IsXms(); #endregion #region Assert Assert.IsTrue(isXms); #endregion } ``` --- ## 參考 [.NET Core 和 .NET Standard 的單元測試最佳做法](https://docs.microsoft.com/zh-tw/dotnet/core/testing/unit-testing-best-practices#best-practices) [單元測試的基本觀念](https://kojenchieh.pixnet.net/blog/post/75411355) [[C#][Unit Test] 01. 軟體上線就等於今晚不用回家? 學"單元測試"可能有辦法挽救您的婚姻。](https://progressbar.tw/posts/11) [Unit Testing Best Practices](https://medium.com/better-programming/unit-testing-best-practices-9bceeafe6edf) [「翻译」测试用例最佳实践](https://xie.infoq.cn/article/c333e77d2996679f13f8aee43) [[測試]單元測試:是否針對非 public method 進行測試?](https://dotblogs.com.tw/hatelove/2012/07/19/why-you-should-not-write-unit-test-with-private-and-protected-method) [使用 Moq 來 Mock protected Method](https://blog.yowko.com/moq-mock-protected-method/) --- ### Thank you! You can find me on - [GitHub](https://github.com/s0920832252) - [Facebook](https://www.facebook.com/fourtune.chen) 若有謬誤 , 煩請告知 , 新手發帖請多包涵 # :100: :muscle: :tada: :sheep: <iframe src="https://skilltree.my/c67b0d8a-9b69-47ce-a50d-c3fc60090493/promotion?w=250" width="250" style="border:none"></iframe>