mcpField
G
generate-testability-wrappers

generate-testability-wrappers

github.com/dotnet开发5.8k3.5k 次安装1 天前更新
代码仓库

关于 generate-testability-wrappers

帮助为依赖静态 API 的 .NET 代码提升可测试性的技能,可采用内置抽象或生成精简包装器。它根据目标框架和组合方式指导抽象选择、依赖注入、注册及确定性测试。

主要功能

  1. 01选择内置或自定义抽象
  2. 02生成精简包装接口
  3. 03指导依赖注入与服务注册
  4. 04提供确定性测试模式

适用场景

  1. 01替换静态时间调用以便测试
  2. 02包装 Environment 或 Console 依赖
  3. 03测试依赖进程的代码
  4. 04为静态 API 保留测试接缝

安装

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

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

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

详细介绍

生成可测试性包装器

为不可测试的静态依赖项生成包装接口、默认实现和 DI 服务注册代码。对于已有 .NET 内置抽象(TimeProvider、IHttpClientFactory)的静态依赖项,引导采用内置抽象。对于没有内置替代方案的静态依赖项,生成自定义的精简包装器。

适用场景

  • 运行 detect-static-dependencies 并确定需要包装的静态依赖项后
  • 用户要求通过将静态依赖项替换为注入的抽象,让某个类变得可测试时
  • 采用 TimeProvider(.NET 8+)或 System.IO.Abstractions 时
  • 为 Environment.*、Console.* 或 Process.* 创建自定义包装器时
  • 已发布的静态 API 需要使用环境接缝,因为无法更改签名时

不适用场景

  • 用户想先查找静态依赖项时(使用 detect-static-dependencies)
  • 用户想批量替换调用点时(使用 migrate-static-to-wrapper)
  • 静态依赖项已由接口包装时

如果目标代码已使用注入的接口或内置抽象,请停止: 不要围绕该接缝添加第二个包装器、项目、注册项或测试。

缺少 DI 包本身并不意味着必须使用环境接缝。对于可实例化的类,优先使用构造函数注入,并显式组合对象或展示所需的注册方式。当 API 是静态的且其签名必须保持静态时,或用户明确禁止更改调用方的构造方式/DI 时,使用步骤 5。

输入

输入必填说明
静态类别是类别:time、filesystem、environment、network、console、process
目标框架是.csproj 中的 TargetFramework(影响可用的内置抽象)
组合方式否现有 DI 框架、显式/手动构造,或不可变的静态 API
命名空间否生成的包装器代码所用的目标命名空间

工作流

步骤 1:确定抽象策略

根据类别和目标框架:

类别.NET 8+.NET 6-7.NET Framework
时间TimeProvider(内置)通过 Microsoft.Bcl.TimeProvider NuGet 使用 TimeProvider自定义 ISystemClock
文件系统System.IO.Abstractions(NuGet)同上同上
HTTPIHttpClientFactory(内置)同上同上
环境自定义 IEnvironmentProvider同上同上
控制台自定义 IConsole同上同上
进程自定义 IProcessRunner同上同上

该表选择的是抽象类型。它如何传递到被测代码中是另一个维度:

  • 可实例化的类:使用构造函数注入,即使当前调用方通过手动方式组合对象也一样;
  • 现有容器:按照其惯例添加可直接编译的注册代码;
  • 无法更改的公共静态 API/签名:使用步骤 5 的环境接缝。

检查是否存在主机构建器、IServiceCollection、现有注册项和构造位置。不要仅仅因为项目当前没有容器,就推断 API“必须保持静态”。

步骤 2:生成内置抽象的采用方案(时间、HTTP)
TimeProvider(.NET 8+)

无需编写包装代码。请完成以下四个部分:生产环境注册、构造函数注入、FakeTimeProvider 测试和测试包。

  1. 在 DI 中注册:
builder.Services.AddSingleton(TimeProvider.System);
  1. 注入类中:
public class OrderProcessor(TimeProvider timeProvider)
{
    public bool IsExpired(Order order)
        => timeProvider.GetUtcNow() > order.ExpiresAt;
}
  1. 使用 FakeTimeProvider 测试:
// Requires Microsoft.Extensions.TimeProvider.Testing NuGet
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 1, 15, 0, 0, 0, TimeSpan.Zero));
var processor = new OrderProcessor(fakeTime);
fakeTime.Advance(TimeSpan.FromDays(1));
Assert.True(processor.IsExpired(order));

断言必须证明:在固定或推进模拟时间后,得到了依赖时间的结果。仅仅创建 FakeTimeProvider 并不算测试。如果项目没有容器,但目标是可实例化的类,仍应注入 TimeProvider,并展示使用 TimeProvider.System 的显式生产环境构造方式;不要将其替换为自定义静态时钟。

在认定接入已完成前,请确认仓库中包含或答案中提供了所有必需内容:测试包引用、生产环境组合/注册、所有受影响的构造函数调用,以及可运行的模拟时间测试。缺少其中任一集成环节的代码片段只是指导,并不代表接入已完成。

TimeProvider(.NET 8 之前)

指南:安装 NuGet 包 Microsoft.Bcl.TimeProvider。API 与上文相同。

IHttpClientFactory

无需编写包装代码。通过 builder.Services.AddHttpClient<MyService>() 注册类型化客户端,并将 HttpClient 直接注入类的构造函数。调用 HTTP 操作时,传入调用方的令牌,以保留取消功能。

测试时,请提供完整的模拟 HttpMessageHandler,使其 SendAsync 返回确定性的 HttpResponseMessage;使用该处理程序构造 HttpClient,并在不访问网络的情况下调用类型化客户端。如果任务要求实现,请编译并运行针对性测试;不要只提供示意性的处理程序方法。

如果生产环境使用类型化客户端注册,测试也应使用同一注册流程:在 ServiceCollection 中配置主处理程序,解析类型化客户端并调用它。手动构造 HttpClient 的测试只能证明类本身有效,无法证明任务要求接入的 DI 注册有效。

第 3 步:生成自定义包装器(Environment、Console、Process)

对于没有内置抽象的类别,请遵循以下模板:

接口 — 定义最小接口

仅包含代码库中实际检测到的方法。不要为所有可能的成员都生成包装器;只包装实际用到的内容。

优先采用无状态、以操作为导向的接口。例如,如果调用方只需要启动进程、等待并返回退出代码,则应公开一个 Run 操作,而不是暴露 Process 生命周期的有状态包装器。

namespace <Namespace>;

/// <summary>
/// Abstraction over <static class> for testability. 
/// </summary>
public interface I<WrapperName>
{
    // One method per detected static call
    <return type> <MethodName>(<parameters>);
}
默认实现 — 委托给真实的静态成员
namespace <Namespace>;

/// <summary>
/// Default implementation that delegates to <static class>.
/// </summary>
public sealed class <WrapperName> : I<WrapperName>
{
    public <return type> <MethodName>(<parameters>)
        => <StaticClass>.<Method>(<arguments>);
}
DI 注册
// In Program.cs or Startup.cs:
builder.Services.AddSingleton<I<WrapperName>, <WrapperName>>();

将注册视为交付内容,而不是总结中的一句话。如果仓库中已有注册位置,请将其添加到该位置;否则,请给出可直接编译的具体语句,并说明调用方应将其放在哪里。无状态委托包装器应注册为单例;如果必须保留状态,请解释为何需要使用更短的生命周期。

第 4 步:生成文件系统包装器接入方案

优先选用成熟的 NuGet 包 System.IO.Abstractions,而不是自定义包装器:

  1. 安装包:
dotnet add package System.IO.Abstractions
  1. 在 DI 中注册:
builder.Services.AddSingleton<IFileSystem, FileSystem>();
  1. 在类中注入 IFileSystem:
public class ConfigLoader(IFileSystem fileSystem)
{
    public string LoadConfig(string path)
        => fileSystem.File.ReadAllText(path);
}
  1. 使用 MockFileSystem 测试:
dotnet add <TestProject> package System.IO.Abstractions.TestingHelpers
var mockFs = new MockFileSystem(new Dictionary<string, MockFileData>
{
    { "/config.json", new MockFileData("{\"key\": \"value\"}") }
});
var loader = new ConfigLoader(mockFs);
Assert.Equal("{\"key\": \"value\"}", loader.LoadConfig("/config.json"));

采用包优先方案时必须严格遵循:添加两个包引用,在生产代码中使用 IFileSystem,注册或显式组合 FileSystem,并在调用使用者代码前为 MockFileSystem 准备数据。不要再生成第二个自定义文件系统接口,也不要提供未经准备的模拟对象,以免测试未能证明所要求的读写行为。

步骤 5:生成保留签名的环境上下文

当 API 必须保持静态,或其已发布的签名无法接受依赖项时,请使用此模式:

public static class Clock
{
    private static readonly AsyncLocal<Func<DateTime>?> s_override = new();
    public static DateTime UtcNow
        => s_override.Value?.Invoke() ?? TimeProvider.System.GetUtcNow().UtcDateTime;

    internal static IDisposable Override(DateTime fixedUtcTime)
    {
        if (fixedUtcTime.Kind != DateTimeKind.Utc)
            throw new ArgumentException("The override must be UTC.", nameof(fixedUtcTime));

        var previous = s_override.Value;
        s_override.Value = () => fixedUtcTime;
        return new Scope(previous);
    }
    private sealed class Scope : IDisposable
    {
        private readonly Func<DateTime>? _previous;
        private bool _disposed;

        public Scope(Func<DateTime>? previous)
        {
            _previous = previous;
        }

        public void Dispose()
        {
            if (_disposed)
                return;

            s_override.Value = _previous;
            _disposed = true;
        }
    }
}

主要权衡:AsyncLocal<T> 可确保并行测试互不干扰;生产环境中的开销是每次调用进行一次空值检查;static readonly 字段几乎没有开销。

此模式必须保持以下三个属性,因为每一项都曾导致实际迁移失败:

  • 限定覆盖范围,并确保可撤销。 返回一个 IDisposable,用于恢复之前的值,这样测试就不会把固定的时间泄漏到下一个测试中。仅提供一个 setter,或要求每个调用点都手动编写 try/finally,都会把这个负担推给每位测试作者。
  • 使用 AsyncLocal<T>,绝不使用 [ThreadStatic]。 [ThreadStatic] 不会跨 await 传递,因此覆盖值会在测试过程中悄然消失。
  • 保留被替换成员的语义。 用本地时间源替换 DateTime.UtcNow,会改变所有现有调用方和存储值所依赖的 DateTimeKind——将 UtcNow 与 GetUtcNow() 配对,将 Now 与 GetLocalNow() 配对。
  • 确认测试程序集能够访问覆盖项。 除非生产项目为该测试程序集添加了精确匹配的 InternalsVisibleTo,否则单独的测试程序集无法访问 internal 覆盖项。如果未添加,仅在允许更改公共 API 时使用已有的公共接缝。绝不可展示测试调用不可访问成员的示例。

同样的模式也适用于非时间类静态成员:将 TimeProvider.System.GetUtcNow() 替换为实际的静态调用,并保留覆盖槽、可释放作用域和原有语义。

步骤 6:放置生成的文件

按照项目现有约定生成文件:

  • 如果有 Abstractions/ 或 Interfaces/ 文件夹,将接口放在那里
  • 如果有 Infrastructure/ 或 Services/ 文件夹,将实现放在那里
  • 否则,将文件放在使用该静态成员的代码旁边

始终生成:

  1. 接口文件(或采用内置抽象的说明)
  2. 默认实现文件
  3. 可编译的 DI 注册代码,应用到现有的注册入口,或展示在确切的组合位置。
  4. 确定性的替换示例或专项测试,用于在不依赖环境资源的情况下测试使用方。

使用环境接缝方案时,完全跳过注册:没有可供注册的容器,而且仍然提供容器注册,正是导致用户最初要求提供接缝的失败模式。

报告完成前,请确认交付内容包含提示词要求的所有项目。尤其是,如果没有添加或展示注册代码,就不要用“单例注册”一语带过;如果没有演示使用方如何接收伪实现,也不要声称其可测试。 对于控制台封装器,请使用一个既能捕获提示词又能提供返回输入的伪实现来测试使用方;仅执行构建或只运行横幅,无法证明提示流程正常。

验证

  • 生成的接口只封装实际检测到的静态成员(而非臆测出的成员)
  • 默认实现委托给真实的静态成员,不改变行为
  • DI 注册对无状态封装使用 AddSingleton,对有状态封装使用 AddTransient
  • 存在成熟库时,优先推荐 NuGet 包(如 System.IO.Abstractions 等)
  • 对于 .NET 8 及更高版本,推荐使用 TimeProvider,而非自定义 ISystemClock
  • 采用 TimeProvider 时,需包含注入、生产环境组合、FakeTimeProvider、其测试包,以及对依赖时间的结果进行断言
  • 采用 HTTP 时,需包含完整的伪处理程序测试,并保留取消功能
  • 在注入路径中,注册或显式组合须可编译就绪,并通过伪对象演示消费者在不依赖真实环境依赖项时的行为
  • 环境上下文模式需包含 AsyncLocal<T>、一个可恢复先前值的作用域 IDisposable,以及对权衡的说明
  • 对于环境接缝路径,不得提出 IServiceCollection 注册;独立测试程序集必须能够访问该覆盖项,并且必须保留被替换成员的返回类型和语义(UtcNow 与 Now 的区别,以及其 DateTimeKind)

常见问题

问题解决方案
把“没有 DI 包”理解为“必须使用环境上下文”注入到可实例化的类中并显式组合;仅将第 5 步用于静态 API/保持签名不变的 API
封装静态类的所有成员只封装代码库中实际调用的方法
在 .NET 8 及更高版本中自定义时间封装改用内置的 TimeProvider
自定义文件系统封装优先使用 NuGet 包 System.IO.Abstractions——经过实战验证且功能完整
单例足以满足需求时仍注册为作用域无状态封装应使用 AddSingleton
忘记测试辅助包时间测试使用 Microsoft.Extensions.TimeProvider.Testing,文件系统测试使用 System.IO.Abstractions.TestingHelpers
环境上下文未使用 AsyncLocal非异步的 [ThreadStatic] 在 async/await 中会失效——务必使用 AsyncLocal<T>
向外部测试展示内部环境覆盖项添加准确的友元程序集,或使用获授权的公共接缝;编译测试项目

评论

支持 Markdown