原文地址:Unit testing best practices
PS:本文未翻譯原文的全部內容,以下為譯文。
編寫單元測試有如下好處:
- 利於回歸測試
- 提供文檔
- 改進代碼設計
但是,難以閱讀和維護的測試代碼則會適得其反。本文會提供一些編寫單元測試的最佳實踐以使得你的測試代碼易於維護和理解。
為什么要寫單元測試?
1. 花更少的時間進行功能測試
功能測試成本相對較高,因為經常需要打開應用並執行一系列操作以驗證結果是否符合預期。測試步驟所涉及領域未必是測試人員所熟知,導致需要其他人協助進行測試。對於細微變化,測試可能需幾秒鍾,亦或幾分鍾來測試較大的變更。最后,對於系統中的每處修改都需要進行重復測試。
反觀單元測試,僅需毫秒級別且無需對系統自身了解過多。單元測試通過與否取決於測試運行器(test runner),而不是某個人。
2. 避免回歸測試
回歸缺陷是在對應用程序進行更改時引入的缺陷。測試人員不僅要測試他們的新特性,還要測試以前存在的特性,以驗證之前實現的特性是否仍然像預期的那樣運行。
通過單元測試,可以在每次構建之后,即便是只修改了一行代碼,重新運行整個測試流程,以確保新代碼不會破壞已有功能。
3. 可執行的文檔
有時對於特定的參數,方法的預期輸出難以確定。你或許會問,如果向方法中傳入空字符串或者null會發生什么?
當編寫具有良好命名的測試用例時,每個用例可以清晰的說明對於給定的輸入會有怎樣的輸出。此外,測試用例還應可以驗證方法是否能夠正常工作。
4. 低耦合代碼
編寫單元測試可以降低代碼耦合度,因為高耦合的代碼將會使得單元測試變得困難重重。
良好的單元測試應具備以下特征
-
快速
對於大型成熟項目可能會有數千個測試用例。每個測試用例應盡可能快的運行,最好在毫秒級別。 -
隔離
單元測試是獨立的,可以單獨運行而不依賴外部元素,如文件系統或數據庫。 -
可重復
在不改變輸入的情況下,單元測試的輸出結果應保持不變。 -
自檢查
單元測試應自動檢測測試是否通過而無需人工干預。 -
耗時少
如果測試代碼所花費的時間遠超編寫代碼的時間,應當考慮重構代碼以便於更好測試。即,確保編寫測試所花費的
最佳實踐
命名
測試用例命名應包含以下幾部分:
- 待測試方法的名稱
- 測試場景
- 預期結果
為什么這么做
良好的命名可以表達測試意圖 。測試不僅僅是用來檢測代碼是否可以正常工作,還可以提供方法的文檔說明。僅僅看一組測試用例,你應該可以推斷出代碼的行為而無需查看代碼。此外,當測試失敗時,應該可以清楚的知道哪些場景不符合預期。
Bad:
[Fact] public void Test_Single() { var stringCalculator = new StringCalculator(); var actual = stringCalculator.Add("0"); Assert.Equal(0, actual); }
Better
[Fact] public void Add_SingleNumber_ReturnsSameNumber() { var stringCalculator = new StringCalculator(); var actual = stringCalculator.Add("0"); Assert.Equal(0, actual); }
編排你的測試代碼(Arranging your tests)
整理(Arrange)、執行、斷言是單元測試的通用模式,主要包含以下三個步驟:
- 創建符合測試條件的對象
- 在對象上執行操作(行為)
- 斷言行為結果是否符合預期
為什么這么做
- 測試步驟清晰
- 避免斷言與行為代碼耦合在一起
可讀性是編寫測試代碼時的一個重要指標。清晰明了的測試步驟可以清楚標明被測代碼的依賴項,及如何調用被測代碼,和行為預期結果。與其合並測試步驟以減少代碼量,不如保持測試代碼具有良好的可讀性。
Bad
[Fact] public void Add_EmptyString_ReturnsZero() { // Arrange var stringCalculator = new StringCalculator(); // Assert Assert.Equal(0, stringCalculator.Add("")); }
Better:
[Fact] public void Add_EmptyString_ReturnsZero() { var stringCalculator = new StringCalculator(); var actual = stringCalculator.Add(""); Assert.Equal(0, actual); }
單元測試粒度盡可能細(Write minimally passing tests)
單元測試的輸入應盡可能簡單以便驗證當前測試行為。
為什么這么做
- 測試用例可以靈活的應對被測代碼的變更
- 更接近於測試代碼行為而非實現細節
測試用例中包含過多信息會增加測試出錯的概率以及使得測試用例的意圖不那么明顯。測試代碼的關注點是行為,給模型設置額外的屬性或者使用非零值是非必需的。
Bad
[Fact] public void Add_SingleNumber_ReturnsSameNumber() { var stringCalculator = new StringCalculator(); var actual = stringCalculator.Add("42"); Assert.Equal(42, actual); }
Better
[Fact] public void Add_SingleNumber_ReturnsSameNumber() { var stringCalculator = new StringCalculator(); var actual = stringCalculator.Add("0"); Assert.Equal(0, actual); }
避免使用魔法字符串(magic strings)
單元測試中的變量命名和生成代碼中的變量命名同等重要,它們不應包含魔法字符串。
為什么這么做
- 不要讓閱讀測試代碼的人對某個特殊值產生疑惑而不得不去閱讀生產代碼
- 顯式的表明你要證明的東西
魔法字符串會讓閱讀測試代碼的人產生疑問,某個特定值到底表示什么意思。這會導致他們去閱讀代碼的具體實現細節而非關注測試本身。盡可能使用常量或枚舉來代替字面量。
Bad
[Fact] public void Add_BigNumber_ThrowsException() { var stringCalculator = new StringCalculator(); Action actual = () => stringCalculator.Add("1001"); Assert.Throws<OverflowException>(actual); }
Better
[Fact] void Add_MaximumSumResult_ThrowsOverflowException() { var stringCalculator = new StringCalculator(); const string MAXIMUM_RESULT = "1001"; Action actual = () => stringCalculator.Add(MAXIMUM_RESULT); Assert.Throws<OverflowException>(actual); }
測試用例中不要包含邏輯判斷
避免在測試代碼中進行手動字符串拼接和使用邏輯條件,如:if,while,for,switch等等。
為什么這么做
- 避免在測試用例中引入BUG
- 關注測試結果而不是實現細節
在測試用引入邏輯判斷會增加測試出錯的概率。你應當充分信任自己的測試用例,當測試失敗時就應該判定被測試代碼有錯誤,這是不容忽視的(不應因為有邏輯分支到而至某些方面未測試到)。
如果一個測試用例中無法避免使用邏輯分支,那么可以考慮將用例拆分為多個。
Bad
[Fact] public void Add_MultipleNumbers_ReturnsCorrectResults() { var stringCalculator = new StringCalculator(); var expected = 0; var testCases = new[] { "0,0,0", "0,1,2", "1,2,3" }; foreach (var test in testCases) { Assert.Equal(expected, stringCalculator.Add(test)); expected += 3; } }
Better
[Theory] [InlineData("0,0,0", 0)] [InlineData("0,1,2", 3)] [InlineData("1,2,3", 6)] public void Add_MultipleNumbers_ReturnsSumOfNumbers(string input, int expected) { var stringCalculator = new StringCalculator(); var actual = stringCalculator.Add(input); Assert.Equal(expected, actual); }
使用幫助方法來構建和銷毀測試依賴項
如果你的多個測試用例需要相似的對象或者狀態,請使用幫助方法而不是Setup
和Teardown
特性來獲取它們。
為什么這么做
- 是測試代碼清晰易讀
- 避免在測試用例中創建不必要(或少創建)對象或狀態
- 避免在不同的測試用例中共享狀態以降低測試用例間的相互依賴
在單元測試框架中,Setup
方法在所有測試用例運行前被調用。這讓Setup
方法看起來很有用(如初始化一些測試依賴項),但很有可能導致測試代碼難以閱讀。不同的測試用例需要不同的測試條件,但Setup
強制不同的測試用例使用相同的測試條件。
xUnit框架在2.0+版本已經移出了SetUp
和TearDown
方法。
Bad
private readonly StringCalculator stringCalculator; public StringCalculatorTests() { stringCalculator = new StringCalculator(); } // more tests... [Fact] public void Add_TwoNumbers_ReturnsSumOfNumbers() { var result = stringCalculator.Add("0,1"); Assert.Equal(1, result); }
Better
[Fact] public void Add_TwoNumbers_ReturnsSumOfNumbers() { var stringCalculator = CreateDefaultStringCalcualtor(); var actual = stringCalculator.Add("0,1"); Assert.Equal(1, actual); } // more tests... private StringCalculator CreateDefaultStringCalcualtor() { return new StringCalculator(); }
避免在同一個測試用例中使用多個斷言
一個測試中應只使用一個斷言。通用的只使用一個斷言的方法包括:
- 為每個斷言編寫一個測試
- 使用參數化的測試
為什么這么做
- 如果有多個斷言,一個斷言失敗,剩余的斷言也不會被計算
- 確保在一個測試不對多種場景做斷言
- 可以清晰明了的知道測試失敗的原因
一種例外情況是,對一個對象進行斷言。在這種場景下可以使用多個斷言來判斷對象的不同屬性值是否符合預期。
Bad
[Fact] public void Add_EdgeCases_ThrowsArgumentExceptions() { Assert.Throws<ArgumentException>(() => stringCalculator.Add(null)); Assert.Throws<ArgumentException>(() => stringCalculator.Add("a")); }
Better
[Theory] [InlineData(null)] [InlineData("a")] public void Add_InputNullOrAlphabetic_ThrowsArgumentException(string input) { var stringCalculator = new StringCalculator(); Action actual = () => stringCalculator.Add(input); Assert.Throws<ArgumentException>(actual); }
通過測試公共方法來驗證私有方法
在多數情況下,無需對私有方法進行測試。私有方法屬於實現細節,它從來都不是孤立存在的(要不也沒存在的必要)。通常,公共方法會調用私有方法,因此我們可以通過對共有方法的測試來驗證私有方法是否符合我們的預期。
public string ParseLogLine(string input) { var sanitizedInput = TrimInput(input); return sanitizedInput; } private string TrimInput(string input) { return input.Trim(); }
對於上述代碼,或許會有人想直接對TrimInput
方法進行測試以確保該方法可以正常工作。然而,ParseLogLine
方法可能會以某種意料之外的方式調用TrimInput
方法而導致整個運行結果有誤。
正確的測試方式是面向公共方法ParseLogLine
,確保該方法能夠正常工作才是我們最終要關心的。一個私有方法返回了正確的結果並不能保證調用者能夠正確的使用這個結果。
public void ParseLogLine_ByDefault_ReturnsTrimmedResult() { var parser = new Parser(); var result = parser.ParseLogLine(" a "); Assert.Equals("a", result); }
存根靜態引用
測試的原則之一是要完全控制測試所依賴的外部條件。這對於含有靜態引用的生產代碼而言會有些困難。
public int GetDiscountedPrice(int price) { if(DateTime.Now == DayOfWeek.Tuesday) { return price / 2; } else { return price; } }
對於上述代碼你可能會編寫如下測試代碼:
public void GetDiscountedPrice_ByDefault_ReturnsFullPrice() { var priceCalculator = new PriceCalculator(); var actual = priceCalculator.GetDiscountedPrice(2); Assert.Equals(2, actual) } public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice() { var priceCalculator = new PriceCalculator(); var actual = priceCalculator.GetDiscountedPrice(2); Assert.Equals(1, actual); }
但,你很快會意識到這里有兩個問題:
- 如果是在周二(Tuesday)運行測試代碼,第二個測試會通過而第一個會失敗
- 如果測試是在其它日期運行,那么第一個測試會通過而第二個則會失敗
為了解決上述問題,需要在生產代碼中開一個口子。一種方法是使用接口,讓生產代碼依賴於接口。
public interface IDateTimeProvider { DayOfWeek DayOfWeek(); } public bool GetDiscountedPrice(int price, IDateTimeProvider dateTimeProvider) { if(dateTimeProvider.DayOfWeek() == DayOfWeek.Tuesday) { return price / 2; } else { return price; } }
現在測試場景變成了:
public void GetDiscountedPrice_ByDefault_ReturnsFullPrice() { var priceCalculator = new PriceCalculator(); var dateTimeProviderStub = new Mock<IDateTimeProvider>(); dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Monday); var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub); Assert.Equals(2, actual); } public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice() { var priceCalculator = new PriceCalculator(); var dateTimeProviderStub = new Mock<IDateTimeProvider>(); dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Tuesday); var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub); Assert.Equals(1, actual); }
現在,我們可以在測試中模擬任意的日期值了(完全控制)。
小結
本文根據自己的理解進行翻譯,部分內容與原文會有出入。
單元測試關注行為是否符合預期而不是具體實現細節,這也是面向對象的特征體現。
上述一些最佳實踐不僅僅可以用於測試代碼,也可以用於其他方面代碼的編寫,如:確保代碼具有良好的可讀性、方法或變量要有良好的命名、方法要職責單一(高內聚)等等。
推薦閱讀
書籍推薦
《Clean C#》這本書講述了一些C#編碼的良好規范,但這些規范也可用於其它語言。現在正在翻譯這本書,點此查看譯文。