您好,登錄后才能下訂單哦!
閱讀目錄:
1.開篇介紹
2.單元測試、測試用例代碼重復問題(大量使用重復的Mock對象及測試數據)
2.1.單元測試的繼承體系(利用超類來減少Mock對象的使用)
2.1.1.公用的MOCK對象;
2.1.2.公用的MOCK行為;
2.1.3.公用的MOCK數據;
3.LINQ表達式的重構寫法(將必要的LINQ寫成普通的Function穿插在LINQ表達式中)
4.面向特定領域的單元測試框架(一切原則即是領域驅動)
4.1.分散測試邏輯、日志記錄(讓測試邏輯可以重組,記錄形式為領域模型)
4.2.測試用例的數據重用(為自動化測試準備固定數據,建立Assert的比較測試數據)
最近一段時間結束了一個Sprint,在這次的開發當中有些東西覺得還不錯有總結分享的價值,所以整理成本文;
重構已是老生常談的話題,我們或多或少對它有所了解但是對它的深刻理解恐怕需要一段實踐過后才能體會到;提到重構就不得不提為它保駕護航的大功臣單元測試,重構能有今天的風光影響力完全少不了單元測試的功勞;最近一段時間寫單元測試用例的時間遠超過我寫邏輯代碼的時間和多的多的代碼量,這是為什么?我一開始很難給自己一個理由去做好這件事,心態上還是轉變不過來,可是每當我心浮氣躁的時候它總能給我點驚喜,讓我繼續下去,天生具有好奇心的程序員怎么會就此結束呢,只有到達了一扇門之后我們回過頭來看一下走的路才能真正的明白這是條對的路還是錯的路;
單元測試簡單寫起來沒有什么太大問題,但是我們不僅為了達到代碼的100%覆蓋還要到達到邏輯的100%覆蓋,代碼的覆蓋不代表邏輯的覆蓋;一個簡單的邏輯判斷雖然只有一行代碼,但是里面可能會有正反向很多種邏輯在里面;比如:Order.ToString()簡單的代碼,想要覆蓋很簡單,只要對象不為空都能正確的覆蓋到,但是如果我們沒有測試到它為NULL的情況下的邊界邏輯,這個時候我們就會漏掉這種可能會導致BUG的邏輯路徑;所以我們會盡可能的多去寫用例來達到最終的理想效果;
(總之把單元測試的所有精力集中在可能會出問題的地方,也是自己最擔心的地方,這個地方通常是邏輯比較復雜的地方;)
單元測試代碼中最常見的代碼就是Mock或者Fake接口邏輯,那么在一個具有上百個用例覆蓋的代碼中會同時使用到一組相關的Mock接口對象,這無形中增加了我們編寫單元測試的效率給后期的維護測試用例帶來了很大的隱患及工作量;
單元測試代碼的組成都是按照用例來劃分,一個用例可以用來包括一個單一入口的所有邏輯也可以是一個判斷分支的部分邏輯;為了構造一個能完美覆蓋的代碼步驟,我們需要構建測試數據、Mock接口,劃分執行順序等等,那么一旦被測試代碼發生一點點的變化都會很大程度上影響測試代碼,畢竟測試代碼都是步步依賴的;
那么我們應該最大程度的限制由于被測試代碼的變動而引起的測試代碼的變動,這個時候我們應該將重構應用到測試代碼中;
將多個相關的測試用例代碼通過超類的方式關聯起來統一管理將大大減少重復代碼的構建;就跟我們重構普通代碼一樣,將多個類之間共享的邏輯代碼或者對象提取出來放到基類中;這當然也同樣適用于測試代碼,只不過需要控制一些更測試相關的邏輯;
其實大部分重復的代碼就是Mock接口的過程,我們需要將它的Mock過程精簡化,但是又不能太過于精簡,一切精簡的過程都是需要犧牲可觀察性;我們需要適當的平衡提取出來的對象個數,將它們放入基類中,然后在Mock的時候能通過一個簡單的方法就能獲取到一個Mock過后的對象;
下面我們來看一下提取公共部分到基類的一個 簡單過程,當然對于大項目而言不一定具有說服力,就當拋磚引玉吧;
首要的任務就是將公共的Mock接口提取出來,因為這一類接口是肯定會在各個用例中共享的,提取過程過主要分為兩個重構過程;
第一:將用例中的公用接口放到類的聲明中,供所有用例使用;
第二:如果需要將公用接口提供給其他的單元測試使用,就需要提取出相關的測試基類;
我們先來看一下第一個過程,看一下測試示例代碼:
/*============================================================================== * Author:深度訓練 * Create time: 2013-10-06 * Blog Address:http://www.cnblogs.com/wangiqngpei557/ * Author Description:特定領域軟件工程實踐; * ==============================================================================*/ namespace UnitTestRefactoring { public class OrderService { private IServiceConnection ServiceConnection; private IServiceReader ServiceReader; private IServiceWriter ServiceWrite; public OrderService(IServiceConnection connection, IServiceReader reader, IServiceWriter writer) { this.ServiceConnection = connection; this.ServiceReader = reader; this.ServiceWrite = writer; } public bool GetOrders(string orderId) { if (string.IsNullOrWhiteSpace(orderId)) return false; return true; } } }
這個類表示遠程Order服務,只有一個方法GetOrders,該方法可以根據OrderId來查詢Order信息,為了簡單起見,如果返回true說明服務調用成功,如果返回false表示調用失敗;其中構造函數包含了三個接口,分別用來表示不同用途的接口抽象;IServiceConnection表示對遠程服務鏈接的抽象,IServiceReader表示對不同服務接口讀取的抽象,IServiceWriter表示對不同服務接口寫入的抽象;這么做可以最大化的分解耦合;
/*============================================================================== * Author:深度訓練 * Create time: 2013-10-06 * Blog Address:http://www.cnblogs.com/wangiqngpei557/ * Author Description:特定領域軟件工程實踐; * ==============================================================================*/ using System; using Microsoft.VisualStudio.TestTools.UnitTesting; using NSubstitute; using UnitTestRefactoring; namespace UnitTestRefactoring.UnitTests { [TestClass] public class OrderService_UnitTests { [TestMethod] public void OrderService_GetOrders_NormalFlows() { IServiceConnection mockServiceConnection = Substitute.For<IServiceConnection>(); IServiceReader mockServiceReader = Substitute.For<IServiceReader>(); IServiceWriter mockServiceWriter = Substitute.For<IServiceWriter>(); OrderService testOrderService = new OrderService(mockServiceConnection, mockServiceReader, mockServiceWriter); bool testResult = testOrderService.GetOrders("10293884"); Assert.AreEqual(true, testResult); } [TestMethod] public void OrderService_GetOrders_OrderIdIsNull() { IServiceConnection mockServiceConnection = Substitute.For<IServiceConnection>(); IServiceReader mockServiceReader = Substitute.For<IServiceReader>(); IServiceWriter mockServiceWriter = Substitute.For<IServiceWriter>(); OrderService testOrderService = new OrderService(mockServiceConnection, mockServiceReader, mockServiceWriter); bool testResult = testOrderService.GetOrders(string.Empty); Assert.AreEqual(false, testResult); } } }
這個單元測試類是專門用來測試剛才那個OrderService的,里面包括兩個GetOrders方法的測試用例;可以一目了然的看見,這兩個測試用例代碼中都包含了對測試類的構造函數的參數接口Mock代碼;
圖1:
像這種簡單的情況下,我們只需要將公共的部分拿出來放到測試的類中聲明,就可以公用這塊對象;
圖2:
這樣可以解決內部重復問題,但是這里需要小心的地方是,當我們在不同的用例之間共享部分Mock邏輯的時候可能會出現問題;比如我們在OrderService_GetOrders_NormalFlows用例中,對IServiceConnection接口進行了部分行為的Mock但是當執行到OrderService_GetOrders_OrderIdIsNull用例時可能是用的我們上一次的Mock邏輯;所以這里需要注意一下,當然如果設計合理的話是不太可能會出現這種問題的;單一職責原則只要滿足我們的接口是不會包含其他的邏輯在里面,也不會出現在不同的用例之間共存相同的接口邏輯;同時也滿足接口隔離原則,就會更加對單元測試有利;
我們接著看一下第二個過程,看一下測試示例代碼:
/*============================================================================== * Author:深度訓練 * Create time: 2013-10-06 * Blog Address:http://www.cnblogs.com/wangiqngpei557/ * Author Description:特定領域軟件工程實踐; * ==============================================================================*/ namespace UnitTestRefactoring { public class ProductService { private IServiceConnection ServiceConnection; private IServiceReader ServiceReader; private IServiceWriter ServiceWrite; public ProductService(IServiceConnection connection, IServiceReader reader, IServiceWriter writer) { this.ServiceConnection = connection; this.ServiceReader = reader; this.ServiceWrite = writer; } public bool GetProduct(string productId) { if (string.IsNullOrWhiteSpace(productId)) return false; return true; } } }
這個是表示Product服務,構造函數中同樣和之前的OrderService一樣的參數列表,然后就是一個簡單的GetProduct方法;
/*============================================================================== * Author:深度訓練 * Create time: 2013-10-06 * Blog Address:http://www.cnblogs.com/wangiqngpei557/ * Author Description:特定領域軟件工程實踐; * ==============================================================================*/ using System; using Microsoft.VisualStudio.TestTools.UnitTesting; using NSubstitute; using UnitTestRefactoring; namespace UnitTestRefactoring.UnitTests { [TestClass] public class ProductService_UnitTests { IServiceConnection mockServiceConnection = Substitute.For<IServiceConnection>(); IServiceReader mockServiceReader = Substitute.For<IServiceReader>(); IServiceWriter mockServiceWriter = Substitute.For<IServiceWriter>(); [TestMethod] public void ProductService_GetProduct_NormalFlows() { ProductService testProductService = new ProductService(mockServiceConnection, mockServiceReader, mockServiceWriter); bool testResult = testProductService.GetProduct("5475684684"); Assert.AreEqual(true, testResult); } [TestMethod] public void ProductService_GetProduct_ProductIsNull() { ProductService testProductService = new ProductService(mockServiceConnection, mockServiceReader, mockServiceWriter); bool testResult = testProductService.GetProduct(string.Empty); Assert.AreEqual(false, testResult); } } }
這是單元測試類,沒有什么特別的,跟之前的OrderService一樣的邏輯;是不是發現兩個測試類都在公用一組相關的接口,這里就需要我們將他們提取出來放入基類中;
using NSubstitute; namespace UnitTestRefactoring.UnitTests { public abstract class ServiceBaseUnitTestClass { protected IServiceConnection mockServiceConnection = Substitute.For<IServiceConnection>(); protected IServiceReader mockServiceReader = Substitute.For<IServiceReader>(); protected IServiceWriter mockServiceWriter = Substitute.For<IServiceWriter>(); } }
提取出來的測試基類;
/*============================================================================== * Author:深度訓練 * Create time: 2013-10-06 * Blog Address:http://www.cnblogs.com/wangiqngpei557/ * Author Description:特定領域軟件工程實踐; * ==============================================================================*/ using Microsoft.VisualStudio.TestTools.UnitTesting; namespace UnitTestRefactoring.UnitTests { [TestClass] public class ProductService_UnitTests : ServiceBaseUnitTestClass { [TestMethod] public void ProductService_GetProduct_NormalFlows() { ProductService testProductService = new ProductService(mockServiceConnection, mockServiceReader, mockServiceWriter); bool testResult = testProductService.GetProduct("5475684684"); Assert.AreEqual(true, testResult); } [TestMethod] public void ProductService_GetProduct_ProductIsNull() { ProductService testProductService = new ProductService(mockServiceConnection, mockServiceReader, mockServiceWriter); bool testResult = testProductService.GetProduct(string.Empty); Assert.AreEqual(false, testResult); } } }
ProductService_UnitTests類;
/*============================================================================== * Author:深度訓練 * Create time: 2013-10-06 * Blog Address:http://www.cnblogs.com/wangiqngpei557/ * Author Description:特定領域軟件工程實踐; * ==============================================================================*/ using Microsoft.VisualStudio.TestTools.UnitTesting; namespace UnitTestRefactoring.UnitTests { [TestClass] public class OrderService_UnitTests : ServiceBaseUnitTestClass { [TestMethod] public void OrderService_GetOrders_NormalFlows() { OrderService testOrderService = new OrderService(mockServiceConnection, mockServiceReader, mockServiceWriter); bool testResult = testOrderService.GetOrders("10293884"); Assert.AreEqual(true, testResult); } [TestMethod] public void OrderService_GetOrders_OrderIdIsNull() { OrderService testOrderService = new OrderService(mockServiceConnection, mockServiceReader, mockServiceWriter); bool testResult = testOrderService.GetOrders(string.Empty); Assert.AreEqual(false, testResult); } } }
OrderService_UnitTests 類;
提取出來的抽象基類能在后面的單元測試重構中幫很大忙,也是為了后面的面向特定領域的單元測試框架做要基礎工作;由于不同的單元測試類具有不同的基類,這里需要我們自己的分析抽象,比如這里跟Service相關的,可能還有跟Order處理流程相關的,相同的一組接口也只能出現在相關的測試類中;
前面2.1.1】小結,我們講了Mock接口對象的重構,這一節我們將來分析一下關于Mock對象行為的重構;在上面的IServiceConnection中我們加入了一個Open方法,用來打開遠程鏈接;
/*============================================================================== * Author:深度訓練 * Create time: 2013-10-06 * Blog Address:http://www.cnblogs.com/wangiqngpei557/ * Author Description:特定領域軟件工程實踐; * ==============================================================================*/ namespace UnitTestRefactoring { public interface IServiceConnection { bool Open(); } }
如果返回true表示遠程鏈接成功建立并且已經成功打開,如果返回false表示鏈接失敗;那么在每一個用例代碼中,只要使用到了IServiceConnection接口都會需要Mock接口的Open方法;
[TestMethod] public void OrderService_GetOrders_NormalFlows() { mockServiceConnection.Open().Returns(true); mockServiceConnection.Close().Returns(true); OrderService testOrderService = new OrderService(mockServiceConnection, mockServiceReader, mockServiceWriter); bool testResult = testOrderService.GetOrders("10293884"); Assert.AreEqual(true, testResult); } [TestMethod] public void OrderService_GetOrders_OrderIdIsNull() { mockServiceConnection.Open().Returns(true); mockServiceConnection.Close().Returns(false); OrderService testOrderService = new OrderService(mockServiceConnection, mockServiceReader, mockServiceWriter); bool testResult = testOrderService.GetOrders(string.Empty); Assert.AreEqual(false, testResult); }
類似這樣的代碼會很多,如果這個時候我們需要每次都在用例中對三個接口都進行類似的重復代碼也算是一種地效率的重復勞動,并且在后面的改動中會很費事;所以這個時候抽象出來的基類就派上用場了,我們可以將構建接口的邏輯代碼放入基類中進行統一構造;
public abstract class ServiceBaseUnitTestClass { protected IServiceConnection mockServiceConnection = Substitute.For<IServiceConnection>(); protected IServiceReader mockServiceReader = Substitute.For<IServiceReader>(); protected IServiceWriter mockServiceWriter = Substitute.For<IServiceWriter>(); protected void InitMockServiceConnection() { this.mockServiceConnection.Open().Returns(true); this.mockServiceConnection.Close().Returns(true); } }
this.InitMockServiceConnection(); OrderService testOrderService = new OrderService(mockServiceConnection, mockServiceReader, mockServiceWriter);
這樣在需要修改接口的時候很容易找到,可能這里兩三個用例,而且用例代碼也很簡單所以看起來沒有太多的必要,但是實際情況沒有這么簡單;
2.1.3】公用的Mock數據
說到Mock數據,其實需要解釋一下,準確點講是Mock時需要用到的測試數據,它是碎片化的簡單的測試數據;它也同樣存在著和2.1.2】小結的修改問題,實踐告訴我單元測試代碼在整個開發周期中最易被修改,當我們簡單的修改一個邏輯之后就需要面臨著大面積的單元測試代碼修改而測試數據修改占比重最大;
因為測試數據相對沒有靈活性,但是測試數據的結構易發生由需求帶來的變化;比如實體的屬性類型,在我們編寫實體測試數據的時候我們用的是String,一段時間過后,實體發生變化很正常;領域模型在開發周期中被修改的次數那是無法估計,因為我們的項目中是需要迭代重構的,我們需要重構來為我們的項目保證最高的質量;
所以單元測試修改的次數和重構的次數應該是成1:0的這樣的比例,修改的范圍那就不是1:10了,有時候甚至是幾何的倍數;
OrderService中的AddOrder方法:
public bool AddOrder(Order order) { if (string.IsNullOrWhiteSpace(order.OrderId)) return false; return true; }
OrderService_AddOrder測試代碼:
[TestMethod] public void OrderService_AddOrder_NormalFlows() { this.InitMockServiceConnection(); OrderService testOrderService = new OrderService(this.mockServiceConnection, this.mockServiceReader, this.mockServiceWriter); Order testOrder = new Order() { OrderId = "123456", SubmitDT = DateTime.Now }; bool testResult = testOrderService.AddOrder(testOrder); Assert.AreEqual(true, testResult); } [TestMethod] public void OrderService_AddOrder_OrderIdIsNull() { this.InitMockServiceConnection(); OrderService testOrderService = new OrderService(this.mockServiceConnection, this.mockServiceReader, this.mockServiceWriter); Order testOrder = new Order() { OrderId = string.Empty, SubmitDT = DateTime.Now }; bool testResult = testOrderService.AddOrder(testOrder); Assert.AreEqual(false, testResult); }
這是兩個用例,用來對AddOrder方法進行測試,里面都包含了一條Order testOrder = new Order() 這樣的測試數據的構造;Order實體是一個比較簡單的對象,屬性也就只有兩個,但是真實環境中不會這么簡單,會有幾十個字段都需要進行測試驗證,再加上N多個用例,會使相同的代碼變的很多;
那么我們同樣需要將這部分的代碼提取出來放到基類中去,適當的留有空間讓用例中修改的特殊的字段;
完整的實體構造:
Order testOrder = this.InitNormalOrder();
測試OrderId為空的邏輯,需要手動設置為String.Empty:
Order testOrder = this.InitNormalOrder(); testOrder.OrderId = string.Empty;
這樣慢慢的就會形成抗變化的測試代碼結構,盡管一開始很別扭,將一些直觀的對象提取出來放入一眼看不見的地方是有點不太舒服,但是長遠看來值得這么做;
3】LINQ表達式的重構寫法(將必要的LINQ寫成普通的Function穿插在LINQ表達式中)
在使用LINQ語法編寫代碼的時候,現在發現最大的問題就是單元測試不太方便,LINQ寫起來很方便,確實是個很不錯的編程思想,在面對集合類型的操作時確實是無法形容的優雅,但是面對單元測試的問題需要解決才行,所以需要我們平衡一下在什么情況下需要將LINQ表達式替換成普通的Function來支持;
LINQ在面對集合類型的時候,能發揮很大的作用;不僅在Linq to Object中,在其他的Linq to Provider中都能在LINQ中找到了合適的使用之地;比如在對遠程Service進行LINQ設計的時候,我們都是按照這樣的方式進行編寫,但是就怕LINQ中帶有邏輯判斷的表達式,這個時候就會在單元測試中總是無法覆蓋到的情況出現,所以就需要將它提取出來使用普通的函數進行替代;
我們來繼續看一下如果使用提取出來的函數解決鏈式的判斷,還是使用上面的OrderService為例:
public Order SelectByOrderId(string orderId) { List<Order> orders = new List<Order>() { new Order(){ OrderId="123", SubmitDT=DateTime.Now.AddDays(1)}, new Order(){ OrderId="234"} }; var list = orders.Where(order => order.OrderId == orderId && order.SubmitDT > DateTime.Now); if (list.Count() > 0) return list.ToList()[0]; return null; }
這是一個根據OrderId獲取Order實例的方法,純粹為了演示;首先構造了一個測試集合,然后使用了Where擴展方法來選擇集合中滿足條件的Order;我們的重點是Where中的條件,條件的第一個表達式很簡單而第二個表達式是SubmitDT必須大于當前的日期,還會有很多類似這樣的判斷,這樣測試起來很困難,而且很難維護,所以我們有必要將他們提取出來;
public Order SelectByOrderId(string orderId) { List<Order> orders = new List<Order>() { new Order(){ OrderId="123", SubmitDT=DateTime.Now.AddDays(1)}, new Order(){ OrderId="234"} }; var list = orders.Where(order => IfOrderSubmitAndOrderId(order, orderId)); if (list.Count() > 0) return list.ToList()[0]; return null; } private bool IfOrderSubmitDt(Order order) { return order.SubmitDT > DateTime.Now; } private bool IfOrderSubmitAndOrderId(Order order, string orderId) { return order.OrderId == orderId && this.IfOrderSubmitDt(order); }
其實這很像企業架構模式中的規約模式,將規則對象化后就能隨便的控制他們,當然這里是提取出方法,如果是大型企業級項目對這些易變化的點是需要抽取出來的;
總之遇到這樣的情況就使用簡單的提取方法的方式將復雜的邏輯提取出來,這也是《重構》中的重構策略的首要的模式;
領域驅動設計已經不是什么新鮮的話題了,它已經被我們或多或少的使用過,它強調一切從領域出發;那么特定領域單元測試框架是一個什么樣的框架呢,需要的價值在哪里;其實從特定領域開發框架,特定領域架構我們能簡單的體會到一絲意思,面向特定領域單元測試框架是在單元測試框架的基礎之上進行二次領域相關的封裝;比如:如何很好的將領域規則獨立起來,如果在單元測試中使用這些獨立起來的領域規則;
其實在軟件開發的任何一個角落都能找到領域驅動的影子,這也是為什么領域驅動會得到我們認可的重要因素;如果一切都圍繞著領域模型來的話,那么任何一個概念都不會牽強的,我們只有關注領域本身才能使軟件真的很有價值,而不是一堆代碼;
下面我們來簡單的看一下 面向特定領域測試框架 的兩個基本功能:
測試代碼執行到最后是需要對其執行的結果進行斷言的,如:Assert.IsTrue(testResult.SubmitDT > DateTime.Now);像這樣的一段代碼我們可以適當的包裝Assert.IsTrue方法,讓他在驗證這段邏輯的時候能識別出領域概念,比如:“Order的提交時間大于今天的時間”,我們可以從兩方面入手,一個是領域的抽象,一個是規則的分解;
如果這里的驗證不通過,我們實時的記錄領域的概念到日志系統,而不是報告那里代碼出問題,這樣就算不是自己寫的代碼都能一目了然;
同樣比較重要的領域概念就是領域數據,領域數據也是單元測試中用例數據;為了能讓測試進行自動化測試,我們需要維護一組相對固定的測試數據來供測試程序運行;其實如果想最大化建立領域測試框架有必要開發一套專門的領域測試工具,它能夠實時的讀取真實數據進行Assert,也就更加的接近自動化測試;
但是單元測試也不需要對真實數據進行驗證,真實數據一般是集成測試的時候使用的,如果能用真實數據進行邏輯測試還是很有保障的;
作者:王清培
出處:http://wangqingpei557.blog.51cto.com/
本文版權歸作者和51CTO共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接,否則保留追究法律責任的權利。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。