testability-obstacle · diff
git:20260827.0be2ef9 to git:20260828.2b9056b
10 added, 1 removed. Audit A to A.
---
name: testability-obstacle
description: >-
C#/.NET test generation that requires the smallest production seam for
DateTime/Task.Delay/File/Environment/Guid/Random, static API preservation,
nested/parallel overrides, or no real I/O. USE ONLY when the target workspace
contains C# source plus a .csproj or .sln. DO NOT USE for audits, bulk
migration, code that already has an injectable seam, or an explicit migration
to a user-named existing abstraction (migrate-static-to-wrapper). Use instead
of general test generation when the requested test is impossible without a
production edit and seam selection is still open.
license: MIT
---
# 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.
## 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 user already selected an existing replacement such as `TimeProvider` or
`IFileSystem` and asks to migrate call sites to it. Use
`migrate-static-to-wrapper`, which also updates affected tests.
- The code is not C#/.NET.
## Inputs
| Input | Required | Description |
|-------|----------|-------------|
| Behavior to test | Yes | The method/workflow and expected observable behavior |
| Target scope | No | Discover the narrowest relevant file/project when omitted |
| Allowed production changes | No | Default to the minimum internal/constructor seam |
## Workflow
### Step 1: Prove the obstacle
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
Choose by dependency and repository constraints:
| Dependency | Preferred seam |
|------------|----------------|
| Current time / timers | Inject `TimeProvider`; use `FakeTimeProvider` in tests |
| Filesystem | Existing repository abstraction; for one write/read operation use an injected delegate when conventions allow, otherwise a one-member interface or an already accepted `System.IO.Abstractions` |
| HTTP | Existing typed `HttpClient`/handler or `IHttpClientFactory` seam |
| Randomness | One generated value: injected delegate with `Random.Shared` as the production default; multiple operations/state: inject `Random` or a minimal generator interface |
| Environment/console/process | Minimal 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`.
Store the provider/value itself in `AsyncLocal<T>`. Do not put a mutable
`Stack<T>`, list, or other shared mutable collection in the slot: child
execution contexts can inherit the same object and corrupt each other's nesting.
When the provider itself is mutable (for example an in-memory store or fake time
provider), establish a fresh provider inside each parallel flow rather than
mutating one inherited instance from a parent context.
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 operation | Replacement |
|-------------------|-------------|
| `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.
+ advancing `FakeTimeProvider`, then awaiting it. For a deadline or boundary,
+ advance to immediately before the deadline and assert the task is still
+ incomplete before advancing across it; an immediate post-start assertion alone
+ does not prove the boundary. Never wait for wall-clock time.
For a nested ambient override, each scope owns the value that was active when it
started. Dispose scopes in LIFO order with `using` (which emits `try/finally`) or
an explicit `finally`; disposing the inner scope restores the outer value, never
an unconditional `null`. For an environment-backed static API, use this shape:
```csharp
public static class FeatureFlags
{
private static readonly AsyncLocal<Func<string, string?>?> s_environment = new();
public static bool IsEnabled(string name)
{
var reader = s_environment.Value;
var value = reader is null
? Environment.GetEnvironmentVariable(name)
: reader(name);
return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}
public static IDisposable OverrideEnvironment(Func<string, string?> reader)
{
ArgumentNullException.ThrowIfNull(reader);
var previous = s_environment.Value;
s_environment.Value = reader;
return new RestoreScope(() => s_environment.Value = previous);
}
private sealed class RestoreScope : IDisposable
{
private Action? _restore;
public RestoreScope(Action restore)
{
_restore = restore;
}
public void Dispose() =>
Interlocked.Exchange(ref _restore, null)?.Invoke();
}
}
```
The exception test must observe the outer value after the exception has escaped
the inner `using` scope but before the outer scope is disposed:
```csharp
using var outer = FeatureFlags.OverrideEnvironment(_ => "true");
Assert.True(FeatureFlags.IsEnabled("Preview"));
Assert.Throws<InvalidOperationException>(() =>
{
using var inner = FeatureFlags.OverrideEnvironment(_ => "false");
Assert.False(FeatureFlags.IsEnabled("Preview"));
throw new InvalidOperationException("test");
});
Assert.True(FeatureFlags.IsEnabled("Preview"));
```
Also overlap two async flows that each establish a fresh override and assert
that each flow sees only its own value. Parallel-only tests do not catch the
common "dispose sets null" bug. Do not mutate process environment variables in
these tests; the scoped reader is the deterministic input. Choose an outer value
different from the production fallback so clearing the slot cannot accidentally
pass the restoration assertion.
### Step 3: Preserve behavior and API shape
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.
Deterministic serialized text is a deliberate exception to preserving ambient
platform formatting. If the user asks for exact reproducible output across
platforms, use the format's explicit separator (use literal `\n` when none is
specified) and assert that literal content. Keep `Environment.NewLine` only
when platform-native output is part of the existing contract.
For time replacements:
- `DateTime.UtcNow` -> `timeProvider.GetUtcNow().UtcDateTime`
- `DateTime.Now` -> `timeProvider.GetLocalNow().LocalDateTime`
- `DateTimeOffset.UtcNow` -> `timeProvider.GetUtcNow()`
- `DateTimeOffset.Now` -> `timeProvider.GetLocalNow()`
### Step 4: Keep production defaults wired
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.
### Step 5: Write deterministic tests
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.
Choose the narrowest seam that supports the behavior. A single
`File.WriteAllText` call can be an injected `Action<string, string>` with a real
default; do not create an interface, implementation, friend-assembly setting,
and extra project wiring unless repository conventions or multiple operations
justify them.
Do not add `InternalsVisibleTo` merely to reach a constructor-injected delegate
or other seam that the test project can already supply. Friend-assembly access
is justified only when the chosen minimum seam must remain internal and the
exact test assembly is known.
### Step 6: Verify the complete path
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.
Inspect the test summary, not only the exit code. Zero discovered tests, a build
without the requested test run, or any failing/erroring test means the task is
incomplete. Fix discovery/execution and rerun before reporting success.
+ For a static ambient seam, completion requires executed tests for substitution,
+ nested restoration, and overlapping async-flow isolation; production compilation
+ alone is never sufficient. Capture the passing test count or requested test
+ names in the handoff. If no test was discovered or the output does not prove
+ execution, correct the project/test source and rerun rather than reporting the
+ seam as validated.
## 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 with at least one requested test discovered.
## Common Pitfalls
| Pitfall | Corrective action |
|---------|-------------------|
| Refactoring before proving a blocker | Reuse an existing seam and write the test directly |
| Wrapping an entire static API | Expose only members exercised by the target |
| Converting `UtcNow` with `.DateTime` | Use `.UtcDateTime` to preserve `DateTimeKind.Utc` |
| Mutable static fake shared by tests | Use constructor injection or a scoped `AsyncLocal<T>` override |
| Adding DI to a library with no container | Compose the dependency explicitly |
| Using temp files as a shortcut | Supply an in-memory fake; the scenario requires no real I/O |
| Stopping after the refactor builds | Write and run the behavior tests that justified the seam |
| Reporting a zero-test run as success | Fix discovery and require the requested tests to execute and pass |