New to Claude Skills? Learn how to install them →

Gdotnet on GitHub

Generate Testability Wrappers

Free

Easily create testable wrappers for static dependencies in C#.

by dotnet5.1k stars on dotnet/skills
1 views
Updated Aug 10, 2026
Get this skill

Free · Opens the source repo

What Generate Testability Wrappers does

Generate Testability Wrappers is designed for developers who need to make static dependencies in their C# applications more testable. Often, static classes and methods can complicate unit testing, as they introduce tight coupling and make it difficult to isolate components. This skill provides a systematic approach to creating wrapper interfaces and dependency injection (DI) registration for these hard-to-test static dependencies. By generating abstractions for commonly used static classes like File, Console, and Process, developers can replace static calls with injected dependencies, thereby improving testability and maintainability of their code.

The skill is particularly useful when working with .NET projects that do not yet have a DI container in place. It offers a way to implement an ambient context seam, allowing developers to inject dependencies without requiring a full DI framework. This means that even if you're working on legacy code or a new project that hasn't adopted DI yet, you can still benefit from the testability improvements this skill provides. The generated wrappers help in creating a clean separation between your application logic and static calls, facilitating easier unit testing and mocking.

Use this skill after identifying static dependencies in your codebase. It guides you through the process of wrapping those dependencies, whether they are built-in abstractions available in .NET or custom implementations you need to create. The skill also assists in adopting features like TimeProvider and IHttpClientFactory, ensuring that your application remains up-to-date with best practices in dependency management and testability. Whether you are a seasoned developer or just starting with unit testing, this skill provides a clear path to making your codebase more robust and testable.

When to use it

Use this skill when you have identified static dependencies that need to be wrapped for testability or when adopting built-in abstractions like `TimeProvider` or `IHttpClientFactory`.

When not to use it

Avoid using this skill if you need to detect static dependencies or bulk-replace existing static calls, as those tasks require different tools.

What you can build with it

Wrapping File System Calls

Use this skill to create an `IFileSystem` wrapper for file operations, allowing for easier unit testing.

Adopting TimeProvider

Implement `TimeProvider` in your application to manage time-dependent logic and facilitate testing with a mock time provider.

Creating Custom Wrappers for Console

Generate a custom `IConsole` wrapper to replace static console calls, making it easier to test console output.

How to install Generate Testability Wrappers

View source

1. Install with the skills CLI

npx skills add dotnet/skills/generate-testability-wrappers --agent claude-code

2. Or install it manually

Download the skill folder and drop it into ~/.claude/skills/ for all projects, or .claude/skills/ to scope it to one repo. Restart Claude Code so it picks up the new skill.

Anthropic's agentic coding CLI, and the reference implementation of Agent Skills. Drop a skill folder into ~/.claude/skills and Claude Code loads it automatically whenever a task matches the skill's description. Claude Code docs

Inside SKILL.md

Written by dotnet

Generate Testability Wrappers

Generate wrapper interfaces, default implementations, and DI service registration code for untestable static dependencies. For statics that already have .NET built-in abstractions (TimeProvider, IHttpClientFactory), guide adoption of the built-in. For statics without built-in alternatives, generate custom minimal wrappers.

When to Use

  • After running detect-static-dependencies and identifying which statics to wrap
  • When the user asks to make a class testable by replacing statics with injected abstractions
  • When adopting TimeProvider (.NET 8+) or System.IO.Abstractions
  • When creating a custom wrapper for Environment.*, Console.*, or Process.*
  • When there is no DI container and the seam has to be ambient rather than injected

When Not to Use

  • The user wants to find statics first (use detect-static-dependencies)
  • The user wants to bulk-replace call sites (use migrate-static-to-wrapper)
  • The static is already behind an interface

A project with no DI container, or a user who does not want to add one, is not a reason to skip this skill — that is exactly what the ambient context seam in Step 5 is for. Choose the seam over constructor injection in that case; do not decline the request and do not propose registering anything in a service collection.

Inputs

InputRequiredDescription
Static categoryYesWhich category: time, filesystem, environment, network, console, process
Target frameworkYesThe TargetFramework from .csproj (affects which built-in abstractions exist)
DI containerNoWhich DI framework: microsoft (default), autofac, none (ambient context)
NamespaceNoTarget namespace for generated wrapper code

Workflow

Step 1: Determine the abstraction strategy

Based on the category and target framework:

Category.NET 8+.NET 6-7.NET Framework
TimeTimeProvider (built-in)TimeProvider via Microsoft.Bcl.TimeProvider NuGetCustom ISystemClock
File systemSystem.IO.Abstractions (NuGet)SameSame
HTTPIHttpClientFactory (built-in)SameSame
EnvironmentCustom IEnvironmentProviderSameSame
ConsoleCustom IConsoleSameSame
ProcessCustom IProcessRunnerSameSame

The table picks which abstraction. How it reaches the code under test is a separate axis: constructor injection when a DI container exists, and the ambient context seam of Step 5 when one does not. Decide that axis first — check for a host builder, IServiceCollection, or an existing container registration — because a static class cannot take a constructor and a project without a container has nowhere to register anything. In that case skip Steps 2–4 and go to Step 5; the abstraction chosen above still applies, it is just reached through the ambient seam.

Step 2: Generate built-in abstraction adoption (Time, HTTP)

TimeProvider (.NET 8+)

No wrapper code needed — guide the user:

  1. Register in DI:
builder.Services.AddSingleton(TimeProvider.System);
  1. Inject into classes:
public class OrderProcessor(TimeProvider timeProvider)
{
    public bool IsExpired(Order order)
        => timeProvider.GetUtcNow() > order.ExpiresAt;
}
  1. Test with FakeTimeProvider:
// Requires Microsoft.Extensions.TimeProvider.Testing NuGet
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 1, 15, 0, 0, 0, TimeSpan.Zero));
var processor = new OrderProcessor(fakeTime);
fakeTime.Advance(TimeSpan.FromDays(1));
Assert.True(processor.IsExpired(order));

TimeProvider (pre-.NET 8)

Guide: install Microsoft.Bcl.TimeProvider NuGet. Same API as above.

IHttpClientFactory

No wrapper code needed — register typed clients via builder.Services.AddHttpClient<MyService>() and inject HttpClient directly into the class constructor.

Step 3: Generate custom wrappers (Environment, Console, Process)

For categories without built-in abstractions, follow this template:

Interface — define the minimal surface

Only include methods that were actually detected in the codebase. Do NOT generate a wrapper for every possible member — wrap only what is used.

namespace <Namespace>;

/// <summary>
/// Abstraction over <static class> for testability. 
/// </summary>
public interface I<WrapperName>
{
    // One method per detected static call
    <return type> <MethodName>(<parameters>);
}

Default implementation — delegate to the real static

namespace <Namespace>;

/// <summary>
/// Default implementation that delegates to <static class>.
/// </summary>
public sealed class <WrapperName> : I<WrapperName>
{
    public <return type> <MethodName>(<parameters>)
        => <StaticClass>.<Method>(<arguments>);
}

DI registration

// In Program.cs or Startup.cs:
builder.Services.AddSingleton<I<WrapperName>, <WrapperName>>();

Step 4: Generate file system wrapper adoption

Prefer the established System.IO.Abstractions NuGet package over custom wrappers:

  1. Install the package:
dotnet add package System.IO.Abstractions
  1. Register in DI:
builder.Services.AddSingleton<IFileSystem, FileSystem>();
  1. Inject IFileSystem into classes:
public class ConfigLoader(IFileSystem fileSystem)
{
    public string LoadConfig(string path)
        => fileSystem.File.ReadAllText(path);
}
  1. Test with MockFileSystem:
dotnet add <TestProject> package System.IO.Abstractions.TestingHelpers
var mockFs = new MockFileSystem(new Dictionary<string, MockFileData>
{
    { "/config.json", new MockFileData("{\"key\": \"value\"}") }
});
var loader = new ConfigLoader(mockFs);
Assert.Equal("{\"key\": \"value\"}", loader.LoadConfig("/config.json"));

Step 5: Generate ambient context alternative (when DI is not available)

If the codebase does not use DI (e.g., old console app, library code), offer the ambient context pattern:

public static class Clock
{
    private static readonly AsyncLocal<Func<DateTimeOffset>?> s_override = new();
    public static DateTimeOffset UtcNow
        => s_override.Value?.Invoke() ?? TimeProvider.System.GetUtcNow();

    public static IDisposable Override(DateTimeOffset fixedTime)
    {
        s_override.Value = () => fixedTime;
        return new Scope();
    }
    private sealed class Scope : IDisposable
    {
        public void Dispose() => s_override.Value = null;
    }
}

Key trade-offs: AsyncLocal<T> ensures parallel tests don't interfere; production cost is one null check per call; the static readonly field is essentially free.

Three properties this pattern must keep, because each has broken a real migration:

  • Scope the override and make it reversible. Return an IDisposable that restores the previous value, so a test cannot leak a pinned time into the next one. A bare setter, or a manual try/finally at each call site, puts that burden on every test author.
  • Use AsyncLocal<T>, never [ThreadStatic]. [ThreadStatic] does not flow across await, so the override silently disappears mid-test.
  • Preserve the semantics of the member you are replacing. Substituting DateTime.UtcNow with a local-time source changes the DateTimeKind every existing caller and stored value depends on — pair UtcNow with GetUtcNow(), and Now with GetLocalNow().

The same shape works for non-time statics: swap TimeProvider.System.GetUtcNow() for the real static call and keep the override slot, the disposable scope, and the original semantics.

Step 6: Place generated files

Generate files following the project's existing conventions:

  • If there is an Abstractions/ or Interfaces/ folder, place the interface there
  • If there is an Infrastructure/ or Services/ folder, place the implementation there
  • Otherwise, create files next to the code that uses the static

Always generate:

  1. The interface file (or adoption instructions for built-in abstractions)
  2. The default implementation file
  3. The DI registration snippet (as a code comment at the bottom of the implementation, or as separate instructions) — skip this one entirely on the ambient-seam path: there is no container to register into, and offering one anyway is the failure mode that made a user ask for the seam in the first place

Validation

  • Generated interface only wraps statics that were actually detected (not speculative)
  • Default implementation delegates to the real static with no behavior changes
  • DI registration uses AddSingleton for stateless wrappers, AddTransient for stateful ones
  • NuGet packages are recommended where established libraries exist (System.IO.Abstractions, etc.)
  • For .NET 8+, TimeProvider is recommended over custom ISystemClock
  • Ambient context pattern includes AsyncLocal<T>, a scoped IDisposable that restores the previous value, and trade-off explanation
  • On the ambient-seam path, no IServiceCollection registration is proposed and the replaced member's semantics (UtcNow vs Now, and its DateTimeKind) are preserved

Common Pitfalls

PitfallSolution
Declining because the project has no DI containerThe ambient seam in Step 5 is the answer for that case — offer it instead of asking the user to adopt a container
Wrapping ALL members of a static classOnly wrap methods actually called in the codebase
Custom time wrapper on .NET 8+Use built-in TimeProvider instead
Custom file system wrapperPrefer System.IO.Abstractions NuGet — battle-tested, complete
Registering scoped when singleton sufficesStateless wrappers should be AddSingleton
Forgetting test helper packagesMicrosoft.Extensions.TimeProvider.Testing for time, System.IO.Abstractions.TestingHelpers for filesystem
Ambient context without AsyncLocalNon-async [ThreadStatic] breaks with async/await — always use AsyncLocal<T>

Frequently asked questions about Generate Testability Wrappers

Similar skills