mcpField
W
writing-mstest-tests

writing-mstest-tests

github.com/dotnet测试与质量5.8k3.6k 次安装1 天前更新
代码仓库

关于 writing-mstest-tests

帮助开发者编写和维护 MSTest 测试,并根据项目已安装的测试组件选择可用 API。重点包括版本兼容的断言和遵循仓库现有约定。

主要功能

  1. 01检查已安装的 MSTest 版本
  2. 02选择兼容的断言 API
  3. 03采用测试命名和 AAA 结构
  4. 04处理异常与集合断言
  5. 05遵循仓库测试约定

适用场景

  1. 01改进现有 MSTest 断言
  2. 02修复 MSTest 诊断问题
  3. 03编写版本兼容的数据驱动测试
  4. 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 / IsNotEmpty3.8Assert.ThrowsException*、CollectionAssert、StringAssert
Assert.IsGreaterThan、IsLessThan、IsInRange、StartsWith、EndsWith、MatchesRegex3.10使用清晰消息的 Assert.IsTrue,或 StringAssert
泛型 Assert.IsInstanceOfType<T>(value, out var typed)仅限 3.4-3.11在 3.0-3.3 中先使用非泛型断言,再进行断言后的类型转换;v4 会直接返回类型化值
ValueTuple DynamicData3.7IEnumerable<object[]>
构造函数注入 TestContext3.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)
值为 nullAssert.IsNull(value)
值不为 nullAssert.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.IsNotNullAssert.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();
}
执行顺序
  1. [AssemblyInitialize] -- 每个程序集执行一次
  2. [ClassInitialize] -- 每个类执行一次
  3. 每个测试:
    • 使用 TestContext 属性注入:构造函数 -> 设置 TestContext 属性 -> [TestInitialize]
    • 使用构造函数注入 TestContext:构造函数(接收 TestContext)-> [TestInitialize]
  4. 测试方法
  5. [TestCleanup] -> DisposeAsync -> Dispose -- 每个测试执行一次
  6. [ClassCleanup] -- 每个类执行一次
  7. [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 命令。进程成功但未发现测试数量,不算验证。必须确认预期的测试用例已发现并通过。

如果编译时发现直接相关的源代码问题,导致修正后的现有测试套件无法运行(例如,提供的生产文件缺少命名空间导入),只做必要的最小修复并重新运行。不要升级软件包,也不要扩大现代化改造范围。在最终交接中报告实际测试数量和所做的修复;切勿将未运行或无输出的测试说成已通过。 在最终交接中,将每项请求的现代化改造对应到确切的修正构造,并注明通过测试的命令。如果预期/实际顺序、精确类型检查、数据发现或类结构是明确要求,不要只用笼统的“已现代化”来概括。

评论

支持 Markdown