generate-testability-wrappers
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseGenerate Testability Wrappers
生成可测试性包装类
Generate wrapper interfaces, default implementations, and DI service registration code for untestable static dependencies. For statics that already have .NET built-in abstractions (, ), guide adoption of the built-in. For statics without built-in alternatives, generate custom minimal wrappers.
TimeProviderIHttpClientFactory为无法测试的静态依赖生成包装接口、默认实现以及DI服务注册代码。对于已有.NET内置抽象的静态类(如、),指导用户采用内置方案;对于无内置替代方案的静态类,则生成自定义的极简包装类。
TimeProviderIHttpClientFactoryWhen to Use
适用场景
- After running and identifying which statics to wrap
detect-static-dependencies - When the user asks to make a class testable by replacing statics with injected abstractions
- When adopting (.NET 8+) or
TimeProviderSystem.IO.Abstractions - When creating a custom wrapper for ,
Environment.*, orConsole.*Process.*
- 运行工具并确定需要包装的静态类之后
detect-static-dependencies - 需要通过将静态类替换为可注入抽象来让某个类具备可测试性时
- 采用(.NET 8+)或
TimeProvider时System.IO.Abstractions - 为、
Environment.*或Console.*创建自定义包装类时Process.*
When Not to Use
不适用场景
- The user wants to find statics first (use )
detect-static-dependencies - The user wants to bulk-replace call sites (use )
migrate-static-to-wrapper - The static is already behind an interface
- The project does not use dependency injection and the user does not want to add it
- 用户首先需要查找静态依赖(请使用工具)
detect-static-dependencies - 用户需要批量替换调用站点(请使用工具)
migrate-static-to-wrapper - 静态类已基于接口实现
- 项目不使用依赖注入且用户无意添加依赖注入
Inputs
输入参数
| Input | Required | Description |
|---|---|---|
| Static category | Yes | Which category: |
| Target framework | Yes | The |
| DI container | No | Which DI framework: |
| Namespace | No | Target namespace for generated wrapper code |
| 输入项 | 是否必填 | 描述 |
|---|---|---|
| 静态类别 | 是 | 类别选项: |
| 目标框架 | 是 | 来自 |
| DI容器 | 否 | DI框架选项: |
| 命名空间 | 否 | 生成的包装代码的目标命名空间 |
Workflow
工作流程
Step 1: Determine the abstraction strategy
步骤1:确定抽象策略
Based on the category and target framework:
| Category | .NET 8+ | .NET 6-7 | .NET Framework |
|---|---|---|---|
| Time | | | Custom |
| File system | | Same | Same |
| HTTP | | Same | Same |
| Environment | Custom | Same | Same |
| Console | Custom | Same | Same |
| Process | Custom | Same | Same |
根据类别和目标框架确定策略:
| 类别 | .NET 8+ | .NET 6-7 | .NET Framework |
|---|---|---|---|
| 时间 | | 通过 | 自定义 |
| 文件系统 | | 同上 | 同上 |
| HTTP | | 同上 | 同上 |
| 环境 | 自定义 | 同上 | 同上 |
| 控制台 | 自定义 | 同上 | 同上 |
| 进程 | 自定义 | 同上 | 同上 |
Step 2: Generate built-in abstraction adoption (Time, HTTP)
步骤2:生成内置抽象的采用方案(时间、HTTP)
TimeProvider (.NET 8+)
TimeProvider(.NET 8+)
No wrapper code needed — guide the user:
- Register in DI:
csharp
builder.Services.AddSingleton(TimeProvider.System);- Inject into classes:
csharp
public class OrderProcessor(TimeProvider timeProvider)
{
public bool IsExpired(Order order)
=> timeProvider.GetUtcNow() > order.ExpiresAt;
}- Test with :
FakeTimeProvider
csharp
// 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));无需生成包装代码,指导用户执行以下操作:
- 在DI中注册:
csharp
builder.Services.AddSingleton(TimeProvider.System);- 注入到类中:
csharp
public class OrderProcessor(TimeProvider timeProvider)
{
public bool IsExpired(Order order)
=> timeProvider.GetUtcNow() > order.ExpiresAt;
}- 使用进行测试:
FakeTimeProvider
csharp
// 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));TimeProvider (pre-.NET 8)
TimeProvider(.NET 8之前版本)
Guide: install NuGet. Same API as above.
Microsoft.Bcl.TimeProvider指导:安装 NuGet包,API与上述一致。
Microsoft.Bcl.TimeProviderIHttpClientFactory
IHttpClientFactory
No wrapper code needed — register typed clients via and inject directly into the class constructor.
builder.Services.AddHttpClient<MyService>()HttpClient无需生成包装代码,通过注册类型化客户端,并将直接注入到类的构造函数中。
builder.Services.AddHttpClient<MyService>()HttpClientStep 3: Generate custom wrappers (Environment, Console, Process)
步骤3:生成自定义包装类(环境、控制台、进程)
For categories without built-in abstractions, follow this template:
对于无内置抽象的类别,遵循以下模板:
Interface — define the minimal surface
接口——定义最小化接口面
Only include methods that were actually detected in the codebase. Do NOT generate a wrapper for every possible member — wrap only what is used.
csharp
namespace <Namespace>;
/// <summary>
/// Abstraction over <static class> for testability.
/// </summary>
public interface I<WrapperName>
{
// One method per detected static call
<return type> <MethodName>(<parameters>);
}仅包含代码库中实际调用的方法。不要为所有可能的成员生成包装类,仅包装实际使用的部分。
csharp
namespace <Namespace>;
/// <summary>
/// Abstraction over <static class> for testability.
/// </summary>
public interface I<WrapperName>
{
// One method per detected static call
<return type> <MethodName>(<parameters>);
}Default implementation — delegate to the real static
默认实现——委托给真实静态类
csharp
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>);
}csharp
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 registration
DI注册
csharp
// In Program.cs or Startup.cs:
builder.Services.AddSingleton<I<WrapperName>, <WrapperName>>();csharp
// In Program.cs or Startup.cs:
builder.Services.AddSingleton<I<WrapperName>, <WrapperName>>();Step 4: Generate file system wrapper adoption
步骤4:生成文件系统包装类的采用方案
Prefer the established NuGet package over custom wrappers:
System.IO.Abstractions- Install the package:
dotnet add package System.IO.Abstractions- Register in DI:
csharp
builder.Services.AddSingleton<IFileSystem, FileSystem>();- Inject into classes:
IFileSystem
csharp
public class ConfigLoader(IFileSystem fileSystem)
{
public string LoadConfig(string path)
=> fileSystem.File.ReadAllText(path);
}- Test with :
MockFileSystem
dotnet add <TestProject> package System.IO.Abstractions.TestingHelperscsharp
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"));优先采用成熟的 NuGet包,而非自定义包装类:
System.IO.Abstractions- 安装包:
dotnet add package System.IO.Abstractions- 在DI中注册:
csharp
builder.Services.AddSingleton<IFileSystem, FileSystem>();- 将注入到类中:
IFileSystem
csharp
public class ConfigLoader(IFileSystem fileSystem)
{
public string LoadConfig(string path)
=> fileSystem.File.ReadAllText(path);
}- 使用进行测试:
MockFileSystem
dotnet add <TestProject> package System.IO.Abstractions.TestingHelperscsharp
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"));Step 5: Generate ambient context alternative (when DI is not available)
步骤5:生成环境上下文替代方案(当无法使用DI时)
If the codebase does not use DI (e.g., old console app, library code), offer the ambient context pattern:
csharp
public static class Clock
{
private static readonly AsyncLocal<Func<DateTimeOffset>?> s_override = new();
public static DateTimeOffset UtcNow
=> s_override.Value?.Invoke() ?? TimeProvider.System.GetUtcNow();
public static IDisposable Override(DateTimeOffset fixedTime)
{
s_override.Value = () => fixedTime;
return new Scope();
}
private sealed class Scope : IDisposable
{
public void Dispose() => s_override.Value = null;
}
}Key trade-offs: ensures parallel tests don't interfere; production cost is one null check per call; the field is essentially free.
AsyncLocal<T>static readonly如果代码库不使用DI(例如旧控制台应用、类库代码),则提供环境上下文模式:
csharp
public static class Clock
{
private static readonly AsyncLocal<Func<DateTimeOffset>?> s_override = new();
public static DateTimeOffset UtcNow
=> s_override.Value?.Invoke() ?? TimeProvider.System.GetUtcNow();
public static IDisposable Override(DateTimeOffset fixedTime)
{
s_override.Value = () => fixedTime;
return new Scope();
}
private sealed class Scope : IDisposable
{
public void Dispose() => s_override.Value = null;
}
}关键权衡:确保并行测试不会互相干扰;生产环境的开销为每次调用一次空值检查;字段几乎无开销。
AsyncLocal<T>static readonlyStep 6: Place generated files
步骤6:放置生成的文件
Generate files following the project's existing conventions:
- If there is an or
Abstractions/folder, place the interface thereInterfaces/ - If there is an or
Infrastructure/folder, place the implementation thereServices/ - Otherwise, create files next to the code that uses the static
Always generate:
- The interface file (or adoption instructions for built-in abstractions)
- The default implementation file
- The DI registration snippet (as a code comment at the bottom of the implementation, or as separate instructions)
遵循项目现有约定放置生成的文件:
- 如果存在或
Abstractions/文件夹,将接口文件放在其中Interfaces/ - 如果存在或
Infrastructure/文件夹,将实现文件放在其中Services/ - 否则,将文件放在使用该静态类的代码旁边
始终生成以下内容:
- 接口文件(或内置抽象的采用说明)
- 默认实现文件
- DI注册代码片段(可作为实现文件底部的代码注释,或单独的说明)
Validation
验证项
- Generated interface only wraps statics that were actually detected (not speculative)
- Default implementation delegates to the real static with no behavior changes
- DI registration uses for stateless wrappers,
AddSingletonfor stateful onesAddTransient - NuGet packages are recommended where established libraries exist (System.IO.Abstractions, etc.)
- For .NET 8+, is recommended over custom
TimeProviderISystemClock - Ambient context pattern includes , scoped disposal, and trade-off explanation
AsyncLocal<T>
- 生成的接口仅包装实际检测到的静态类(而非推测的)
- 默认实现委托给真实静态类,无行为变更
- DI注册中,无状态包装类使用,有状态包装类使用
AddSingletonAddTransient - 在已有成熟库的场景下推荐使用NuGet包(如System.IO.Abstractions等)
- 对于.NET 8+,推荐使用而非自定义
TimeProviderISystemClock - 环境上下文模式包含、作用域释放以及权衡说明
AsyncLocal<T>
Common Pitfalls
常见陷阱
| Pitfall | Solution |
|---|---|
| Wrapping ALL members of a static class | Only wrap methods actually called in the codebase |
| Custom time wrapper on .NET 8+ | Use built-in |
| Custom file system wrapper | Prefer |
| Registering scoped when singleton suffices | Stateless wrappers should be |
| Forgetting test helper packages | |
Ambient context without | Non-async |
| 陷阱 | 解决方案 |
|---|---|
| 包装静态类的所有成员 | 仅包装代码库中实际调用的方法 |
| 在.NET 8+中使用自定义时间包装类 | 改用内置的 |
| 使用自定义文件系统包装类 | 优先采用 |
| 当单例足够时仍注册为作用域 | 无状态包装类应使用 |
| 忘记使用测试辅助包 | 时间测试使用 |
环境上下文未使用 | 非异步的 |