csharp-mstest

csharp-mstest

熱門

取得 MSTest 3.x/4.x 單元測試的最佳做法,包括現代化的斷言 API 和資料驅動測試

3.7萬星標
4569分支
更新於 2026/7/14
SKILL.md
readonlyread-only
name
csharp-mstest
description

取得 MSTest 3.x/4.x 單元測試的最佳做法,包括現代化的斷言 API 和資料驅動測試

MSTest 最佳做法 (MSTest 3.x/4.x)

你的目標是協助我使用現代化的 MSTest 與最新的 API 和最佳做法來撰寫有效的單元測試。

專案設定

  • 使用獨立的測試專案,命名慣例為 [ProjectName].Tests
  • 參考 MSTest 3.x+ NuGet 套件(包含分析器)
  • 考慮使用 MSTest.Sdk 簡化專案設定
  • 使用 dotnet test 執行測試

測試類別結構

  • 測試類別使用 [TestClass] 屬性
  • 預設將測試類別密封 (seal) 以提升效能與設計清晰度
  • 測試方法使用 [TestMethod](優先於 [DataTestMethod]
  • 遵循 Arrange-Act-Assert (AAA) 模式
  • 使用 MethodName_Scenario_ExpectedBehavior 模式命名測試
[TestClass]
public sealed class CalculatorTests
{
    [TestMethod]
    public void Add_TwoPositiveNumbers_ReturnsSum()
    {
        // Arrange
        var calculator = new Calculator();

        // Act
        var result = calculator.Add(2, 3);

        // Assert
        Assert.AreEqual(5, result);
    }
}

測試生命週期

  • 優先使用建構函式而非 [TestInitialize] - 可啟用 readonly 欄位,並遵循標準 C# 模式
  • 使用 [TestCleanup] 處理即使測試失敗也必須執行的清理作業
  • 當需要非同步設定時,可將建構函式與非同步 [TestInitialize] 結合使用
[TestClass]
public sealed class ServiceTests
{
    private readonly MyService _service;  // 透過建構函式啟用 readonly

    public ServiceTests()
    {
        _service = new MyService();
    }

    [TestInitialize]
    public async Task InitAsync()
    {
        // 僅用於非同步初始化
        await _service.WarmupAsync();
    }

    [TestCleanup]
    public void Cleanup() => _service.Reset();
}

執行順序

  1. 組件初始化 - [AssemblyInitialize](每個測試組件一次)
  2. 類別初始化 - [ClassInitialize](每個測試類別一次)
  3. 測試初始化(每個測試方法):
    1. 建構函式
    2. 設定 TestContext 屬性
    3. [TestInitialize]
  4. 測試執行 - 測試方法執行
  5. 測試清理(每個測試方法):
    1. [TestCleanup]
    2. DisposeAsync(如果實作)
    3. Dispose(如果實作)
  6. 類別清理 - [ClassCleanup](每個測試類別一次)
  7. 組件清理 - [AssemblyCleanup](每個測試組件一次)

現代化斷言 API

MSTest 提供三個斷言類別:AssertStringAssertCollectionAssert

Assert 類別 - 核心斷言

// 相等性
Assert.AreEqual(expected, actual);
Assert.AreNotEqual(notExpected, actual);
Assert.AreSame(expectedObject, actualObject);      // 參考相等性
Assert.AreNotSame(notExpectedObject, actualObject);

// 空值檢查
Assert.IsNull(value);
Assert.IsNotNull(value);

// 布林值
Assert.IsTrue(condition);
Assert.IsFalse(condition);

// 失敗/無結論
Assert.Fail("測試因...而失敗");
Assert.Inconclusive("測試無法完成,因為...");

例外測試(優先於 [ExpectedException]

// Assert.Throws - 符合 TException 或其衍生型別
var ex = Assert.Throws<ArgumentException>(() => Method(null));
Assert.AreEqual("值不能為 null。", ex.Message);

// Assert.ThrowsExactly - 僅符合確切型別
var ex = Assert.ThrowsExactly<InvalidOperationException>(() => Method());

// 非同步版本
var ex = await Assert.ThrowsAsync<HttpRequestException>(async () => await client.GetAsync(url));
var ex = await Assert.ThrowsExactlyAsync<InvalidOperationException>(async () => await Method());

集合斷言(Assert 類別)

Assert.Contains(expectedItem, collection);
Assert.DoesNotContain(unexpectedItem, collection);
Assert.ContainsSingle(collection);  // 恰好一個元素
Assert.HasCount(5, collection);
Assert.IsEmpty(collection);
Assert.IsNotEmpty(collection);

字串斷言(Assert 類別)

Assert.Contains("expected", actualString);
Assert.StartsWith("prefix", actualString);
Assert.EndsWith("suffix", actualString);
Assert.DoesNotStartWith("prefix", actualString);
Assert.DoesNotEndWith("suffix", actualString);
Assert.MatchesRegex(@"\d{3}-\d{4}", phoneNumber);
Assert.DoesNotMatchRegex(@"\d+", textOnly);

比較斷言

Assert.IsGreaterThan(lowerBound, actual);
Assert.IsGreaterThanOrEqualTo(lowerBound, actual);
Assert.IsLessThan(upperBound, actual);
Assert.IsLessThanOrEqualTo(upperBound, actual);
Assert.IsInRange(actual, low, high);
Assert.IsPositive(number);
Assert.IsNegative(number);

型別斷言

// MSTest 3.x - 使用 out 參數
Assert.IsInstanceOfType<MyClass>(obj, out var typed);
typed.DoSomething();

// MSTest 4.x - 直接傳回型別結果
var typed = Assert.IsInstanceOfType<MyClass>(obj);
typed.DoSomething();

Assert.IsNotInstanceOfType<WrongType>(obj);

Assert.That (MSTest 4.0+)

Assert.That(result.Count > 0);  // 自動在失敗訊息中擷取運算式

StringAssert 類別

注意: 當有對應的 Assert 類別方法時,優先使用(例如 Assert.Contains("expected", actual) 優於 StringAssert.Contains(actual, "expected"))。

StringAssert.Contains(actualString, "expected");
StringAssert.StartsWith(actualString, "prefix");
StringAssert.EndsWith(actualString, "suffix");
StringAssert.Matches(actualString, new Regex(@"\d{3}-\d{4}"));
StringAssert.DoesNotMatch(actualString, new Regex(@"\d+"));

CollectionAssert 類別

注意: 當有對應的 Assert 類別方法時,優先使用(例如 Assert.Contains)。

// 包含
CollectionAssert.Contains(collection, expectedItem);
CollectionAssert.DoesNotContain(collection, unexpectedItem);

// 相等性(相同元素、相同順序)
CollectionAssert.AreEqual(expectedCollection, actualCollection);
CollectionAssert.AreNotEqual(unexpectedCollection, actualCollection);

// 等價性(相同元素、任意順序)
CollectionAssert.AreEquivalent(expectedCollection, actualCollection);
CollectionAssert.AreNotEquivalent(unexpectedCollection, actualCollection);

// 子集檢查
CollectionAssert.IsSubsetOf(subset, superset);
CollectionAssert.IsNotSubsetOf(notSubset, collection);

// 元素驗證
CollectionAssert.AllItemsAreInstancesOfType(collection, typeof(MyClass));
CollectionAssert.AllItemsAreNotNull(collection);
CollectionAssert.AllItemsAreUnique(collection);

資料驅動測試

DataRow

[TestMethod]
[DataRow(1, 2, 3)]
[DataRow(0, 0, 0, DisplayName = "零值")]
[DataRow(-1, 1, 0, IgnoreMessage = "已知問題 #123")]  // MSTest 3.8+
public void Add_ReturnsSum(int a, int b, int expected)
{
    Assert.AreEqual(expected, Calculator.Add(a, b));
}

DynamicData

資料來源可以傳回下列任何型別:

  • IEnumerable<(T1, T2, ...)> (ValueTuple) - 建議使用,提供型別安全(MSTest 3.7+)
  • IEnumerable<Tuple<T1, T2, ...>> - 提供型別安全
  • IEnumerable<TestDataRow> - 提供型別安全,並可控制測試中繼資料(顯示名稱、類別)
  • IEnumerable<object[]> - 最不建議,無型別安全

注意: 建立新的測試資料方法時,建議使用 ValueTupleTestDataRow,而非 IEnumerable<object[]>object[] 方法不提供編譯時期型別檢查,可能因型別不符而導致執行階段錯誤。

[TestMethod]
[DynamicData(nameof(TestData))]
public void DynamicTest(int a, int b, int expected)
{
    Assert.AreEqual(expected, Calculator.Add(a, b));
}

// ValueTuple - 建議使用(MSTest 3.7+)
public static IEnumerable<(int a, int b, int expected)> TestData =>
[
    (1, 2, 3),
    (0, 0, 0),
];

// TestDataRow - 需要自訂顯示名稱或中繼資料時使用
public static IEnumerable<TestDataRow<(int a, int b, int expected)>> TestDataWithMetadata =>
[
    new((1, 2, 3)) { DisplayName = "正數" },
    new((0, 0, 0)) { DisplayName = "零值" },
    new((-1, 1, 0)) { DisplayName = "混合正負號", IgnoreMessage = "已知問題 #123" },
];

// IEnumerable<object[]> - 新程式碼應避免使用(無型別安全)
public static IEnumerable<object[]> LegacyTestData =>
[
    [1, 2, 3],
    [0, 0, 0],
];

TestContext

TestContext 類別提供測試執行資訊、取消支援和輸出方法。
完整參考請參閱 TestContext 文件

存取 TestContext

// 屬性(MSTest 會抑制 CS8618 - 不要使用可為 null 或 = null!)
public TestContext TestContext { get; set; }

// 建構函式注入(MSTest 3.6+)- 建議使用以確保不可變性
[TestClass]
public sealed class MyTests
{
    private readonly TestContext _testContext;

    public MyTests(TestContext testContext)
    {
        _testContext = testContext;
    }
}

// 靜態方法以參數接收
[ClassInitialize]
public static void ClassInit(TestContext context) { }

// 清理方法可選擇性使用(MSTest 3.6+)
[ClassCleanup]
public static void ClassCleanup(TestContext context) { }

[AssemblyCleanup]
public static void AssemblyCleanup(TestContext context) { }

取消權杖

務必使用 TestContext.CancellationToken 搭配 [Timeout] 進行合作式取消:

[TestMethod]
[Timeout(5000)]
public async Task LongRunningTest()
{
    await _httpClient.GetAsync(url, TestContext.CancellationToken);
}

測試執行屬性

TestContext.TestName              // 目前測試方法名稱
TestContext.TestDisplayName       // 顯示名稱(3.7+)
TestContext.CurrentTestOutcome    // 通過/失敗/進行中
TestContext.TestData              // 參數化測試資料(3.7+,在 TestInitialize/Cleanup 中使用)
TestContext.TestException         // 測試失敗時的例外(3.7+,在 TestCleanup 中使用)
TestContext.DeploymentDirectory   // 包含部署項目的目錄

輸出與結果檔案

// 寫入測試輸出(有助於偵錯)
TestContext.WriteLine("正在處理項目 {0}", itemId);

// 將檔案附加到測試結果(記錄檔、螢幕截圖)
TestContext.AddResultFile(screenshotPath);

// 跨測試方法儲存/擷取資料
TestContext.Properties["SharedKey"] = computedValue;

進階功能

重試不穩定測試(MSTest 3.9+)

[TestMethod]
[Retry(3)]
public void FlakyTest() { }

條件式執行(MSTest 3.10+)

根據作業系統或 CI 環境跳過或執行測試:

// 作業系統特定測試
[TestMethod]
[OSCondition(OperatingSystems.Windows)]
public void WindowsOnlyTest() { }

[TestMethod]
[OSCondition(OperatingSystems.Linux | OperatingSystems.MacOS)]
public void UnixOnlyTest() { }

[TestMethod]
[OSCondition(ConditionMode.Exclude, OperatingSystems.Windows)]
public void SkipOnWindowsTest() { }

// CI 環境測試
[TestMethod]
[CICondition]  // 僅在 CI 中執行(預設:ConditionMode.Include)
public void CIOnlyTest() { }

[TestMethod]
[CICondition(ConditionMode.Exclude)]  // 在 CI 中跳過,在本機執行
public void LocalOnlyTest() { }

平行化

// 組件層級
[assembly: Parallelize(Workers = 4, Scope = ExecutionScope.MethodLevel)]

// 針對特定類別停用
[TestClass]
[DoNotParallelize]
public sealed class SequentialTests { }

工作項目追蹤(MSTest 3.8+)

將測試連結到工作項目,以便在測試報告中進行追蹤:

// Azure DevOps 工作項目
[TestMethod]
[WorkItem(12345)]  // 連結到工作項目 #12345
public void Feature_Scenario_ExpectedBehavior() { }

// 多個工作項目
[TestMethod]
[WorkItem(12345)]
[WorkItem(67890)]
public void Feature_CoversMultipleRequirements() { }

// GitHub 問題(MSTest 3.8+)
[TestMethod]
[GitHubWorkItem("https://github.com/owner/repo/issues/42")]
public void BugFix_Issue42_IsResolved() { }

工作項目關聯會出現在測試結果中,可用於:

  • 追蹤測試涵蓋範圍與需求
  • 將錯誤修正連結到回歸測試
  • 在 CI/CD 管線中產生追蹤報告

應避免的常見錯誤

// ❌ 錯誤的引數順序
Assert.AreEqual(actual, expected);
// ✅ 正確
Assert.AreEqual(expected, actual);

// ❌ 使用 ExpectedException(已過時)
[ExpectedException(typeof(ArgumentException))]
// ✅ 使用 Assert.Throws
Assert.Throws<ArgumentException>(() => Method());

// ❌ 使用 LINQ Single() - 例外不明確
var item = items.Single();
// ✅ 使用 ContainsSingle - 更好的失敗訊息
var item = Assert.ContainsSingle(items);

// ❌ 強制轉型 - 例外不明確
var handler = (MyHandler)result;
// ✅ 型別斷言 - 失敗時顯示實際型別
var handler = Assert.IsInstanceOfType<MyHandler>(result);

// ❌ 忽略取消權杖
await client.GetAsync(url, CancellationToken.None);
// ✅ 傳遞測試取消權杖
await client.GetAsync(url, TestContext.CancellationToken);

// ❌ 將 TestContext 設為可為 null - 導致不必要的 null 檢查
public TestContext? TestContext { get; set; }
// ❌ 使用 null! - MSTest 已為此屬性抑制 CS8618
public TestContext TestContext { get; set; } = null!;
// ✅ 宣告時不使用可為 null 或初始設定式 - MSTest 會處理警告
public TestContext TestContext { get; set; }

測試組織

  • 依功能或元件分組測試
  • 使用 [TestCategory("Category")] 進行篩選
  • 使用 [TestProperty("Name", "Value")] 設定自訂中繼資料(例如 [TestProperty("Bug", "12345")]
  • 使用 [Priority(1)] 標記關鍵測試
  • 啟用相關的 MSTest 分析器(MSTEST0020 用於建構函式偏好)

模擬與隔離

  • 使用 Moq 或 NSubstitute 模擬相依性
  • 使用介面以利模擬
  • 模擬相依性以隔離受測單元