testability-obstacle

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Resolve a Testability Obstacle

解决可测试性障碍

Introduce the smallest behavior-preserving seam needed to test a specific C# behavior, then add deterministic tests that prove both the behavior and the seam. The production edit is a means to the requested test, not an invitation to redesign adjacent code.
引入测试特定C#行为所需的最小行为保留接缝,然后添加既能验证行为又能验证接缝的确定性测试。生产代码的修改是为了实现所需测试的手段,而非重构相邻代码的借口。

When to Use

适用场景

  • A requested test would otherwise read/write the real filesystem.
  • Behavior depends on the current time, delay, random value, environment, console, process, or another ambient dependency.
  • The user explicitly permits or requests a safe production seam.
  • Existing tests cannot control a dependency without process-global mutation.
  • 所需测试原本需要读写真实文件系统。
  • 行为依赖于当前时间、延迟、随机值、环境、控制台、进程或其他环境依赖项。
  • 用户明确允许或要求添加安全的生产接缝。
  • 现有测试无法在不修改全局进程状态的情况下控制依赖项。

When Not to Use

不适用场景

  • The dependency is already injected or passed as an argument. Write tests with a fake through the existing seam using
    code-testing-agent
    .
  • The user wants a repository-wide testability audit. Use
    detect-static-dependencies
    .
  • The user wants wrappers generated but not call sites/tests changed. Use
    generate-testability-wrappers
    .
  • The user requests a broad mechanical migration. Use
    migrate-static-to-wrapper
    , then generate tests separately.
  • The code is not C#/.NET.
  • 依赖项已被注入或作为参数传递。使用
    code-testing-agent
    通过现有接缝编写测试假对象。
  • 用户需要对整个仓库进行可测试性审计。使用
    detect-static-dependencies
  • 用户希望生成包装器但不修改调用站点/测试。使用
    generate-testability-wrappers
  • 用户要求进行大范围的机械迁移。使用
    migrate-static-to-wrapper
    ,然后单独生成测试。
  • 代码并非C#/.NET语言。

Inputs

输入参数

InputRequiredDescription
Behavior to testYesThe method/workflow and expected observable behavior
Target scopeNoDiscover the narrowest relevant file/project when omitted
Allowed production changesNoDefault to the minimum internal/constructor seam
输入项是否必填描述
待测试行为方法/工作流及预期可观察行为
目标范围若未指定,自动发现最窄的相关文件/项目
允许的生产代码变更默认最小内部/构造函数接缝

Workflow

工作流程

Step 1: Prove the obstacle

步骤1:验证障碍存在

Read the target production path and its existing tests. Identify the exact ambient operation preventing a deterministic test and the behavior that must remain unchanged. Do not run a repository-wide static scan for a single-class request.
If an adequate seam already exists, stop refactoring and use it. This skill adds no value when a fake can already be supplied.
读取目标生产代码路径及其现有测试。找出确切阻碍确定性测试的环境操作,以及必须保持不变的行为。针对单个类的请求,无需运行仓库范围的静态扫描。
如果已有足够的接缝存在,停止重构并直接使用。当已经可以提供假对象时,本技能无法带来额外价值。

Step 2: Select the smallest safe seam

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

Choose by dependency and repository constraints:
DependencyPreferred seam
Current time / timersInject
TimeProvider
; use
FakeTimeProvider
in tests
FilesystemExisting repository file abstraction; otherwise the smallest interface or
System.IO.Abstractions
when already used/accepted
HTTPExisting typed
HttpClient
/handler or
IHttpClientFactory
seam
RandomnessInject
Random
or a minimal generator interface
Environment/console/processMinimal interface containing only members used by the target
The scoped
AsyncLocal<T>
rule applies to every static API that must retain its public static shape — clocks, filesystem access, environment lookups, identity generation, and randomness. The scope captures and restores the previous value; never implement
Dispose()
as an unconditional assignment to
null
.
Constructor injection is the default for instance classes. Reuse the repository's DI and naming conventions, but do not add a DI container to a class library just to satisfy this workflow.
For a static class or a public API that cannot change, use a scoped ambient seam only when constructor/parameter injection is impossible. The override must:
  • flow across
    await
    (
    AsyncLocal<T>
    , not
    [ThreadStatic]
    );
  • return
    IDisposable
    and restore the previous value, including nested scopes;
  • default to the real production dependency;
  • avoid a process-global mutable fake that makes tests non-parallel.
Use built-in fake-time-aware overloads instead of inventing an
IDelay
wrapper:
Ambient operationReplacement
Task.Delay(delay, token)
Task.Delay(delay, timeProvider, token)
new CancellationTokenSource(delay)
new CancellationTokenSource(delay, timeProvider)
PeriodicTimer(period)
new PeriodicTimer(period, timeProvider)
when the target framework provides it
Test delayed behavior by starting the operation, proving it is incomplete, advancing
FakeTimeProvider
, then awaiting it. Never wait for wall-clock time.
For a nested ambient override, disposing the inner scope must restore the outer value, not clear the slot. Capture the previous value per scope:
csharp
public static IDisposable OverrideClock(Func<DateTimeOffset> clock)
{
    var previous = s_clock.Value;
    s_clock.Value = clock;
    return new Scope(() => s_clock.Value = previous);
}
Add tests for both nesting and parallel async flows; parallel-only tests do not catch the common "dispose sets null" bug.
根据依赖项和仓库约束选择:
依赖项首选接缝
当前时间/计时器注入
TimeProvider
;在测试中使用
FakeTimeProvider
文件系统仓库中已有的文件抽象;否则使用最小接口,或在已使用/接受的情况下使用
System.IO.Abstractions
HTTP已有的类型化
HttpClient
/处理器或
IHttpClientFactory
接缝
随机性注入
Random
或最小生成器接口
环境/控制台/进程仅包含目标所使用成员的最小接口
对于所有必须保留公共静态形态的静态API(时钟、文件系统访问、环境查询、标识生成和随机性),均适用作用域
AsyncLocal<T>
规则。作用域会捕获并恢复之前的值;绝不要将
Dispose()
实现为无条件赋值为
null
构造函数注入是实例类的默认方式。复用仓库的DI和命名约定,但不要仅为满足本工作流向类库添加DI容器。
对于静态类或无法更改的公共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
,然后等待完成。绝不要等待真实时钟时间。
对于嵌套环境覆盖,释放内部作用域必须恢复外部值,而非清空槽位。每个作用域都要捕获之前的值:
csharp
public static IDisposable OverrideClock(Func<DateTimeOffset> clock)
{
    var previous = s_clock.Value;
    s_clock.Value = clock;
    return new Scope(() => s_clock.Value = previous);
}
添加针对嵌套和并行异步流的测试;仅并行测试无法发现常见的“释放时设为null”错误。

Step 3: Preserve behavior and API shape

步骤3:保留行为和API形态

Keep the production change mechanical:
  • Wrap only members used by the target behavior.
  • Default implementations delegate directly to the original API.
  • Preserve exceptions, path handling, time zone, and
    DateTime.Kind
    .
  • Keep existing public signatures unless the user explicitly permits an API change.
  • Do not move business logic into the wrapper or fix unrelated production bugs.
For time replacements:
  • DateTime.UtcNow
    ->
    timeProvider.GetUtcNow().UtcDateTime
  • DateTime.Now
    ->
    timeProvider.GetLocalNow().LocalDateTime
  • DateTimeOffset.UtcNow
    ->
    timeProvider.GetUtcNow()
  • DateTimeOffset.Now
    ->
    timeProvider.GetLocalNow()
保持生产代码变更的机械性:
  • 仅包装目标行为所使用的成员。
  • 默认实现直接委托给原始API。
  • 保留异常、路径处理、时区和
    DateTime.Kind
  • 除非用户明确允许API变更,否则保持现有公共签名不变。
  • 不要将业务逻辑移入包装器,也不要修复无关的生产代码bug。
对于时间替换:
  • DateTime.UtcNow
    ->
    timeProvider.GetUtcNow().UtcDateTime
  • DateTime.Now
    ->
    timeProvider.GetLocalNow().LocalDateTime
  • DateTimeOffset.UtcNow
    ->
    timeProvider.GetUtcNow()
  • DateTimeOffset.Now
    ->
    timeProvider.GetLocalNow()

Step 4: Keep production defaults wired

步骤4:保持生产默认配置

Update every composition root or constructor call affected by the seam. Production must still use real time/filesystem/etc. by default. If the project uses DI, register the default implementation with the lifetime matching repository conventions. If it does not use DI, compose explicitly; do not introduce a container.
Build the affected production project before writing tests. A compile failure here is a seam problem, not a test problem.
更新所有受接缝影响的组合根或构造函数调用。生产环境默认仍需使用真实时间/文件系统等。如果项目使用DI,按照仓库约定的生命周期注册默认实现。如果不使用DI,则显式组合;不要引入容器。
编写测试前,先构建受影响的生产项目。此处的编译失败属于接缝问题,而非测试问题。

Step 5: Write deterministic tests

步骤5:编写确定性测试

Use the repository's existing test project. If none exists, invoke
scaffold-dotnet-test-project
first.
Tests must supply controlled dependencies:
  • fixed/advanced time rather than wall-clock waiting;
  • an in-memory fake filesystem or hand-rolled fake rather than temp/real files;
  • no environment mutation, external process, console input, or network.
Assert the requested business result and at least one interaction/state observable that proves the fake dependency drove the path. Include a production-default test only when it can remain deterministic; never touch the real filesystem merely to prove the adapter delegates.
使用仓库现有的测试项目。如果没有,先调用
scaffold-dotnet-test-project
测试必须提供受控依赖项:
  • 使用固定/推进的时间,而非等待真实时钟;
  • 使用内存中的假文件系统或手动实现的假对象,而非临时/真实文件;
  • 不修改环境、不调用外部进程、不使用控制台输入或网络。
断言所需的业务结果,以及至少一个能证明假对象驱动执行路径的交互/状态可观察项。仅当生产默认测试能保持确定性时才添加;绝不要仅仅为了验证适配器的委托逻辑而访问真实文件系统。

Step 6: Verify the complete path

步骤6:验证完整路径

Run the affected production build, targeted test project, and repository-level test command. Re-read the diff and confirm:
  1. every production change is required by the seam;
  2. no real ambient resource is used by the new tests;
  3. current-time semantics and public behavior are preserved;
  4. existing tests were not replaced or duplicated.
运行受影响的生产构建、目标测试项目和仓库级测试命令。重新查看差异并确认:
  1. 所有生产代码变更都是接缝所需的;
  2. 新测试未使用任何真实环境资源;
  3. 当前时间语义和公共行为得以保留;
  4. 现有测试未被替换或重复。

Output Contract

输出约定

Provide a compact
Requirement | Evidence
table. Cite the production seam, production default wiring, exact test names, and passing commands. If a package restore or build blocks validation, report that blocker rather than claiming the tests pass.
提供简洁的「需求 | 证据」表格。引用生产接缝、生产默认配置、确切测试名称和通过的命令。如果包还原或构建阻碍了验证,请报告该障碍,而非声称测试通过。

Validation

验证清单

  • The original obstacle was concrete and in the requested path.
  • An existing seam was reused when available.
  • The new abstraction exposes only members required by the target behavior.
  • Production defaults still delegate to the original dependency.
  • Time conversions preserve local/UTC and
    DateTime.Kind
    semantics.
  • Static ambient overrides are async-safe, scoped, nested, and reversible.
  • New tests use fixed/in-memory dependencies and no real I/O or wall clock.
  • Production build and targeted/repository tests pass.
  • 原始障碍是具体的且位于请求路径中。
  • 已有接缝在可用时被复用。
  • 新抽象仅暴露目标行为所需的成员。
  • 生产默认实现仍委托给原始依赖项。
  • 时间转换保留了本地/UTC和
    DateTime.Kind
    语义。
  • 静态环境覆盖支持异步安全、作用域、嵌套和可逆。
  • 新测试使用固定/内存依赖项,无真实I/O或时钟等待。
  • 生产构建和目标/仓库测试通过。

Common Pitfalls

常见陷阱

PitfallCorrective action
Refactoring before proving a blockerReuse an existing seam and write the test directly
Wrapping an entire static APIExpose only members exercised by the target
Converting
UtcNow
with
.DateTime
Use
.UtcDateTime
to preserve
DateTimeKind.Utc
Mutable static fake shared by testsUse constructor injection or a scoped
AsyncLocal<T>
override
Adding DI to a library with no containerCompose the dependency explicitly
Using temp files as a shortcutSupply an in-memory fake; the scenario requires no real I/O
Stopping after the refactor buildsWrite and run the behavior tests that justified the seam
陷阱纠正措施
在验证障碍前就进行重构复用已有接缝并直接编写测试
包装整个静态API仅暴露目标所使用的成员
使用
.DateTime
转换
UtcNow
使用
.UtcDateTime
以保留
DateTimeKind.Utc
测试间共享可变静态假对象使用构造函数注入或作用域
AsyncLocal<T>
覆盖
向无容器的库添加DI显式组合依赖项
使用临时文件作为捷径提供内存中的假对象;该场景无需真实I/O
重构完成并构建后就停止编写并运行证明接缝合理性的行为测试