共用方式為


.NET 的單元測試最佳做法

撰寫單元測試有許多優點。 他們有助於進行回歸分析、提供文件,並協助進行良好的設計。 但是,當單元測試難以閱讀和脆弱時,它們可能會對您的程式代碼基底造成破壞。 本文說明設計單元測試以支援 .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);
}

避免在單元測試中撰寫邏輯

當您撰寫單元測試時,請避免手動串連、邏輯條件,例如 ifwhileforswitch等條件。 如果您在測試套件中包含邏輯,引進 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);
}

使用協助程式方法,而不是安裝及卸除

如果您在測試中需要類似的對象或狀態,若存在 SetupTeardown 屬性,請優先使用輔助方法,而不是這些屬性。 協助程式方法優先於這些屬性,原因有數個:

  • 讀取測試時較少混淆,因為每個測試中都會顯示所有程序代碼
  • 為指定的測試設定太多或太少的機會較少
  • 在測試之間共享狀態的機會較少,這會在測試之間建立不必要的相依性

在單元測試架構中,會在測試套件內的每個單元測試之前呼叫 Setup 屬性。 有些程式設計人員認為此行為很有用,但通常會導致膨脹且難以閱讀測試。 每個測試通常有不同的設定和執行需求。 不幸的是,Setup 屬性會強制您使用每個測試完全相同的要求。

備註

SetUp 2.x 版和更新版本中會移除 TearDown 屬性。

原始程式碼

套用最佳做法

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 值,而且可以在呼叫方法時回傳任何值。