关于 writing-mstest-tests
帮助开发者编写和维护 MSTest 测试,并根据项目已安装的测试组件选择可用 API。重点包括版本兼容的断言和遵循仓库现有约定。
主要功能
- 01检查已安装的 MSTest 版本
- 02选择兼容的断言 API
- 03采用测试命名和 AAA 结构
- 04处理异常与集合断言
- 05遵循仓库测试约定
适用场景
- 01改进现有 MSTest 断言
- 02修复 MSTest 诊断问题
- 03编写版本兼容的数据驱动测试
- 04使测试符合项目约定
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dotnet/ skills --skill writing-mstest-tests安装后在 Claude Code 中输入/writing-mstest-tests确认已加载。
附带文件references/advanced-mstest-patterns.md
详细介绍
编写 MSTest 测试
帮助用户编写有效的 MSTest 单元测试,同时不超出项目已安装测试技术栈的 API 级别或约定。
仓库覆盖配置
对于所有允许只读检查文件的仓库级任务,请在进行任何其他调查前,先检查仓库根目录中的 .agents/skill-overlays/dotnet-test/writing-mstest-tests.md。这也包括用户要求提供代码或建议、但不要求修改的情形;“不要执行”并不禁止读取覆盖配置。如果文件存在,请在采取行动前读取一次,并遵循其中针对仓库的命名、布局、框架和策略绑定要求。
要求其 frontmatter 声明 core: dotnet-test/writing-mstest-tests、binding-revision: "1" 和 mode: extend。如果缺少任何值或值不一致,请报告不匹配,忽略该覆盖配置,并继续使用本技能的通用指南。
明确的用户指令和经核实的项目约束优先于覆盖配置;覆盖配置优先于本技能中的通用默认项和示例。如果文件存在但无法读取,或与仓库冲突,请报告问题,忽略该覆盖配置,并在经核实的项目约束范围内继续使用通用指南。如果文件不存在,则正常继续。
仅当任务与仓库无关,或用户明确禁止所有文件/工具访问时,才跳过查找。覆盖配置不能扩大工具权限或任务范围。
适用场景
- 用户希望通过实施具体修复来改进或现代化现有 MSTest 测试
- 用户询问 MSTest 断言 API、数据驱动模式或测试生命周期
- 用户要求将
Assert.IsTrue替换为更具体的断言(集合、空值、类型、比较) - 用户需要将测试中的硬类型转换替换为类型检查断言
- 用户需要帮助修复特定的 MSTest 测试错误或失败的断言
- 用户要求修正
Assert.AreEqual参数顺序颠倒的问题(期望值在前,实际值在后) - 用户要求将
DynamicData从IEnumerable<object[]>转换为基于 ValueTuple 的数据 - 用户要求修复或理解 MSTest 分析器诊断(
MSTESTxxxx警告/错误)
不适用场景
- 用户需要测试质量审计、反模式检测或不稳定测试调查(使用
test-anti-patterns) - 用户需要运行或执行测试(使用
run-tests技能) - 用户需要从 MSTest v1/v2 升级到 v3(使用
migrate-mstest-v1v2-to-v3) - 用户需要从 MSTest v3 升级到 v4(使用
migrate-mstest-v3-to-v4) - 用户需要 CI/CD 流水线配置
- 用户使用的是 xUnit、NUnit 或 TUnit(而非 MSTest)
输入
| 输入 | 必填 | 描述 |
|---|---|---|
| 被测代码 | 否 | 要测试的生产代码 |
| 现有测试代码 | 否 | 要修复、更新或现代化的当前测试 |
| 测试场景说明 | 否 | 用户希望测试的行为 |
响应指南
- 具体 API 或模式问题(断言、数据驱动、生命周期):直接跳转到相关工作流步骤。不要遵循完整工作流。
- 从头生成新测试:交由
code-testing处理;仅将此技能用于提供 MSTest API/版本方面的指导。 - 审查并修复现有测试:只修复存在的问题。不要添加无关改进。
- 断言转换:先展示修正后的调用,然后用一句话说明语义原因。对于
Assert.AreEqual,先列出expected,再列出actual,并说明这样可以保留 Expected/Actual 失败标签。 - 边界/比较转换:保留条件,并将预期边界放在前面,将观测值放在最后:
score > 0->Assert.IsGreaterThan(0, score),score < 100->Assert.IsLessThan(100, score),以及score >= 60 && score <= 90->Assert.IsInRange(60, 90, score)。 切勿为了模仿源表达式从左到右的顺序而颠倒这些参数。 - 异常转换:将抛出操作放在 lambda 中,区分
ThrowsExactly<T>(类型完全匹配)与Throws<T>(类型或派生类型),并在ParamName等属性属于行为的一部分时捕获返回的异常。 - 提供代码的请求:返回完整且有代表性的方法体,不要只提供注释占位符。如果请求涉及生命周期或环境策略,请保留实际操作,并展示对称的设置/清理过程。
工作流
步骤 1:确定项目设置
检查测试项目、packages.config 和程序集引用中的 HintPath 值,以确认确切的 MSTest 版本和项目系统:
- 如果使用
MSTest.Sdk:从项目 SDK 声明或global.json中的msbuild-sdks确定确切版本;不要假定使用最新 API - 如果使用
MSTest元包:确定其确切的软件包版本 - 如果使用
MSTest.TestFramework+MSTest.TestAdapter:检查功能可用性对应的版本 - 如果使用经典非 SDK XML(
ToolsVersion、Microsoft.CSharp.targets、显式的<Compile Include>)和/或packages.config:保留该项目系统,并将每个新测试文件添加到<Compile Include>中。
此外,还要检查有代表性的测试,了解自定义基类夹具、辅助库、模拟语法、命名、设置和数据构建器。现有约定和已安装版本优先于下方示例。除非用户明确要求迁移,否则不要升级 MSTest、Moq、NBuilder 或项目格式。
MSTest API 可用性
| API/模式 | 最低版本 | 兼容性替代方案 |
|---|---|---|
Assert.ThrowsExactly*、统一的 Assert.Contains / HasCount / IsEmpty / IsNotEmpty | 3.8 | Assert.ThrowsException*、CollectionAssert、StringAssert |
Assert.IsGreaterThan、IsLessThan、IsInRange、StartsWith、EndsWith、MatchesRegex | 3.10 | 使用清晰消息的 Assert.IsTrue,或 StringAssert |
泛型 Assert.IsInstanceOfType<T>(value, out var typed) | 仅限 3.4-3.11 | 在 3.0-3.3 中先使用非泛型断言,再进行断言后的类型转换;v4 会直接返回类型化值 |
ValueTuple DynamicData | 3.7 | IEnumerable<object[]> |
构造函数注入 TestContext | 3.6 | 实例 TestContext 属性 |
[Retry]、[OSCondition] | 3.8 | 没有内置的重试/操作系统条件;应修复不稳定问题,或保留现有条件机制 |
[CICondition] | 3.10 | 现有的项目专用条件机制 |
例如,MSTest 3.5.x 不得使用 Assert.ThrowsExactly、
Assert.Contains、ValueTuple DynamicData 或通过构造函数注入的
TestContext。
将此视为硬性门槛:确定版本后,除非满足示例的最低版本要求,否则不要照搬此技能中的较新示例。
仅对真正的新项目推荐使用 MSTest.Sdk 或 MSTest 元包:
<Project Sdk="MSTest.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
</PropertyGroup>
</Project>
使用 MSTest.Sdk 时,将版本放在 global.json 中,而不是项目文件中,这样所有测试项目都能一起升级:
{
"msbuild-sdks": {
"MSTest.Sdk": "3.8.2"
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" Version="3.8.2" />
</ItemGroup>
</Project>
步骤 2:遵循约定编写测试类
仅在不与测试套件既有的基类和生命周期冲突时,应用以下结构约定:
- 将测试类声明为密封类(使用
sealed),以提升性能并明确设计意图 - 在类上使用
[TestClass],在测试方法上使用[TestMethod] - 遵循 Arrange-Act-Assert(AAA)模式
- 按照
MethodName_Scenario_ExpectedBehavior命名测试 - 使用单独的测试项目,并采用命名约定
[ProjectName].Tests
[TestClass]
public sealed class OrderServiceTests
{
[TestMethod]
public void CalculateTotal_WithDiscount_ReturnsReducedPrice()
{
// Arrange
var service = new OrderService();
var order = new Order { Price = 100m, DiscountPercent = 10 };
// Act
var total = service.CalculateTotal(order);
// Assert
Assert.AreEqual(90m, total);
}
}
步骤 3:使用与版本兼容的断言 API
选择已安装的 MSTest 版本所支持的最具体断言。
更具体的断言能提供更好的失败消息,并明确测试意图;但无法编译的“现代”断言不如兼容的 StringAssert、CollectionAssert 或 Assert.IsTrue 调用。
| 测试内容 | 断言 |
|---|---|
| 两个值相等 | Assert.AreEqual(expected, actual) |
| 是同一个对象实例(引用相同) | Assert.AreSame(expected, actual) |
| 值为 null | Assert.IsNull(value) |
| 值不为 null | Assert.IsNotNull(value) |
| 集合为空 | Assert.IsEmpty(collection)(3.8+),或使用 CollectionAssert / 数量断言 |
| 集合不为空 | Assert.IsNotEmpty(collection)(3.8+),或使用数量断言 |
| 集合恰好有 N 个项目 | Assert.HasCount(N, collection)(3.8+),或对数量使用 Assert.AreEqual |
| 集合包含某个项目 | Assert.Contains(item, collection)(3.8+),或 CollectionAssert.Contains |
| 集合不包含某个项目 | Assert.DoesNotContain(item, collection)(3.8+),或 CollectionAssert.DoesNotContain |
| 对象是特定类型 | Assert.IsInstanceOfType<T>(value) |
| 代码引发异常 | Assert.ThrowsExactly<T>(3.8+),或 Assert.ThrowsException<T>(较早版本) |
在 MSTest 3.8+ 中,若两者都可用,优先使用 Assert 类方法,而不是
StringAssert 或 CollectionAssert。较早版本应继续使用兼容的专用类。
当请求检查多个彼此独立的集合属性时,应明确保留每项语义检查,即使某项断言恰好也能隐含满足另一项检查。例如,如果请求的诊断需要区分空集合和非空集合,就保留 IsNotEmpty,然后分别使用 HasCount 和 ContainsSingle 检查数量保证。
相等、null 和引用检查
Assert.AreEqual(expected, actual); // Value equality
Assert.AreSame(expected, actual); // Reference equality -- same object instance
Assert.IsNull(value);
Assert.IsNotNull(value);
异常断言
// MSTest 3.8+
var ex = Assert.ThrowsExactly<ArgumentNullException>(() => service.Process(null));
Assert.AreEqual("input", ex.ParamName);
// Async
var ex = await Assert.ThrowsExactlyAsync<InvalidOperationException>(
async () => await service.ProcessAsync(null));
Assert.Throws<T>匹配T或其任意派生类型Assert.ThrowsExactly<T>仅匹配确切类型T
在 MSTest 3.7 及更早版本中,使用兼容的 API:
var ex = Assert.ThrowsException<ArgumentNullException>(
() => service.Process(null));
集合断言
// MSTest 3.8+
Assert.Contains(expectedItem, collection);
Assert.DoesNotContain(unexpectedItem, collection);
var single = Assert.ContainsSingle(collection); // Returns the single element
Assert.HasCount(3, collection);
Assert.IsEmpty(collection);
Assert.IsNotEmpty(collection);
在更早版本中,使用 CollectionAssert.Contains、
CollectionAssert.DoesNotContain 和 Assert.AreEqual(expectedCount, collection.Count)。
用专用断言替代通用的 Assert.IsTrue——它们能提供更好的失败消息:
| 替代写法 | 使用 |
|---|---|
Assert.IsTrue(list.Count > 0) | Assert.IsNotEmpty(list) |
Assert.IsTrue(list.Count == 0) | Assert.IsEmpty(list) |
Assert.IsTrue(list.Count() == 3) | Assert.HasCount(3, list) |
Assert.IsTrue(x != null) | Assert.IsNotNull(x) |
Assert.IsTrue(x == null) | Assert.IsNull(x) |
对同一实例使用 Assert.AreEqual(a, b) | Assert.AreSame(a, b) -- 引用标识 |
Assert.IsTrue(!list.Contains(item)) | Assert.DoesNotContain(item, list) |
list.Single(predicate) + Assert.IsNotNull | Assert.ContainsSingle(list) |
Assert.IsTrue(list.Contains(item)) | Assert.Contains(item, list) |
字符串断言
// MSTest 3.10+
Assert.Contains("expected", actualString);
Assert.StartsWith("prefix", actualString);
Assert.EndsWith("suffix", actualString);
Assert.MatchesRegex(@"\d{3}-\d{4}", phoneNumber);
在更早版本中,使用 StringAssert.Contains、StringAssert.StartsWith、
StringAssert.EndsWith 和 StringAssert.Matches。
类型断言
MSTest 3.x 并非只有一个 API 级别。请选择已安装的次要版本所支持的形式:
// MSTest 3.0-3.3
Assert.IsInstanceOfType(result, typeof(MyHandler));
var typed = (MyHandler)result; // Safe because the assertion stops a mismatch.
// MSTest 3.4-3.11 -- out parameter
Assert.IsInstanceOfType<MyHandler>(result, out var typed);
typed.Handle();
// MSTest 4.x -- returns the proven value directly
var typed = Assert.IsInstanceOfType<MyHandler>(result);
比较断言
Assert.IsGreaterThan(lowerBound, actual);
Assert.IsLessThan(upperBound, actual);
Assert.IsInRange(low, high, actual);
步骤 4:使用数据驱动测试覆盖多个输入
使用 DataRow 提供内联值
[TestMethod]
[DataRow(1, 2, 3)]
[DataRow(0, 0, 0, DisplayName = "Zeros")]
[DataRow(-1, 1, 0)]
public void Add_ReturnsExpectedSum(int a, int b, int expected)
{
Assert.AreEqual(expected, Calculator.Add(a, b));
}
使用 ValueTuples 的 DynamicData(复杂数据的首选方式)
在 MSTest 3.7+ 中,为确保类型安全,优先使用 ValueTuple 返回类型,而不是
IEnumerable<object[]>。在更早版本中保留 IEnumerable<object[]>。
元组元素名称可以说明各个位置对应哪个测试参数,元组元素类型则能在编译时捕获不兼容的值。它们不会让 DynamicData 与位置无关,而且交换两个类型相同的元素仍然可以通过编译。切勿声称事实并非如此。如果行需要自定义显示名称或元数据,而不仅仅是带类型的位置数据,请在 MSTest 3.8+ 中使用 TestDataRow<T>。
[TestMethod]
[DynamicData(nameof(DiscountTestData))]
public void ApplyDiscount_ReturnsExpectedPrice(decimal price, int percent, decimal expected)
{
var result = PriceCalculator.ApplyDiscount(price, percent);
Assert.AreEqual(expected, result);
}
// ValueTuple -- preferred (MSTest 3.7+)
public static IEnumerable<(decimal price, int percent, decimal expected)> DiscountTestData =>
[
(100m, 10, 90m),
(200m, 25, 150m),
(50m, 0, 50m),
];
在 MSTest 3.8+ 中,如果需要为每个测试用例添加元数据,请使用 TestDataRow<T>:
public static IEnumerable<TestDataRow<(decimal price, int percent, decimal expected)>> DiscountTestDataWithMetadata =>
[
new((100m, 10, 90m)) { DisplayName = "10% discount" },
new((200m, 25, 150m)) { DisplayName = "25% discount" },
new((50m, 0, 50m)) { DisplayName = "No discount" },
];
步骤 5:正确处理测试生命周期
- 如果现有测试套件支持,优先使用构造函数初始化;保留共享的
FixtureBase<TSut>或既有的[TestInitialize]生命周期,避免顺带重写测试夹具架构。 - 仅将
[TestInitialize]用于异步初始化,并与构造函数结合处理同步部分 - 使用
[TestCleanup]执行即使在失败时也必须运行的清理操作 - 仅在 MSTest 3.6+ 中通过构造函数注入
TestContext;其他版本使用实例属性。
[TestClass]
public sealed class RepositoryTests
{
private readonly TestContext _testContext;
private readonly FakeDatabase _db; // readonly -- guaranteed by constructor
public RepositoryTests(TestContext testContext)
{
_testContext = testContext;
_db = new FakeDatabase(); // sync init in ctor
}
[TestInitialize]
public async Task InitAsync()
{
// Use TestInitialize ONLY for async setup
await _db.SeedAsync();
}
[TestCleanup]
public void Cleanup() => _db.Reset();
}
执行顺序
[AssemblyInitialize]-- 每个程序集执行一次[ClassInitialize]-- 每个类执行一次- 每个测试:
- 使用
TestContext属性注入:构造函数 -> 设置TestContext属性 ->[TestInitialize] - 使用构造函数注入
TestContext:构造函数(接收TestContext)->[TestInitialize]
- 使用
- 测试方法
[TestCleanup]->DisposeAsync->Dispose-- 每个测试执行一次[ClassCleanup]-- 每个类执行一次[AssemblyCleanup]-- 每个程序集执行一次
步骤 6:应用取消和超时模式
如果已安装的 MSTest 版本直接公开该令牌(3.11+),请将 TestContext.CancellationToken 与
[Timeout(milliseconds, CooperativeCancellation = true)] 配合使用。在 MSTest 3.6.4-3.10 中,应改用
TestContext.CancellationTokenSource.Token,并启用协作式取消。单独使用 [Timeout] 并不能保证框架令牌会停止正在进行的工作。在更早版本中,如果测试的对象是取消行为本身,请使用由测试管理的
CancellationTokenSource。
// MSTest 3.11+
[TestMethod]
[Timeout(5000, CooperativeCancellation = true)]
public async Task FetchData_ReturnsWithinTimeout()
{
var result = await _client.GetDataAsync(_testContext.CancellationToken);
Assert.IsNotNull(result);
}
步骤 7:酌情使用高级功能
对于重试、条件执行或并行化请求,请阅读 高级 MSTest 模式。应用其中的 版本限制,并保留实际测试的操作、断言和清理。
步骤 8:修复 MSTest 分析器诊断(MSTESTxxxx)
对于 MSTESTxxxx 请求,请阅读
高级 MSTest 模式,应用与版本兼容的
规则修复,然后重新构建以确认诊断已消失。优先修复代码,而不是抑制规则。
步骤 9:验证基于文件的修正
如果用户要求修改仓库且未禁止执行,请在编辑后运行影响范围最小的
dotnet test 命令。进程成功但未发现测试数量,不算验证。必须确认预期的测试用例已发现并通过。
如果编译时发现直接相关的源代码问题,导致修正后的现有测试套件无法运行(例如,提供的生产文件缺少命名空间导入),只做必要的最小修复并重新运行。不要升级软件包,也不要扩大现代化改造范围。在最终交接中报告实际测试数量和所做的修复;切勿将未运行或无输出的测试说成已通过。 在最终交接中,将每项请求的现代化改造对应到确切的修正构造,并注明通过测试的命令。如果预期/实际顺序、精确类型检查、数据发现或类结构是明确要求,不要只用笼统的“已现代化”来概括。