Skills migrate-static-to-wrapper
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/migrate-static-to-wrapper" ~/.claude/skills/dotnet-skills-migrate-static-to-wrapper && rm -rf "$T"
plugins/dotnet-test/skills/migrate-static-to-wrapper/SKILL.mdMigrate Static to Wrapper
Perform mechanical, codemod-style replacement of static dependency call sites with calls to injected wrapper interfaces or built-in abstractions. Operates on a bounded scope (single file, project, or namespace) so migrations can be done incrementally.
When to Use
- After wrappers have been generated (via
) or built-in abstractions identifiedgenerate-testability-wrappers - Migrating
→DateTime.UtcNow
across a projectTimeProvider.GetUtcNow() - Migrating
→File.*
across a namespaceIFileSystem.File.* - Adding constructor injection for the new abstraction to affected classes
- Incremental migration: one project or namespace at a time
When Not to Use
- No wrapper or abstraction exists yet (use
first)generate-testability-wrappers - The user wants to detect statics, not migrate them (use
)detect-static-dependencies - The code does not use dependency injection and the user hasn't chosen ambient context
- Migrating between test frameworks (use the appropriate migration skill)
Inputs
| Input | Required | Description |
|---|---|---|
| Static pattern | Yes | What to replace (e.g., , ) |
| Replacement abstraction | Yes | What to use instead (e.g., , ) |
| Scope | Yes | File path, project (.csproj), namespace, or directory to migrate |
| Injection strategy | No | (default), , or |
Workflow
Step 1: Verify prerequisites
Before modifying any code:
-
Confirm the wrapper/abstraction exists: Check that the interface or built-in abstraction is available in the project. For
, verify the target framework is .NET 8+ orTimeProvider
is referenced. ForMicrosoft.Bcl.TimeProvider
, verify the NuGet package is referenced.System.IO.Abstractions -
Confirm DI registration exists: Check
orProgram.cs
for the service registration. If missing, add it before proceeding.Startup.cs -
Identify all files in scope: List the
files that will be modified. Exclude test projects,.cs
,obj/
, and generated code.bin/
Step 2: Plan the migration for each file
For each file containing the static pattern, determine:
- Which class(es) contain the call sites — identify the class declarations
- Whether the class already has the dependency injected — check constructors for existing
,TimeProvider
, etc. parametersIFileSystem - The replacement expression for each call site
Replacement mapping
| Category | Original | DI replacement |
|---|---|---|
| Time | | |
| Time | | |
| Time | | |
| Time | | |
| File | | |
| File | | |
| File | | |
| File | | |
| Env | | |
| Console | | |
| Process | | |
Apply the same pattern for other members in each category.
Step 3: Add constructor injection
Add the new dependency following the class's existing pattern:
- Primary constructor (C# 12+): Add parameter to primary constructor:
public class OrderProcessor(ILogger<OrderProcessor> logger, TimeProvider timeProvider) - Traditional constructor: Add
field + constructor parameter, matching the existing field naming convention (private readonly
or_camelCase
)m_camelCase
Step 4: Replace call sites
Perform each replacement mechanically. For each call site:
- Replace the static call with the wrapper call
- Preserve the surrounding code structure (whitespace, comments, chaining)
- Add required
directives if not already presentusing
Adding using directives
| Abstraction | Using directive |
|---|---|
| None (in namespace) |
| |
| (usually already present) |
| Custom wrappers | |
Step 5: Update affected test files
If test files exist for the migrated classes:
- Update constructor calls — add the new parameter to test class instantiation
- Use test doubles:
→TimeProvider
fromnew FakeTimeProvider()Microsoft.Extensions.TimeProvider.Testing
→IFileSystem
fromnew MockFileSystem()System.IO.Abstractions.TestingHelpers- Custom wrappers →
or hand-rolled fakenew Mock<IWrapperName>()
Step 6: Build verification
After all changes in the current scope:
dotnet build <project.csproj>
If the build fails:
- Missing using: Add the required
directiveusing - Missing NuGet package: Run
dotnet add package <name> - Constructor mismatch in tests: Update test instantiation (Step 5)
- Ambiguous call: Fully qualify the wrapper call
Step 7: Report changes
Summarize what was done:
## Migration Summary **Pattern**: DateTime.UtcNow → TimeProvider.GetUtcNow() **Scope**: MyProject/Services/ ### Files Modified (production) | File | Call Sites Replaced | Injection Added | |------|--------------------:|:----------------| | OrderProcessor.cs | 3 | Yes (constructor) | | NotificationService.cs | 1 | Yes (primary ctor) | ### Files Modified (tests) | File | Change | |------|--------| | OrderProcessorTests.cs | Added FakeTimeProvider parameter | ### Remaining (out of scope) - MyProject/Legacy/ — 8 call sites not migrated (different namespace)
Validation
- All call sites in scope were replaced (none missed)
- Constructor injection added to all affected classes
- Field naming follows existing class conventions
- Required
directives addedusing - Required NuGet packages referenced
- Build succeeds after migration
- Test files updated with appropriate test doubles
- No behavioral changes introduced (wrapper delegates directly to the static)
Common Pitfalls
| Pitfall | Solution |
|---|---|
| Replacing statics in test code | Only replace in production code; tests should use fakes/mocks |
| Breaking static classes | Static classes can't have constructors — use ambient context for these |
Missing NuGet | Add to test project |
| Replacing in expression-bodied members without updating return type | → when using — verify type compatibility |
| Migrating too much at once | Stick to the defined scope — one project or namespace per run |
| Forgetting DI registration | Always verify / has the registration before replacing call sites |