在之前的文章《單元測試培訓系列:(一)單元測試概念以及必要性》中最後一段有提到,單元測試其實是完全為了測試先行,測試驅動準備的,並簡單闡述了一下實施的流程,很多朋友對此高度興趣,希望能更深入瞭解具體是如何實施的。
隔離,是單元測試中最重要的概念。一個被單元測試的方法,需要與所有依賴項進行隔離。而依賴項包括了環境的依賴項(I/O,網路,資料庫,系統時間等)以及外部類和方法的依賴。因此,隔離性保障了單元測試是最小粒度的測試。
但隔離也導致了單元測試的局限性,主要是以下兩個方面:
1. 通過單元測試是不能檢測到一個方法修改後對系統的影響範圍的。
單元測試因為隔離了對其他方法的依賴,因此當一個方法因為重構或者修改BUG等原因進行了改變時,運行已有的單元測試只能檢測到這個被修改的方法本身是否依然符合以前預期的目標;而對修改這個方法對整個系統有任何影響,是完全無法通過運行單元測試得知的!!
很多朋友一直錯把整合測試和單元測試混為一談,認為單元測試能夠檢測到一個方法改變後對整個系統哪些部分造成了影響,這種想法很顯然是錯誤的:因為單元測試的每個測試方法都把被測試的方法和其他方法、外部環境隔離開來,每一個被測試的方法都不依賴其他方法的具體實現,因此,即便其他類或方法的實現發生了改變,只要介面依然保持原樣,對當前的單元測試是都不會產生任何影響的!!
2. 單元測試對於需求變更基本沒有太大作用。
需求發生變更,首先要改變的就是單元測試!因為單元測試的關注點是每個方法的進出項(輸入值和輸出值)是否滿足期望值。當發生需求變更時,意味著相關方法的預期值發生改變,此前相關的單元測試不再具有價值,需要重新編寫。
因為以上兩個原因,對一個已有系統的代碼追加單元測試的價值也變得非常雞肋了。對於一個已有系統追加單元測試之後,單元測試唯一能在某個方法的內部實現進行重構的時候起到作用(例如修改BUG和演算法最佳化,並且是在不修改當前調用關係以及相關介面的前提下)
基於測試先行來使用單元測試
說了這麼多局限性,估計很打擊大家積極性,難道單元測試就那麼一無是處嗎?非也,而是單元測試的使用情境沒對。
測試先行還是測試驅動也好,都是目標導向方法論的具體實踐,其目的都是在編寫代碼之前先行確定好要編寫代碼的目標以及校正方式。而再來看單元測試,單元測試只關注一個單獨方法本身的功能以及這個方法的進出項是否滿足期望值。這根本上就是和測試先行的目標是完全吻合的。可以說單元測試是測試先行的具體實施辦法。
有了這個基調,我們再來看如何把單元測試按照測試先行的指導來進行實施,因為該篇文章的關注點還是單元測試,就不過多探討如何實施測試先行或者測試驅動。
我們直接從拿到一個具體的業務功能模組開始。
一、設計簡要的類圖以及類之間關係
我們首先簡要的為該模組設計出基本的類圖和類之間的關係(雖然有些敏捷方法對測試先行的要求是不做設計,只做測試案例,然後再編寫測試代碼和實現代碼,但在這裡個人還是按照先設計功能的類結構方式)
在這個階段,基本只需要定義好幾個類,而類裡面的成員則沒有必要在一開始就全部設計出來。(推薦使用Visual Studio裡的項目選項View Class Diagram進行代碼和類設計的同步進行)
來看下面這個例子:一個訂單系統,訂單明細裡面的每條訂單的產品分為服裝和數位產品類,而服裝的價格來源是Vancl,數位產品的價格來源是Newegg. 首先這個訂單系統需要一個訂單統計總價格的功能。
如所示,定義了主要的類以及彼此之間的關係, 除了一些資料屬性外,還沒有定義這幾個類的方法。
ProductOrder類, 即定單類,需要實現方法Count,包含一個IList<BaseOrderDetail>類型的屬性OrderDetails。
BaseOrderDetail類,抽象類別,包含ProductID和Amount屬性以及一個IPriceProvide類型的屬性PriceProvider,以及一個Count方法,即每條明細合計自己的總價。
ClothingOrderDetail類,BaseOrderDetail的子類,即服裝類的訂單明細,該類的PriceProvider屬性應該為VanclProvider類的執行個體。
DigialOrderDetail類,BaseOrderDetail的子類,即數位類的訂單明細,該類的PriceProvider屬性應該為NeweggProvider類的執行個體。
IPriceProvide介面,該介面定義提供了從第三方擷取產品價格的查詢方法QueryPriceByProductID。
VanclProvider類,實現了IPriceProvide介面,提供Vancl的產品價格查詢。
NeweggProvider類,實現了IPriceProvide介面,提供Newegg的產品價格查詢。
二、為某一個類上的某一個公開方法編寫單元測試
當類以及類之間關係設計好之後,就開始根據業務功能的需要,逐步設計類的成員以及方法以實現這個類的功能。而在設計一個類的方法時,則是根據業務需求的要求來設計的,因此在編寫一個類中的公開方法時,對這個類需要達成什麼樣的效果,應該是非常明確的。在明確了目標之後,其實編寫單元測試就已經可以實現了,儘管現在實現代碼還根本不存在。根據上面的例子,我們首先來編寫ProductOrder類的Count方法的單元測試,此時,Count方法是沒有真正實現的,甚至Count方法都還不存在(在單元測試代碼中使用dynamitic關鍵字調用不存在的Count方法)。
ProductOrder類的Count方法,其實是統計所有單據中包含的所有貨物的價格,因此可以分析得知Count方法依賴於BaseOrderDetail類的Count方法,並且屬性OrderDetails會包含多個BaseOrderDetail類,這意味著在編寫單元測試時,我們需要把這些BaseOrderDetail類都使用Mock對象代替。
Unit Test for Count /// <summary> /// A test for the method Count ///</summary> [TestMethod()] public void TestCount() { // Arrange dynamic target = new ProductOrder(); decimal expected = 8748.55m; // Mock a jacket order detail and set return value of the method Count(), no matter real price and amount. Mock<BaseOrderDetail> mockJacketOrderDetail = new Mock<BaseOrderDetail>(); mockJacketOrderDetail.Setup(e => e.Count()).Returns(350.55m); // Mock a iPAD order detail and set return value of the method Count(), no matter real price and amount. Mock<BaseOrderDetail> mockiPadOrderDetail = new Mock<BaseOrderDetail>(); mockiPadOrderDetail.Setup(e => e.Count()).Returns(3499.00m); // Mock a iPAD order detail and set return value of the method Count(), no matter real price and amount. Mock<BaseOrderDetail> mockNotebookDetail = new Mock<BaseOrderDetail>(); mockNotebookDetail.Setup(e => e.Count()).Returns(4899.00m); target.OrderDetails = new List<BaseOrderDetail>(); target.OrderDetails.Add(mockJacketOrderDetail.Object); target.OrderDetails.Add(mockiPadOrderDetail.Object); target.OrderDetails.Add(mockNotebookDetail.Object); // Acction decimal actual = target.Count(); // Assert Assert.AreEqual(actual, expected); }
在這個單元測試代碼中,我們因為估計到實現Count方法需要依賴於OrderDetails屬性,併合計裡面所有BaseOrderDetail對象的價格,而BaseOrderDetail的價格是通過BaseOrderDetail.Count()方法獲得的。我們只是測試ProductOrder的Count()方法,因此不需要再深入思考BaseOrderDetail.Count是如何?的,只需要對BaseOrderDetail.Count()方法進行Mock即可。
因此在這段代碼中,添加了三個Mock的BaseOrderDetail對象,並分別設定他們的Count()方法傳回值,最後把它們都加入到ProductOrder的OrdeDetails屬性中。到此,這個單元測試就寫完了,但其實此時,我們的Count方法還根本沒實現。所以運行單元測試會拋出異常。
二、編寫方法的真正實現邏輯
那麼,接下來,我們來實現這個方法的實際邏輯。
Unit Test for Count /// <summary> /// Count the total of the all orders. /// </summary> /// <returns></returns> public decimal Count() { decimal result; result = this.OrderDetails.Sum(e => e.Count()); return result; }
現在再運行之前的單元測試,會發現該單元測試已經順利通過。大家是否看明白:單元測試只需要知道對被測試方法的期望值以及該方法可能依賴的項即可編寫,無論這些方法是否已經真的實現。甚至可能的依賴項都是可以不用一開始編寫的,而可以等到實現代碼時發現需要調用其他方法時,再修改相應的單元測試代碼進行隔離。
在這裡再多寫一個例子,方便大家加深認識。這次對BaseOrderDetail上的Count方法編寫單元測試。
Unit Test for Count /// <summary> ///A test for Count ///</summary> [TestMethod()] public void TestCount() { // Arrange BaseOrderDetail target = new ClothingOrderDetail(); decimal expected = 1000.00m; // Action decimal actual = target.Count(); // Assert Assert.AreEqual(expected, actual); }
這段代碼是最簡單的單元測試架構,如果執行肯定會因為Count方法還未實現而拋出異常。接下來我們編寫Count方法的實現,
Unit Test for Count /// <summary> /// A abstract class of BaseOrderDetail. /// </summary> public abstract class BaseOrderDetail { /// <summary> /// The identity of the current product. /// </summary> public Guid ProductID { get; set; } /// <summary> /// The amount of the current detail. /// </summary> public int Amount { get; set; } /// <summary> /// A third-part price provider. /// </summary> public IPriceProvide PriceProvider { get; set; } /// <summary> /// Count the totoal of the current oder detail /// </summary> /// <returns></returns> public virtual decimal Count() { decimal result; decimal price = this.PriceProvider.QueryPriceByProductID(this.ProductID); result = price * this.Amount; return result; }
可以看到BaseOrderDetail中的Count方法依賴於自身的屬性PriceProvider,而該屬性本應該是根據訂單的類型指定對應的VanclProvder類的執行個體。如果此時,我們再運行單元測試,必然會引發Null 參考異常,原因是此時的PriceProvider屬性並沒有賦值。(在實際啟動並執行代碼中可以使用例如Autofac或者Unity之類的IOC容器實現初始化)
因此,我們需要在單元測試中添加Mock對象,來隔離對外部對象的依賴。修改後的單元測試代碼如下:
Unit Test for Count /// <summary> ///A test for Count ///</summary> [TestMethod()] public void TestCount() { // Arrange BaseOrderDetail target = new ClothingOrderDetail(); decimal expected = 5555m; target.Amount = 100; Mock<IPriceProvide> mockPriceProvider = new Mock<IPriceProvide>(); mockPriceProvider.Setup(e => e.QueryPriceByProductID(It.IsAny<Guid>())).Returns(55.55m); target.PriceProvider = mockPriceProvider.Object; // Action decimal actual = target.Count(); // Assert Assert.AreEqual(expected, actual); }
至此,兩個單元測試都能順利的通過驗證。
三、當需求發生改變時,修改單元測試以及實現代碼
我們假定我們的開發已經進入到一定階段(已經有了兩個類和方法的實現,並已經穩定),這時候需求發生了變更,我們來看下此時應該如何進行操作。
第一種情況,需求變更為,如果訂單明細是ClothingOrderDetail類型時,Count方法需要在價格乘以數量之後增加額外的10元郵費。這種情況,其實只需要在ClothingOrderDetail類中重載BaseOrderDetail的Count方法即可,因此編寫新的單元測試和方法實現即可。
從這裡可以注意到一點單元測試的特性:雖然我們修改了BaseOrderDetail的需求和實現,但ProductOrder中Count方法的單元測試並無法檢測到這種變化,甚至BaseOrderDetail的Count方法修改後會引發異常,也無法通過單元測試得知BaseOrderDetail中Count方法產生的異常會到只ProductOrder中的Count方法也會發生異常。這是單元測試的局限性。
第二種情況,需求變更為,如果訂單的總額度超過5000,可以享受減免100的優惠。此時,可以先修改ProductOrder.Count方法的單元測試中的期望值為8648.55,再修改實際的代碼實現如下:
Unit Test for Count /// <summary> /// Count the total of the all orders. /// </summary> /// <returns></returns> public decimal Count() { decimal result; result = this.OrderDetails.Sum(e => e.Count()); // Rreat Offer if (result >= 5000) { result = result - 100; } return result; }
可以看到其實這是在實現代碼中添加了一個條件判斷分支,我們也許需要為條件分支再單獨寫一個單元測試(略)。
第三種情況,即需求發生重大變化,導致代碼結構發生嚴重變化,那麼基本之前的所有單元測試和實現代碼都已經作廢,甚至編譯無法通過。
四、對某個方法內的實現進行最佳化
其實單元測試在開發完成後能起到的作用,僅限於這種情境,對一個方法的實現進行最佳化,例如VanclProvider類的QueryPriceByProductID方法由以前從資料庫查詢,改為先從本機快取中讀取,若緩衝不存在才從資料庫中讀取,因為期望值並為發生改變,只是修改了這個方法的實現方式,因此在修改之後依然可以通過單元測試進行驗收是否修改正確,即新的修改是否依然符合早期的需求(期望值沒發生改變,則單元測試不發生改變)。
誤區:
很多朋友一直把單元測試和整合測試搞混,因此一直有個誤區:
認為只要對所有方法都編寫了單元測試,在將來代碼發生變化之後,可以通過運行單元測試快速的通過電腦校正得到所有受當前代碼變化影響的用例以及影響範圍。實際上,如果你仔細看到這裡,你應該已經知道,這其實是不可能的。
單元測試的隔離性,導致了代碼的影響不會傳遞:一個方法的實現導致了異常,並不會影響到其他調用過它的方法在原有的單元測試中順利通過(Mock方法代替了實際方法的執行)。
總結:
通過以上這些例子,描述了如何結合測試先行的思想去做單元測試,核心的一點即是:目標先行,每實現一個方法之前先考慮這個方法要達到的目標是什麼,再編寫這個目標的檢測手段(單元測試),最終去實現這個方法,並通過之前設想和編寫的檢測手段去驗證這個實現是否已經達到預期的目標。
另一方面,這個過程也強烈的反應出了,單元測試作為測試的一種手段,其實在代碼實現後,即維護階段能起到的作用非常的小:
當需求發生變化時,需求的預期值必然發生變化,意味著單元測試需要被修改或重新編寫。此時,原有的單元測試已經起不到事後檢查的作用了。
只有在需求未發生改變時,對現有代碼進行不涉及結構改變的最佳化時,用於修改後的代碼是否依然滿足之前的需求預期。但實際應用中,這種情境少之又少。