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();
}
執行順序
- 組件初始化 -
[AssemblyInitialize](每個測試組件一次) - 類別初始化 -
[ClassInitialize](每個測試類別一次) - 測試初始化(每個測試方法):
- 建構函式
- 設定
TestContext屬性 [TestInitialize]
- 測試執行 - 測試方法執行
- 測試清理(每個測試方法):
[TestCleanup]DisposeAsync(如果實作)Dispose(如果實作)
- 類別清理 -
[ClassCleanup](每個測試類別一次) - 組件清理 -
[AssemblyCleanup](每個測試組件一次)
現代化斷言 API
MSTest 提供三個斷言類別:Assert、StringAssert 和 CollectionAssert。
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[]>- 最不建議,無型別安全
注意: 建立新的測試資料方法時,建議使用
ValueTuple或TestDataRow,而非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 模擬相依性
- 使用介面以利模擬
- 模擬相依性以隔離受測單元






