mcpField
T
testability-obstacle

testability-obstacle

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

关于 testability-obstacle

testability-obstacle 面向需要为环境依赖行为编写确定性测试的 C# 开发者,指导以最小且保持行为的生产代码改动建立接缝,并完成针对性验证。

主要功能

  1. 01定位具体测试障碍
  2. 02选择最小依赖接缝
  3. 03保持行为与公开 API
  4. 04使用替身编写确定性测试
  5. 05构建并运行针对性验证

适用场景

  1. 01测试读写文件的代码
  2. 02控制时间、随机值或进程状态
  3. 03不重构相邻代码而增加测试接缝

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dotnet/skills --skill testability-obstacle

安装后在 Claude Code 中输入/testability-obstacle确认已加载。

详细介绍

解决可测试性障碍

引入满足测试特定 C# 行为所需的最小行为保持型接缝,然后添加确定性测试,以验证行为和接缝。生产代码的修改是为了实现所请求的测试,并不意味着可以重新设计相邻代码。

适用场景

  • 否则,所请求的测试将读取/写入真实文件系统。
  • 行为依赖当前时间、延迟、随机值、环境、控制台、进程或其他环境依赖项。
  • 用户明确允许或要求提供安全的生产代码接缝。
  • 现有测试无法控制某项依赖,除非修改进程全局状态。

不适用场景

  • 依赖项已经注入或作为参数传入。使用现有接缝,通过 fake 编写测试,并使用 code-testing。
  • 用户希望对整个代码库进行可测试性审计。使用 detect-static-dependencies。
  • 用户希望生成包装器,但不修改调用点/测试。使用 generate-testability-wrappers。
  • 用户要求进行大范围机械式迁移。先使用 migrate-static-to-wrapper,再单独生成测试。
  • 用户已选择现有替代方案(例如 TimeProvider 或 IFileSystem),并要求将调用点迁移到该方案。使用 migrate-static-to-wrapper,它也会更新受影响的测试。
  • 代码不是 C#/.NET。

输入

输入必填说明
要测试的行为是方法/工作流及预期的可观察行为
目标范围否若未指定,则查找相关范围最小的文件/项目
允许的生产代码修改否默认采用最小范围的内部/构造函数接缝

工作流

步骤 1:确认障碍

阅读目标生产代码路径及其现有测试。找出阻碍确定性测试的具体环境操作,以及必须保持不变的行为。对于针对单个类的请求,不要运行整个代码库的静态扫描。

如果已有足够的接缝,则停止重构并使用该接缝。如果已经可以提供 fake,这项技能就没有价值。

步骤 2:选择最小且安全的接缝

根据依赖项和代码库约束进行选择:

依赖项首选接缝
当前时间/计时器注入 TimeProvider;在测试中使用 FakeTimeProvider
文件系统使用代码库现有的抽象;对于单次写入/读取操作,如果符合惯例,则使用注入的委托,否则使用仅含一个成员的接口,或已被接受的 System.IO.Abstractions
HTTP使用现有的强类型 HttpClient/处理程序,或 IHttpClientFactory 接缝
随机性单个最终生成值:注入 Func<int>,并在真实默认实现中保留范围选择;只有当范围参数是测试必须验证的行为时,才注入 Func<int, int, int>;多个操作/状态:注入 Random 或最简生成器接口
环境/控制台/进程仅包含目标实际使用成员的最简接口

适用于所有必须保留公共静态形式的静态 API(时钟、文件系统访问、环境查询、标识生成和随机性)的作用域 AsyncLocal<T> 规则。作用域会捕获并恢复先前的值;绝不能将 Dispose() 实现为无条件赋值 null。

将提供程序/值本身存储在 AsyncLocal<T> 中。不要在槽位中放入可变的 Stack<T>、列表或其他共享可变集合:子执行上下文可能继承同一个对象,并破坏彼此的嵌套。当提供程序本身可变时(例如内存存储或 fake 时间提供程序),应在每个并行流中建立新的提供程序,而不是修改从父上下文继承的实例。

对于实例类,默认使用构造函数注入。沿用代码库的 DI 和命名惯例,但不要仅为满足此工作流而向类库添加 DI 容器。 除非用户授权更改 API,否则应保留现有的公共构造方式。保留一个无参公共构造函数,作为使用真实依赖项的默认构造函数;并将仅供测试使用的委托/提供程序构造函数设置为测试项目可访问的最低可见性。不要仅为方便测试,就把这个接缝变成新的公共可选参数。

对于静态类或无法更改的公共 API,仅当不可能通过构造函数/参数注入时,才使用有作用域的环境接缝。此覆盖机制必须:

  • 能跨越 await 传递(使用 AsyncLocal<T>,而非 [ThreadStatic]);
  • 返回 IDisposable,并恢复先前的值,包括嵌套作用域;
  • 默认使用真实的生产依赖项;
  • 避免使用导致测试无法并行运行的进程级全局可变伪对象。

使用内置的、支持伪时间的重载,而不是自行创建 IDelay 包装器:

环境操作替换方式
Task.Delay(delay, token)Task.Delay(delay, timeProvider, token)
new CancellationTokenSource(delay)new CancellationTokenSource(delay, timeProvider)
PeriodicTimer(period)当目标框架提供时,使用 new PeriodicTimer(period, timeProvider)

测试延迟行为时,先启动操作并确认其尚未完成,再推进 FakeTimeProvider,然后等待操作完成。测试截止时间或边界时,应先将时间推进到截止时间之前,并断言任务仍未完成,然后再推进时间使其越过截止时间;仅在操作启动后立即断言,并不能证明边界正确。绝不要等待实际时间流逝。

对于嵌套的环境覆盖,每个作用域都应捕获其启动时生效的值,并且只恢复一次。使用 using/finally 按后进先出顺序释放作用域;绝不要无条件将槽位重置为 null。测试必须在内部作用域正常结束后,以及在需要时内部作用域因异常展开后,观察到外部值。使用不同的值,避免清空槽位时也意外通过测试。此外,应让彼此独立的异步流程重叠运行,并断言每个流程只能看到自己的全新覆盖值。测试环境接缝时,不要修改进程环境变量。

var previous = s_provider.Value;
s_provider.Value = provider;
return new RestoreScope(() => s_provider.Value = previous);
第 3 步:保留行为和 API 形态

生产代码的更改应保持机械性:

  • 仅包装目标行为所用的成员。
  • 默认实现直接委托给原始 API。
  • 保留异常、路径处理、时区和 DateTime.Kind。
  • 保留现有公共签名,除非用户明确允许更改 API。
  • 不要将业务逻辑移入包装器,也不要修复无关的生产代码错误。

确定性序列化文本是保留环境格式的一个有意例外。如果用户要求跨平台生成完全可复现的输出,请使用格式指定的分隔符(若未指定,则使用字面量 \n),并断言该字面量内容。只有当平台原生输出属于现有契约时,才保留 Environment.NewLine。

时间替换方式:

  • DateTime.UtcNow -> timeProvider.GetUtcNow().UtcDateTime
  • DateTime.Now -> timeProvider.GetLocalNow().LocalDateTime
  • DateTimeOffset.UtcNow -> timeProvider.GetUtcNow()
  • DateTimeOffset.Now -> timeProvider.GetLocalNow()
第 4 步:确保生产默认值正确接入

更新受该接缝影响的每个组合根或构造函数调用。生产环境默认仍必须使用真实的时间、文件系统等依赖项。如果项目使用 DI,应按照仓库惯例的生命周期注册默认实现。如果项目不使用 DI,则显式组合;不要引入容器。 现有的手动工厂必须显式传入真实依赖项(例如,new ExpirationPolicy(TimeProvider.System))。不要通过可选构造函数转移职责,也不要向工厂添加可选的提供程序参数。

编写测试前,先构建受影响的生产项目。此处的编译失败说明接缝存在问题,而不是测试存在问题。

步骤 5:编写确定性测试

使用仓库现有的测试项目。如果不存在测试项目,请先调用 scaffold-dotnet-test-project。

测试必须使用可控依赖:

  • 使用固定时间或可推进的时间,而不是等待墙上时钟;
  • 使用内存中的假文件系统或手动编写的假对象,而不是临时文件或真实文件;
  • 不得修改环境、启动外部进程、读取控制台输入或访问网络。

编写测试前,先检查其测试项目,并遵循现有的测试框架、 全局 using 和断言惯例。使用该项目已引用的框架包;绝不要为了让测试通过编译而添加手动编写的 FactAttribute、替代测试框架类型或无关的测试项目配置。

断言所需的业务结果,以及至少一个可观察的交互或状态,以证明假依赖驱动了该执行路径。仅当生产默认行为测试仍可保持确定性时,才加入此类测试;绝不要只为证明适配器会委派操作而触及真实文件系统。

覆盖每一项明确要求的行为和边界情况。可以使用 theory 或共享辅助方法来精简测试套件,但不要为了减少测试数量而删掉任何情况,也不要用更小的测试集替换保留的测试。接缝应尽可能精简;验证仍须完整。

选择能够支持所需行为的最窄接缝。单次 File.WriteAllText 调用可以通过注入 Action<string, string> 并保留真实默认实现来处理;除非仓库惯例或多项操作有此需要,否则不要创建接口、实现、友元程序集设置及额外的项目接线。

保持公共 API 表面和现有签名不变。不要仅为测试添加公共依赖注入构造函数。如果某个类目前只有隐式的公共无参构造函数,且确切的测试程序集已知,则保留该构造函数的行为,并将仅供测试使用的构造函数设为 internal;在这种狭窄情况下,添加 InternalsVisibleTo 是合理的,因为这可以避免将接缝暴露为公共 API。若仓库已有友元程序集惯例,优先遵循该惯例。

如果现有公共接缝已接受假对象,或测试项目可以通过其他方式提供假对象,则不要添加 InternalsVisibleTo。只有在为保留公共 API,所选的最小构造函数或委托接缝必须保持为 internal,且确切的测试程序集已知时,才有理由开放友元程序集访问。

步骤 6:验证完整路径

运行受影响的生产构建和最精简的定向测试命令。只有在用户要求广泛验证、仓库约定要求使用该入口,或接缝改动了目标范围之外也会使用的共享组合逻辑时,才运行仓库级测试命令。重新阅读 diff 并确认:

  1. 每项生产代码更改都是接缝所必需的;
  2. 新测试没有使用任何真实的环境资源;
  3. 当前时间语义和公共行为保持不变;
  4. 现有测试未被替换或重复。

检查测试摘要,而不只是退出代码。发现的测试数量为零、只构建而未运行所需测试,或任何测试失败/报错,都意味着任务尚未完成。修复发现或执行问题后重新运行,再报告成功。 新测试无法编译时,应根据现有测试框架修正其导入、断言重载或异步测试形式,然后再考虑修改生产接缝;不要在源代码中模拟缺失的框架 API。 对于静态环境接缝,只有在测试实际执行并覆盖替换、嵌套恢复、按要求验证的异常恢复,以及重叠异步流隔离后,才能视为完成;仅生产代码编译成功绝不足以验证。交接时记录通过的测试数量或所请求的测试名称。如果没有发现任何测试,或输出无法证明测试确实执行,应修正项目/测试源代码并重新运行,不要声称接缝已通过验证。

输出约定

提供一张简洁的 需求 | 证据 表格。注明生产代码接缝、生产默认接线方式、准确的测试名称和通过的命令。如果包还原或构建阻碍了验证,请报告该阻碍,而不要声称测试通过。

验证

  • 原有障碍具体存在于请求涉及的路径中。
  • 有现成接缝时,已优先复用。
  • 新抽象仅公开目标行为所需的成员。
  • 生产默认实现仍委托给原有依赖项。
  • 现有内部测试接缝足以满足需求时,未扩大公共 API。
  • 时间转换保留本地时间/UTC 和 DateTime.Kind 语义。
  • 静态环境覆盖可安全用于异步操作,且作用域明确、支持嵌套并可还原。
  • 新测试使用固定/内存依赖项,不执行真实 I/O,也不依赖系统时钟。
  • 生产构建以及针对性测试/仓库测试均通过,且至少发现并运行了一个请求的测试。

常见陷阱

陷阱纠正措施
尚未确认障碍就进行重构复用现有接缝,并直接编写测试
封装整个静态 API仅公开目标所用到的成员
使用 .DateTime 转换 UtcNow使用 .UtcDateTime 以保留 DateTimeKind.Utc
多个测试共享可变静态伪对象使用构造函数注入或作用域化的 AsyncLocal<T> 覆盖
在没有容器的库中添加 DI显式组合依赖项
图省事而使用临时文件提供内存伪对象;此场景不需要真实 I/O
重构构建成功后便停止编写并运行证明该接缝合理的行为测试
将零测试运行报告为成功修复测试发现问题,并要求发现且通过请求的测试

评论

支持 Markdown