.NET單元測試的藝術-3.測試代碼

開篇:上一篇咱們學習單元測試和核心技術:存根、模擬對象和隔離框架,它們是咱們進行高質量單元測試的技術基礎。本篇會集中在管理和組織單元測試的技術,以及如何確保在真實項目中進行高質量的單元測試。html

系列目錄:

1.入門git

2.核心技術安全

3.測試代碼框架

1、測試層次和組織

1.1 測試項目的兩種目錄結構

  (1)集成測試和單元測試在同一個項目裏,但放在不一樣的目錄和命名空間裏。基礎類放在單獨的文件夾裏。ide

  (2)集成測試和單元測試位於不一樣的項目中,有不一樣的命名空間。工具

實踐中推薦使用第二種目錄結構,由於若是咱們不把這兩種測試分開,人們可能就不會常常地運行這些測試。既然測試都寫好了,爲何人們不肯意按照須要運行它們呢?一個緣由是:開發人員有可能懶得運行測試,或者沒有實踐運行測試。單元測試

1.2 構建綠色安全區

  將集成測試和單元測試分開放置,其實就給團隊的開發人員構建了綠色安全區,這個區只包含單元測試。學習

  由於集成測試的本質決定了它運行時間較長,開發人員頗有可能天天運行屢次單元測試,較少運行集成測試。測試

單元測試所有經過至少可使開發人員對代碼質量比較有信心,專一於提升編碼效率。並且咱們應該將測試自動化,編寫每日構建腳本,並藉助持續集成工具幫助咱們自動執行這些腳本。編碼

1.3 將測試類映射到被測試代碼

  (1)將測試映射到項目

  建立一個測試項目,用被測試項目的名字加上後綴.UnitTests來命名。

  例如:Manulife.MyLibrary → Manulife.MyLibrary.UnitTests 和 Manulife.MyLibrary.IntegrationTests,這種方法看起來簡單直觀,開發人員可以從項目名稱找到對應的全部測試。

  (2)將測試映射到類

  ① 每一個被測試類或者被測試工做單元對應一個測試類:LogAnalyzer → LogAnalyzer.UnitTests

  ② 每一個功能對應一個測試類:有一個LoginManager類,測試方法爲ChangePassword(這個方法測試用例特別多,須要單獨放在一個測試類裏邊) → 建立兩個類 LoginManagerTests 和 LoginManagerTests-ChangePassword,前者只包含對ChangePassword方法的測試,後者包含該類其餘全部測試。

  (3)將測試映射到具體的工做單元入口

  測試方法的命名應該有意義,這樣人們能夠很容易地找到全部相關的測試方法。

  這裏,迴歸一下第一篇中提到的測試方法名稱的規範,通常包含三個部分:[UnitOfWorkName]_[ScenarioUnderTest]_[ExpectedBehavior]

    • UnitOfWorkName  被測試的方法、一組方法或者一組類
    • Scenario  測試進行的假設條件,例如「登入失敗」,「無效用戶」或「密碼正確」等
    • ExpectedBehavior  在測試場景指定的條件下,你對被測試方法行爲的預期  

  示例:IsValidFileName_BadExtension_ReturnsFalse,IsValidFileName_EmptyName_Throws 等

1.4 注入橫切關注點

  當須要處理相似時間管理、異常或日誌的橫切關注點時,使用它們的地方會很是多,若是把它們實現成可注入的,產生的代碼會很容易測試,但卻很難閱讀和理解。這裏咱們來看一個例子,假設應用程序使用當前時間進行寫日誌,相關代碼以下:

    public static class TimeLogger
    {
        public static string CreateMessage(string info)
        {
            return DateTime.Now.ToShortDateString() + " " + info;
        }
    }

  爲了使這段代碼容易測試,若是使用以前的依賴注入技術,那麼咱們須要建立一個ITimeProvider接口,還必須在每一個用到DateTime的地方使用到這個接口。這樣作很是耗時,實際上,還有更直接的方法解決這個問題。

  Step1.建立一個名爲SystemTime的定製類,在全部的產品代碼裏邊使用這個定製類,而非標準的內建類DateTime。

    public class SystemTime
    {
        private static DateTime _date;

        public static void Set(DateTime custom)
        {
            _date = custom;
        }

        public static void Reset()
        {
            _date = DateTime.MinValue;
        }

        public static DateTime Now
        {
            get
            {
                // 若是設置了時間,SystemTime就返回假時間,不然返回真時間
                if (_date != DateTime.MinValue)
                {
                    return _date;
                }
                return DateTime.Now;
            }
        }
    }
View Code

  閱讀這段代碼,其中有一個小技巧:SystemTime類提供一個特殊方法Set,它會修改系統中的當前時間,也就是說,每一個使用這個SystemTime類的人看到的都是你指定的日期和時間。有了這樣的代碼,每一個使用這個SystemTime類的人看到的都會是你指定的日期和時間。

  Step2.在測試項目中使用SystemTime進行測試。

    [TestFixture]
    public class TimeLoggerTests
    {
        [Test]
        public void SettingSystemTime_Always_ChangesTime()
        {
            SystemTime.Set(new DateTime(2000, 1, 1));
            string output = TimeLogger.CreateMessage("a");

            StringAssert.Contains("2000/1/1", output);
        }

        /// <summary>
        /// 在每一個測試結束時重置日期
        /// </summary>
        [TearDown]
        public void AfterEachTest()
        {
            SystemTime.Reset();
        }
    }
View Code

  在測試中,咱們首先假定設置一個日期,而後進行斷言。而且藉助TearDown方法,確保當前測試不會改變其餘測試的值

Note : 這樣作的好處就在於不用注入一大堆接口,咱們所付出的代價僅僅在於在測試類中加入一個簡單的[TearDown]方法,確保當前測試不會改變其餘測試的值。

1.5 使用繼承使測試代碼可重用

  推薦你們在測試代碼中使用繼承機制,經過實現基類,能夠較好地展示面向對象的魔力。在實踐中,通常有三種模式會被使用到:

  (1)抽象測試基礎結構類模式

    /// <summary>
    /// 測試類集成模式
    /// </summary>
    [TestFixture]
    public class BaseTestsClass
    {
        /// <summary>
        /// 重構爲通用可讀的工具方法,由派生類使用
        /// </summary>
        /// <returns>FakeLogger</returns>
        public ILogger FakeTheLogger()
        {
            LoggingFacility.Logger = Substitute.For<ILogger>();
            return LoggingFacility.Logger;
        }

        [TearDown]
        public void ClearLogger()
        {
            // 測試之間要重置靜態資源
            LoggingFacility.Logger = null;
        }
    }

    [TestFixture]
    public class LogAnalyzerTests : BaseTestsClass
    {
        [Test]
        public void Analyze_EmptyFile_ThrowsException()
        {
            // 調用基類的輔助方法
            FakeTheLogger();

            LogAnalyzer analyzer = new LogAnalyzer();
            analyzer.Analyze("myemptyfile.txt");

            // 測試方法的其他部分
        }
    }
View Code

  使用此模式要注意繼承最好不要超過一層,若是繼承層數過多,不只可讀性急劇降低,編譯也很容易出錯。

  (2)測試類類模板模式

    /// <summary>
    /// 測試模板類模式
    /// </summary>
    [TestFixture]
    public abstract class TemplateStringParserTests
    {
        [Test]
        public abstract void TestGetStringVersionFromHeader_SingleDigit_Found();
        [Test]
        public abstract void TestGetStringVersionFromHeader_WithMinorVersion_Found();
        [Test]
        public abstract void TestGetStringVersionFromHeader_WithRevision_Found();
    }

    [TestFixture]
    public class XMLStrignParserTests : TemplateStringParserTests
    {
        protected IStringParser GetParser(string input)
        {
            return new XMLStringParser(input);
        }

        [Test]
        public override void TestGetStringVersionFromHeader_SingleDigit_Found()
        {
            IStringParser parser = GetParser("<Header>1</Header>");

            string versionFromHeader = parser.GetTextVersionFromHeader();
            Assert.AreEqual("1", versionFromHeader);
        }

        [Test]
        public override void TestGetStringVersionFromHeader_WithMinorVersion_Found()
        {
            IStringParser parser = GetParser("<Header>1.1</Header>");

            string versionFromHeader = parser.GetTextVersionFromHeader();
            Assert.AreEqual("1.1", versionFromHeader);
        }

        [Test]
        public override void TestGetStringVersionFromHeader_WithRevision_Found()
        {
            IStringParser parser = GetParser("<Header>1.1.1</Header>");

            string versionFromHeader = parser.GetTextVersionFromHeader();
            Assert.AreEqual("1.1", versionFromHeader);
        }
    }
View Code

  使用此模式能夠確保開發者不會遺忘重要的測試,基類包含了抽象的測試方法,派生類必須實現這些抽象方法。

  (3)抽象測試驅動類模式

    /// <summary>
    /// 抽象「填空」測試驅動類模式
    /// </summary>
    public abstract class FillInTheBlankStringParserTests
    {
        // 返回接口的抽象方法
        protected abstract IStringParser GetParser(string input);
        // 抽象輸入方法(屬性),爲派生類提供特定格式的數據
        protected abstract string HeaderVersion_SingleDigit { get; }
        protected abstract string HeaderVersion_WithMinorVersion { get; }
        protected abstract string HeaderVersion_WithRevision { get; }
        // 若是須要,預先爲派生類定義預期的輸出
        public const string EXPECTED_SINGLE_DIGIT = "1";
        public const string EXPECTED_WITH_MINORVERSION = "1.1";
        public const string EXPECTED_WITH_REVISION = "1.1.1";

        [Test]
        public void TestGetStringVersionFromHeader_SingleDigit_Found()
        {
            string input = HeaderVersion_SingleDigit;
            IStringParser parser = GetParser(input);

            string versionFromHeader = parser.GetTextVersionFromHeader();
            Assert.AreEqual(EXPECTED_SINGLE_DIGIT, versionFromHeader);
        }

        [Test]
        public void TestGetStringVersionFromHeader_WithMinorVersion_Found()
        {
            string input = HeaderVersion_WithMinorVersion;
            IStringParser parser = GetParser(input);

            string versionFromHeader = parser.GetTextVersionFromHeader();
            Assert.AreEqual(EXPECTED_WITH_MINORVERSION, versionFromHeader);
        }

        [Test]
        public void TestGetStringVersionFromHeader_WithRevision_Found()
        {
            string input = HeaderVersion_WithRevision;
            IStringParser parser = GetParser(input);

            string versionFromHeader = parser.GetTextVersionFromHeader();
            Assert.AreEqual(EXPECTED_WITH_REVISION, versionFromHeader);
        }
    }

    public class DBLogStringParserTests : GenericParserTests<DBLogStringParser>
    {
        protected override string GetInputHeaderSingleDigit()
        {
            return "Header;1";
        }

        protected override string GetInputHeaderWithMinorVersion()
        {
            return "Header;1.1";
        }

        protected override string GetInputHeaderWithRevision()
        {
            return "Header;1.1.1";
        }
    }
View Code

  此模式在基類中實現測試方法,並提供派生類能夠實現的抽象方法鉤子。固然,只是大部分的測試代碼在基類中,派生類也能夠加入本身的特殊測試。

  此模式的要點在於:你不是具體地測試一個類,而是測試產品代碼中的一個接口或者基類。

  固然,在.NET中咱們也能夠經過泛型來實現此模式,例以下面的代碼:

    public abstract class GenericParserTests<T> where T : IStringParser // 01.定義參數的泛型約束
    {
        protected abstract string GetInputHeaderSingleDigit();
        protected abstract string GetInputHeaderWithMinorVersion();
        protected abstract string GetInputHeaderWithRevision();

        // 02.返回泛型變量而非接口
        protected T GetParser(string input)
        {
            // 03.返回泛型
            return (T)Activator.CreateInstance(typeof(T), input);
        }

        [Test]
        public void TestGetStringVersionFromHeader_SingleDigit_Found()
        {
            string input = GetInputHeaderSingleDigit();
            T parser = GetParser(input);

            bool result = parser.HasCorrectHeader();
            Assert.AreEqual(false, result);
        }

        [Test]
        public void TestGetStringVersionFromHeader_WithMinorVersion_Found()
        {
            string input = GetInputHeaderWithMinorVersion();
            T parser = GetParser(input);

            bool result = parser.HasCorrectHeader();
            Assert.AreEqual(false, result);
        }

        [Test]
        public void TestGetStringVersionFromHeader_WithRevision_Found()
        {
            string input = GetInputHeaderWithRevision();
            T parser = GetParser(input);

            bool result = parser.HasCorrectHeader();
            Assert.AreEqual(false, result);
        }
    }

    public class DBLogStringParserTests : GenericParserTests<DBLogStringParser>
    {
        protected override string GetInputHeaderSingleDigit()
        {
            return "Header;1";
        }

        protected override string GetInputHeaderWithMinorVersion()
        {
            return "Header;1.1";
        }

        protected override string GetInputHeaderWithRevision()
        {
            return "Header;1.1.1";
        }
    }
View Code

2、優秀單元測試的支柱

  要編寫優秀的單元測試,它們應該同時具備 可靠性可維護性可讀性

2.1 編寫可靠的測試

  一個可靠的測試能讓你以爲本身對事態瞭如指掌,可以從容應對。如下是一些指導原則和技術:

  (1)決定什麼時候刪除或修改測試

  一旦測試寫好並經過,一般咱們不該該修改或刪除這些測試,由於它們是咱們得綠色保護網。可是,有時候咱們仍是須要修改或者刪除測試,因此須要理解什麼狀況下修改或刪除測試會帶來問題,什麼狀況下又是合理的。通常來講,若是有產品缺陷、測試缺陷、語義或者API更改或者是因爲衝突或無效測試,咱們須要修改和刪除測試代碼。

  (2)避免測試中的邏輯

  隨着測試中邏輯的增多,出現測試缺陷的概率就會呈現指數倍的增加。若是單元測試中包含了下列語句就是包含了不該該有的邏輯:

  • switch、if或else語句;
  • foreach、for或while循環;

  這種作法不值得推薦,由於這樣的測試可讀性較差,也比較脆弱。一般來講,一個單元測試應該是一系列方法的調用和斷言,可是不包含控制流程語句,甚至不該該將斷言語句放在try-catch中

  (3)只測試一個關注點

  若是咱們的單元測試對多個對象進行了斷言,那麼這個測試有可能測試了多個關注點。在一個單元測試中驗證多個關注點會使得事情變得複雜,卻沒有什麼價值。你應該在分開的、獨立的單元測試中驗證多餘的關注點,這樣才能發現真正失敗的地方。

  (4)把單元測試和集成測試分開

  掐面討論了測試的綠色安全區,咱們須要的就是準備一個單獨的單元測試項目,項目中僅包含那些在內存中運行,結果穩定,可重複執行的測試。

  (5)用代碼審查確保代碼覆蓋率

  若是覆蓋率低於20%,說明咱們缺乏不少測試,咱們不會知道下一個開發人員將怎麼修改咱們得代碼。若是沒有回失敗的測試,可能就不會發現這些錯誤。

2.2 編寫可維護性的測試

  可維護性是大多數開發者在編寫單元測試時面對的核心問題之一。爲此咱們須要:

  (1)只測試公共契約

  (2)刪除重複測試(去除重複代碼)

  (3)實施測試隔離

  測試隔離的基本概念是:一個測試應該老是在它本身的小世界中運行,與其餘相似或不一樣的工做的測試隔離,甚至不知道其餘測試的存在

2.3 編寫可讀性的測試

  不可讀的測試幾乎沒有任何意義,它是咱們向項目的下一代開發者講述的故事,幫助開發者理解一個應用程序的組成及其開端。

  (1)單元測試命名

  這個前面咱們討論過,應該包括三部分:被測試方法名_測試場景_預期行爲,若是開發人員都是用這種規範,其餘的開發人員就能很容易進入項目,理解測試。

  (2)變量命名

  經過合理命名變量,你能夠確保閱讀測試的人能夠儘快地理解你要驗證什麼(相對於理解產品代碼中你想要實現什麼)。請看下面的一個例子:

    [Test]
    public void BadlyNameTest()
    {
        LogAnalyzer log = new LogAnalyzer();
        int result = log.GetLineCount("abc.txt");

        Assert.AreEqual(-100, result);
    }

    [Test]
    public void GoodNameTest()
    {
        LogAnalyzer log = new LogAnalyzer();
        int result = log.GetLineCount("abc.txt");
        const int COULD_NOT_READ_FILE = -100;

        Assert.AreEqual(-COULD_NOT_READ_FILE, result);
    }
View Code

  通過改進後,咱們會很容易理解這個返回值的意義。

  (3)有意義的斷言

  只有當測試確實須要,而且找不到別的辦法使測試更清晰時,你才應該編寫定製的斷言信息。編寫好的斷言信息就像編寫好的異常信息,一不當心就會犯錯,使讀者產生誤解,浪費他們的時間。

  (4)斷言和操做分離

  爲了可讀性,請不要把斷言和方法調用寫在同一行。

    // 斷言和操做寫在了同一行
    Assert.AreEqual(-COULD_NOT_READ_FILE, log.GetLineCount("abc.txt"));

3、小結

  這一篇咱們學習了:

  • 儘可能將測試自動化,儘量屢次地運行測試,儘量持續地進行產品交付;
  • 把集成測試和單元測試分開,爲整個團隊構建一個綠色安全區,該區域中全部的測試都必須經過;
  • 按照項目和類型組織測試,把測試分別放在不一樣的目錄、文件夾或者命名空間中;
  • 使用測試類層次,對一個層次中相關的幾個類進行同一組測試,或者對共享一個通用接口或者基類的類型進行同一組測試;
  • 優秀單元測試具備三大支柱:可讀性、可維護性與可靠性,它們相輔相成。
  • 若是人們能讀懂你的測試,就能理解和維護測試,若是測試可以經過,它們也會信任測試。一旦實現這個目標,你就能知道系統是否正常工做,具備了處理變動和在須要時修改代碼的能力;

附件下載

  本系列文章的示例代碼:點此下載

參考資料

      The Art of Unit Testing

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

 

相關文章
相關標籤/搜索