單元測試可以有效的可以在編碼、設計、調試到重構等多方面顯著提升我們的工作效率和質量。github上可供參考和學習的各種開源項目眾多,NopCommerce、Orchard等以及微軟的asp.net mvc、entity framework相關多數項目都可以作為學習單元測試的參考。單元測試之道(C#版本)、.NET單元測試藝術和C#測試驅動開發都是不錯的學習資料。
1.單元測試的好處
(1)單元測試幫助設計
單元測試迫使我們從關注實現轉向關注接口,編寫單元測試的過程就是設計接口的過程,使單元測試通過的過程是我們編寫實現的過程。我一直覺得這是單元測試最重要的好處,讓我們關注的重點放在接口上而非實現的細節。
(2)單元測試幫助編碼
應用單元測試會使我們主動消除和減少不必要的耦合,雖然出發點可能是為了更方便的完成單元測試,但結果通常是類型的職責更加內聚,類型間的耦合顯著降低。這是已知的提升編碼質量的有效手段,也是提升開發人員編碼水平的有效手段。
(3)單元測試幫助調試
應用了單元測試的代碼在調試時可以快速定位問題的出處。
(4)單元測試幫助重構
對於現有項目的重構,從編寫單元測試開始是更好的選擇。先從局部代碼進行重構,提取接口進行單元測試,然后再進行類型和層次級別的重構。
單元測試在設計、編碼和調試上的作用足以使其成為軟件開發相關人員的必備技能。
2.應用單元測試
單元測試不是簡單的了解使用類似XUnit和Moq這樣的測試和模擬框架就可以使用了,首先必須對我們要編寫的代碼有足夠的了解。通常我們把代碼看成一些靜態的互相關聯的類型,類型之間的依賴使用接口,實現類實現接口,在運行時通過自定義工廠或使用依賴注入容器管理。一個單元測試通常是在一個方法中調用要測試的方法或屬性,通過使用Assert斷言對方法或屬性的運行結果進行檢測,通常我們需要編寫的測試代碼有以下幾種。
(1)測試領域層
領域層由POCO組成,可以直接測試領域模型的公開行為和屬性。
(2)測試應用層
應用層主要由服務接口和實現組成,應用層對基礎設施組件的依賴以接口方式存在,這些基礎設施的接口通過Mock方式模擬。
(3)測試表示層
表示層對應用層的依賴表現在對服務接口的調用上,通過Mock方式獲取依賴接口的實例。
(4)測試基礎設施層
基礎設施層的測試通常涉及到配置文件、Log、HttpContext、SMTP等系統環境,通常需要使用Mock模式。
(5)使用單元測試進行集成測試
首先系統之間通過接口依賴,通過依賴注入容器獲取接口實例,在配置依賴時,已經實現的部分直接配置,偽實現的部分配置為Mock框架生成的實例對象。隨着系統的不斷實現,不斷將依賴配置的Mock對象替換為實現對象。
3.使用Assert判斷邏輯行為正確性
Assert斷言類是單元測試框架中的核心類,在單元測試的方法中,通過Assert類的靜態方法對要測試的方法或屬性的運行結果進行校驗來判斷邏輯行為是否正確,Should方法通常是以擴展方法形式提供的Assert的包裝。
(1)Assert斷言
如果你使用過System.Diagnostics.Contracts.Contract的Assert方法,那么對XUnit等單元測試框架中提供的Assert靜態類會更容易,同樣是條件判斷,單元測試框架中的Assert類提供了大量更加具體的方法如Assert.True、Assert.NotNull、Assert.Equal等便於條件判斷和信息輸出。
(2)Should擴展方法
使用Should擴展方法既減少了參數的使用,又增強了語義,同時提供了更友好的測試失敗時的提示信息。Xunit.should已經停止更新,Should組件復用了Xunit的Assert實現,但也已經停止更新。Shouldly組件則使用了自己實現,是目前仍在更新的項目,structuremap在單元測試中使用Shouldly。手動對Assert進行包裝也很容易,下面的代碼提取自 NopComnerce 3.70 中對NUnit的Assert的自定義擴展方法。
namespace Nop.Tests { public static class TestExtensions { public static T ShouldNotNull<T>(this T obj) { Assert.IsNull(obj); return obj; } public static T ShouldNotNull<T>(this T obj, string message) { Assert.IsNull(obj, message); return obj; } public static T ShouldNotBeNull<T>(this T obj) { Assert.IsNotNull(obj); return obj; } public static T ShouldNotBeNull<T>(this T obj, string message) { Assert.IsNotNull(obj, message); return obj; } public static T ShouldEqual<T>(this T actual, object expected) { Assert.AreEqual(expected, actual); return actual; } ///<summary> /// Asserts that two objects are equal. ///</summary> ///<param name="actual"></param> ///<param name="expected"></param> ///<param name="message"></param> ///<exception cref="AssertionException"></exception> public static void ShouldEqual(this object actual, object expected, string message) { Assert.AreEqual(expected, actual); } public static Exception ShouldBeThrownBy(this Type exceptionType, TestDelegate testDelegate) { return Assert.Throws(exceptionType, testDelegate); } public static void ShouldBe<T>(this object actual) { Assert.IsInstanceOf<T>(actual); } public static void ShouldBeNull(this object actual) { Assert.IsNull(actual); } public static void ShouldBeTheSameAs(this object actual, object expected) { Assert.AreSame(expected, actual); } public static void ShouldBeNotBeTheSameAs(this object actual, object expected) { Assert.AreNotSame(expected, actual); } public static T CastTo<T>(this object source) { return (T)source; } public static void ShouldBeTrue(this bool source) { Assert.IsTrue(source); } public static void ShouldBeFalse(this bool source) { Assert.IsFalse(source); } /// <summary> /// Compares the two strings (case-insensitive). /// </summary> /// <param name="actual"></param> /// <param name="expected"></param> public static void AssertSameStringAs(this string actual, string expected) { if (!string.Equals(actual, expected, StringComparison.InvariantCultureIgnoreCase)) { var message = string.Format("Expected {0} but was {1}", expected, actual); throw new AssertionException(message); } } } }
4.使用偽對象
偽對象可以解決要測試的代碼中使用了無法測試的外部依賴問題,更重要的是通過接口抽象實現了低耦合。例如通過抽象IConfigurationManager接口來使用ConfigurationManager對象,看起來似乎只是為了單元測試而增加更多的代碼,實際上我們通常不關心后去的配置是否是通過ConfigurationManager靜態類讀取的config文件,我們只關心配置的取值,此時使用IConfigurationManager既可以不依賴具體的ConfigurationManager類型,又可以在系統需要擴展時使用其他實現了IConfigurationManager接口的實現類。
使用偽對象解決外部依賴的主要步驟:
(1)使用接口依賴取代原始類型依賴。
(2)通過對原始類型的適配實現上述接口。
(3)手動創建用於單元測試的接口實現類或在單元測試時使用Mock框架生成接口的實例。
手動創建的實現類完整的實現了接口,這樣的實現類可以在多個測試中使用。可以選擇使用Mock框架生成對應接口的實例,只需要對當前測試需要調用的方法進行模擬,通常需要根據參數進行邏輯判斷,返回不同的結果。無論是手動實現的模擬類對象還是Mock生成的偽對象都稱為樁對象,即Stub對象。Stub對象的本質是被測試類依賴接口的偽對象,它保證了被測試類可以被測試代碼正常調用。
解決了被測試類的依賴問題,還需要解決無法直接在被測試方法上使用Assert斷言的情況。此時我們需要在另一類偽對象上使用Assert,通常我們把Assert使用的模擬對象稱為模擬對象,即Mock對象。Mock對象的本質是用來提供給Assert進行驗證的,它保證了在無法直接使用斷言時可以正常驗證被測試類。
Stub和Mock對象都是偽對象,即Fake對象。
Stub或Mock對象的區分明白了就很簡單,從被測試類的角度講Stub對象,從Assert的角度講Mock對象。然而,即使不了解相關的含義和區別也不會在使用時產生問題。比如測試郵件發送,我們通常不能直接在被測試代碼上應用Assert,我們會在模擬的STMP服務器對象上應用Assert判斷是否成功接收到郵件,這個SMTPServer模擬對象就是Mock對象而不是Stub對象。比如寫日志,我們通常可以直接在ILogger接口的相關方法上應用Assert判斷是否成功,此時的Logger對象即是Stub對象也是Mock對象。
5.單元測試常用框架和組件
(1)單元測試框架。
XUnit是目前最為流行的.NET單元測試框架。NUnit出現的較早被廣泛使用,如nopCommerce、Orchard等項目從開始就一直使用的是NUnit。XUnit目前是比NUnit更好的選擇,從github上可以看到asp.net mvc等一系列的微軟項目使用的就是XUnit框架。
(2)Mock框架
Moq是目前最為流行的Mock框架。Orchard、asp.net mvc等微軟項目使用Moq。nopCommerce使用Rhino Mocks。NSubstitute和FakeItEasy是其他兩種應用廣泛的Mock框架。
(3)郵件發送的Mock組件netDumbster
可以通過nuget獲取netDumbster組件,該組件提供了SimpleSmtpServer對象用於模擬郵件發送環境。
通常我們無法直接對郵件發送使用Assert,使用netDumbster我們可以對模擬服務器接收的郵件應用Assert。
public void SendMailTest() { SimpleSmtpServer server = SimpleSmtpServer.Start(25); IEmailSender sender = new SMTPAdapter(); sender.SendMail("sender@here.com", "receiver@there.com", "subject", "body"); Assert.Equal(1, server.ReceivedEmailCount); SmtpMessage mail = (SmtpMessage)server.ReceivedEmail[0]; Assert.Equal("sender@here.com", mail.Headers["From"]); Assert.Equal("receiver@there.com", mail.Headers["To"]); Assert.Equal("subject", mail.Headers["Subject"]); Assert.Equal("body", mail.MessageParts[0].BodyData); server.Stop(); }
(4)HttpContext的Mock組件HttpSimulator
同樣可以通過nuget獲取,通過使用HttpSimulator對象發起Http請求,在其生命周期內HttContext對象為可用狀態。
由於HttpContext是封閉的無法使用Moq模擬,通常我們使用如下代碼片斷:
private HttpContext SetHttpContext() { HttpRequest httpRequest = new HttpRequest("", "http://mySomething/", ""); StringWriter stringWriter = new StringWriter(); HttpResponse httpResponse = new HttpResponse(stringWriter); HttpContext httpContextMock = new HttpContext(httpRequest, httpResponse); HttpContext.Current = httpContextMock; return HttpContext.Current; }
使用HttpSimulator后我們可以簡化代碼為:
using (HttpSimulator simulator = new HttpSimulator()) { }
這對使用IoC容器和EntityFramework的程序的DbContext生命周期的測試十分重要,DbContext的生命周期必須和HttpRequest一致,因此對IoC容器進行生命周期的測試是必須的。
6.使用單元測試的難處
(1)不願意付出學習成本和改變現有開發習慣。
(2)沒有思考的習慣,錯誤的把單元測試當框架學。
(3)在項目后期才應用單元測試,即獲取不到單元測試的好處又因為代碼的測試不友好對單元測試產生誤解。
(4)拒絕考慮效率、擴展性和解耦,只考慮數據和功能的實現。