.NET單元測試的藝術-2.核心技術

開篇:上一篇咱們學習基本的單元測試基礎知識和入門實例。可是,若是咱們要測試的方法依賴於一個外部資源,如文件系統、數據庫、Web服務或者其餘難以控制的東西,那又該如何編寫測試呢?爲了解決這些問題,咱們須要建立測試存根僞對象模擬對象。這一篇中咱們會開始接觸這些核心技術,藉助存根破除依賴,使用模擬對象進行交互測試,使用隔離框架支持適應將來和可用性的功能。html

系列目錄:

1.入門git

2.核心技術github

3.測試代碼web

1、破除依賴-存根

1.1 爲什麼使用存根?

  當咱們要測試的對象依賴另外一個你沒法控制(或者還未實現)的對象,這個對象多是Web服務、系統時間、線程調度或者不少其餘東西。數據庫

  那麼重要的問題來了:你的測試代碼不能控制這個依賴的對象向你的代碼返回什麼值,也不能控制它的行爲(例如你想摸你一個異常)。編程

  所以,這種狀況下你可使用存根框架

1.2 存根簡介

  (1)外部依賴項ide

一個外部依賴項是系統中的一個對象,被測試代碼與這個對象發生交互,但你不能控制這個對象。(常見的外部依賴項包括:文件系統、線程、內存以及時間等)函數

  (2)存根單元測試

一個存根(Stub)是對系統中存在的一個依賴項(或者協做者)的可控制的替代物。經過使用存根,你在測試代碼時無需直接處理這個依賴項。

1.3 發現項目中的外部依賴

  繼續上一篇中的LogAn案例,假設咱們的IsValidLogFilename方法會首先讀取配置文件,若是配置文件說支持這個擴展名,就返回true:

    public bool IsValidLogFileName(string fileName)
    {
        // 讀取配置文件
        // 若是配置文件說支持這個擴展名,則返回true
    }

  那麼問題來了:一旦測試依賴於文件系統,咱們進行的就是集成測試,會帶來全部與集成測試相關的問題—運行速度較慢,須要配置,一次測試多個內容等。

  換句話說,儘管代碼自己的邏輯是徹底正確的,可是這種依賴可能致使測試失敗。

1.4 避免項目中的直接依賴

  想要破除直接依賴,能夠參考如下兩個步驟:

  (1)找到被測試對象使用的外部接口或者API;

  (2)把這個接口的底層實現替換成你能控制的東西;

  對於咱們的LogAn項目,咱們要作到替代實例不會訪問文件系統,這樣便破除了文件系統的依賴性。所以,咱們能夠引入一個間接層來避免對文件系統的直接依賴。訪問文件系統的代碼被隔離在一個FileExtensionManager類中,這個類以後將會被一個存根類替代,以下圖所示:

  在上圖中,咱們引入了存根 ExtensionManagerStub 破除依賴,如今咱們得代碼不該該知道也不會關心它使用的擴展管理器的內部實現。

1.5 重構代碼提升可測試性

  有兩類打破依賴的重構方法,兩者相互依賴,他們被稱爲A型和B型重構。

  (1)A型 把具體類抽象成接口或委託;

  下面咱們實踐抽取接口將底層實現變爲可替換的,繼續上述的IsValidLogFileName方法。

  Step1.咱們將和文件系統打交道的代碼分離到一個單獨的類中,以便未來在代碼中替換帶對這個類的調用。

  ①使用抽取出的類

    public bool IsValidLogFileName(string fileName)
    {
        FileExtensionManager manager = new FileExtensionManager();
        return manager.IsValid(fileName);
    }
View Code

  ②定義抽取出的類

    public class FileExtensionManager : IExtensionManager
    {
        public bool IsValid(string fileName)
        {
            bool result = false;
            // 讀取文件

            return result;
        }
    }
View Code

  Step2.而後咱們從一個已知的類FileExtensionManager抽取出一個接口IExtensionManager。

    public interface IExtensionManager
    {
        bool IsValid(string fileName);
    }
View Code

  Step3.建立一個實現IExtensionManager接口的簡單存根代碼做爲可替換的底層實現。

    public class AlwaysValidFakeExtensionManager : IExtensionManager
    {
        public bool IsValid(string fileName)
        {
            return true;
        }
    }
View Code

  因而,IsValidLogFileName方法就能夠進行重構了:

    public bool IsValidLogFileName(string fileName)
    {
        IExtensionManager manager = new FileExtensionManager();
        return manager.IsValid(fileName);
    }
View Code

  可是,這裏被測試方法仍是對具體類進行直接調用,咱們必須想辦法讓測試方法調用僞對象而不是IExtensionManager的本來實現,因而咱們想到了DI(依賴注入),這時就須要B型重構。

  (2)B型 重構代碼,從而可以對其注入這種委託和接口的僞實現。

  剛剛咱們想到了依賴注入,依賴注入的主要表現形式就是構造函數注入與屬性注入,因而這裏咱們主要來看看構造函數層次與屬性層次如何注入一個僞對象。

  ① 經過構造函數注入僞對象

  根據上圖所示的流程,咱們能夠重構LogAnalyzer代碼:

    public class LogAnalyzer
    {
        private IExtensionManager manager;

        public LogAnalyzer(IExtensionManager manager)
        {
            this.manager = manager;
        }

        public bool IsValidLogFileName(string fileName)
        {
            return manager.IsValid(fileName);
        }
    }
View Code

  其次,再添加新的測試代碼:

    [TestFixture]
    public class LogAnalyzerTests
    {
        [Test]
        public void IsValidFileName_NameSupportExtension_ReturnsTrue()
        {
            // 準備一個返回true的存根
            FakeExtensionManager myFakeManager = new FakeExtensionManager();
            myFakeManager.WillBeValid = true;
            // 經過構造器注入傳入存根
            LogAnalyzer analyzer = new LogAnalyzer(myFakeManager);
            bool result = analyzer.IsValidLogFileName("short.ext");

            Assert.AreEqual(true, result);
        }

        // 定義一個最簡單的存根
        internal class FakeExtensionManager : IExtensionManager
        {
            public bool WillBeValid = false;
            public bool IsValid(string fileName)
            {
                return WillBeValid;
            }
        }
    }    
View Code

Note:這裏將僞存根類和測試代碼放在一個文件裏,由於目前這個僞對象只在這個測試類內部使用。它比起手工實現的僞對象和測試代碼放在不一樣文件中,將它們放在一個文件裏的話,定位、閱讀以及維護代碼都要容易的多。  

  ② 經過屬性設置注入僞對象

  構造函數注入只是方法之一,屬性也常常用來實現依賴注入。

  根據上圖所示的流程,咱們能夠重構LogAnalyzer類:

    public class LogAnalyzer
    {
        private IExtensionManager manager;

        // 容許經過屬性設置依賴項
        public IExtensionManager ExtensionManager
        {
            get
            {
                return manager;
            }

            set
            {
                manager = value;
            }
        }

        public LogAnalyzer()
        {
            this.manager = new FileExtensionManager();
        }

        public bool IsValidLogFileName(string fileName)
        {
            return manager.IsValid(fileName);
        }
    }
View Code

  其次,新增一個測試方法,改成屬性注入方式:

    [Test]
    public void IsValidFileName_SupportExtension_ReturnsTrue()
    {
        // 設置要使用的存根,確保其返回true
        FakeExtensionManager myFakeManager = new FakeExtensionManager();
        myFakeManager.WillBeValid = true;
        // 建立analyzer,注入存根
        LogAnalyzer log = new LogAnalyzer();
        log.ExtensionManager = myFakeManager;
        bool result = log.IsValidLogFileName("short.ext");

        Assert.AreEqual(true, result);
    }
View Code

Note : 若是你想代表被測試類的某個依賴項是可選的,或者測試能夠放心使用默認建立的這個依賴項實例,這時你就可使用屬性注入。

1.6 抽取和重寫

  抽取和重寫是一項強大的技術,可直接替換依賴項,實現起來快速乾淨,可讓咱們編寫更少的接口、更多的虛函數。

  仍是繼續上面的例子,首先改造被測試類(位於Manulife.LogAn),添加一個返回真實實例的虛工廠方法,正常在代碼中使用工廠方法:

    public class LogAnalyzerUsingFactoryMethod
    {
        public bool IsValidLogFileName(string fileName)
        {
            // use virtual method
            return GetManager().IsValid(fileName);
        }

        protected virtual IExtensionManager GetManager()
        {
            // hard code
            return new FileExtensionManager();
        }
    }
View Code

  其次,在改造測試項目(位於Manulife.LogAn.UnitTests),建立一個新類,聲明這個新類繼承自被測試類,建立一個咱們要替換的接口(IExtensionManager)類型的公共字段(不須要屬性get和set方法):

    public class TestableLogAnalyzer : LogAnalyzerUsingFactoryMethod
    {
        public IExtensionManager manager;

        public TestableLogAnalyzer(IExtensionManager manager)
        {
            this.manager = manager;
        }

        // 返回你指定的值
        protected override IExtensionManager GetManager()
        {
            return this.manager;
        }
    }
View Code

  最後,改造測試代碼,這裏咱們建立的是新派生類而非被測試類的實例,配置這個新實例的公共字段,設置成咱們在測試中建立的存根實例FakeExtensionManager:

    [Test]
    public void OverrideTest()
    {
        FakeExtensionManager stub = new FakeExtensionManager();
        stub.WillBeValid = true;
        // 建立被測試類的派生類的實例
        TestableLogAnalyzer logan = new TestableLogAnalyzer(stub);
        bool result = logan.IsValidLogFileName("stubfile.ext");

        Assert.AreEqual(true, result);
    }
View Code

2、交互測試-模擬對象

  工做單元可能有三種最終結果,目前爲止,咱們編寫過的測試只針對前兩種:返回值和改變系統狀態。如今,咱們來了解如何測試第三種最終結果-調用第三方對象。

2.1 模擬對象與存根的區別

  模擬對象和存根之間的區別很小,但兩者之間的區別很是微妙,但又很重要。兩者最根本的區別在於:

存根不會致使測試失敗,而模擬對象能夠

  下圖展現了存根和模擬對象之間的區別,能夠看到測試會使用模擬對象驗證測試是否失敗。

2.2 第一個手工模擬對象

  建立和使用模擬對象的方法與使用存根相似,只是模擬對象比存根多作一件事:它保存通信的歷史記錄,這些記錄以後用於預期(Expection)驗證。

  假設咱們的被測試項目LogAnalyzer須要和一個外部的Web Service交互,每次LogAnalyzer遇到一個太短的文件名,這個Web Service就會收到一個錯誤消息。遺憾的是,要測試的這個Web Service尚未徹底實現。就算實現了,使用這個Web Service也會致使測試時間過長。

  所以,咱們須要重構設計,建立一個新的接口,以後用於這個接口建立模擬對象。這個接口只包括咱們須要調用的Web Service方法。

  Step1.抽取接口,被測試代碼可使用這個接口而不是直接調用Web Service。而後建立實現接口的模擬對象,它看起來十分像存根,可是它還存儲了一些狀態信息,而後測試能夠對這些信息進行斷言,驗證模擬對象是否正確調用。

    public interface IWebService
    {
        void LogError(string message);
    }

    public class FakeWebService : IWebService
    {
        public string LastError;
        public void LogError(string message)
        {
            this.LastError = message;
        }
    }
View Code

  Step2.在被測試類中使用依賴注入(這裏是構造函數注入)消費Web Service:

    public class LogAnalyzer
    {
        private IWebService service;

        public LogAnalyzer(IWebService service)
        {
            this.service = service;
        }

        public void Analyze(string fileName)
        {
            if (fileName.Length < 8)
            {
                // 在產品代碼中寫錯誤日誌
                service.LogError(string.Format("Filename too short : {0}",fileName));
            }
        }
    }
View Code

  Step3.使用模擬對象測試LogAnalyzer:

    [Test]
    public void Analyze_TooShortFileName_CallsWebService()
    {
        FakeWebService mockService = new FakeWebService();
        LogAnalyzer log = new LogAnalyzer(mockService);

        string tooShortFileName = "abc.ext";
        log.Analyze(tooShortFileName);
        // 使用模擬對象進行斷言
        StringAssert.Contains("Filename too short : abc.ext", mockService.LastError);
    }
View Code

  能夠看出,這裏的測試代碼中咱們是對模擬對象進行斷言,而非LogAnalyzer類,由於咱們測試的是LogAnalyzer和Web Service之間的交互

2.3 同時使用模擬對象和存根

  假設咱們得LogAnalyzer不只須要調用Web Service,並且若是Web Service拋出一個錯誤,LogAnalyzer還須要把這個錯誤記錄在另外一個外部依賴項裏,即把錯誤用電子郵件發送給Web Service管理員,以下代碼所示:

    if (fileName.Length < 8)
    {
        try
        {
            // 在產品代碼中寫錯誤日誌
            service.LogError(string.Format("Filename too short : {0}", fileName));
        }
        catch (Exception ex)
        {
            email.SendEmail("a", "subject", ex.Message);
        }
    }
View Code

  能夠看出,這裏LogAnalyzer有兩個外部依賴項:Web Service和電子郵件服務。咱們看到這段代碼只包含調用外部對象的邏輯,沒有返回值,也沒有系統狀態的改變,那麼咱們如何測試當Web Service拋出異常時LogAnalyzer正確地調用了電子郵件服務呢?

  咱們能夠在測試代碼中使用存根替換Web Service來模擬異常,而後模擬郵件服務來檢查調用。測試的內容是LogAnalyzer與其餘對象的交互。

  Step1.抽取Email接口,封裝Email類

    public interface IEmailService
    {
        void SendEmail(EmailInfo emailInfo);
    }

    public class EmailInfo
    {
        public string Body;
        public string To;
        public string Subject;

        public EmailInfo(string to, string subject, string body)
        {
            this.To = to;
            this.Subject = subject;
            this.Body = body;
        }

        public override bool Equals(object obj)
        {
            EmailInfo compared = obj as EmailInfo;

            return To == compared.To && Subject == compared.Subject 
                && Body == compared.Body;
        }
    }
View Code

  Step2.封裝EmailInfo類,重寫Equals方法

    public class EmailInfo
    {
        public string Body;
        public string To;
        public string Subject;

        public EmailInfo(string to, string subject, string body)
        {
            this.To = to;
            this.Subject = subject;
            this.Body = body;
        }

        public override bool Equals(object obj)
        {
            EmailInfo compared = obj as EmailInfo;

            return To == compared.To && Subject == compared.Subject 
                && Body == compared.Body;
        }
    }
View Code

  Step3.建立FakeEmailService模擬對象,改造FakeWebService爲存根

    public class FakeEmailService : IEmailService
    {
        public EmailInfo email = null;

        public void SendEmail(EmailInfo emailInfo)
        {
            this.email = emailInfo;
        }
    }

    public class FakeWebService : IWebService
    {
        public Exception ToThrow;
        public void LogError(string message)
        {
            if (ToThrow != null)
            {
                throw ToThrow;
            }
        }
    }
View Code

  Step4.改造LogAnalyzer類適配兩個Service

    public class LogAnalyzer
    {
        private IWebService webService;
        private IEmailService emailService;

        public LogAnalyzer(IWebService webService, IEmailService emailService)
        {
            this.webService = webService;
            this.emailService = emailService;
        }

        public void Analyze(string fileName)
        {
            if (fileName.Length < 8)
            {
                try
                {
                    webService.LogError(string.Format("Filename too short : {0}", fileName));
                }
                catch (Exception ex)
                {
                    emailService.SendEmail(new EmailInfo("someone@qq.com", "can't log", ex.Message));
                }
            }
        }
    }
View Code

  Step5.編寫測試代碼,建立預期對象,並使用預期對象斷言全部的屬性

    [Test]
    public void Analyze_WebServiceThrows_SendsEmail()
    {
        FakeWebService stubService = new FakeWebService();
        stubService.ToThrow = new Exception("fake exception");
        FakeEmailService mockEmail = new FakeEmailService();

        LogAnalyzer log = new LogAnalyzer(stubService, mockEmail);
        string tooShortFileName = "abc.ext";
        log.Analyze(tooShortFileName);
        // 建立預期對象
        EmailInfo expectedEmail = new EmailInfo("someone@qq.com", "can't log", "fake exception");
        // 用預期對象同時斷言全部屬性
        Assert.AreEqual(expectedEmail, mockEmail.email);
    }
View Code

總結:每一個測試應該只測試一件事情,測試中應該也最多隻有一個模擬對象。一個測試只能指定工做單元三種最終結果中的一個,否則的話天下大亂。

3、隔離(模擬)框架

3.1 爲什麼使用隔離框架

  對於複雜的交互場景,可能手工編寫模擬對象和存根就會變得很不方便,所以,咱們能夠藉助隔離框架來幫咱們在運行時自動生成存根和模擬對象。

一個隔離框架是一套可編程的API,使用這套API建立僞對象比手工編寫容易得多,快得多,並且簡潔得多。

  隔離框架的主要功能就在於幫咱們生成動態僞對象,動態僞對象是運行時建立的任何存根或者模擬對象,它的建立不須要手工編寫代碼(硬編碼)。

3.2 關於NSubstitute隔離框架

  Nsubstitute是一個開源的框架,源碼是C#實現的。你能夠在這裏得到它的源碼:https://github.com/nsubstitute/NSubstitute

  NSubstitute 更注重替代(Substitute)概念。它的設計目標是提供一個優秀的測試替代的.NET模擬框架。它是一個模擬測試框架,用最簡潔的語法,使得咱們可以把更多的注意力放在測試工做,減輕咱們的測試配置工做,以知足咱們的測試需求,幫助完成測試工做。它提供最常常須要使用的測試功能,且易於使用,語句更符合天然語言,可讀性更高。對於單元測試的新手或只專一於測試的開發人員,它具備簡單、友好的語法,使用更少的lambda表達式來編寫完美的測試程序。

  NSubstitute 採用的是Arrange-Act-Assert測試模式,你只須要告訴它應該如何工做,而後斷言你所指望接收到的請求,就大功告成了。由於你有更重要的代碼要編寫,而不是去考慮是須要一個Mock仍是一個Stub。

  在.NET項目中,咱們仍然能夠經過NuGet來安裝NSubsititute:

3.3 使用NSubstitute模擬對象

  NSub是一個受限框架,它最適合爲接口建立僞對象。咱們繼續之前的例子,來看下面一段代碼,它是一個手寫的僞對象FakeLogger,它會檢查日誌調用是否正確執行。此處咱們沒有使用隔離框架。

    public interface ILogger
    {
        void LogError(string message);
    }

    public class FakeLogger : ILogger
    {
        public string LastError;
        public void LogError(string message)
        {
            LastError = message;
        }
    }

    

    [Test]
    public void Analyze_TooShortFileName_CallLogger()
    {
        // 建立僞對象
        FakeLogger logger = new FakeLogger();
        MyLogAnalyzer analyzer = new Chapter5.MyLogAnalyzer(logger);
        analyzer.MinNameLength = 6;
        analyzer.Analyze("a.txt");

        StringAssert.Contains("too short", logger.LastError);
    }
View Code

  如今咱們看看如何使用NSub僞造一個對象,換句話說,以前咱們手動寫的FakeLogger在這裏就不用再手動寫了:

    [Test]
    public void Analyze_TooShortFileName_CallLogger()
    {
        // 建立模擬對象,用於測試結尾的斷言
        ILogger logger = Substitute.For<ILogger>();
        MyLogAnalyzer analyzer = new MyLogAnalyzer(logger);
        analyzer.MinNameLength = 6;
        analyzer.Analyze("a.txt");

        // 使用NSub API設置預期字符串
        logger.Received().LogError("Filename too short : a.txt");
    }
View Code

  須要注意的是:

  (1)ILogger接口自身並無這個Received方法;

  (2)NSub命名空間提供了一個擴展方法Received,這個方法能夠斷言在測試中調用了僞對象的某個方法;

  (3)經過在LogError()前調用Received(),實際上是NSub在詢問僞對象的這個方法是否調用過。

3.4 使用NSubstitute模擬值

  若是接口的方法返回不爲空,如何從實現接口的動態僞對象返回一個值呢?咱們能夠藉助NSub強制方法返回一個值:

    [Test]
    public void Returns_ByDefault_WorksForHardCodeArgument()
    {
        IFileNameRules fakeRules = Substitute.For<IFileNameRules>();
        // 強制方法返回假值
        fakeRules.IsValidLogFileName("strict.txt").Returns(true);

        Assert.IsTrue(fakeRules.IsValidLogFileName("strict.txt"));
    }
View Code

  若是咱們不想關心方法的參數,即不管參數是什麼,方法應該老是返回一個價值,這樣的話測試會更容易維護,所以咱們能夠藉助NSub的參數匹配器:

    [Test]
    public void Returns_ByDefault_WorksForAnyArgument()
    {
        IFileNameRules fakeRules = Substitute.For<IFileNameRules>();
        // 強制方法返回假值
        fakeRules.IsValidLogFileName(Arg.Any<string>()).Returns(true);

        Assert.IsTrue(fakeRules.IsValidLogFileName("anything.txt"));
    }
View Code

  Arg.Any<Type>稱爲參數匹配器,在隔離框架中被普遍使用,控制參數處理。

  若是咱們須要模擬一個異常,也能夠藉助NSub來解決:

    [Test]
    public void Returns_ArgAny_Throws()
    {
        IFileNameRules fakeRules = Substitute.For<IFileNameRules>();

        fakeRules.When(x => x.IsValidLogFileName(Arg.Any<string>())).
            Do(context => { throw new Exception("fake exception"); });

        Assert.Throws<Exception>(() => fakeRules.IsValidLogFileName("anything"));
    }
View Code

  這裏,使用了Assert.Throws驗證被測試方法確實拋出了一個異常。When和Do兩個方法顧名思義表明了何時發生了什麼事,發生了事以後要觸發其餘什麼事。須要注意的是,這裏When方法必須使用Lambda表達式。

3.5 同時使用模擬對象和存根

  這裏咱們在一個場景中結合使用兩種類型的僞對象:一個用做存根,另外一個用做模擬對象。

  繼續前面的一個例子,LogAnalyzer要使用一個MailServer類和一個WebService類,此次需求有變化:若是日誌對象拋出異常,LogAnalyzer須要通知Web服務,以下圖所示:

  咱們須要確保的是:若是日誌對象拋出異常,LogAnalyzer會把這個問題通知WebService。下面是被測試類的代碼:

    public interface IWebService
    {
        void Write(string message);
    }

    public class LogAnalyzerNew
    {
        private ILogger _logger;
        private IWebService _webService;

        public LogAnalyzerNew(ILogger logger, IWebService webService)
        {
            _logger = logger;
            _webService = webService;
        }

        public int MinNameLength
        {
            get; set;
        }

        public void Analyze(string fileName)
        {
            if (fileName.Length < MinNameLength)
            {
                try
                {
                    _logger.LogError(string.Format("Filename too short : {0}", fileName));
                }
                catch (Exception ex)
                {
                    _webService.Write("Error From Logger : " + ex.Message);
                }
            }
        }
    }
View Code

  如今咱們藉助NSubstitute進行測試:

    [Test]
    public void Analyze_LoggerThrows_CallsWebService()
    {
        var mockWebService = Substitute.For<IWebService>();
        var stubLogger = Substitute.For<ILogger>();
        // 不管輸入什麼都拋出異常
        stubLogger.When(logger => logger.LogError(Arg.Any<string>()))
            .Do(info => { throw new Exception("fake exception"); });

        var analyzer = new LogAnalyzerNew(stubLogger, mockWebService);
        analyzer.MinNameLength = 10;
        analyzer.Analyze("short.txt");
        //驗證在測試中調用了Web Service的模擬對象,調用參數字符串包含 "fake exception"
        mockWebService.Received().Write(Arg.Is<string>(s => s.Contains("fake exception")));
    }
View Code

  這裏咱們不須要手工實現僞對象,可是代碼的可讀性已經變差了,由於有一堆Lambda表達式,不過它也幫咱們避免了在測試中使用方法名字符串。

4、小結

  本篇咱們學習了單元測試的核心技術:存根、模擬對象以及隔離框架。使用存根能夠幫助咱們破除依賴,模擬對象與存根的區別主要在於存根不會致使測試失敗,而模擬對象則能夠。要辨別你是否使用了存根,最簡單的方法是:存根永遠不會致使測試失敗,測試老是對被測試類進行斷言。使用隔離框架,測試代碼會更加易讀、易維護,重點是能夠幫助咱們節省很多時間編寫模擬對象和存根。

參考資料

      The Art of Unit Testing

  (1)Roy Osherove 著,金迎 譯,《單元測試的藝術(第2版)》

  (2)匠心十年,《NSubsititue徹底手冊

  (3)張善友,《單元測試模擬框架:NSubstitute

 

相關文章
相關標籤/搜索