csharp-mstest by github/awesome-copilot
npx skills add https://github.com/github/awesome-copilot --skill csharp-mstest你的目标是帮助我使用现代 MSTest、当前 API 和最佳实践编写有效的单元测试。
[项目名称].Testsdotnet test 运行测试[TestClass] 特性[TestMethod](优先于 [DataTestMethod])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);
}
}
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
[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](每个测试程序集一次)MSTest 提供三个断言类:Assert、StringAssert 和 CollectionAssert。
// 相等性
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("Test failed due to...");
Assert.Inconclusive("Test cannot be completed because...");
[ExpectedException])// Assert.Throws - 匹配 TException 或派生类型
var ex = Assert.Throws<ArgumentException>(() => Method(null));
Assert.AreEqual("Value cannot be 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.Contains(expectedItem, collection);
Assert.DoesNotContain(unexpectedItem, collection);
Assert.ContainsSingle(collection); // 恰好一个元素
Assert.HasCount(5, collection);
Assert.IsEmpty(collection);
Assert.IsNotEmpty(collection);
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(result.Count > 0); // 在失败消息中自动捕获表达式
注意: 当有等效的
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+"));
注意: 当有等效的
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);
[TestMethod]
[DataRow(1, 2, 3)]
[DataRow(0, 0, 0, DisplayName = "Zeros")]
[DataRow(-1, 1, 0, IgnoreMessage = "Known issue #123")] // MSTest 3.8+
public void Add_ReturnsSum(int a, int b, int expected)
{
Assert.AreEqual(expected, Calculator.Add(a, b));
}
数据源可以返回以下任何类型:
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 = "Positive numbers" },
new((0, 0, 0)) { DisplayName = "Zeros" },
new((-1, 1, 0)) { DisplayName = "Mixed signs", IgnoreMessage = "Known issue #123" },
];
// IEnumerable<object[]> - 避免在新代码中使用(无类型安全)
public static IEnumerable<object[]> LegacyTestData =>
[
[1, 2, 3],
[0, 0, 0],
];
TestContext 类提供测试运行信息、取消支持和输出方法。完整参考请参阅 TestContext 文档。
// 属性 (MSTest 抑制 CS8618 - 不要使用可为空或 = 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("Processing item {0}", itemId);
// 将文件附加到测试结果(日志、截图)
TestContext.AddResultFile(screenshotPath);
// 跨测试方法存储/检索数据
TestContext.Properties["SharedKey"] = computedValue;
[TestMethod]
[Retry(3)]
public void FlakyTest() { }
根据操作系统或 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 { }
将测试链接到工作项,以便在测试报告中实现可追溯性:
// 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() { }
工作项关联会出现在测试结果中,并可用于:
// ❌ 错误的参数顺序
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 设为可为空 - 导致不必要的空值检查
public TestContext? TestContext { get; set; }
// ❌ 使用 null! - MSTest 已为此属性抑制 CS8618
public TestContext TestContext { get; set; } = null!;
// ✅ 声明时不使用可为空或初始化器 - MSTest 会处理该警告
public TestContext TestContext { get; set; }
[TestCategory("Category")] 进行过滤[TestProperty("Name", "Value")] 存储自定义元数据(例如 [TestProperty("Bug", "12345")])[Priority(1)]每周安装量
7.3K
代码库
GitHub Stars
26.7K
首次出现
2026年2月25日
安全审计
安装于
codex7.2K
gemini-cli7.2K
opencode7.2K
cursor7.2K
github-copilot7.2K
amp7.2K
Your goal is to help me write effective unit tests with modern MSTest, using current APIs and best practices.
[ProjectName].Testsdotnet testUse [TestClass] attribute for test classes
Seal test classes by default for performance and design clarity
Use [TestMethod] for test methods (prefer over [DataTestMethod])
Follow Arrange-Act-Assert (AAA) pattern
Name tests using pattern 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);
}
}
Prefer constructors over[TestInitialize] - enables readonly fields and follows standard C# patterns
Use [TestCleanup] for cleanup that must run even if test fails
Combine constructor with async [TestInitialize] when async setup is needed
[TestClass] public sealed class ServiceTests { private readonly MyService _service; // readonly enabled by constructor
public ServiceTests()
{
_service = new MyService();
}
[TestInitialize]
public async Task InitAsync()
{
// Use for async initialization only
await _service.WarmupAsync();
}
[TestCleanup]
public void Cleanup() => _service.Reset();
}
[AssemblyInitialize] (once per test assembly)[ClassInitialize] (once per test class)TestContext property[TestInitialize][TestCleanup]DisposeAsync (if implemented)Dispose (if implemented)MSTest provides three assertion classes: Assert, StringAssert, and CollectionAssert.
// Equality
Assert.AreEqual(expected, actual);
Assert.AreNotEqual(notExpected, actual);
Assert.AreSame(expectedObject, actualObject); // Reference equality
Assert.AreNotSame(notExpectedObject, actualObject);
// Null checks
Assert.IsNull(value);
Assert.IsNotNull(value);
// Boolean
Assert.IsTrue(condition);
Assert.IsFalse(condition);
// Fail/Inconclusive
Assert.Fail("Test failed due to...");
Assert.Inconclusive("Test cannot be completed because...");
[ExpectedException])// Assert.Throws - matches TException or derived types
var ex = Assert.Throws<ArgumentException>(() => Method(null));
Assert.AreEqual("Value cannot be null.", ex.Message);
// Assert.ThrowsExactly - matches exact type only
var ex = Assert.ThrowsExactly<InvalidOperationException>(() => Method());
// Async versions
var ex = await Assert.ThrowsAsync<HttpRequestException>(async () => await client.GetAsync(url));
var ex = await Assert.ThrowsExactlyAsync<InvalidOperationException>(async () => await Method());
Assert.Contains(expectedItem, collection);
Assert.DoesNotContain(unexpectedItem, collection);
Assert.ContainsSingle(collection); // exactly one element
Assert.HasCount(5, collection);
Assert.IsEmpty(collection);
Assert.IsNotEmpty(collection);
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 - uses out parameter
Assert.IsInstanceOfType<MyClass>(obj, out var typed);
typed.DoSomething();
// MSTest 4.x - returns typed result directly
var typed = Assert.IsInstanceOfType<MyClass>(obj);
typed.DoSomething();
Assert.IsNotInstanceOfType<WrongType>(obj);
Assert.That(result.Count > 0); // Auto-captures expression in failure message
Note: Prefer
Assertclass equivalents when available (e.g.,Assert.Contains("expected", actual)overStringAssert.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+"));
Note: Prefer
Assertclass equivalents when available (e.g.,Assert.Contains).
// Containment
CollectionAssert.Contains(collection, expectedItem);
CollectionAssert.DoesNotContain(collection, unexpectedItem);
// Equality (same elements, same order)
CollectionAssert.AreEqual(expectedCollection, actualCollection);
CollectionAssert.AreNotEqual(unexpectedCollection, actualCollection);
// Equivalence (same elements, any order)
CollectionAssert.AreEquivalent(expectedCollection, actualCollection);
CollectionAssert.AreNotEquivalent(unexpectedCollection, actualCollection);
// Subset checks
CollectionAssert.IsSubsetOf(subset, superset);
CollectionAssert.IsNotSubsetOf(notSubset, collection);
// Element validation
CollectionAssert.AllItemsAreInstancesOfType(collection, typeof(MyClass));
CollectionAssert.AllItemsAreNotNull(collection);
CollectionAssert.AllItemsAreUnique(collection);
[TestMethod]
[DataRow(1, 2, 3)]
[DataRow(0, 0, 0, DisplayName = "Zeros")]
[DataRow(-1, 1, 0, IgnoreMessage = "Known issue #123")] // MSTest 3.8+
public void Add_ReturnsSum(int a, int b, int expected)
{
Assert.AreEqual(expected, Calculator.Add(a, b));
}
The data source can return any of the following types:
IEnumerable<(T1, T2, ...)> (ValueTuple) - preferred , provides type safety (MSTest 3.7+)IEnumerable<Tuple<T1, T2, ...>> - provides type safetyIEnumerable<TestDataRow> - provides type safety plus control over test metadata (display name, categories)IEnumerable<object[]> - least preferred , no type safetyNote: When creating new test data methods, prefer
ValueTupleorTestDataRowoverIEnumerable<object[]>. Theobject[]approach provides no compile-time type checking and can lead to runtime errors from type mismatches.
[TestMethod]
[DynamicData(nameof(TestData))]
public void DynamicTest(int a, int b, int expected)
{
Assert.AreEqual(expected, Calculator.Add(a, b));
}
// ValueTuple - preferred (MSTest 3.7+)
public static IEnumerable<(int a, int b, int expected)> TestData =>
[
(1, 2, 3),
(0, 0, 0),
];
// TestDataRow - when you need custom display names or metadata
public static IEnumerable<TestDataRow<(int a, int b, int expected)>> TestDataWithMetadata =>
[
new((1, 2, 3)) { DisplayName = "Positive numbers" },
new((0, 0, 0)) { DisplayName = "Zeros" },
new((-1, 1, 0)) { DisplayName = "Mixed signs", IgnoreMessage = "Known issue #123" },
];
// IEnumerable<object[]> - avoid for new code (no type safety)
public static IEnumerable<object[]> LegacyTestData =>
[
[1, 2, 3],
[0, 0, 0],
];
The TestContext class provides test run information, cancellation support, and output methods. See TestContext documentation for complete reference.
// Property (MSTest suppresses CS8618 - don't use nullable or = null!)
public TestContext TestContext { get; set; }
// Constructor injection (MSTest 3.6+) - preferred for immutability
[TestClass]
public sealed class MyTests
{
private readonly TestContext _testContext;
public MyTests(TestContext testContext)
{
_testContext = testContext;
}
}
// Static methods receive it as parameter
[ClassInitialize]
public static void ClassInit(TestContext context) { }
// Optional for cleanup methods (MSTest 3.6+)
[ClassCleanup]
public static void ClassCleanup(TestContext context) { }
[AssemblyCleanup]
public static void AssemblyCleanup(TestContext context) { }
Always use TestContext.CancellationToken for cooperative cancellation with [Timeout]:
[TestMethod]
[Timeout(5000)]
public async Task LongRunningTest()
{
await _httpClient.GetAsync(url, TestContext.CancellationToken);
}
TestContext.TestName // Current test method name
TestContext.TestDisplayName // Display name (3.7+)
TestContext.CurrentTestOutcome // Pass/Fail/InProgress
TestContext.TestData // Parameterized test data (3.7+, in TestInitialize/Cleanup)
TestContext.TestException // Exception if test failed (3.7+, in TestCleanup)
TestContext.DeploymentDirectory // Directory with deployment items
// Write to test output (useful for debugging)
TestContext.WriteLine("Processing item {0}", itemId);
// Attach files to test results (logs, screenshots)
TestContext.AddResultFile(screenshotPath);
// Store/retrieve data across test methods
TestContext.Properties["SharedKey"] = computedValue;
[TestMethod]
[Retry(3)]
public void FlakyTest() { }
Skip or run tests based on OS or CI environment:
// OS-specific tests
[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 environment tests
[TestMethod]
[CICondition] // Runs only in CI (default: ConditionMode.Include)
public void CIOnlyTest() { }
[TestMethod]
[CICondition(ConditionMode.Exclude)] // Skips in CI, runs locally
public void LocalOnlyTest() { }
// Assembly level
[assembly: Parallelize(Workers = 4, Scope = ExecutionScope.MethodLevel)]
// Disable for specific class
[TestClass]
[DoNotParallelize]
public sealed class SequentialTests { }
Link tests to work items for traceability in test reports:
// Azure DevOps work items
[TestMethod]
[WorkItem(12345)] // Links to work item #12345
public void Feature_Scenario_ExpectedBehavior() { }
// Multiple work items
[TestMethod]
[WorkItem(12345)]
[WorkItem(67890)]
public void Feature_CoversMultipleRequirements() { }
// GitHub issues (MSTest 3.8+)
[TestMethod]
[GitHubWorkItem("https://github.com/owner/repo/issues/42")]
public void BugFix_Issue42_IsResolved() { }
Work item associations appear in test results and can be used for:
// ❌ Wrong argument order
Assert.AreEqual(actual, expected);
// ✅ Correct
Assert.AreEqual(expected, actual);
// ❌ Using ExpectedException (obsolete)
[ExpectedException(typeof(ArgumentException))]
// ✅ Use Assert.Throws
Assert.Throws<ArgumentException>(() => Method());
// ❌ Using LINQ Single() - unclear exception
var item = items.Single();
// ✅ Use ContainsSingle - better failure message
var item = Assert.ContainsSingle(items);
// ❌ Hard cast - unclear exception
var handler = (MyHandler)result;
// ✅ Type assertion - shows actual type on failure
var handler = Assert.IsInstanceOfType<MyHandler>(result);
// ❌ Ignoring cancellation token
await client.GetAsync(url, CancellationToken.None);
// ✅ Flow test cancellation
await client.GetAsync(url, TestContext.CancellationToken);
// ❌ Making TestContext nullable - leads to unnecessary null checks
public TestContext? TestContext { get; set; }
// ❌ Using null! - MSTest already suppresses CS8618 for this property
public TestContext TestContext { get; set; } = null!;
// ✅ Declare without nullable or initializer - MSTest handles the warning
public TestContext TestContext { get; set; }
[TestCategory("Category")] for filtering[TestProperty("Name", "Value")] for custom metadata (e.g., [TestProperty("Bug", "12345")])[Priority(1)] for critical testsWeekly Installs
7.3K
Repository
GitHub Stars
26.7K
First Seen
Feb 25, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
codex7.2K
gemini-cli7.2K
opencode7.2K
cursor7.2K
github-copilot7.2K
amp7.2K
97,600 周安装
[ClassCleanup] (once per test class)[AssemblyCleanup] (once per test assembly)