單元測試的藝術-入門篇
前記:前段時間團隊在推行單元測試,對於分配的測試任務也很快的完成,但覺得自己對單元測試的理解也不夠透徹,所以就買了《單元測試的藝術》這本書來尋找一些我想要的答案。這本書並不是手把手教你寫單元測試代碼的,而是教你一些思想,循序漸進,最終達到能夠寫出可靠的、可維護的、可讀的測試。本篇文章是入門篇,主要是講解單元測試的概念、與集成測試的區別以及如何使用框架進行最基礎的單元測試等。
一、單元測試的基礎
1.1、什麽是單元測試
單元測試是一段自動化的代碼,這段代碼調用被測試的工作單元,之後對這個單元的單個最終結果的某些假設進行檢驗。單元測試幾乎都是用單元測試框架編寫的。單元測試容易編寫,能快速運行。單元測試可靠、可讀、並且可維護
特征:
- 自動化、可重復執行;
- 很容易實現;
- 第二天還有意義;
- 任何人都應該能一鍵運行它;
- 運行速度應該很快;
- 結果應該是穩定的;
- 能完全控制被測試的單元;
- 完全隔離(獨立於其他測試的運行);
1.2、什麽是集成測試
集成測試是對一個工作單元進行的測試,這個測試對被測試的工作單元沒有完全的控制,並使用該單元的一個或多個真實依賴物,例如時間,網絡、數據庫、線程或隨機數產生器等。
1.3、單元測試與集成測試的區別在哪裏?
單元測試與集成測試最大的區別在於:集成測試依賴於一個或多個真實的模塊,當運行集成測試時,出現失敗的情況後你並不能立即判斷是哪裏出了問題,因此找到缺陷的根源會比較困難
二、TDD(測試驅動開發)
2.1、傳統的開發流程
[虛線代表是一個可選的行為]
2.2、TDD的開發流程
[這是一個螺旋式的過程]
由上面的兩個圖中可以看出TDD與傳統開發模式的區別:先編寫一個會失敗的測試,然後創建產品代碼,並確保這個測試通過,接下來是重構代碼或者創建另一個會失敗的測試。
三、開始使用框架進行基礎的單元測試
3.1、單元測試框架的作用
單元測試框架是幫助開發人員進行單元測試的代碼庫和模塊。
3.2、NUnit
NUnit 是一套開源的基於.NET平臺的類Xunit白盒測試架構,支持所有的.NET平臺。這套架構的特點是開源,使用方便,功能齊全。很適合作為.NET語言開發的產品模塊的白盒測試框架。
起初是從流行的Java單元測試框架JUnit直接移植過來的,之後NUnit在設計和可用性上做了極大地改進,和JUnit有了很大的區別,給日新月異的測試框架生態系統註入了新的活力。
如何在VS安裝並運行呢?用Nuget是最方便的一種形式了,如下圖:
3.3、編寫第一個單元測試
(1)假定我們要測試下面這段代碼:
public class LogAnalyzer { public bool IsValidLogFileName(string fileName) { if (fileName.EndsWith(".SLF")) { return false; } return true; } }
這個方法是用來檢查文件擴展名的,以此判斷是否是一個有效的文件。在上面的程序中,故意在if條件語句中少了一個‘!’號,這樣,我們可以看到測試失敗時在測試運行期間會顯示什麽內容。
(2)新建一個類庫項目,名稱最好為[ProjectUnderTest].UnitTests;並添加一個類,類型為[ClassName]Tests的類;在類中就可以寫測試方法,一般測試方法是這樣子來命名的:[UnitOfWorkName]_[ScenarioUnderTest]_[ExceptedBehavior]。
(3)我們需要明確的是如何編寫測試代碼,一般來說,一個單元測試包含三個行為:
① 準備(Arrange)對象,創建對象,進行必要的設置;
② 操作(Act)對象;
③ 斷言(Assert)某件事情是預期的;
(4)根據以上步驟,編寫第一個單元測試方法
[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]:標識這個類是一個包含自動化NUnit測試的類和[Test]:標識這個方法是一個需要調用的自動化測試是NUnit的特有屬性,NUnit用屬性機制來識別和加載測試。
3.4、運行過程與結果
從上圖可以看出,測試方法並沒有通過,我們期望(Expected)的結果是False,而實際(Actual)的結果卻是True。並且還幫你指出了行號。
四、更多NUnit屬性的介紹
4.1、參數化測試
NUnit有個很酷的功能,叫做參數化測試。可以從現有的測試中任意選擇一個,進行一下修改:
(1)把屬性[Test]替換成屬性[TestCase]
(2)把測試中用到的硬編碼的值替換成這個測試方法的參數
(3)把替換掉的值放在屬性的括號中[TestCase(param1,param2,...)]
[TestCase("filewithbadextension.SLF")] [TestCase("filewithbadextension.slf")] public void IsValidLogFileName_ValidExtensions_ReturnsTrue(string file) { LogAnalyzer analyzer=new LogAnalyzer(); bool result = analyzer.IsValidLogFileName(file); Assert.True(result); }
需要註意的是:這個時候你需要用一個比較通用的名字重新命令這個測試方法。
當然,[TestCase("")]不僅僅只可以寫一個參數,也可以寫N個參數。
[TestCase("filewithbadextension.SLF",true)] [TestCase("filewithbadextension.slf",true)] public void IsValidLogFileName_ValidExtensions_ReturnsTrue(string file,bool excepted) { LogAnalyzer analyzer = new LogAnalyzer(); bool result = analyzer.IsValidLogFileName(file); Assert.AreEqual(excepted,result); }
4.2、[Setup]與[TearDown]
進行單元測試時,很重要的一點是保證之前測試的遺留數據或者實例得到銷毀,新測試的狀態是重建的。幸好,NUnit有一些特別的屬性,可以很方便地控制測試前後的設置和清理狀態工作,就是[SetUp]和[TearDown]動作屬性。
[SetUp] NUnit每次在運行測試類裏的任何一個測試時都會先運行這個方法
[TearDown] 這個屬性標識一個方法應該在測試類裏的每個測試運行之後執行。
private LogAnalyzer _logAnalyzer = null; [SetUp] public void Setup() { _logAnalyzer=new LogAnalyzer(); } [Test] public void IsValidFileName_validFileLowerCased_ReturnsTrue() { bool result = _logAnalyzer.IsValidLogFileName("hello.slf"); Assert.IsTrue(result,"filename should be valid!"); } [Test] public void IsValidFileName_validFileUpperCased_ReturnsTrue() { bool result = _logAnalyzer.IsValidLogFileName("hello.SLF"); Assert.IsTrue(result, "filename should be valid!"); } [TearDown] public void TearDown() { _logAnalyzer = null; }
雖然SetUp與TearDown用起來很方便,但是不建議使用,因為這種方式隨著代碼的增加,後面測試方法很快就變得難以閱讀了,最好是采用工廠方法來初始化被測試的實例。
4.3、檢驗預期的異常
我們現在修改一下要測試的代碼,在輸入為Null或者Empty的時候,就跑出一個異常。
public class LogAnalyzer { public bool IsValidLogFileName(string fileName) {
if(string.IsNullOrEmpty(fileName))
{
throw new ArgumentException("filename has to be provided");
}
if (fileName.EndsWith(".SLF")) { return false; } return true; }
測試代碼如下:
[Test] [ExpectedException(typeof (ArgumentException), ExceptedMessage = "fileName has to be provided")] public void IsValidFileName_EmptyFileName_ThrowsException() { MakeLogAnalyzer().IsValidLogFileName(string.Empty); } private LogAnalyzer MakeLogAnalyzer() { return new LogAnalyzer(); }
註意:以上的代碼雖然是正確的,但是在NUint3.0中已經棄用了,原因是采用這種方法,你可能不知道哪一行代碼拋出的這個異常,如果你的構造函數有問題,也拋出這個異常,那你所寫的測試也會通過,但事實上是錯誤的。NUint提供了一個新的API,Assert.Catch<T>(delegate)。以下是使用Assert.Catch編寫的測試代碼:
[Test] public void IsValidFileName_EmptyFileName_ThrowsException() { var ex = Assert.Catch<ArgumentException>(() => { MakeLogAnalyzer().IsValidLogFileName(""); }); StringAssert.Contains("fileName has to be provided",ex.Message); } private LogAnalyzer MakeLogAnalyzer() { return new LogAnalyzer(); }
4.4、忽略測試
有時候代碼有問題,但是你又需要把代碼簽入到主代碼中(這種情況應該是少中極少,因為這是一種錯誤的方式)。可以采用[Ignore]屬性。示例如下:
[Test] [Ignore("it has some problems")] public void IsValidFileName_validFileUpperCased_ReturnsTrue() { bool result = MakeLogAnalyzer().IsValidLogFileName("hello.SLF"); Assert.IsTrue(result, "filename should be valid!"); }
結果如下:
4.5、設置測試的類型
可以把測試按指定的測試類別運行,例如:慢測試和快測試。使用[Category]屬性可以實現這個功能。
[Test] [Category("Fast Tests")] public void IsValidFileName_ValidFile_ReturnTrue() { Assert.IsTrue(MakeLogAnalyzer().IsValidLogFileName("xxx.SLF")); }
4.6、測試系統狀態的改變而非返回值
上面所有測試示例,都是有根據被測試方法的返回值來進行測試,但一個工程裏面不可能每個方法都是有返回值的,有的是需要判斷系統狀態的改變的,稱為基於狀態的測試。
定義:通過檢查被測試系統及其協作方(依賴物)在被測試方法執行後行為的改變,判定被測試方法是否正確工作。
//被測試代碼
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")) { return false; } WasLastFileNameValid = true; return true; } }
測試代碼:
[TestCase("filewithbadextension.SLF", true)] [TestCase("filewithbadextension.slf", true)] public void IsValidLogFileName_ValidExtensions_ReturnsTrue(string file, bool excepted) { LogAnalyzer analyzer = MakeLogAnalyzer(); analyzer.IsValidLogFileName(file); Assert.AreEqual(excepted, analyzer.WasLastFileNameValid); } private LogAnalyzer MakeLogAnalyzer() { return new LogAnalyzer(); }
五、總結
- 創建測試類、項目和方法的管理;
- 測試命名要有規範;
- 使用工廠方法重用測試中的代碼,例如用來創建和初始化所有測試都要用到的對象代碼;
- 盡量不要使用[SetUp]和[TearDown],因為它們使測試變得難以理解。
單元測試的藝術-入門篇