目錄
- PART I: 入門
- Chap 1: 單元測試基礎
- Chap 2: 第一個單元測試
- PART II: 核心技術
- Chap 3: 透過虛設常式解決依賴問題
- Chap 4: 使用模擬物件驗證互動
- Chap 5: 隔離(模擬)框架
- Chap 6: 深入了解隔離框架
- PART III: 測試程式碼
- Chap 7: 測試階層和組織
- Chap 8: 好的單元測試的支柱
- Chap 9: 在組織中導入單元測試
- Chap 10: 遺留程式碼
- Chap 11: 設計與可測試性
前言
在開始之前,這本書談的是 xUnit 框架,跟你所使用的框架可能不盡相同(以 iOS 為例,Xcode 自帶 XCTest 框架),現在很多框架都非常智慧,因此你可能看著看著就沒耐心了,但先別急著跳過,這本書的重點是「概念」,套句鳥哥思維:「多學點總是好事。」學會一個程式碼,你便看得懂大多程式碼,所以別害怕看到不熟悉的框架、程式碼,盡可能從中找到一些靈感與快樂!
ㄧ、撰寫第一個測試程式
單元測試框架提供哪些東西
- 輕鬆撰寫結構化的測試
- 執行一個或全部單元測試
- 確認執行結果
xUnit 框架
通常單元測試框架都以它們所支援的程式語言開頭加上 Unit 作為名字,它們統稱為 xUnit 框架。這本書中,我們會使用 .Net 單元測試框架 NUnit。
NUnit
安裝 NUnit: 建議使用 NuGet 安裝。
NUnit GUI: 以下為示意圖,但因我用 mac 所以沒使用這個工具 Q。
載入方案:我們設立一個 LogAnalyzer。判斷 fileName 的結尾是否為 .SLF。(以下程式碼為故意寫錯,應為
!(fileName.EndsWith(".SLF))
。也許是那天晚上,夜色寂靜、睡眼惺忪時寫下的錯誤程式碼,單元測試來拯救世界的時刻。)public class LogAnalyzer { public bool IsValidLogFileName(string fileName) { if (fileName.EndsWith(".SLF")) // INCORRECT HERE { return false; } return true; } }
單元測試的命名:
[UnitOfWorkName]_[ScenarioUnderTest]_[ExpectedBehavior]
- UnitOfWorkName:被測試的方法、一組方法或一組類別
- ScenarioUnderTest:測試進行的假設條件,例如登入失敗、無效的使用者、密碼正確
- ExpectedBehavior:在測試情境的假設條件下,行為的預期。
- 回傳一個結果值(一個真實的值或是一個 Exception)
- 系統狀態的改變(在系統中新增一個使用者,導致下次登入時系統的行為發生改變)
- 呼叫外部第三方系統提供的服務(與一個外部的 Web 服務進行互動)
NUnit 特性:TestFixture 與 Test
- 特性 [TestFixture] 標記一個包含自動化 NUnit 測試的類別。
- 特性 [Test] 可以加在一個方法上,用來標記這個方法是一個需要被執行的自動化測試。
- 提示:NUnit 要求測試方法必須是公開的且為 void,在基本設定情況下,不允許傳入參數,但是有時這些情況可以使用參數。
- 撰寫第一個測試程式
- 一個單元測試包含三種行為
- 準備 (Arrange) 物件、建立物件、進行必要的設定
- 操作 (Act) 物件
- 驗證 (Assert) 某件事符合預期
- LogAnalyzerTests 測試本人
[TestFixture] public class LogAnalyzerTests { [Test] public void IsValidFileName_BadExtension_ReturnsFalse() { LogAnalyzer analyzer = new LogAnalyzer(); bool result = analyzer.IsValidLogFileName("filewithbadextension.foo"); Assert.False(result); } }
- 一個單元測試包含三種行為
Assert 類別:Assert 類別屬於 NUnit.Framework 的命名空間裡,提供靜態方法。範例:
- Assert.False(result);
- Assert.AreEqual(expextedObject, actualObject, message);
經過一輪測試後,我們會發現測試失敗了(不論是 NUnit GUI,還是程式碼內 Test Results),因此我們來修正一下那晚月夜風高、醉生夢死,意外手殘 (腦殘?) 寫錯的地方,認真說這種蟲最難抓,你以為自己很聰明,直到這一刻天崩地裂、懷疑人生。
public class LogAnalyzer { public bool IsValidLogFileName(string fileName) { if !(fileName.EndsWith(".SLF")) // FIXED HERE { return false; } return true; } }
新增正向測試:來另外新增兩個測試吧!
[Test] public void IsValidLogFileName_GoodExtensionLowercase_ReturnsTrue() { LogAnalyzer analyzer = new LogAnalyzer(); bool result = analyzer.IsValidLogFileName("filewithgoodextension.slf"); Assert.True(result); } [Test] public void IsValidLogFileName_GoodExtensionUppercase_ReturnsTrue() { LogAnalyzer analyzer = new LogAnalyzer(); bool result = analyzer.IsValidLogFileName("filewithgoodextension.SLF"); Assert.True(result); }
糟糕,依照原本的程式碼,測試又失敗了,我們需要處理大小寫的問題。
public class LogAnalyzer { public bool IsValidLogFileName(string fileName) { if !(fileName.EndsWith(".SLF", StringComparison.CurrentCultureIgnoreCase)) // ADJUST HERE { return false; } return true; } }
棒棒噠!美妙的綠色!由紅到綠:測試成功!
二、使用參數來重構測試
到目前為止,所寫的測試都不容易維護。假設 LogAnalyzer 的建構式裡加一個參數,那 3 個的測試就都會編譯失敗 QQ,你可能想說不怕不怕大不了「工人」方式一個一個改,但... 倘若 30 個呢?300 個呢?身為電腦怪客,能自動化 裝逼 就自動化 裝逼,怎能這麼凡夫俗子對吧,所以,讓我們來重構這些測試吧!
// Add [Test]
[Test("filewithgoodextension.SLF")]
[Test("filewithgoodextension.slf")]
public class LogAnalyzerTests
{
[Test]
public void IsValidFileName_BadExtension_ReturnsFalse(string file) // ADD Parameter
{
LogAnalyzer analyzer = new LogAnalyzer();
bool result = analyzer.IsValidLogFileName(file); // Input file
Assert.False(result);
}
}
- 把特性 [Test] 換成 [TestCase]
- 把測試中所寫死的值,替換持這個測試方法的參數
- 把被替換掉的名字放到特性中的括號裡,如 [TestCase(param1, param2, ...)]
- 用一個比較共用的名字
- 把這個測試方法上,對每個需要合併的測試方法,用其測試值新增一個 [TestCase(...)] 特性
- 移除其他測試,只保留這一個帶有多個 [TestCase] 特性的測試方法
我們還可以更近一步,加入負面的測試,走起~
// Adjust [Test] Parameter
[Test("filewithgoodextension.SLF", true)]
[Test("filewithgoodextension.slf", true)]
[Test("filewithbadextension.foo", false)]
public class LogAnalyzerTests
{
[Test]
public void IsValidFileName_BadExtension_ReturnsFalse(string file, bool expected)
{
LogAnalyzer analyzer = new LogAnalyzer();
bool result = analyzer.IsValidLogFileName(file);
Assert.AreEqual(expected, result); // ADJUST AreEqual and set expected boolean value
}
}
三、更多的 NUnit 特性
setup 和 teardown
- [Setup]: 這個特性可以像特性 [Test] 一樣,夾在一個方法上,NUnit 每次在執行測試類別裡的任何一個測試方法之前,都會先呼叫標記 [Setup] 的方法。
- [TearDown]: 這個特性標記這個方法應該在每個測試執行完畢之後被呼叫。
private LogAnalyzer m_analyzer = null;
[SetUp]
public void SetUp() // setup 特性
{
m_analyzer = new LogAnalyzer();
}
[Test]
public void IsValidFileName_validFileLowerCased_ReturnsTrue()
{
bool result = m_analyzer.IsValidLogFileName("whatever.slf");
Assert.IsTrue(result, "filename should be valid");
}
[Test]
public void IsValidFileName_validFileUpperCased_ReturnsTrue()
{
bool result = m_analyzer.IsValidLogFileName("whatever.SLF");
Assert.IsTrue(result, "filename should be valid");
}
[TearDown]
public void TearDown() // TearDown 特性
{
// 反模式,不要在實作上這樣做
m_analyzer = null;
}
以上呈現了如何使用特性 [SetUp] 和 [TearDown],確保每個測試都得到一個新的 Analyzer 類別新的物件,同時能省去重複輸入。但要注意,使用 [SetUp] 越多,測試的可讀性就越差。
- 延伸閱讀:XCTest 與 NUnit 的 Setup & TearDown,當時看這個部分時,一直在思考為什麼跟我想的不太一樣(依據我對 iOS XCTest 的了解),目前看來是 Life Cycle 不同。
在實際工作中,作者 不 使用 setup 來初始化測試,而是使用工廠模式。(詳情作法會在 Chap 7: 測試階層和組織 )
NUnit 還包含其他輔助設定和清理系統狀態的特性,例如 [TestFixtureSetUp] 和 [TestFixtureTearDown],可以在該測試類別裡的測試執行前,進行狀態的設定。
在實際專案中,幾乎 永遠不會 用到 TearDown 和 TestFixture 方法。如果用到的話,很有可能是相依檔案系統或資料庫,所寫的是整合測試。
驗證預期的例外
if (string.IsNullOrEmpty(fileName)
{
throw new ArgumentException("filename has to be provided");
}
針對這個我們有兩種驗證方式:
- NUnit 有一個特別的特性可以用來測試例外: [ExpectedException]。此為 不該使用的方式 ,曾經很流行。
[Test]
[ExpectedException(typeof(ArgumenException), ExceptedMessage = "filename has to be provided")]
public void IsValidFileName_EmptyFileName_ThrowsException()
{
m_analyzer.IsValidLogFileName(string.isEmpty);
}
private LogAnalyzer MakeAnalyzer() // 工廠模式
{
return new LogAnalyzer();
}
上面程式碼有幾個需要特別留意的點:
- 預期的例外訊息做為一個參數傳遞給 [ExpectedException] 特性。
- 測試本身沒有使用 Assert 類別進行驗證。[ExpectedException] 就包含了驗證的檢查。
- 因為呼叫了被測試方法,並預期拋出一個例外,所以測試哺需要被測試方法取得回傳的 bool 值。
那為什麼不該使用呢?簡單來說,因為這個特性的意思,類同於將整個方法包在一個 try-catch 裡面,如果沒有例外被「攔截」到,就認為是失敗。這個作法有個很大的問題,就是你不知道在整個測試方法的大區塊裡,是不是我們期望的那一行所拋出的例外。
- NUnit 提供了一個更新的 API:Assert.Catch(delegate)
[Test]
public void IsValidFileName_EmptyFileName_Throws()
{
LogAnalyzer la = makeAnalyzer();
var ex = Assert.Catch<Exception>(() => la.IsValidLogFileName(""));
StringAssert.Contains("filename has to be provided", ex.Message);
}
- 如果 Lambda 中的程式碼拋出了一個符合預期的例外,測試就會通過。反之,就會失敗。
- Assert.Catch 函數會回傳 Lambda 內所拋出的例外的物件,你可以後續對這個例外進行驗證。
- 使用 StringAssert。它能夠簡化字串驗證的輔助函數,增加可讀性。
- 沒有用 Asser.AreEqual 來進行完整字串驗證,而是使用 StringAssert.Contains,因為隨著程式碼功能越來越多,字串內容經常發生變化,使用 StringAssert.Contains 更容易維護。
忽略此測試
有時候測試有問題,但 PM 又很 機車 緊迫盯人,需要趕快進版。在這種在於罕見跟不罕見之間的情況,可以加上 [Ignore] 特性。
[Test]
[Ignore("there is a problem with this test. DAMN!")]
public void IsValidFileName_ValidFile_ReturnsTrue()
{
...
}
- 補充:NUnit GUI 介面,會呈現黃色。
NUnit 流利語法
除了 Assert*. 用法外,還可以使用流利語法:Assert.That 開頭。
[Test]
public void IsValidFileName_EmptyFileName_ThrowsFluent()
{
LogAnalyzer la = MakeAnalyzer();
var ex = Assert.Catch<ArgumentException>(() => la.IsValidLogFileName(""));
Assert.That(ex.Message, Is.StringContaining("filename has to be provided");
}
設定測試分類
你可以將測試,按照指定的測試分類來執行。如:執行快慢。使用 [Category] 特性可以完成此功能。
[Test]
[Category("Fast Tests")]
public void IsValidFileName_ValidFile_ReturnsTrue()
{
...
}
測試系統狀態的改變,而非驗證回傳值
我們先來回顧一下,工作單元的三種最終結果:
- 回傳值
- 系統狀態的改變
- 第三方系統
在 Chap 1: 單元測試的基礎,我們學會針對工作單元的 回傳值 進行測試,接下來我們將會討論第二種驗證方式:系統狀態的改變 ——驗證被測試系統,在執行某個動作的前後,其行為所發生的變化是否符合預期。
public class LogAnalyzers
{
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
}
}
LogAnalyzer 記住了最後一次的驗證結果。因為 WasLastFileNameValid
是基於另一個方法被呼叫的情況,你需要用另外的方法來驗證這段邏輯是否正常。
[Test]
public void IsValidFileName_WhenCallid_ChangesWasLastFileNameValid()
{
LogAnalyzer la = MakeAnalyzer();
la.IsValidLogFileName("badname.foo");
Assert.False(la.WasLastFileNameValid); // 驗證系統狀態
}
- 注意:這裡測試的是
IsValidLogFileName
的功能,卻又對另一段程式碼進行驗證。
命名的延伸:
- 如果沒有前置動作而有預期的回傳值,可以用 ByDefault,如
Sum_ByDefault_ReturnsZero
- 針對第二種或第三種結果(改變狀態 或 呼叫第三方系統),如果不需要進行前置設定,可以用 WhenCall 或 Always。如
Sum_WhenCalled_CallsTheLogger
或Sum_Always_CallsTheLogger
小結
在本章中,我們學會用 NUnit 來撰寫第一個測試程式;利用 [TestCase]、[SetUp] 和 [TearDown] 來確保狀態乾淨;使用工廠模式來讓測試更好維護;使用 [Ignore] 來略過有問題的測試;測試分類 [Category] 來分類,如:Fast Tests;Assert.Catch() 確保程式在預期時候拋出例外;我們還討論了,如果測試的物件方法並非回傳值,你必須測試物件互動後的系統狀態。
最後,記住以下幾點:
- 建立測試類別、專案和方法的慣例:對每個待測試類別建立對應的測試類別,對每個帶測試的專案建立一個測試專案,對每個工作單元建立至少一個測試方法。
- 使用
[UnitOfWorkName]_[ScenarioUnderTest]_[ExpectedBehavior]
讓測試命名一針見血。 - 使用工廠模式來重用程式
- 盡量不要在單元測試中用到 [SetUp] 和 [TearDown],它們會讓可讀性變差,多加利用工廠模式。
這章好累好多,也好充實!(寫到我腰痛... orz )在下一章「Chapter 3: 透過虛設常式解決依賴問題」,我們要討論真實的測試情境。討論假物件、模擬物件與虛設常式的概念。