開篇:最近在看Roy Osherove的《單元測試的藝術》一書,頗有收獲。因此,將其記錄下來,並分為四個部分分享成文,與各位Share。本篇作為入門,介紹了單元測試的基礎知識,例如:如何使用一個測試框架,基本的自動化測試屬性等等,還有對應的三種測試類型。相信你可以對編寫單元測試從一無所知到及格水平,這也是原書作者的目標。
系列目錄:
1.入門
2.核心技術
3.測試代碼
一、單元測試基礎
1.1 什么是單元測試
一個單元測試是一段自動化的代碼,這段代碼調用被測試的工作單元,之后對這個單元的單個最終結果的某些假設進行檢驗。
單元測試幾乎都是用單元測試框架編寫的。單元測試容易編寫,能夠快速運行。單元測試可靠、可讀,並且可維護。
只要產品代碼不發生變化,單元測試的結果是穩定的。
1.2 與集成測試的區別
集成測試是對一個工作單元進行的測試,這個測試對被測試的工作單元沒有完全的控制,並使用該單元的一個或多個真實依賴物,例如時間、網絡、數據庫、線程或隨機數產生器等。
總的來說,集成測試會使用真實依賴物,而單元測試則把被測試單元和其依賴物隔離開,以保證單元測試結果高度穩定,還可以輕易控制和模擬被測試單元行為的任何方面。
二、測試驅動開發基礎
2.1 傳統的單元測試流程
2.2 測試驅動開發的概要流程
如上圖所示,TDD和傳統開發方式不同,我們首先會編寫一個會失敗的測試,然后創建產品代碼,並確保這個測試通過,接下來就是重構代碼或者創建另一個會失敗的測試。
三、第一個單元測試
3.1 NUnit 單元測試框架
NUnit 是從流行的Java單元測試框架JUnit直接移植過來的,之后NUnit在設計和可用性上做了極大地改進,和JUnit有了很大的區別,給日新月異的測試框架生態系統注入了新的活力。
作為一名.NET程序員,如何在VS中安裝NUnit並能夠在VS中直接運行測試呢?
Step1.在NuGet中找到NUnit並安裝
Step2.在NuGet中找到NUnit Test Adapter並安裝
3.2 LogAn 項目介紹
LogAn (Log And Notificaition)
場景:公司有很多內部產品,用於在客戶場地監控公司的應用程序。所有這些監控產品都會寫日志文件,日志文件存放在一個特定的目錄中。日志文件的格式是你們公司自己制定的,無法用現有的第三方軟件進行解析。你的任務是:實現一個產品,對這些日志文件進行分析,在其中搜索特定的情況和事件,這個產品就是LogAn。找到特定的情況和事件后,這個產品應該通知相關的人員。
在本次的單元測試實踐中,我們會一步一步編寫測試來驗證LogAn的解析、事件識別以及通知功能。首先,我們需要了解使用NUnit來編寫單元測試。
3.3 編寫第一個測試
(1)我們的測試從以下這個LogAnalyzer類開始,這個類暫時只有一個方法IsValidLogFileName:
public class LogAnalyzer { public bool IsValidLogFileName(string fileName) { if (fileName.EndsWith(".SLF")) { return false; } return true; } }
這個方法檢查文件擴展名,據此判斷一個文件是不是有效的日志文件。
這里在if中故意去掉了一個!運算符,因此這個方法就包含了一個Bug-當文件名以.SLF結尾時會返回false,而不是返回true。這樣,我們就能看到測試失敗時在測試運行期中顯示什么內容。
(2)新建一個類庫項目,命名為Manulife.LogAn.UnitTests(被測試項目項目名為Manulife.LogAn.Lib)。添加一個類,取名為LogAnalyzerTests.cs。
(3)在LogAnalyzerTests類中新增一個測試方法,取名為IsValidFileName_BadExtension_ReturnsFalse()。
首先,我們要明確如何編寫測試代碼,一般來說,一個單元測試通常包含三個行為:
因此,根據以上三個行為,我們可以編寫出以下的測試方法:(其中斷言部分使用了NUnit框架提供的Assert類)
[TestFixture] public class LogAnalyzerTests { [Test] public void IsValidFileName_BadExtension_ReturnsFalse() { LogAnalyzer analyzer = new LogAnalyzer(); bool result = analyzer.IsValidLogFileName("filewithbadextension.foo"); Assert.AreEqual(false, result); } }
其中,屬性[TestFixture]和[Test]是NUnit的特有屬性,NUnit用屬性機制來識別和加載測試。這些屬性就像一本書里的書簽,幫助測試框架識別記載程序集里面的重要部分,以及哪些部分是需要調用的測試。
1.[TestFixture]加載一個類上,標識這個類是一個包含自動化NUnit測試的類;
2.[Test]加在一個方法上,標識這個方法是一個需要調用的自動化測試;
另外,再說一下測試方法名稱的規范,一般包含三個部分:[UnitOfWorkName]_[ScenarioUnderTest]_[ExpectedBehavior]
1.UnitOfWorkName 被測試的方法、一組方法或者一組類
2.Scenario 測試進行的假設條件,例如“登入失敗”,“無效用戶”或“密碼正確”等
3.ExpectedBehavior 在測試場景指定的條件下,你對被測試方法行為的預期
3.4 運行第一個測試
(1)編寫好測試代碼之后,點擊"測試"->"運行"->"所有測試"
(2)然后,點擊"測試"->"窗口"->"測試窗口管理器",你會看到以下場景
從上圖可以看出,我們得測試方法並沒有通過,我們期望(Expected)的結果是False,而實際(Actual)的結果卻是True。
3.5 繼續添加測試方法
(1)通常在進行單元測試時我們會考慮到代碼覆蓋率,點擊"測試"->"分析代碼覆蓋率"->"所有測試",你可以看到以下結果:80%
(2)這時,我們需要想出完善的測試策略來覆蓋所有的情況,因此我們添加一些測試方法來提高我們的代碼覆蓋率。這里我們添加兩個方法,一個測試大寫文件擴展名,一個測試小寫文件擴展名:
[Test] public void IsValidFileName_GoodExtensionLowercase_ReturnsTrue() { LogAnalyzer analyzer = new LogAnalyzer(); bool result = analyzer.IsValidLogFileName("filewithgoodextension.slf"); Assert.AreEqual(true, result); } [Test] public void IsValidFileName_GoodExtensionUppercase_ReturnsTrue() { LogAnalyzer analyzer = new LogAnalyzer(); bool result = analyzer.IsValidLogFileName("filewithgoodextension.SLF"); Assert.AreEqual(true, result); }
這時測試結果如下圖所示:
這時再來看看代碼覆蓋率:100%
(3)為了讓所有的測試都能通過,這時我們需要修改源代碼,改用大小寫不敏感的字符串匹配:
public bool IsValidLogFileName(string fileName) { if (!fileName.EndsWith(".SLF", StringComparison.CurrentCultureIgnoreCase)) { return false; } return true; }
這時,我們再來運行一下所有的測試(也可以選擇 運行未通過的測試)來看下由紅到綠的快感。單元測試的理念很簡單:只有所有的測試都通過,繼續前行的綠燈才會亮起。哪怕只有一個測試失敗了,進度條上都會亮起紅燈,顯示你的系統(或者測試)出現了問題。
四、更多的NUnit
4.1 參數化重構單元測試
NUnit中有個叫做 參數化測試(Parameterized Tests)的功能,我們可以借助[TestCase]標簽特性來重構我們的單元測試:
[TestCase("filewithgoodextension.slf")] [TestCase("filewithgoodextension.SLF")] public void IsValidFileName_ValidExtensions_ReturnsTrue(string fileName) { LogAnalyzer analyzer = new LogAnalyzer(); bool result = analyzer.IsValidLogFileName(fileName); Assert.AreEqual(true, result); }
可以看到,借助TestCase特性,測試數目沒有改變,但是測試代碼卻變得更易維護,更加易讀。
4.2 SetUp和TearDown
NUnit還有一些特別的標簽特性,可以很方便地控制測試前后的設置和清理狀態工作,他們就是[SetUp]和[TearDown]。
1.[SetUp] 這個標簽加在一個方法上,NUnit每次在運行測試類里的任何一個測試時都會先運行這個setup方法;
2.[TearDown] 這個標簽標識一個方法應該在測試類里的每個測試運行完成之后執行;
[TestFixture] public class LogAnalyzerTests { private LogAnalyzer analyzer = null; [SetUp] public void Setup() { analyzer = new LogAnalyzer(); } [Test] public void IsValidFileName_ValidFileLowerCased_ReturnsTrue() { bool result = analyzer.IsValidLogFileName("whatever.slf"); Assert.IsTrue(result, "filename should be valid!"); } [Test] public void IsValidFileName_ValidFileUpperCased_ReturnsTrue() { bool result = analyzer.IsValidLogFileName("whatever.SLF"); Assert.IsTrue(result, "filename should be valid!"); } [TearDown] public void TearDown() { analyzer = null; } }
我們可以把setup和teardown方法想象成測試類中測試的構造函數和析構函數,在每個測試類中只能有一個setup和teardown方法,這兩個方法對測試類中的每個方法只執行一次。
不過,使用[Setup]越多,測試代碼可讀性就越差。原書作者推薦采用工廠方法(Factory Method)初始化被測試的實例。
/// <summary> /// 工廠方法初始化 LogAnalyzer /// 既節省編寫代碼的時間,又使每個測試內的代碼更簡潔易讀 /// 同時保證 LogAnalyzer 總是用同樣的方式初始化 /// </summary> private static LogAnalyzer MakeAnalyzer() { return new LogAnalyzer(); }
在測試方法中可以直接使用:
[Test] public void IsValidFileName_BadExtension_ReturnsFalse() { LogAnalyzer analyzer = MakeAnalyzer(); bool result = analyzer.IsValidLogFileName("filewithbadextension.foo"); Assert.AreEqual(false, result); }
4.3 檢驗預期的異常
很多時候,我們的方法中會拋出一些異常,這時如果我們的測試也應該做一些修改。在NUnit中,提供了一個API : Assert.Catch<T>(delegate)
首先,我們修改一下被測試的方法,增加一行判斷文件名是否為空的代碼:
public bool IsValidLogFileName(string fileName) { if(string.IsNullOrEmpty(fileName)) { throw new ArgumentException("filename has to be provided"); } if (!fileName.EndsWith(".SLF", StringComparison.CurrentCultureIgnoreCase)) { return false; } return true; }
然后,我們新增一個測試方法,使用Assert.Catch來檢測異常是否一致:
[Test] public void IsValidFileName_EmptyName_Throws() { LogAnalyzer analyzer = new LogAnalyzer(); // 使用Assert.Catch var ex = Assert.Catch<Exception>(() => analyzer.IsValidLogFileName(string.Empty)); // 使用Assert.Catch返回的Exception對象 StringAssert.Contains("filename has to be provided", ex.Message); }
4.4 忽略測試
有時候測試代碼有問題,但是我們又需要把代碼簽入到主代碼樹中。在這種罕見的情況下(雖然確實非常少),可以給那些測試代碼自身有問題的測試加一個[Ignore]標簽特性。
[Test] [Ignore("there is a problem with this test!")] public void IsValidFileName_ValidFile_ReturnsTrue() { // ... }
可以看到,這個測試確實被忽略了:
4.5 設置測試的類別
我們可以把測試按照指定的測試類別運行,使用[Category]標簽特性就可以實現這個功能:
[Test] [Category("Fast Tests")] public void IsValidFileName_BadExtension_ReturnsFalse() { LogAnalyzer analyzer = new LogAnalyzer(); bool result = analyzer.IsValidLogFileName("filewithbadextension.foo"); Assert.AreEqual(false, result); }
4.6 測試系統狀態的改變
此前我們得測試都有返回值,而很多要測試的方法都沒有返回值,而只是改變對象中的某些狀態,我們又該如何測試呢?
首先,我們修改IsValidLogFileName方法,增加一個狀態屬性:
public class LogAnalyzer { public bool WasLastFileNameValid { get; set; } public bool IsValidLogFileName(string fileName) { // 改變系統狀態 WasLastFileNameValid = false; if(string.IsNullOrEmpty(fileName)) { throw new ArgumentException("filename has to be provided"); } if (!fileName.EndsWith(".SLF", StringComparison.CurrentCultureIgnoreCase)) { return false; } // 改變系統狀態 WasLastFileNameValid = true; return true; } }
其次,我們編寫一個測試,對系統狀態進行斷言:
[TestCase("badfile.foo", false)] [TestCase("goodfile.slf", true)] public void IsValidFileName_WhenCalled_ChangesWasLastFileNameValid(string fileName, bool expected) { LogAnalyzer analyzer = new LogAnalyzer(); analyzer.IsValidLogFileName(fileName); Assert.AreEqual(expected, analyzer.WasLastFileNameValid); }
五、小結
這一篇作為入門,帶領大家領略了一下單元測試的概念,如何編寫單元測試,如何在VS中應用NUnit進行單元測試。相信大家以前都用過MSTest,而我們這里卻使用了NUnit。所以,下面我們來總結一下MSTest與NUnit在特性標簽上的一些區別:
MS Test Attribute | NUnit Attribute | 用途 |
[TestClass] | [TestFixture] | 定義一個測試類,里面可以包含很多測試函數和初始化、銷毀函數(以下所有標簽和其他斷言)。 |
[TestMethod] | [Test] | 定義一個獨立的測試函數。 |
[ClassInitialize] | [TestFixtureSetUp] | 定義一個測試類初始化函數,每當運行測試類中的一個或多個測試函數時,這個函數將會在測試函數被調用前被調用一次(在第一個測試函數運行前會被調用)。 |
[ClassCleanup] | [TestFixtureTearDown] | 定義一個測試類銷毀函數,每當測試類中的選中的測試函數全部運行結束后運行(在最后一個測試函數運行結束后運行)。 |
[TestInitialize] | [SetUp] | 定義測試函數初始化函數,每個測試函數運行前都會被調用一次。 |
[TestCleanup] | [TearDown] | 定義測試函數銷毀函數,每個測試函數執行完后都會被調用一次。 |
[AssemblyInitialize] | -- | 定義測試Assembly初始化函數,每當這個Assembly中的有測試函數被運行前,會被調用一次(在Assembly中第一個測試函數運行前會被調用)。 |
[AssemblyCleanup] | -- | 定義測試Assembly銷毀函數,當Assembly中所有測試函數運行結束后,運行一次。(在Assembly中所有測試函數運行結束后被調用) |
[DescriptionAttribute] | [Category] | 定義標識分組。 |
目前為止,我們的單元測試都還很簡單也還比較順利。但是,如果我們要測試的方法依賴於一個外部資源,如文件系統、數據庫、Web服務或者其他難以控制的東西,那又該如何編寫測試呢?為了解決這些問題,我們需要創建測試存根、偽對象及模擬對象,下一篇核心技術將會介紹這些內容,讓我們跟隨Roy Osherove的《單元測試的藝術》一起去探尋吧。
參考資料
(1)Roy Osherove 著,金迎 譯,《單元測試的藝術(第2版)》
(2)Aileer,《對比MS Test與NUnit Test框架》