Use the open-source free `coverlet` toolchain for .NET code coverage.
Resolve a Testability Obstacle
Make C# ambient-dependent behavior testable and add deterministic tests. USE FOR: DateTime/Task.Delay/File/Environment/Guid/Random, constructor injection for instance classes, preserving static APIs, nested override restore, parallel isolation, or no real I/O. DO NOT USE FOR: audits, wrapper-only/bulk migration, or an existing injectable 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 file abstraction; otherwise the smallest interface or System.IO.Abstractions when already used/accepted | | HTTP | Existing typed HttpClient/handler or IHttpClientFactory seam | | Randomness | 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.
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
IDisposableand 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.
For a nested ambient override, disposing the inner scope must restore the outer value, not clear the slot. Capture the previous value per scope:
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.
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.
For time replacements:
DateTime.UtcNow->timeProvider.GetUtcNow().UtcDateTimeDateTime.Now->timeProvider.GetLocalNow().LocalDateTimeDateTimeOffset.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.
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:
- every production change is required by the seam;
- no real ambient resource is used by the new tests;
- current-time semantics and public behavior are preserved;
- existing tests were not replaced or duplicated.
Related skills
Write, run, or repair .NET tests that use MSTest.
Write, run, or repair .NET tests that use NUnit.