Skills generate-testability-wrappers
git clone https://github.com/dotnet/skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/dotnet/skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/dotnet-test/skills/generate-testability-wrappers" ~/.claude/skills/dotnet-skills-generate-testability-wrappers && rm -rf "$T"
plugins/dotnet-test/skills/generate-testability-wrappers/SKILL.mdGenerate 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
and identifying which statics to wrapdetect-static-dependencies - When the user asks to make a class testable by replacing statics with injected abstractions
- When adopting
(.NET 8+) orTimeProviderSystem.IO.Abstractions - When creating a custom wrapper for
,Environment.*
, orConsole.*Process.*
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
- The project does not use dependency injection and the user does not want to add it
Inputs
| Input | Required | Description |
|---|---|---|
| Static category | Yes | Which category: , , , , , |
| Target framework | Yes | The from (affects which built-in abstractions exist) |
| DI container | No | Which DI framework: (default), , (ambient context) |
| Namespace | No | Target 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 |
|---|---|---|---|
| Time | (built-in) | via NuGet | Custom |
| File system | (NuGet) | Same | Same |
| HTTP | (built-in) | Same | Same |
| Environment | Custom | Same | Same |
| Console | Custom | Same | Same |
| Process | Custom | Same | Same |
Step 2: Generate built-in abstraction adoption (Time, HTTP)
TimeProvider (.NET 8+)
No wrapper code needed — guide the user:
- Register in DI:
builder.Services.AddSingleton(TimeProvider.System);
- Inject into classes:
public class OrderProcessor(TimeProvider timeProvider) { public bool IsExpired(Order order) => timeProvider.GetUtcNow() > order.ExpiresAt; }
- 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:
- Install the package:
dotnet add package System.IO.Abstractions
- Register in DI:
builder.Services.AddSingleton<IFileSystem, FileSystem>();
- Inject
into classes:IFileSystem
public class ConfigLoader(IFileSystem fileSystem) { public string LoadConfig(string path) => fileSystem.File.ReadAllText(path); }
- 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.
Step 6: Place generated files
Generate files following the project's existing conventions:
- If there is an
orAbstractions/
folder, place the interface thereInterfaces/ - If there is an
orInfrastructure/
folder, place the implementation thereServices/ - Otherwise, create files next to the code that uses the static
Always generate:
- The interface file (or adoption instructions for built-in abstractions)
- The default implementation file
- The DI registration snippet (as a code comment at the bottom of the implementation, or as separate instructions)
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
for stateless wrappers,AddSingleton
for stateful onesAddTransient - NuGet packages are recommended where established libraries exist (System.IO.Abstractions, etc.)
- For .NET 8+,
is recommended over customTimeProviderISystemClock - Ambient context pattern includes
, scoped disposal, and trade-off explanationAsyncLocal<T>
Common Pitfalls
| Pitfall | Solution |
|---|---|
| Wrapping ALL members of a static class | Only wrap methods actually called in the codebase |
| Custom time wrapper on .NET 8+ | Use built-in instead |
| Custom file system wrapper | Prefer NuGet — battle-tested, complete |
| Registering scoped when singleton suffices | Stateless wrappers should be |
| Forgetting test helper packages | for time, for filesystem |
Ambient context without | Non-async breaks with / — always use |