Migrate MSTest v1 or v2 test projects to MSTest v3.
Migrate Static to Wrapper
Replace existing static dependency call sites with a wrapper or built-in abstraction that already exists or is registered in DI. Codemod-style bulk replacement of DateTime.Now/UtcNow to TimeProvider, File.ReadAllText to IFileSystem, and similar, across a bounded scope (file, project, namespace), adding constructor injection to affected classes and updating their unit tests to use a test double. USE FOR: replace DateTime.UtcNow/DateTime.Now with TimeProvider and add the constructor parameter, migrate static call sites to a wrapper already in DI, bulk replace File.* with IFileSystem, scoped migration of statics in only certain files, migrate a service to TimeProvider and update its unit tests to a controllable/fake time source, update test doubles when migrating off static DateTime/File calls. DO NOT USE FOR: detecting statics (use detect-static-dependencies), creating or registering the wrapper when it does not exist yet (use generate-testability-wrappers), migrating between test frameworks.
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
TimeProvider, verify the target framework is .NET 8+ orMicrosoft.Bcl.TimeProvideris referenced. ForSystem.IO.Abstractions, verify the NuGet package is referenced.
- Confirm DI registration exists: Check
Program.csorStartup.csfor the service registration. If missing, add it before proceeding.
- Identify all files in scope: List the
.csfiles that will be modified. Exclude test projects,obj/,bin/, and generated code.
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,IFileSystem, etc. parameters - The replacement expression for each call site
#### Replacement mapping
| Category | Original | DI replacement | |----------|----------|----------------| | Time | DateTime.Now | _timeProvider.GetLocalNow().LocalDateTime | | Time | DateTime.UtcNow | _timeProvider.GetUtcNow().UtcDateTime | | Time | DateTime.Today | _timeProvider.GetLocalNow().LocalDateTime.Date | | Time | DateTimeOffset.Now | _timeProvider.GetLocalNow() | | Time | DateTimeOffset.UtcNow | _timeProvider.GetUtcNow() | | File | File.ReadAllText(path) | _fileSystem.File.ReadAllText(path) | | File | File.WriteAllText(path, text) | _fileSystem.File.WriteAllText(path, text) | | File | File.Exists(path) | _fileSystem.File.Exists(path) | | File | Directory.Exists(path) | _fileSystem.Directory.Exists(path) | | Env | Environment.GetEnvironmentVariable(name) | _env.GetEnvironmentVariable(name) | | Console | Console.WriteLine(msg) | _console.WriteLine(msg) | | Process | Process.Start(info) | _processRunner.Start(info) |
Apply the same pattern for other members in each category.
> Preserve `DateTimeKind` — this is the most common silent regression. TimeProvider.GetUtcNow() / GetLocalNow() return a DateTimeOffset. Converting back to DateTime must keep the original `Kind`, otherwise you introduce a behavioral change even though the code still compiles: > > - DateTime.UtcNow has Kind == Utc → use .UtcDateTime (not .DateTime, which yields Kind == Unspecified). > - DateTime.Now has Kind == Local → use .LocalDateTime (not .DateTime). > - When a call site consumes a DateTimeOffset directly (a field/parameter/return already typed DateTimeOffset), drop the .UtcDateTime/.LocalDateTime suffix and assign the DateTimeOffset as-is — don't force it back through DateTime. > > Match the target member's type: if the surrounding field/property is DateTime, keep it DateTime (via the Kind-correct property above); do not change it to DateTimeOffset as part of a "mechanical" migration — that is a design change, not a delegation.
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
private readonlyfield + constructor parameter, matching the existing field naming convention (_camelCaseorm_camelCase)
#### Static classes: use ambient context (no constructor injection)
A static class with only static members cannot receive constructor injection — adding an instance constructor or instance field would break it. Do not convert it to a non-static class just to inject the dependency; that changes its design and every call site. Instead, apply the ambient context pattern: expose a static, settable seam that defaults to the real implementation and is overridden once at composition/test setup.
When the user wants to keep the class static, the ambient seam below is the answer — present it as *the* solution and implement it directly. Do not hedge by offering "convert it to a non-static class" or "pass TimeProvider as a method parameter" as co-equal alternatives; those change the class's design or public API and are not what was asked. Lead with the seam, then note the parallelism trade-off.
public static class TimestampFormatter
{
// Ambient seam — defaults to the real clock, swap in tests.
public static TimeProvider Clock { get; set; } = TimeProvider.System;
public static string Now() => Clock.GetUtcNow().ToString("O");
}
- Production: leave
Clockat itsTimeProvider.Systemdefault, or assign the DI-resolvedTimeProvideronce at startup (TimestampFormatter.Clock = app.Services.GetRequiredService<TimeProvider>();). - Tests: override
Clockwith aFakeTimeProviderand always restore it in a `finally` so a failing assertion can't leak the fake into other tests:
``csharp var original = TimestampFormatter.Clock; TimestampFormatter.Clock = new FakeTimeProvider(instant); try { // exercise code under test } finally { TimestampFormatter.Clock = original; } ``
- Parallelism caveat: a mutable static seam is process-global. Tests that mutate it must not run in parallel with each other (or with code that reads it) — put them in a non-parallel collection/class (e.g. xUnit
[Collection]with parallelization disabled, or MSTest[DoNotParallelize]). Only if the class is *not* required to stay static and its tests must run fully parallel should you consider converting the caller to an instance with constructor injection instead — otherwise keep the ambient seam. - The same seam works for other statics (
IFileSystem, custom wrappers): apublic static <Abstraction> X { get; set; }defaulting to the real implementation, with the same restore-in-finallyand non-parallel discipline.
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
usingdirectives if not already present
#### Adding using directives
| Abstraction | Using directive | |------------|-----------------| | TimeProvider | None (in System namespace) | | IFileSystem | using System.IO.Abstractions; | | IHttpClientFactory | using System.Net.Http; (usually already present) | | Custom wrappers | using <wrapper namespace>; |
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 → new FakeTimeProvider() from Microsoft.Extensions.TimeProvider.Testing - IFileSystem → new MockFileSystem() from System.IO.Abstractions.TestingHelpers - Custom wrappers → new Mock<IWrapperName>() or hand-rolled fake
Step 6: Build verification
After all changes in the current scope:
dotnet build <project.csproj>
If the build fails:
- Missing using: Add the required
usingdirective - 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:
Related skills
Fix build errors and breaking changes after upgrading MSTest from v3 to v4, or plan a complete MSTest v3-to-v4 migration.
Migrates .NET test projects from VSTest to Microsoft.Testing.Platform (MTP).