ASP.NET 系列:單元測試

剛哥521發表於2016-02-19

單元測試可以有效的可以在編碼、設計、除錯到重構等多方面顯著提升我們的工作效率和質量。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 MocksNSubstitute和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)拒絕考慮效率、擴充套件性和解耦,只考慮資料和功能的實現。

相關文章