1. 程式人生 > >.NET重構—單元測試的程式碼重構

.NET重構—單元測試的程式碼重構

閱讀目錄:

  • 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的比較測試資料)

1】開篇介紹

最近一段時間結束了一個Sprint,在這次的開發當中有些東西覺得還不錯有總結分享的價值,所以整理成本文;

重構已是老生常談的話題,我們或多或少對它有所瞭解但是對它的深刻理解恐怕需要一段實踐過後才能體會到;提到重構就不得不提為它保駕護航的大功臣單元測試,重構能有今天的風光影響力完全少不了單元測試的功勞;最近一段時間寫單元測試用例的時間遠超過我寫邏輯程式碼的時間和多的多的程式碼量,這是為什麼?我一開始很難給自己一個理由去做好這件事,心態上還是轉變不過來,可是每當我心浮氣躁的時候它總能給我點驚喜,讓我繼續下去,天生具有好奇心的程式設計師怎麼會就此結束呢,只有到達了一扇門之後我們回過頭來看一下走的路才能真正的明白這是條對的路還是錯的路;

單元測試簡單寫起來沒有什麼太大問題,但是我們不僅為了達到程式碼的100%覆蓋還要到達到邏輯的100%覆蓋,程式碼的覆蓋不代表邏輯的覆蓋;一個簡單的邏輯判斷雖然只有一行程式碼,但是裡面可能會有正反向很多種邏輯在裡面;比如:Order.ToString()簡單的程式碼,想要覆蓋很簡單,只要物件不為空都能正確的覆蓋到,但是如果我們沒有測試到它為NULL的情況下的邊界邏輯,這個時候我們就會漏掉這種可能會導致BUG的邏輯路徑;所以我們會盡可能的多去寫用例來達到最終的理想效果;

(總之把單元測試的所有精力集中在可能會出問題的地方,也是自己最擔心的地方,這個地方通常是邏輯比較複雜的地方;)

2】單元測試、測試用例程式碼重複問題(大量使用重複的Mock物件及測試資料)

單元測試程式碼中最常見的程式碼就是Mock或者Fake介面邏輯,那麼在一個具有上百個用例覆蓋的程式碼中會同時使用到一組相關的Mock介面物件,這無形中增加了我們編寫單元測試的效率給後期的維護測試用例帶來了很大的隱患及工作量;

單元測試程式碼的組成都是按照用例來劃分,一個用例可以用來包括一個單一入口的所有邏輯也可以是一個判斷分支的部分邏輯;為了構造一個能完美覆蓋的程式碼步驟,我們需要構建測試資料、Mock介面,劃分執行順序等等,那麼一旦被測試程式碼發生一點點的變化都會很大程度上影響測試程式碼,畢竟測試程式碼都是步步依賴的;

那麼我們應該最大程度的限制由於被測試程式碼的變動而引起的測試程式碼的變動,這個時候我們應該將重構應用到測試程式碼中;

2.1】單元測試的繼承體系(利用超類來減少Mock物件的使用)

將多個相關的測試用例程式碼通過超類的方式關聯起來統一管理將大大減少重複程式碼的構建;就跟我們重構普通程式碼一樣,將多個類之間共享的邏輯程式碼或者物件提取出來放到基類中;這當然也同樣適用於測試程式碼,只不過需要控制一些更測試相關的邏輯;

其實大部分重複的程式碼就是Mock介面的過程,我們需要將它的Mock過程精簡化,但是又不能太過於精簡,一切精簡的過程都是需要犧牲可觀察性;我們需要適當的平衡提取出來的物件個數,將它們放入基類中,然後在Mock的時候能通過一個簡單的方法就能獲取到一個Mock過後的物件;

下面我們來看一下提取公共部分到基類的一個 簡單過程,當然對於大專案而言不一定具有說服力,就當拋磚引玉吧;

2.1.1】公用的Mock物件

首要的任務就是將公共的Mock介面提取出來,因為這一類介面是肯定會在各個用例中共享的,提取過程過主要分為兩個重構過程;

第一:將用例中的公用介面放到類的宣告中,供所有用例使用;

第二:如果需要將公用介面提供給其他的單元測試使用,就需要提取出相關的測試基類;

我們先來看一下第一個過程,看一下測試示例程式碼:

 1 /*==============================================================================
 2 * Author:深度訓練
 3 * Create time: 2013-10-06
 4 * Blog Address:http://www.cnblogs.com/wangiqngpei557/
 5 * Author Description:特定領域軟體工程實踐;
 6 * ==============================================================================*/ 
 7 
 8 namespace UnitTestRefactoring
 9 {
10     public class OrderService
11     {
12         private IServiceConnection ServiceConnection;
13         private IServiceReader ServiceReader;
14         private IServiceWriter ServiceWrite; 
15 
16         public OrderService(IServiceConnection connection, IServiceReader reader, IServiceWriter writer)
17         {
18             this.ServiceConnection = connection;
19             this.ServiceReader = reader;
20             this.ServiceWrite = writer;
21         } 
22 
23         public bool GetOrders(string orderId)
24         {
25             if (string.IsNullOrWhiteSpace(orderId))
26                 return false;
27             return true;
28         }
29     }
30 } 
View Code

這個類表示遠端Order服務,只有一個方法GetOrders,該方法可以根據OrderId來查詢Order資訊,為了簡單起見,如果返回true說明服務呼叫成功,如果返回false表示呼叫失敗;其中建構函式包含了三個介面,分別用來表示不同用途的介面抽象;IServiceConnection表示對遠端服務連結的抽象,IServiceReader表示對不同服務介面讀取的抽象,IServiceWriter表示對不同服務介面寫入的抽象;這麼做可以最大化的分解耦合;

 1 /*==============================================================================
 2 * Author:深度訓練
 3 * Create time: 2013-10-06
 4 * Blog Address:http://www.cnblogs.com/wangiqngpei557/
 5 * Author Description:特定領域軟體工程實踐;
 6 * ==============================================================================*/ 
 7 
 8 using System;
 9 using Microsoft.VisualStudio.TestTools.UnitTesting;
10 using NSubstitute;
11 using UnitTestRefactoring; 
12 
13 namespace UnitTestRefactoring.UnitTests
14 {
15     [TestClass]
16     public class OrderService_UnitTests
17     {
18         [TestMethod]
19         public void OrderService_GetOrders_NormalFlows()
20         {
21             IServiceConnection mockServiceConnection = Substitute.For<IServiceConnection>();
22             IServiceReader mockServiceReader = Substitute.For<IServiceReader>();
23             IServiceWriter mockServiceWriter = Substitute.For<IServiceWriter>(); 
24 
25             OrderService testOrderService = new OrderService(mockServiceConnection, mockServiceReader, mockServiceWriter);
26             bool testResult = testOrderService.GetOrders("10293884"); 
27 
28             Assert.AreEqual(true, testResult);
29         } 
30 
31         [TestMethod]
32         public void OrderService_GetOrders_OrderIdIsNull()
33         {
34             IServiceConnection mockServiceConnection = Substitute.For<IServiceConnection>();
35             IServiceReader mockServiceReader = Substitute.For<IServiceReader>();
36             IServiceWriter mockServiceWriter = Substitute.For<IServiceWriter>(); 
37 
38             OrderService testOrderService = new OrderService(mockServiceConnection, mockServiceReader, mockServiceWriter);
39             bool testResult = testOrderService.GetOrders(string.Empty); 
40 
41             Assert.AreEqual(false, testResult);
42         }
43     }
44 } 
View Code

這個單元測試類是專門用來測試剛才那個OrderService的,裡面包括兩個GetOrders方法的測試用例;可以一目瞭然的看見,這兩個測試用例程式碼中都包含了對測試類的建構函式的引數介面Mock程式碼;

圖1:

像這種簡單的情況下,我們只需要將公共的部分拿出來放到測試的類中宣告,就可以公用這塊物件;

圖2:

這樣可以解決內部重複問題,但是這裡需要小心的地方是,當我們在不同的用例之間共享部分Mock邏輯的時候可能會出現問題;比如我們在OrderService_GetOrders_NormalFlows用例中,對IServiceConnection介面進行了部分行為的Mock但是當執行到OrderService_GetOrders_OrderIdIsNull用例時可能是用的我們上一次的Mock邏輯;所以這裡需要注意一下,當然如果設計合理的話是不太可能會出現這種問題的;單一職責原則只要滿足我們的介面是不會包含其他的邏輯在裡面,也不會出現在不同的用例之間共存相同的介面邏輯;同時也滿足介面隔離原則,就會更加對單元測試有利;

我們接著看一下第二個過程,看一下測試示例程式碼:

 1 /*==============================================================================
 2 * Author:深度訓練
 3 * Create time: 2013-10-06
 4 * Blog Address:http://www.cnblogs.com/wangiqngpei557/
 5 * Author Description:特定領域軟體工程實踐;
 6 * ==============================================================================*/ 
 7 
 8 namespace UnitTestRefactoring
 9 {
10     public class ProductService
11     {
12         private IServiceConnection ServiceConnection;
13         private IServiceReader ServiceReader;
14         private IServiceWriter ServiceWrite; 
15 
16         public ProductService(IServiceConnection connection, IServiceReader reader, IServiceWriter writer)
17         {
18             this.ServiceConnection = connection;
19             this.ServiceReader = reader;
20             this.ServiceWrite = writer;
21         } 
22 
23         public bool GetProduct(string productId)
24         {
25             if (string.IsNullOrWhiteSpace(productId))
26                 return false;
27             return true;
28         }
29     }
30 } 
View Code

這個是表示Product服務,建構函式中同樣和之前的OrderService一樣的引數列表,然後就是一個簡單的GetProduct方法;

 1 /*==============================================================================
 2 * Author:深度訓練
 3 * Create time: 2013-10-06
 4 * Blog Address:http://www.cnblogs.com/wangiqngpei557/
 5 * Author Description:特定領域軟體工程實踐;
 6 * ==============================================================================*/ 
 7 
 8 using System;
 9 using Microsoft.VisualStudio.TestTools.UnitTesting;
10 using NSubstitute;
11 using UnitTestRefactoring; 
12 
13 namespace UnitTestRefactoring.UnitTests
14 {
15     [TestClass]
16     public class ProductService_UnitTests
17     {
18         IServiceConnection mockServiceConnection = Substitute.For<IServiceConnection>();
19         IServiceReader mockServiceReader = Substitute.For<IServiceReader>();
20         IServiceWriter mockServiceWriter = Substitute.For<IServiceWriter>(); 
21 
22         [TestMethod]
23         public void ProductService_GetProduct_NormalFlows()
24         {
25             ProductService testProductService = new ProductService(mockServiceConnection, mockServiceReader, mockServiceWriter);
26             bool testResult = testProductService.GetProduct("5475684684"); 
27 
28             Assert.AreEqual(true, testResult);
29         } 
30 
31         [TestMethod]
32         public void ProductService_GetProduct_ProductIsNull()
33         {
34             ProductService testProductService = new ProductService(mockServiceConnection, mockServiceReader, mockServiceWriter);
35             bool testResult = testProductService.GetProduct(string.Empty); 
36 
37             Assert.AreEqual(false, testResult);
38         }
39     }
40 } 
View Code

這是單元測試類,沒有什麼特別的,跟之前的OrderService一樣的邏輯;是不是發現兩個測試類都在公用一組相關的介面,這裡就需要我們將他們提取出來放入基類中;

 1 using NSubstitute; 
 2 
 3 namespace UnitTestRefactoring.UnitTests
 4 {
 5     public abstract class ServiceBaseUnitTestClass
 6     {
 7         protected IServiceConnection mockServiceConnection = Substitute.For<IServiceConnection>();
 8         protected IServiceReader mockServiceReader = Substitute.For<IServiceReader>();
 9         protected IServiceWriter mockServiceWriter = Substitute.For<IServiceWriter>();
10     }
11 } 
View Code

提取出來的測試基類;

 1 /*==============================================================================
 2 * Author:深度訓練
 3 * Create time: 2013-10-06
 4 * Blog Address:http://www.cnblogs.com/wangiqngpei557/
 5 * Author Description:特定領域軟體工程實踐;
 6 * ==============================================================================*/ 
 7 
 8 using Microsoft.VisualStudio.TestTools.UnitTesting; 
 9 
10 namespace UnitTestRefactoring.UnitTests
11 {
12     [TestClass]
13     public class ProductService_UnitTests : ServiceBaseUnitTestClass
14     {
15         [TestMethod]
16         public void ProductService_GetProduct_NormalFlows()
17         {
18             ProductService testProductService = new ProductService(mockServiceConnection, mockServiceReader, mockServiceWriter);
19             bool testResult = testProductService.GetProduct("5475684684"); 
20 
21             Assert.AreEqual(true, testResult);
22         } 
23 
24         [TestMethod]
25         public void ProductService_GetProduct_ProductIsNull()
26         {
27             ProductService testProductService = new ProductService(mockServiceConnection, mockServiceReader, mockServiceWriter);
28             bool testResult = testProductService.GetProduct(string.Empty); 
29 
30             Assert.AreEqual(false, testResult);
31         }
32     }
33 } 
View Code

ProductService_UnitTests類;

 1 /*==============================================================================
 2 * Author:深度訓練
 3 * Create time: 2013-10-06
 4 * Blog Address:http://www.cnblogs.com/wangiqngpei557/
 5 * Author Description:特定領域軟體工程實踐;
 6 * ==============================================================================*/ 
 7 
 8 using Microsoft.VisualStudio.TestTools.UnitTesting; 
 9 
10 namespace UnitTestRefactoring.UnitTests
11 {
12     [TestClass]
13     public class OrderService_UnitTests : ServiceBaseUnitTestClass
14     {
15         [TestMethod]
16         public void OrderService_GetOrders_NormalFlows()
17         {
18             OrderService testOrderService = new OrderService(mockServiceConnection, mockServiceReader, mockServiceWriter);
19             bool testResult = testOrderService.GetOrders("10293884"); 
20 
21             Assert.AreEqual(true, testResult);
22         } 
23 
24         [TestMethod]
25         public void OrderService_GetOrders_OrderIdIsNull()
26         {
27             OrderService testOrderService = new OrderService(mockServiceConnection, mockServiceReader, mockServiceWriter);
28             bool testResult = testOrderService.GetOrders(string.Empty); 
29 
30             Assert.AreEqual(false, testResult);
31         }
32     }
33 } 
View Code

OrderService_UnitTests 類;

提取出來的抽象基類能在後面的單元測試重構中幫很大忙,也是為了後面的面向特定領域的單元測試框架做要基礎工作;由於不同的單元測試類具有不同的基類,這裡需要我們自己的分析抽象,比如這裡跟Service相關的,可能還有跟Order處理流程相關的,相同的一組介面也只能出現在相關的測試類中;

2.1.2】公用的Mock行為

前面2.1.1】小結,我們講了Mock介面物件的重構,這一節我們將來分析一下關於Mock物件行為的重構;在上面的IServiceConnection中我們加入了一個Open方法,用來開啟遠端連結;

 1 /*==============================================================================
 2 * Author:深度訓練
 3 * Create time: 2013-10-06
 4 * Blog Address:http://www.cnblogs.com/wangiqngpei557/
 5 * Author Description:特定領域軟體工程實踐;
 6 * ==============================================================================*/ 
 7 
 8 namespace UnitTestRefactoring
 9 {
10     public interface IServiceConnection
11     {
12         bool Open();
13     }
14 } 
View Code

如果返回true表示遠端連結成功建立並且已經成功開啟,如果返回false表示連結失敗;那麼在每一個用例程式碼中,只要使用到了IServiceConnection介面都會需要Mock介面的Open方法;

 1 [TestMethod]
 2         public void OrderService_GetOrders_NormalFlows()
 3         {
 4             mockServiceConnection.Open().Returns(true);
 5             mockServiceConnection.Close().Returns(true); 
 6 
 7             OrderService testOrderService = new OrderService(mockServiceConnection, mockServiceReader, mockServiceWriter);
 8             bool testResult = testOrderService.GetOrders("10293884"); 
 9 
10             Assert.AreEqual(true, testResult);
11         } 
12 
13         [TestMethod]
14         public void OrderService_GetOrders_OrderIdIsNull()
15         {
16             mockServiceConnection.Open().Returns(true);
17             mockServiceConnection.Close().Returns(false); 
18 
19             OrderService testOrderService = new OrderService(mockServiceConnection, mockServiceReader, mockServiceWriter);
20             bool testResult = testOrderService.GetOrders(string.Empty); 
21 
22             Assert.AreEqual(false, testResult);
23         } 
View Code

類似這樣的程式碼會很多,如果這個時候我們需要每次都在用例中對三個介面都進行類似的重複程式碼也算是一種地效率的重複勞動,並且在後面的改動中會很費事;所以這個時候抽象出來的基類就派上用場了,我們可以將構建介面的邏輯程式碼放入基類中進行統一構造;

 1 public abstract class ServiceBaseUnitTestClass
 2 {
 3     protected IServiceConnection mockServiceConnection = Substitute.For<IServiceConnection>();
 4     protected IServiceReader mockServiceReader = Substitute.For<IServiceReader>();
 5     protected IServiceWriter mockServiceWriter = Substitute.For<IServiceWriter>(); 
 6 
 7     protected void InitMockServiceConnection()
 8     {
 9         this.mockServiceConnection.Open().Returns(true);
10         this.mockServiceConnection.Close().Returns(true);
11     }
12 } 
View Code
1 this.InitMockServiceConnection();
2 OrderService testOrderService = new OrderService(mockServiceConnection, mockServiceReader, mockServiceWriter); 
View Code

這樣在需要修改介面的時候很容易找到,可能這裡兩三個用例,而且用例程式碼也很簡單所以看起來沒有太多的必要,但是實際情況沒有這麼簡單;

2.1.3】公用的Mock資料

說到Mock資料,其實需要解釋一下,準確點講是Mock時需要用到的測試資料,它是碎片化的簡單的測試資料;它也同樣存在著和2.1.2】小結的修改問題,實踐告訴我單元測試程式碼在整個開發週期中最易被修改,當我們簡單的修改一個邏輯之後就需要面臨著大面積的單元測試程式碼修改而測試資料修改佔比重最大;

因為測試資料相對沒有靈活性,但是測試資料的結構易發生由需求帶來的變化;比如實體的屬性型別,在我們編寫實體測試資料的時候我們用的是String,一段時間過後,實體發生變化很正常;領域模型在開發週期中被修改的次數那是無法估計,因為我們的專案中是需要迭代重構的,我們需要重構來為我們的專案保證最高的質量;

所以單元測試修改的次數和重構的次數應該是成1:0的這樣的比例,修改的範圍那就不是1:10了,有時候甚至是幾何的倍數;

OrderService中的AddOrder方法:

1 public bool AddOrder(Order order)
2        {
3            if (string.IsNullOrWhiteSpace(order.OrderId))
4                return false;
5            return true;
6        } 
View Code

OrderService_AddOrder測試程式碼:

 1 [TestMethod]
 2        public void OrderService_AddOrder_NormalFlows()
 3        {
 4            this.InitMockServiceConnection();
 5            OrderService testOrderService = new OrderService(this.mockServiceConnection, this.mockServiceReader, this.mockServiceWriter); 
 6 
 7            Order testOrder = new Order() { OrderId = "123456", SubmitDT = DateTime.Now }; 
 8 
 9            bool testResult = testOrderService.AddOrder(testOrder); 
10 
11            Assert.AreEqual(true, testResult);
12        } 
13 
14        [TestMethod]
15        public void OrderService_AddOrder_OrderIdIsNull()
16        {
17            this.InitMockServiceConnection();
18            OrderService testOrderService = new OrderService(this.mockServiceConnection, this.mockServiceReader, this.mockServiceWriter); 
19 
20            Order testOrder = new Order() { OrderId = string.Empty, SubmitDT = DateTime.Now }; 
21 
22            bool testResult = testOrderService.AddOrder(testOrder); 
23 
24            Assert.AreEqual(false, testResult);
25        } 
View Code

這是兩個用例,用來對AddOrder方法進行測試,裡面都包含了一條Order testOrder = new Order() 這樣的測試資料的構造;Order實體是一個比較簡單的物件,屬性也就只有兩個,但是真實環境中不會這麼簡單,會有幾十個欄位都需要進行測試驗證,再加上N多個用例,會使相同的程式碼變的很多;

那麼我們同樣需要將這部分的程式碼提取出來放到基類中去,適當的留有空間讓用例中修改的特殊的欄位;

完整的實體構造: