Agent Skillsdotnet/skills › testability-obstacle

testability-obstacle

GitHub

专为C#/.NET设计,用于在无法直接测试时引入最小化生产代码接缝(如时间、文件、随机数等),以支持确定性单元测试生成。

plugins/dotnet-test/skills/testability-obstacle/SKILL.md dotnet/skills

Trigger Scenarios

需要为依赖环境变量的C#代码编写单元测试 目标方法存在静态依赖且未提供注入点

Install

npx skills add dotnet/skills --skill testability-obstacle -g -y
More Options

Non-standard path

npx skills add https://github.com/dotnet/skills/tree/main/plugins/dotnet-test/skills/testability-obstacle -g -y

Use without installing

npx skills use dotnet/skills@testability-obstacle

指定 Agent (Claude Code)

npx skills add dotnet/skills --skill testability-obstacle -a claude-code -g -y

安装 repo 全部 skill

npx skills add dotnet/skills --all -g -y

预览 repo 内 skill

npx skills add dotnet/skills --list

SKILL.md

Frontmatter
{
    "name": "testability-obstacle",
    "license": "MIT",
    "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."
}

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.

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:

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:

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.

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

Version History

  • be7b560 Current 2026-08-28 04:53

    修复嵌套测试覆盖恢复问题;增强测试技能评估,增加独立刺激数量并优化验证夹具;提升技能可靠性与路由逻辑。

  • 7953ba8 2026-08-14 01:14

Same Skill Collection

.agents/skills/create-custom-agent/SKILL.md
.agents/skills/create-skill-test/SKILL.md
.agents/skills/create-skill/SKILL.md
.agents/skills/improve-skill-quality/SKILL.md
.github/skills/agentic-workflows/SKILL.md
eng/skill-validator/tests/fixtures/sample-skill/SKILL.md
plugins/dotnet-advanced/skills/csharp-scripts/SKILL.md
plugins/dotnet-advanced/skills/nuget-trusted-publishing/SKILL.md
plugins/dotnet-advanced/skills/vectorization/SKILL.md
plugins/dotnet-aspnetcore/skills/configuring-opentelemetry-dotnet/SKILL.md
plugins/dotnet-aspnetcore/skills/dotnet-webapi/SKILL.md
plugins/dotnet-aspnetcore/skills/minimal-api-file-upload/SKILL.md
plugins/dotnet-blazor/skills/create-blazor-project/SKILL.md
plugins/dotnet-blazor/skills/fetch-and-send-data/SKILL.md
plugins/dotnet-blazor/skills/support-prerendering/SKILL.md
plugins/dotnet-blazor/skills/use-js-interop/SKILL.md
plugins/dotnet-data/skills/optimizing-ef-core-queries/SKILL.md
plugins/dotnet-diag/skills/analyzing-dotnet-performance/SKILL.md
plugins/dotnet-diag/skills/clr-activation-debugging/SKILL.md
plugins/dotnet-diag/skills/dotnet-trace-collect/SKILL.md
plugins/dotnet-diag/skills/dump-collect/SKILL.md
plugins/dotnet-experimental/skills/exp-mock-usage-analysis/SKILL.md
plugins/dotnet-experimental/skills/exp-simd-vectorization/SKILL.md
plugins/dotnet-msbuild/skills/binlog-failure-analysis/SKILL.md
plugins/dotnet-msbuild/skills/copy-to-output-directory/SKILL.md
plugins/dotnet-msbuild/skills/msbuild-server/SKILL.md
plugins/dotnet-msbuild/skills/resolve-project-references/SKILL.md
plugins/dotnet-test-migration/skills/migrate-xunit-to-mstest/SKILL.md
plugins/dotnet-test-migration/skills/migrate-xunit-to-xunit-v3/SKILL.md
plugins/dotnet-test/skills/assertion-quality/SKILL.md
plugins/dotnet-test/skills/code-testing-extensions/SKILL.md
plugins/dotnet-test/skills/crap-score/SKILL.md
plugins/dotnet-test/skills/filter-syntax/SKILL.md
plugins/dotnet-test/skills/find-untested-sources/SKILL.md
plugins/dotnet-test/skills/test-tagging/SKILL.md
plugins/dotnet-upgrade/skills/dotnet-aot-compat/SKILL.md
.agents/skills/authoring-github-workflows/SKILL.md
plugins/dotnet-advanced/skills/dotnet-pinvoke/SKILL.md
plugins/dotnet-ai/skills/mcp-csharp-create/SKILL.md
plugins/dotnet-ai/skills/mcp-csharp-debug/SKILL.md
plugins/dotnet-ai/skills/mcp-csharp-test/SKILL.md
plugins/dotnet-ai/skills/technology-selection/SKILL.md
plugins/dotnet-aspnetcore/skills/convert-blazor-server-to-webapp/SKILL.md
plugins/dotnet-blazor/skills/author-component/SKILL.md
plugins/dotnet-blazor/skills/collect-user-input/SKILL.md
plugins/dotnet-blazor/skills/configure-auth/SKILL.md
plugins/dotnet-blazor/skills/coordinate-components/SKILL.md
plugins/dotnet-blazor/skills/plan-ui-change/SKILL.md
plugins/dotnet-data/skills/create-datadriven-aspnetcore/SKILL.md
plugins/dotnet-diag/skills/android-tombstone-symbolication/SKILL.md

Metadata

Files
0
Version
be7b560
Hash
fdb5d1ab
Indexed
2026-08-14 01:14

Accueil - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-08-28 06:39
浙ICP备14020137号-1 $Carte des visiteurs$