{"slug":"testability-obstacle","title":"testability-obstacle","summary":"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: aud","platform":"Claude","tags":[],"authorName":"LLM Mart","authorSlug":"llm-mart","score":0,"source":"github","price":null,"verified":false,"createdAt":"2026-08-24T05:37:34.865148Z","repo":{"url":"https://github.com/dotnet/skills","stars":5471,"forks":418,"license":"MIT","updatedAt":"2026-09-24T06:38:55Z"},"bodyHtml":"<hr>\n<h2>name: testability-obstacle\ndescription: &gt;-\nMake C# ambient-dependent behavior testable and add deterministic\ntests. USE FOR: DateTime/Task.Delay/File/Environment/Guid/Random, constructor\ninjection for instance classes, preserving static APIs, nested override\nrestore, parallel isolation, or no real I/O. DO NOT USE FOR: audits,\nwrapper-only/bulk migration, or an existing injectable seam.\nlicense: MIT</h2>\n<h1>Resolve a Testability Obstacle</h1>\n<p>Introduce the smallest behavior-preserving seam needed to test a specific C#\nbehavior, then add deterministic tests that prove both the behavior and the seam.\nThe production edit is a means to the requested test, not an invitation to\nredesign adjacent code.</p>\n<h2>When to Use</h2>\n<ul>\n<li>A requested test would otherwise read/write the real filesystem.</li>\n<li>Behavior depends on the current time, delay, random value, environment, console,\nprocess, or another ambient dependency.</li>\n<li>The user explicitly permits or requests a safe production seam.</li>\n<li>Existing tests cannot control a dependency without process-global mutation.</li>\n</ul>\n<h2>When Not to Use</h2>\n<ul>\n<li>The dependency is already injected or passed as an argument. Write tests with\na fake through the existing seam using <code>code-testing-agent</code>.</li>\n<li>The user wants a repository-wide testability audit. Use\n<code>detect-static-dependencies</code>.</li>\n<li>The user wants wrappers generated but not call sites/tests changed. Use\n<code>generate-testability-wrappers</code>.</li>\n<li>The user requests a broad mechanical migration. Use\n<code>migrate-static-to-wrapper</code>, then generate tests separately.</li>\n<li>The code is not C#/.NET.</li>\n</ul>\n<h2>Inputs</h2>\n<table>\n<thead>\n<tr>\n<th>Input</th>\n<th>Required</th>\n<th>Description</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Behavior to test</td>\n<td>Yes</td>\n<td>The method/workflow and expected observable behavior</td>\n</tr>\n<tr>\n<td>Target scope</td>\n<td>No</td>\n<td>Discover the narrowest relevant file/project when omitted</td>\n</tr>\n<tr>\n<td>Allowed production changes</td>\n<td>No</td>\n<td>Default to the minimum internal/constructor seam</td>\n</tr>\n</tbody>\n</table>\n<h2>Workflow</h2>\n<h3>Step 1: Prove the obstacle</h3>\n<p>Read the target production path and its existing tests. Identify the exact ambient\noperation preventing a deterministic test and the behavior that must remain\nunchanged. Do not run a repository-wide static scan for a single-class request.</p>\n<p>If an adequate seam already exists, stop refactoring and use it. This skill adds\nno value when a fake can already be supplied.</p>\n<h3>Step 2: Select the smallest safe seam</h3>\n<p>Choose by dependency and repository constraints:</p>\n<table>\n<thead>\n<tr>\n<th>Dependency</th>\n<th>Preferred seam</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Current time / timers</td>\n<td>Inject <code>TimeProvider</code>; use <code>FakeTimeProvider</code> in tests</td>\n</tr>\n<tr>\n<td>Filesystem</td>\n<td>Existing repository file abstraction; otherwise the smallest interface or <code>System.IO.Abstractions</code> when already used/accepted</td>\n</tr>\n<tr>\n<td>HTTP</td>\n<td>Existing typed <code>HttpClient</code>/handler or <code>IHttpClientFactory</code> seam</td>\n</tr>\n<tr>\n<td>Randomness</td>\n<td>Inject <code>Random</code> or a minimal generator interface</td>\n</tr>\n<tr>\n<td>Environment/console/process</td>\n<td>Minimal interface containing only members used by the target</td>\n</tr>\n</tbody>\n</table>\n<p>The scoped <code>AsyncLocal&lt;T&gt;</code> rule applies to every static API that must retain its\npublic static shape — clocks, filesystem access, environment lookups, identity\ngeneration, and randomness. The scope captures and restores the previous value;\nnever implement <code>Dispose()</code> as an unconditional assignment to <code>null</code>.</p>\n<p>Constructor injection is the default for instance classes. Reuse the repository's\nDI and naming conventions, but do not add a DI container to a class library just\nto satisfy this workflow.</p>\n<p>For a static class or a public API that cannot change, use a scoped ambient seam\nonly when constructor/parameter injection is impossible. The override must:</p>\n<ul>\n<li>flow across <code>await</code> (<code>AsyncLocal&lt;T&gt;</code>, not <code>[ThreadStatic]</code>);</li>\n<li>return <code>IDisposable</code> and restore the previous value, including nested scopes;</li>\n<li>default to the real production dependency;</li>\n<li>avoid a process-global mutable fake that makes tests non-parallel.</li>\n</ul>\n<p>Use built-in fake-time-aware overloads instead of inventing an <code>IDelay</code> wrapper:</p>\n<table>\n<thead>\n<tr>\n<th>Ambient operation</th>\n<th>Replacement</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>Task.Delay(delay, token)</code></td>\n<td><code>Task.Delay(delay, timeProvider, token)</code></td>\n</tr>\n<tr>\n<td><code>new CancellationTokenSource(delay)</code></td>\n<td><code>new CancellationTokenSource(delay, timeProvider)</code></td>\n</tr>\n<tr>\n<td><code>PeriodicTimer(period)</code></td>\n<td><code>new PeriodicTimer(period, timeProvider)</code> when the target framework provides it</td>\n</tr>\n</tbody>\n</table>\n<p>Test delayed behavior by starting the operation, proving it is incomplete,\nadvancing <code>FakeTimeProvider</code>, then awaiting it. Never wait for wall-clock time.</p>\n<p>For a nested ambient override, disposing the inner scope must restore the outer\nvalue, not clear the slot. Capture the previous value per scope:</p>\n<pre><code>public static IDisposable OverrideClock(Func&lt;DateTimeOffset&gt; clock)\n{\n    var previous = s_clock.Value;\n    s_clock.Value = clock;\n    return new Scope(() =&gt; s_clock.Value = previous);\n}\n</code></pre>\n<p>Add tests for both nesting and parallel async flows; parallel-only tests do not\ncatch the common \"dispose sets null\" bug.</p>\n<h3>Step 3: Preserve behavior and API shape</h3>\n<p>Keep the production change mechanical:</p>\n<ul>\n<li>Wrap only members used by the target behavior.</li>\n<li>Default implementations delegate directly to the original API.</li>\n<li>Preserve exceptions, path handling, time zone, and <code>DateTime.Kind</code>.</li>\n<li>Keep existing public signatures unless the user explicitly permits an API change.</li>\n<li>Do not move business logic into the wrapper or fix unrelated production bugs.</li>\n</ul>\n<p>For time replacements:</p>\n<ul>\n<li><code>DateTime.UtcNow</code> -&gt; <code>timeProvider.GetUtcNow().UtcDateTime</code></li>\n<li><code>DateTime.Now</code> -&gt; <code>timeProvider.GetLocalNow().LocalDateTime</code></li>\n<li><code>DateTimeOffset.UtcNow</code> -&gt; <code>timeProvider.GetUtcNow()</code></li>\n<li><code>DateTimeOffset.Now</code> -&gt; <code>timeProvider.GetLocalNow()</code></li>\n</ul>\n<h3>Step 4: Keep production defaults wired</h3>\n<p>Update every composition root or constructor call affected by the seam. Production\nmust still use real time/filesystem/etc. by default. If the project uses DI,\nregister the default implementation with the lifetime matching repository\nconventions. If it does not use DI, compose explicitly; do not introduce a\ncontainer.</p>\n<p>Build the affected production project before writing tests. A compile failure here\nis a seam problem, not a test problem.</p>\n<h3>Step 5: Write deterministic tests</h3>\n<p>Use the repository's existing test project. If none exists, invoke\n<code>scaffold-dotnet-test-project</code> first.</p>\n<p>Tests must supply controlled dependencies:</p>\n<ul>\n<li>fixed/advanced time rather than wall-clock waiting;</li>\n<li>an in-memory fake filesystem or hand-rolled fake rather than temp/real files;</li>\n<li>no environment mutation, external process, console input, or network.</li>\n</ul>\n<p>Assert the requested business result and at least one interaction/state observable\nthat proves the fake dependency drove the path. Include a production-default test\nonly when it can remain deterministic; never touch the real filesystem merely to\nprove the adapter delegates.</p>\n<h3>Step 6: Verify the complete path</h3>\n<p>Run the affected production build, targeted test project, and repository-level\ntest command. Re-read the diff and confirm:</p>\n<ol>\n<li>every production change is required by the seam;</li>\n<li>no real ambient resource is used by the new tests;</li>\n<li>current-time semantics and public behavior are preserved;</li>\n<li>existing tests were not replaced or duplicated.</li>\n</ol>\n<h2>Output Contract</h2>\n<p>Provide a compact <code>Requirement | Evidence</code> table. Cite the production seam,\nproduction default wiring, exact test names, and passing commands. If a package\nrestore or build blocks validation, report that blocker rather than claiming the\ntests pass.</p>\n<h2>Validation</h2>\n<ul>\n<li><input disabled=\"disabled\" type=\"checkbox\"> The original obstacle was concrete and in the requested path.</li>\n<li><input disabled=\"disabled\" type=\"checkbox\"> An existing seam was reused when available.</li>\n<li><input disabled=\"disabled\" type=\"checkbox\"> The new abstraction exposes only members required by the target behavior.</li>\n<li><input disabled=\"disabled\" type=\"checkbox\"> Production defaults still delegate to the original dependency.</li>\n<li><input disabled=\"disabled\" type=\"checkbox\"> Time conversions preserve local/UTC and <code>DateTime.Kind</code> semantics.</li>\n<li><input disabled=\"disabled\" type=\"checkbox\"> Static ambient overrides are async-safe, scoped, nested, and reversible.</li>\n<li><input disabled=\"disabled\" type=\"checkbox\"> New tests use fixed/in-memory dependencies and no real I/O or wall clock.</li>\n<li><input disabled=\"disabled\" type=\"checkbox\"> Production build and targeted/repository tests pass.</li>\n</ul>\n<h2>Common Pitfalls</h2>\n<table>\n<thead>\n<tr>\n<th>Pitfall</th>\n<th>Corrective action</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Refactoring before proving a blocker</td>\n<td>Reuse an existing seam and write the test directly</td>\n</tr>\n<tr>\n<td>Wrapping an entire static API</td>\n<td>Expose only members exercised by the target</td>\n</tr>\n<tr>\n<td>Converting <code>UtcNow</code> with <code>.DateTime</code></td>\n<td>Use <code>.UtcDateTime</code> to preserve <code>DateTimeKind.Utc</code></td>\n</tr>\n<tr>\n<td>Mutable static fake shared by tests</td>\n<td>Use constructor injection or a scoped <code>AsyncLocal&lt;T&gt;</code> override</td>\n</tr>\n<tr>\n<td>Adding DI to a library with no container</td>\n<td>Compose the dependency explicitly</td>\n</tr>\n<tr>\n<td>Using temp files as a shortcut</td>\n<td>Supply an in-memory fake; the scenario requires no real I/O</td>\n</tr>\n<tr>\n<td>Stopping after the refactor builds</td>\n<td>Write and run the behavior tests that justified the seam</td>\n</tr>\n</tbody>\n</table>\n","files":[{"path":"SKILL.md","sizeBytes":15414,"isText":true}],"reviewScore":null,"reviewSummary":null,"trust":{"provenance":"trusted-source-unreviewed","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow.","bodySource":null},"bodyLocked":false,"purchaseUrl":null,"sourceUrl":null,"report":{"provenance":"trusted-source-unreviewed","screen":{"ran":true,"outcome":"clean","suspicious":0,"notes":0,"hiddenCharacters":false},"virusScan":{"engine":"clamav","status":"clean","scannedAt":"2026-09-11T07:27:44.097541Z","sha256":"1781913FE79C763FF7469C3048157F4E6ED7819A6798D38123BA21744E3F0B0E","sizeBytes":6078},"review":null,"source":{"repositoryUrl":"https://github.com/dotnet/skills","path":"plugins/dotnet-test/skills/testability-obstacle","license":"MIT","commit":"e115891bd2ac3c7eefd5e30a405f7b5638f5e429","subtreeSha":"F5D5F7A03B909B89B756099E5A9DE45CF036D056BA57907F98FE887E53A5F0D1","lastSyncedAt":"2026-09-24T06:48:49.987562Z"},"reviewedAt":"2026-09-11T07:29:13.05964Z","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow."},"install":[{"target":"skills-cli","command":"npx skills add https://github.com/dotnet/skills/tree/main/plugins/dotnet-test/skills/testability-obstacle"},{"target":"claude-code","command":"claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install dotnet-skills@llmmart"},{"target":"git","command":"git clone https://github.com/dotnet/skills.git"}]}