這一篇,本文會介紹一下基本的斷言概念,但重點會放在企業級單元測試的相關功能上面。下面來跟大家分享一下xUnit.Net的斷言,主要涉及到以下內容:
- 關於斷言的概念
- xUnit.Net常用的斷言
- 關於單元測試實踐的討論
- xUnit.Net比較器:IEqualityComparer接口
- 重構Demo:淺談UT框架實踐
- 擴展實現 : 集合比較
- 異步處理
- 結合.Net平台能力:類型擴展
(一)關於斷言的概念
提到斷言,我想先說說概念上的東西。其實,斷言不是單元測試才有的東西。先看一段斷言的概念描述:
斷言表示為一些布爾表達式,程序員相信在程序中的某個特定點該表達式值為真,可以在任何時候啟用和禁用斷言驗證,因此可以在測試時啟用斷言而在部署時禁用斷言。同樣,程序投入運行后,最終用戶在遇到問題時可以重新啟用斷言。使用斷言可以創建更穩定、品質更好且不易於出錯的代碼。當需要在一個值為FALSE時中斷當前操作的話,可以使用斷言(單元測試必須使用斷言)。
以上是我認為比較靠譜的一段關於斷言的定義。換言之,斷言其實是用來判斷程序是否運行正常(比如:檢查數據完整性,邏輯是否正確等等)的功能,它是可以打開和關閉的。這一點在.NET和Java等常見的開發平台下都有相關的支持。而這一功能被廣泛的運用在單元測試領域,開篇先強調一下這一點,以免錯誤的引導了閱讀本文的小伙伴。
(二)xUnit.Net常用的斷言
談到具體的斷言部分,相信只要是稍微了解過單元測試的小伙伴都不會感到陌生,這里我就不再贅述了。簡單的列出xUnit.Net常用的一些斷言列表:
- Equal / NotEqual : 驗證兩個對象值相等
- Same / NotSame : 驗證兩個對象引用是否相同
- Contains / DoesNotContain : 驗證對象是否包含(字符串\可枚舉的集合)
- InRange / NotInRange : 驗證對象是否在某個范圍內
- Empty / NotEmpty : 驗證對象是否為空
- IsType / IsNotType : 驗證對象是否為某個類型
- Null / NotNull : 驗證對象是否為空
- True / False : 驗證表達式結果為True/False
- IsAssignableFrom : 驗證類型是否可以轉化為某個類型
- IsType / IsNotType : 驗證對象是否是某個類型
- Throws / ThrowsAsync : 驗證操作是否拋出異常
需要說明的是,每種方法xUnit.Net都提供了多種重載(包括泛型重載)。下面是官網上提供的關於xUnit.Net與其他單元測試框架對斷言支持的比較。
(三)關於單元測試實踐的討論
如果只是寫寫Demo,前面講到的東西已經足夠了。但如果你真的想把項目的單元測試做好(搭建一個企業級的單元測試框架),我下面要講的東西也許是更重要的。企業級的單元測試經常會被大家冠以類似這樣的名頭:“單元測試需要做,但是... ... 項目時間緊、代碼不可測試、Mock數據之后覆蓋率底下......(此處省略N多原因)”。單元測試的好處毋庸置疑,但是實踐起來卻屢屢受挫。我想主要的原因有以下兩點:
- 沒有好的管理機制的要求:項目往往是要求功能的產出。
- 內部框架支持不好。
第一點這里就不多說了,這個系列是技術主導的系列(關於管理相關的實踐,我會在敏捷相關的系列中跟大家分享)。那什么是內部框架的支持呢?也就是團隊內部對單元測試框架恰當的擴展和封裝。請注意,這里我用了恰當兩個字,這才是重點呦~~。目前,封裝,架構,設計模式等等詞語滿天飛。我經常看見一些初出茅廬的小伙伴大談模式和框架,動不動就要重構系統(結果,你懂的~~~)。在我看來,所有項目都會有自己的框架(即使你沒有做任何的設計)。這里,需要提出一個問題:我們的設計是否是恰當的?其實,這是一個很難給出准確判斷的事情,但具體的判斷方式卻很簡單。就是看一下框架有沒有達成最初的目的(對如何開始架構設計有興趣的小伙伴可以去讀讀《恰如其分的軟件架構》,也后有機會會跟大家分享讀后感... ...)。比如,我們設計框架是為了提高生產率。那么我們就應當對復雜的技術細節進行封裝,為使用框架的人提供簡單、易用、學習成本低的接口。如果設計框架是為了避免安全性問題,那么最后我們就需要考量整個框架在實際的使用過程中的對安全性的提升程度... ... 很遺憾,我們現在遇到的很多框架是為了設計框架而設計的框架。結果和我們的預期南轅北轍。
吐槽到此結束,本文也沒有打算來講一個單元測試框架如何設計。現在,我們來看看就單元測試的框架設計而言。我們應當做那些設計和恰當的封裝呢?當然,沒有銀彈能兼顧所有的問題。下面要提到的主要是結合本文提到的知識點做一些相關擴展。
(四)xUnit.Net比較器:IEqualityComparer接口
仔細觀察一下Assert所提供的方法定義,你會發現很多需要比較的操作都提供了一個接收IEqualityComparer對象的實現。這一小節,我們就來看看如何使用這個功能。顧名思義IEqualityComparer接口用於兩個對象的比較。當我們需要自定義一些比較邏輯時,這個功能應當是首選。先看一下IEqualityComparer的定義:
1 namespace System.Collections.Generic 2 { 3 public interface IEqualityComparer<in T> 4 { 5 bool Equals(T x, T y); 6 int GetHashCode(T obj); 7 } 8 }
可以看到,該接口接收一個用於比較的類型,並且定義了兩個方法:
- Equals : 提供自定義的比較邏輯。
- GetHashCode : 提供對象HasCode生成邏輯。
在xUnit.Net執行斷言判斷的時,如果使用了自定義的比較邏輯,就會使用Equals判斷是否相等,用GetHashCode來獲取對象的標識(這個不是每次都會用到,有興趣可以看看xUNit.Net的源碼)。下面的Code是來自xUnit.Net官網的列子:
1 class DateComparer : IEqualityComparer<DateTime> 2 { 3 public bool Equals(DateTime x, DateTime y) 4 { 5 return x.Date == y.Date; 6 } 7 8 public int GetHashCode(DateTime obj) 9 { 10 return obj.GetHashCode(); 11 } 12 } 13 14 [Fact(DisplayName = "Assert.DateComparer.Demo01")] 15 public void Assert_DateComparer_Demo01() 16 { 17 var firstTime = DateTime.Now.Date; 18 var later = firstTime.AddMinutes(90); 19 20 Assert.NotEqual(firstTime, later); 21 Assert.Equal(firstTime, later, new DateComparer()); 22 }
這里只是一個Demo,更多的使用場景是我們可以用這樣的方式為自定義的類提供比較邏輯。上面的代碼簡單的實現了針對日期的比較邏輯,步驟如下:
- 創建DateComparer類,實現IEqualityComparer接口定義的方法。
- 在Assert.Equal 中使用對應的方法並傳入相應的比較類(其中定義了比較邏輯)。
(五)重構Demo:淺談UT框架實踐
說到這里,如果你只是想了解一下xUnit.Net的使用。那么,你可以跳過這一部分,從下一個小節開始看了。我准備從設計比較函數的角度來談談單元測試框架的設計(也同樣適用於很多的開發框架設計)。
在實際的應用中,你可以使用Demo中的操作(很多公司也確實是這么做的)。隨着項目的演進(一段時間以后)你會發現代碼中到處散落着實現了IEqualityComparer的對象,這樣的維護成本可想而知。建議的做法是對系統中IEqualityComparer類型統一封裝,同時使用單件模式他們的構造進行控制。
首先,我們來使用單件模式重構剛剛的DateComparer類,如Code標黑的部分所示,這里屏蔽了DateComparer的構造函數,並實現了單件模式的調用:
1 class DateComparer : IEqualityComparer<DateTime> 2 { 3 private DateComparer() { } 4 5 private static DateComparer _instance; 6 public static DateComparer Instance 7 { 8 get 9 { 10 if (_instance == null) 11 { 12 _instance = new DateComparer(); 13 } 14 return _instance; 15 } 16 } 17 18 public bool Equals(DateTime x, DateTime y) 19 { 20 return x.Date == y.Date; 21 } 22 23 public int GetHashCode(DateTime obj) 24 { 25 return obj.GetHashCode(); 26 } 27 }
其次,創建一個工廠類統一的控制所有單件比較類的創建邏輯:
1 class SingletonFactory 2 { 3 public static DateComparer CreateDateComparer() 4 { 5 return DateComparer.Instance; 6 } 7 //Other Comparer ... ... 8 }
現在,實際的測試代碼會變成下面的樣子:
1 public class SingletonFactory_Demo 2 { 3 [Fact(DisplayName = "Assert.Singleton.DateComparer.Demo01")] 4 public void Assert_Singleton_DateComparer_Demo01() 5 { 6 var firstTime = DateTime.Now.Date; 7 var later = firstTime.AddMinutes(90); 8 9 Assert.NotEqual(firstTime, later); 10 Assert.Equal(firstTime, later, SingletonFactory.CreateDateComparer()); 11 } 12 }
其實,調用本身的代碼量並沒有減少(反而多了),那么我們為什么要這樣實現呢?回到之前關於單元測試實踐的討論中所提到的。對於單元測試框架的設計我們的目的是什么? 這里我列出來幾個:
- 提高開發效率(降低框架使用者的學習成本)。
- 易於維護和管理。
- 降低Test Case運行的時間成本
關於使用者的成本,之前的做法需要使用者明白如何構建IEqualityComparer接口,並定義比較方法。而后一種方法,IEqualityComparer的實現是由框架開發人員或者熟悉xUnit.Net的資深程序員來做的。也就是說,降低了框架使用者的學習成本。也許有人會反駁,這個邏輯很簡單不需要封裝。如果我們需要比較的對象描述是兩份很復雜的財務報表的對象呢?這樣的比較邏輯是不是需要具有相關的業務知識以及對IEqualityComparer接口(雖然接口很簡單)的了解呢?這個樣封裝使得復雜的邏輯被分離開來。
關於可維護性要從兩個方面來說。第一,實際的項目中所有針對IEqualityComparer的實現都是統一維護的,無論是創建者還是后來的維護人員都能輕易的找到系統中已有的實現。第二,由於做了一些拆分。可以讓更熟悉比較邏輯(復雜對象)的專家來完成框架的代碼。而開發人員可以專心的編寫測試邏輯,而不是關注對象比較。
關於降低Test Case運行的時間成本。試想一下,如果比較對象的是很耗時或者資源開銷的操作(例如需要調用外部的服務....),使用單例模式是不是就大大減低了這方面的成本。
與此同時,我們會提出一個架構層面的約定:“不要直接使用實現了IEqualityComparer的比較對象,如果當前的測試框架沒有提供你想要的功能,請按框架的實現方式提交你的Code”。 相信大家很容易理解這個約定的原因,但是如果它是在本文的一開始就提出的,你也許會覺得很不可理解吧~~~~ 那么,什么是框架設計?
我的理解: 框架設計 = 恰當的約束 + Code;
Code就是框架代碼的具體實現,而約束恰恰是更加重要的一環。如果一個框架沒有靠譜的約束列表,最后的實現和架構師起初的設想一定是南轅北轍的。
當然,用Assert來談UT的框架設計,貌似有些管中窺豹的味道。這里只是想用例子來分享一下本人對框架設計的一些認識。又扯遠了,還是回到xUnit.Net的功能上面吧。
(六)擴展實現 : 集合比較Demo
言歸正傳,前面我們已經向大家展示了如何在類型級別擴展斷言的比較能力(即IEqualityComparer<T>接口),這里我們來實現一個邏輯稍復雜也更加實用一些的比較類。先看以一下場景 : 我們要比較兩個集合的內容是否一致(與數據數序無關)。如果理解了之前的例子,實現這個功能應該很容易:
首先,定義實現了IEqualityComparer接口的比較類,也之前不同的是:這里泛型的類型指定為可枚舉的集合(IEnumerable<T>)。比較方法中,排序對比結果。
1 class CollectionEquivalenceComparer<T> : IEqualityComparer<IEnumerable<T>> 2 where T : IEquatable<T> 3 { 4 public bool Equals(IEnumerable<T> x, IEnumerable<T> y) 5 { 6 List<T> leftList = new List<T>(x); 7 List<T> rightList = new List<T>(y); 8 leftList.Sort(); 9 rightList.Sort(); 10 11 IEnumerator<T> enumeratorX = leftList.GetEnumerator(); 12 IEnumerator<T> enumeratorY = rightList.GetEnumerator(); 13 14 while (true) 15 { 16 bool hasNextX = enumeratorX.MoveNext(); 17 bool hasNextY = enumeratorY.MoveNext(); 18 19 if (!hasNextX || !hasNextY) 20 return (hasNextX == hasNextY); 21 22 if (!enumeratorX.Current.Equals(enumeratorY.Current)) 23 return false; 24 } 25 } 26 27 public int GetHashCode(IEnumerable<T> obj) 28 { 29 throw new NotImplementedException(); 30 } 31 }
然后,我們看一下Test Case:
1 [Fact] 2 public void DuplicatedItemInOneListOnly() 3 { 4 List<int> left = new List<int>(new int[] { 4, 16, 12, 27, 4 }); 5 List<int> right = new List<int>(new int[] { 4, 12, 16, 27 }); 6 7 Assert.NotEqual(left, right, new CollectionEquivalenceComparer<int>()); 8 } 9 10 [Fact] 11 public void DuplicatedItemInBothLists() 12 { 13 List<int> left = new List<int>(new int[] { 4, 16, 12, 27, 4 }); 14 List<int> right = new List<int>(new int[] { 4, 12, 16, 4, 27 }); 15 16 Assert.Equal(left, right, new CollectionEquivalenceComparer<int>()); 17 }
例子很簡單,但卻是很多單元測試會經常使用的功能。so ... ... 列出來給大家,順便鞏固一下IEqualityComparer的使用。
(七)異步處理
實際的單元測試中,一些測試方法需要通過異步的方式調用。xUnit.Net很好的結合了C#所提供的異步操作能力。1.9之前的xUnit.Net使用了Task的方式來實現異步操作,這里就不介紹了(已是過去時~~~)。1.9之后的xUnit.Net版本結合C#中 async / await 提供的能力。非常簡單的實現了針對異步方法的測試需求,先看一個Demo:
1 public class Assert_Async 2 { 3 [Fact] 4 public async void CodeThrowsAsync() 5 { 6 Func<Task> testCode = () => Task.Factory.StartNew(ThrowingMethod); 7 8 var ex = await Assert.ThrowsAsync<NotImplementedException>(testCode); 9 10 Assert.IsType<NotImplementedException>(ex); 11 } 12 13 [Fact] 14 public async void RecordAsync() 15 { 16 Func<Task> testCode = () => Task.Factory.StartNew(ThrowingMethod); 17 18 var ex = await Record.ExceptionAsync(testCode); 19 20 Assert.IsType<NotImplementedException>(ex); 21 } 22 23 void ThrowingMethod() 24 { 25 throw new NotImplementedException(); 26 } 27 }
如上面的Code所示,使用xUnit.Net編寫異步處理相關的Unit Test,一般有以下幾個步驟:
- Test Case 方法標記為 : async
- 定義待測試的方法
- 使用Assert.ThrowsAsync或者Record.ExceptionAsync來執行線程操作
- 判斷結果
(八)結合.Net平台能力:類型擴展
xUnit.Net的一個特點之一,就是充分的發揮了C#語言和.Net平台本身的能力。從異步的處理就可見一斑,這一部分的最后我打算跟分享一下如何使用靜態擴展方法來增強類型系統本身對斷言的支持。看一下官網提供的Demo:
1 namespace Xunit.Extensions.AssertExtensions 2 { 3 /// <summary> 4 /// Extensions which provide assertions to classes derived from <see cref="Boolean"/>. 5 /// </summary> 6 public static class BooleanAssertionExtensions 7 { 8 /// <summary> 9 /// Verifies that the condition is false. 10 /// </summary> 11 /// <param name="condition">The condition to be tested</param> 12 /// <exception cref="FalseException">Thrown if the condition is not false</exception> 13 public static void ShouldBeFalse(this bool condition) 14 { 15 Assert.False(condition); 16 } 17 18 /// <summary> 19 /// Verifies that the condition is false. 20 /// </summary> 21 /// <param name="condition">The condition to be tested</param> 22 /// <param name="userMessage">The message to show when the condition is not false</param> 23 /// <exception cref="FalseException">Thrown if the condition is not false</exception> 24 public static void ShouldBeFalse(this bool condition, 25 string userMessage) 26 { 27 Assert.False(condition, userMessage); 28 } 29 30 /// <summary> 31 /// Verifies that an expression is true. 32 /// </summary> 33 /// <param name="condition">The condition to be inspected</param> 34 /// <exception cref="TrueException">Thrown when the condition is false</exception> 35 public static void ShouldBeTrue(this bool condition) 36 { 37 Assert.True(condition); 38 } 39 40 /// <summary> 41 /// Verifies that an expression is true. 42 /// </summary> 43 /// <param name="condition">The condition to be inspected</param> 44 /// <param name="userMessage">The message to be shown when the condition is false</param> 45 /// <exception cref="TrueException">Thrown when the condition is false</exception> 46 public static void ShouldBeTrue(this bool condition, 47 string userMessage) 48 { 49 Assert.True(condition, userMessage); 50 } 51 } 52 }
這里利用了C#的靜態擴展方法對類型bool 進行了擴展(當然,你也可以擴展任何一個已有的類型),內部使用 Assert 做了一些常規的斷言判斷。現在Unit Test Case的調用代碼就變成了如下所示:
1 [Fact] 2 public void ShouldBeTrue() 3 { 4 Boolean val = true; 5 6 val.ShouldBeTrue(); 7 } 8 9 [Fact] 10 public void ShouldBeFalse() 11 { 12 Boolean val = false; 13 14 val.ShouldBeFalse(); 15 } 16 17 [Fact] 18 public void ShouldBeTrueWithMessage() 19 { 20 Boolean val = false; 21 22 Exception exception = Record.Exception(() => val.ShouldBeTrue("should be true")); 23 24 Assert.StartsWith("should be true", exception.Message); 25 }
當然,對於這種用法本人還是持保留意見的。畢竟只是Assert的簡單封裝,更像是語法糖。但貌似很多團隊有這樣的開發風格,仁者見仁啦~~~。
總結:
本文主要介紹了xUnit.Net的斷言相關的使用,擴展。也簡單的談到了UT(Unit Test)框架的設計。這一篇文章吐了很多槽,回顧一下:
- 關於斷言的概念
- xUnit.Net常用的斷言
- 關於單元測試實踐的討論
- xUnit.Net比較器:IEqualityComparer接口
- 重構Demo:淺談UT框架實踐
- 擴展實現 : 集合比較
- 異步處理
- 結合.Net平台能力:類型擴展
小北De系列文章:
《[小北De編程手記] : Selenium For C# 教程》
《[小北De編程手記]:C# 進化史》(未完成)
《[小北De編程手記]:玩轉 xUnit.Net》(未完成)
Demo地址:https://github.com/DemoCnblogs/xUnit.Net