撰寫單元測試有許多優點。 他們有助於進行回歸分析、提供文件,並協助進行良好的設計。 但是,當單元測試難以閱讀和脆弱時,它們可能會對您的程式代碼基底造成破壞。 本文說明設計單元測試以支援 .NET Core 和 .NET Standard 專案的一些最佳做法。 您將學習方法,讓您的測試保持穩定且易於理解。
單元測試的優點
下列各節說明為 .NET Core 和 .NET Standard 專案撰寫單元測試的數個原因。
執行功能測試的時間較少
功能測試的成本很高。 它們通常牽涉到開啟應用程式,並執行您(或其他人員)必須遵循的一系列步驟,才能驗證預期的行為。 測試人員可能不一定會知道這些步驟。 他們必須聯繫一個更有知識的人,來在這方面進行測試。 測試本身可能需要幾秒鐘的時間進行微不足道的變更,或花費數分鐘的時間進行較大的變更。 最後,針對您在系統中所做的每一項變更,都必須重複此程式。 另一方面,單元測試可能需要毫秒的時間,可以在按下按鈕時執行,而且不一定需要系統的任何知識。 測試執行器會判斷測試通過還是失敗,而不是個人。
防止退步的保護
回歸缺陷是對應用程式進行變更時所導入的錯誤。 測試人員不僅要測試其新功能,而且會事先測試存在的測試功能,以確認現有的功能仍如預期般運作。 透過單元測試,您可以在每次建置之後,或在變更程式碼行之後,重新執行整個測試套件。 這種方法有助於增加新程式代碼不會中斷現有功能的信心。
可執行文件
在給定特定輸入時,某個特定方法的作用及行為不一定能夠明顯看出。 您可能會問自己:如果我傳遞空白字串或 Null,此方法的行為如何? 當您有一套具名完善的單元測試時,每個測試都應該清楚說明指定輸入的預期輸出。 此外,測試應該能夠驗證它是否實際運作。
耦合度較低的代碼
當程式代碼緊密結合時,很難進行單元測試。 若未針對您所撰寫的程式代碼建立單元測試,結合可能較不明顯。 撰寫程式碼的測試能自然地使程式碼解耦,因為要不然會較難測試。
良好的單元測試特性
有數個重要特性可定義良好的單元測試:
- 快速:對於成熟的專案而言,擁有數千個單元測試並不罕見。 單元測試應該花點時間才能執行。 毫秒。
- 隔離:單元測試是獨立的、可以隔離執行,而且與文件系統或資料庫等任何外部因素沒有任何相依性。
- 可重複:執行單元測試時,結果應該保持一致。 如果您未在執行之間變更任何項目,測試一律會傳回相同的結果。
- 自我檢查:測試應該會自動偵測是否已通過或失敗,而不需要任何人工交互作用。
- 及時性:與所測試的程式碼相比,單元測試的撰寫不應花費過多的時間。 如果您發現與撰寫程式代碼相比,測試程式代碼需要大量的時間,請考慮更可測試的設計。
程式代碼涵蓋範圍和程式代碼品質
高程式代碼涵蓋範圍百分比通常與較高的程式代碼品質相關聯。 不過,度量本身 無法 判斷程式代碼的品質。 設定過於雄心勃勃的程式代碼涵蓋範圍百分比目標可能會適得其反。 請考慮具有數千個條件式分支的複雜專案,並假設您設定了95個% 程式代碼涵蓋範圍的目標。 目前,專案維持 90%% 程式碼覆蓋率。 在其餘5個% 考慮所有邊緣案例所花費的時間量可能是一項艱巨的任務,而價值主張會迅速減少。
高程式代碼涵蓋範圍百分比不是成功的指標,也不表示高程式代碼品質。 它只代表單元測試所涵蓋的程式代碼數量。 如需詳細資訊,請參閱 單元測試程式代碼涵蓋範圍。
單元測試術語
在單元測試的情境中經常使用幾個術語:假物件、模擬物件,以及 存根物件。 不幸的是,這些詞彙可能會誤用,因此請務必瞭解正確的使用方式。
Fake:Fake 是一個一般用語,可用來描述存根或模擬物件。 物件究竟是虛擬物件還是模擬物件,取決於該物件的使用情境。 換句話說,假物件可以是虛擬或模擬物件。
模擬:模擬對象是系統中的假物件,可決定單元測試是否通過或失敗。 模擬開始做為假的,直到進入
Assert
作業為止,它仍然是假的。Stub:存根是系統中現有依賴(或協作者)的可控制替代品。 藉由使用存根,您可以測試程序代碼,而不需直接處理相依性。 根據預設,存根會以假項目開始。
請考慮下列程式碼:
var mockOrder = new MockOrder();
var purchase = new Purchase(mockOrder);
purchase.ValidateOrders();
Assert.True(purchase.CanBeShipped);
此程式代碼會顯示稱為仿真的存根。 但在這種情況下,這個存根確實只是個存根。 程序代碼的目的是傳遞順序作為具現化 Purchase
(受測系統)物件的方法。 類別名稱 MockOrder
具有誤導性,因為順序是存根,而不是模擬。
下列程式代碼顯示更精確的設計:
var stubOrder = new FakeOrder();
var purchase = new Purchase(stubOrder);
purchase.ValidateOrders();
Assert.True(purchase.CanBeShipped);
當類別重新命名為 FakeOrder
時,類別會更泛型。 類別可以做為模擬或存根,根據測試案例的需求。 在第一個範例中,FakeOrder
類別會當做存根使用,而且不會在 Assert
作業期間使用。 程序代碼會將 FakeOrder
類別傳遞至 Purchase
類別,只是為了滿足建構函式的需求。
若要使用 類別作為模擬,您可以更新程式代碼:
var mockOrder = new FakeOrder();
var purchase = new Purchase(mockOrder);
purchase.ValidateOrders();
Assert.True(mockOrder.Validated);
在此設計中,程式碼會檢查模擬物件上的屬性(進行斷言),因此,mockOrder
類別是模擬物件。
這很重要
請務必正確使用術語。 如果您稱存根為「模擬」,其他開發人員將會對您的意圖進行虛假假設。
關於模擬和存根的主要差異是,模擬就像存根一樣,只是在 Assert
過程中有所不同。 您可以對模擬物件執行 Assert
作業,但不會對存根執行。
最佳做法
撰寫單元測試時,有幾個重要的最佳做法要遵循。 下列各節提供範例,示範如何將最佳做法套用至您的程序代碼。
避免基礎結構相依性
在撰寫單元測試時,請嘗試不引進基礎結構的相依性。 相依性會使測試變慢且脆弱,而且應該保留給整合測試。 您可以遵循 明確相依性原則,並使用 .NET 相依性插入,來避免應用程式中的這些相依性。 您也可以將單元測試保留在與整合測試不同的專案中。 此方法可確保單元測試項目沒有基礎結構套件的參考或相依性。
遵循測試命名標準
測試的名稱應該包含三個部分:
- 正在測試之方法的名稱
- 測試方法的情境
- 叫用案例時的預期行為
命名標準很重要,因為它們有助於表達測試用途和應用程式。 測試不只是確保您的程式代碼能夠運作。 他們也會提供文件。 只要查看單元測試套件,您應該能夠推斷程式代碼的行為,而不需要查看程式碼本身。 此外,當測試失敗時,您可以看到哪些案例不符合您的預期。
原始程式碼
[Fact]
public void Test_Single()
{
var stringCalculator = new StringCalculator();
var actual = stringCalculator.Add("0");
Assert.Equal(0, actual);
}
套用最佳做法
[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
var stringCalculator = new StringCalculator();
var actual = stringCalculator.Add("0");
Assert.Equal(0, actual);
}
安排測試
「排列、行動、斷言」模式是撰寫單元測試的常見方法。 如其名所示,模式是由三個主要工作所組成:
- 排列 您的物件、建立並視需要加以設定
- 對物件操作
- 確認 某事如預期。
當您遵循模式時,可以清楚地將正在測試的項目與安排和斷言任務區分開來。 此模式也有助於減少斷言與 Act 工作(執行任務)中程式碼混用的機會。
可讀性是撰寫單元測試時最重要的層面之一。 在測試中分隔每個模式動作,可以清楚地強調呼叫程式碼所需的依賴項、呼叫程式碼的方式,以及您嘗試驗證的內容。 雖然可以結合一些步驟並減少測試的大小,但整體目標是盡可能讓測試成為可閱讀的。
原始程式碼
[Fact]
public void Add_EmptyString_ReturnsZero()
{
// Arrange
var stringCalculator = new StringCalculator();
// Assert
Assert.Equal(0, stringCalculator.Add(""));
}
套用最佳做法
[Fact]
public void Add_EmptyString_ReturnsZero()
{
// Arrange
var stringCalculator = new StringCalculator();
// Act
var actual = stringCalculator.Add("");
// Assert
Assert.Equal(0, actual);
}
撰寫僅能通過的基本測試
單元測試的輸入應該是驗證您目前測試行為所需的最簡單資訊。 極簡主義方法可協助測試在程式代碼基底的未來變更上更具彈性,並專注於驗證實作的行為。
包含比通過目前測試所需資訊更多的測試,在測試中引進錯誤的機會較高,而且可能會讓測試的意圖變得不那麼清楚。 撰寫測試時,您想要專注於行為。 在模型上設定額外的屬性,或在不需要時使用非零值,只會減去您嘗試確認的內容。
原始程式碼
[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
var stringCalculator = new StringCalculator();
var actual = stringCalculator.Add("42");
Assert.Equal(42, actual);
}
套用最佳做法
[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
var stringCalculator = new StringCalculator();
var actual = stringCalculator.Add("0");
Assert.Equal(0, actual);
}
避免魔術字串
Magic 字串 是直接在您的單元測試中硬式編碼的字串值,而不需要任何額外的批注或內容。 這些值可讓您的程式代碼較不易閱讀且難以維護。 魔術字串可能會對測試的讀者造成混淆。 如果字串看起來不一般,他們可能會想知道為何為參數或傳回值選擇特定值。 這種類型的字串值可能會讓他們更仔細地查看實作詳細數據,而不是將焦點放在測試上。
小提示
設立目標,使您的單元測試程式碼能夠盡可能清楚地表達意圖。 不要使用魔術字串,而是將任何硬式編碼的值指派給常數。
原始程式碼
[Fact]
public void Add_BigNumber_ThrowsException()
{
var stringCalculator = new StringCalculator();
Action actual = () => stringCalculator.Add("1001");
Assert.Throws<OverflowException>(actual);
}
套用最佳做法
[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 的機會會大幅增加。 您要尋找 Bug 的最後一個位置是在測試套件內。 您應該對測試運作有高度的信心,否則您無法信任它們。 不值得信任的測試是沒有任何價值的。 當測試失敗時,您會有一種感覺,您的程式碼出了問題,而且無法忽視它。
小提示
如果在測試中新增邏輯似乎不可避免,請考慮將測試分割成兩個或多個不同的測試,以限制邏輯需求。
原始程式碼
[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;
}
}
套用最佳做法
[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
屬性會強制您使用每個測試完全相同的要求。
原始程式碼
套用最佳做法
private readonly StringCalculator stringCalculator;
public StringCalculatorTests()
{
stringCalculator = new StringCalculator();
}
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
var stringCalculator = CreateDefaultStringCalculator();
var actual = stringCalculator.Add("0,1");
Assert.Equal(1, actual);
}
// More tests...
// More tests...
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
var result = stringCalculator.Add("0,1");
Assert.Equal(1, result);
}
private StringCalculator CreateDefaultStringCalculator()
{
return new StringCalculator();
}
避免多個 Act 任務
當您撰寫測試時,請嘗試在每個測試中只執行一個行為任務。 實作單一 Act 工作的一些常見方法包括為每個 Act 建立個別的測試或使用參數化測試。 對於每個測試,使用單一「Act」任務有幾個好處:
- 如果測試失敗,您可以輕鬆地分辨出是哪個 Act 任務失敗。
- 您可以確保測試只著重於單一案例。
- 您清楚了解測試失敗的原因。
多個 Act 工作需要個別斷言,並且您無法保證所有 Assert 工作都會執行。 在大部分單元測試框架中,當單元測試中的斷言失敗後,所有後續的測試都會自動被視為失敗。 此程式可能會造成混淆,因為某些工作功能可能會解譯為失敗。
原始程式碼
[Fact]
public void Add_EmptyEntries_ShouldBeTreatedAsZero()
{
// Act
var actual1 = stringCalculator.Add("");
var actual2 = stringCalculator.Add(",");
// Assert
Assert.Equal(0, actual1);
Assert.Equal(0, actual2);
}
套用最佳做法
[Theory]
[InlineData("", 0)]
[InlineData(",", 0)]
public void Add_EmptyEntries_ShouldBeTreatedAsZero(string input, int expected)
{
// Arrange
var stringCalculator = new StringCalculator();
// Act
var actual = stringCalculator.Add(input);
// Assert
Assert.Equal(expected, actual);
}
使用公用方法驗證私用方法
在大部分情況下,您不需要在程式碼中測試私人方法。 私有方法是實作細節,不會單獨存在。 在開發程式中的某個時間點,您會介紹一個公開的方法,以呼叫私用方法做為其實作的一部分。 當您撰寫單元測試時,您關心的是公用方法調用私有方法的最終結果。
請考慮下列程式代碼案例:
public string ParseLogLine(string input)
{
var sanitizedInput = TrimInput(input);
return sanitizedInput;
}
private string TrimInput(string input)
{
return input.Trim();
}
在測試方面,您的第一個反應可能是撰寫 TrimInput
方法的測試,以確保其如預期般運作。 不過,ParseLogLine
方法可能會用您意想不到的方式操控 sanitizedInput
物件。 未知的行為可能會使您的測試因為 TrimInput
方法而變得無效。
此情境下,更佳的測試方式是驗證對外的 ParseLogLine
方法:
public void ParseLogLine_StartsAndEndsWithSpace_ReturnsTrimmedResult()
{
var parser = new Parser();
var result = parser.ParseLogLine(" a ");
Assert.Equals("a", result);
}
當您遇到私用方法時,請找出呼叫私用方法的公用方法,然後針對公用方法撰寫測試。 僅僅因為私用方法傳回預期的結果,並不表示最終呼叫私用方法的系統會正確使用結果。
使用縫隙處理存根靜態參考
單元測試的其中一個原則是,它必須完全控制受測的系統。 不過,當生產程式代碼包含對靜態參考的呼叫時,這個原則可能會有問題(例如,DateTime.Now
)。
檢查下列程式代碼案例:
public int GetDiscountedPrice(int price)
{
if (DateTime.Now.DayOfWeek == DayOfWeek.Tuesday)
{
return price / 2;
}
else
{
return price;
}
}
您可以撰寫此程式代碼的單元測試嗎? 您可以嘗試在 price
上執行 Assert 任務:
public void GetDiscountedPrice_NotTuesday_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);
}
不幸的是,您很快就意識到測試有一些問題:
- 如果測試套件在星期二執行,則第二個測試通過,但第一個測試會失敗。
- 如果測試套件在任何其他日期執行,則第一個測試會通過,但第二個測試會失敗。
若要解決這些問題,您必須將 接縫 引入您的生產程序代碼。 其中一種方法是包裝您需要在介面中控制的程序代碼,並讓生產程式代碼相依於該介面:
public interface IDateTimeProvider
{
DayOfWeek DayOfWeek();
}
public int GetDiscountedPrice(int price, IDateTimeProvider dateTimeProvider)
{
if (dateTimeProvider.DayOfWeek() == DayOfWeek.Tuesday)
{
return price / 2;
}
else
{
return price;
}
}
您也需要撰寫新版本的測試套件:
public void GetDiscountedPrice_NotTuesday_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);
}
現在,測試套件可以完全控制 DateTime.Now
值,而且可以在呼叫方法時回傳任何值。