Use this skill before answering or editing whenever an MSTest v1/v2 project is being upgraded or repaired for v3.
MSTest v3 -> v4 Migration
Use this skill before answering, planning, or editing any MSTest 3.x-to-4.x upgrade or post-upgrade failure. Triggers include "MSTest v4 breaking changes"; CS0507/CS0103/CS1061/CS1615; ExecuteAsync, CallerInfo, DisplayName, or custom TestMethodAttribute; ClassCleanupBehavior; ContainsKey; ThrowsExactly or ExpectedException; IsInstanceOfType out parameters; TestTimeout.Infinite; ManagedType; net6/net7 compatibility; TestCase.Id history; TestName in ClassInitialize; TreatDiscoveryWarningsAsErrors; discovery errors after a clean build; and MSTest.Sdk/MTP or vstest.console discovery changes. Do not use for v1/v2-to-v3 leftovers, framework conversion, runner-only migration, or a general .NET upgrade.
Workflow
> Commit strategy: Do not create commits unless the user asks. Keep package, > source, and behavioral changes logically separable in the diff, but finish and > verify the requested migration.
Step 1: Assess the project
- Identify the current MSTest version by checking package references for
MSTest,MSTest.TestFramework,MSTest.TestAdapter, orMSTest.Sdkin.csproj,Directory.Build.props, orDirectory.Packages.props. - Confirm the project is on MSTest v3 (3.x). If on v1 or v2, use
migrate-mstest-v1v2-to-v3first. - Check target framework(s) -- MSTest v4 drops support for .NET Core 3.1 through .NET 7. Supported target frameworks are: net8.0, net9.0, net462 (.NET Framework 4.6.2+), uap10.0.16299 (UWP), net9.0-windows10.0.17763.0 (modern UWP), and net8.0-windows10.0.18362.0 (WinUI).
- Check for custom
TestMethodAttributesubclasses -- these require changes in v4. - Check for usages of
ExpectedExceptionAttribute-- removed in v4 (deprecated since v3 with analyzer MSTEST0006). - Check for usages of
Assert.ThrowsException(deprecated) -- removed in v4. - Run a clean build to establish a baseline of existing errors/warnings.
Step 2: Update packages to MSTest v4
First resolve the latest stable v4 version from the configured package source. Pin that exact version consistently in the metapackage, individual packages, MSTest.Sdk, and central package management.
- For the
MSTestmetapackage, update itsPackageReferenceto the resolved exact version. - For individual packages, update
MSTest.TestFrameworkandMSTest.TestAdapterto that same version. - For
MSTest.Sdk, update the SDK version in the project orglobal.jsonpin to that same version.
Run dotnet restore, then dotnet build. Collect all errors for Step 3.
Step 3: Resolve source breaking changes
Work through compilation errors systematically. Use this quick-lookup table to identify all applicable changes, then apply each fix:
| Error / Pattern in code | Breaking change | Fix | |---|---|---| | Custom TestMethodAttribute overrides Execute | Execute removed | Change to ExecuteAsync returning Task<TestResult[]> (3.1) | | [TestMethod("name")] or custom attribute constructor | CallerInfo params added | Use DisplayName = "name" named param; propagate CallerInfo in subclasses (3.2) | | ClassCleanupBehavior.EndOfClass | Enum removed | Remove argument: just [ClassCleanup] (3.3) | | TestContext.Properties.Contains("key") | Properties is IDictionary<string, object> | Change to ContainsKey("key") (3.4) | | [Timeout(TestTimeout.Infinite)] | TestTimeout enum removed | Replace with [Timeout(int.MaxValue)] (3.5) | | TestContext.ManagedType | Property removed | Use FullyQualifiedTestClassName (3.6) | | Assert.AreEqual(a, b, "msg {0}", arg) | Message+params overloads removed | Use string interpolation: $"msg {arg}" (3.7) | | Assert.ThrowsException<T>(...) | Renamed | Replace with Assert.ThrowsExactly<T>(...) or Assert.Throws<T>(...) (3.7) | | Assert.IsInstanceOfType<T>(obj, out var t) | Out parameter removed | Use var t = Assert.IsInstanceOfType<T>(obj) (3.7) | | [ExpectedException(typeof(T))] | Attribute removed | Move assertion into test body: Assert.ThrowsExactly<T>(() => ...) (3.8) | | Project targets net5.0, net6.0, or net7.0 | TFM dropped | Change to net8.0 or net9.0 (3.9) |
> Important: Scan the entire project for ALL patterns above before starting fixes. Multiple breaking changes often coexist in the same project.
#### 3.1 TestMethodAttribute.Execute -> ExecuteAsync
If you have custom TestMethodAttribute subclasses that override Execute, change to ExecuteAsync. This change was made because the v3 synchronous Execute API caused deadlocks when test code used async/await internally -- the synchronous wrapper would block the thread while the async operation needed that same thread to complete.
// Before (v3)
public sealed class MyTestMethodAttribute : TestMethodAttribute
{
public override TestResult[] Execute(ITestMethod testMethod)
{
// custom logic
return result;
}
}
// After (v4) -- Option A: wrap synchronous logic with Task.FromResult
public sealed class MyTestMethodAttribute : TestMethodAttribute
{
public override Task<TestResult[]> ExecuteAsync(ITestMethod testMethod)
{
// custom logic (synchronous)
return Task.FromResult(result);
}
}
// After (v4) -- Option B: make properly async
public sealed class MyTestMethodAttribute : TestMethodAttribute
{
public override async Task<TestResult[]> ExecuteAsync(ITestMethod testMethod)
{
// custom async logic
return await base.ExecuteAsync(testMethod);
}
}
Use Task.FromResult when your override logic is purely synchronous. Use async/await when you call base.ExecuteAsync or other async methods.
#### 3.2 TestMethodAttribute CallerInfo constructor
TestMethodAttribute now uses [CallerFilePath] and [CallerLineNumber] parameters in its constructor.
If you inherit from TestMethodAttribute, propagate caller info to the base class:
public class MyTestMethodAttribute : TestMethodAttribute
{
public MyTestMethodAttribute(
[CallerFilePath] string callerFilePath = "",
[CallerLineNumber] int callerLineNumber = -1)
: base(callerFilePath, callerLineNumber)
{
}
}
If the subclass has its own display-name constructor, do not pass that string to the v4 base constructor. Propagate only caller information and assign the DisplayName property:
public sealed class NamedTestMethodAttribute : TestMethodAttribute
{
public NamedTestMethodAttribute(
string displayName,
[CallerFilePath] string callerFilePath = "",
[CallerLineNumber] int callerLineNumber = -1)
: base(callerFilePath, callerLineNumber)
{
DisplayName = displayName;
}
}
If you use `[TestMethodAttribute("Custom display name")]`, switch to the named parameter syntax:
// Before (v3)
[TestMethodAttribute("Custom display name")]
// After (v4)
[TestMethodAttribute(DisplayName = "Custom display name")]
#### 3.3 ClassCleanupBehavior enum removed
The ClassCleanupBehavior enum is removed. In v3, this enum controlled whether class cleanup ran at end of class (EndOfClass) or end of assembly (EndOfAssembly). In v4, class cleanup always runs at end of class. Remove the enum argument:
// Before (v3)
[ClassCleanup(ClassCleanupBehavior.EndOfClass)]
public static void ClassCleanup(TestContext testContext) { }
// After (v4)
[ClassCleanup]
public static void ClassCleanup(TestContext testContext) { }
If you previously used ClassCleanupBehavior.EndOfAssembly, move that cleanup logic to an [AssemblyCleanup] method instead.
#### 3.4 TestContext.Properties type change
TestContext.Properties changed from IDictionary to IDictionary<string, object>. Update any Contains calls to ContainsKey:
// Before (v3)
testContext.Properties.Contains("key");
// After (v4)
testContext.Properties.ContainsKey("key");
#### 3.5 TestTimeout enum removed
The TestTimeout enum (with only TestTimeout.Infinite) is removed. Replace with int.MaxValue:
// Before (v3)
[Timeout(TestTimeout.Infinite)]
// After (v4)
[Timeout(int.MaxValue)]
#### 3.6 TestContext.ManagedType removed
The TestContext.ManagedType property is removed. Use TestContext.FullyQualifiedTestClassName instead.
#### 3.7 Assert API signature changes
- Message + params removed: Assert methods that accepted both
messageandobject[]parameters now accept onlymessage. Use string interpolation instead of format strings:
// Before (v3)
Assert.AreEqual(expected, actual, "Expected {0} but got {1}", expected, actual);
// After (v4)
Assert.AreEqual(expected, actual, $"Expected {expected} but got {actual}");
- Assert.ThrowsException renamed: The
Assert.ThrowsExceptionAPIs are renamed. UseAssert.ThrowsExactly(strict type match) orAssert.Throws(accepts derived exception types):
// Before (v3)
Assert.ThrowsException<InvalidOperationException>(() => DoSomething());
// After (v4) -- exact type match (same behavior as old ThrowsException)
Assert.ThrowsExactly<InvalidOperationException>(() => DoSomething());
// After (v4) -- also catches derived exception types
Assert.Throws<InvalidOperationException>(() => DoSomething());
- Assert.IsInstanceOfType out parameter changed:
Assert.IsInstanceOfType<T>(x, out var t)changes tovar t = Assert.IsInstanceOfType<T>(x):
// Before (v3)
Assert.IsInstanceOfType<MyType>(obj, out var typed);
// After (v4)
var typed = Assert.IsInstanceOfType<MyType>(obj);
Apply this assignment rewrite to every occurrence, preserving the concrete asserted type and all later uses of the typed variable. When source is available, show or edit the actual method rather than substituting a generic MyType example, then verify that the project compiles.
- Assert.AreEqual for IEquatable\<T\> removed: If you get generic type inference errors, explicitly specify the type argument as
object.
#### 3.8 ExpectedExceptionAttribute removed
The [ExpectedException] attribute is removed in v4. In MSTest 3.2, the MSTEST0006 analyzer was introduced to flag [ExpectedException] usage and suggest migrating to Assert.ThrowsExactly while still on v3 (a non-breaking change). In v4, the attribute is gone entirely. Migrate to Assert.ThrowsExactly:
// Before (v3)
[ExpectedException(typeof(InvalidOperationException))]
[TestMethod]
public void TestMethod()
{
MyCall();
}
// After (v4)
[TestMethod]
public void TestMethod()
{
Assert.ThrowsExactly<InvalidOperationException>(() => MyCall());
}
When the test has setup code before the throwing call, wrap only the throwing call in the lambda -- keep Arrange/Act separation clear:
// Before (v3)
[ExpectedException(typeof(ArgumentNullException))]
[TestMethod]
public void Validate_NullInput_Throws()
{
var service = new ValidationService();
service.Validate(null); // throws here
}
// After (v4)
[TestMethod]
public void Validate_NullInput_Throws()
{
var service = new ValidationService();
Assert.ThrowsExactly<ArgumentNullException>(() => service.Validate(null));
}
For async test methods, use Assert.ThrowsExactlyAsync:
// Before (v3)
[ExpectedException(typeof(HttpRequestException))]
[TestMethod]
public async Task FetchData_BadUrl_Throws()
{
await client.GetAsync("https://localhost:0");
}
// After (v4)
[TestMethod]
public async Task FetchData_BadUrl_Throws()
{
await Assert.ThrowsExactlyAsync<HttpRequestException>(
() => client.GetAsync("https://localhost:0"));
}
If `[ExpectedException]` used the `AllowDerivedTypes` property, use Assert.ThrowsAsync<T> (base type matching) instead of Assert.ThrowsExactlyAsync<T> (exact type matching).
For a focused migration, convert every attributed method in the supplied source, wrap only the statement expected to throw, preserve arrange/setup statements outside the lambda, and run the affected tests. A prose-only API substitution is incomplete when editable project files are present.
#### 3.9 Dropped target frameworks
MSTest v4 supports .NET 8 and later and .NET Framework 4.6.2 and later. Platform-specific supported targets also include uap10.0.16299 (UWP), with modern UWP and WinUI using their corresponding supported Windows-specific .NET TFMs. .NET Core 3.1 through .NET 7 are dropped.
If the test project targets an unsupported framework, update TargetFramework:
<!-- Before -->
<TargetFramework>net6.0</TargetFramework>
<!-- After -->
<TargetFramework>net8.0</TargetFramework>
#### 3.10 Unfolding strategy moved to TestMethodAttribute
The UnfoldingStrategy property (introduced in MSTest 3.7) has moved from individual data source attributes (DataRowAttribute, DynamicDataAttribute) to TestMethodAttribute.
#### 3.11 ConditionBaseAttribute.ShouldRun renamed
The ConditionBaseAttribute.ShouldRun property is renamed to IsConditionMet.
#### 3.12 Internal/removed types
Several types previously public are now internal or removed:
MSTestDiscoverer,MSTestExecutor,AssemblyResolver,LogMessageListenerTestExecutionManager,TestMethodInfo,TestResultExtensionsUnitTestOutcomeExtensions,GenericParameterHelperITestMethodin PlatformServices assembly (the one in TestFramework is unchanged)
If your code references any of these, find alternative approaches or remove the dependency.
Step 4: Address behavioral changes
These changes won't cause build errors but may affect test runtime behavior.
| Symptom | Cause | Fix | |---|---|---| | Tests show as new in Azure DevOps / test history lost | TestCase.Id generation changed (4.3) | No code fix; history will re-baseline | | TestContext.TestName throws in [ClassInitialize] | v4 enforces lifecycle scope (4.2) | Move access to [TestInitialize] or test methods | | Tests not discovered / discovery failures | TreatDiscoveryWarningsAsErrors now true (4.4) | Fix warnings, or set to false in .runsettings | | Tests hang that didn't before | AppDomain disabled by default (4.1) | Set DisableAppDomain to false in .runsettings RunConfiguration | | vstest.console can't find tests with MSTest.Sdk after the v4 upgrade | MSTest.Sdk defaults to MTP; v4 stopped adding Microsoft.NET.Test.Sdk in MTP mode (4.5) | Add an explicit package while preserving MTP, set UseVSTest, or switch CI to dotnet test | | New warnings from analyzers | Analyzer severities upgraded (4.6) | Fix warnings or suppress in .editorconfig |
#### 4.1 DisableAppDomain defaults to true
AppDomains are disabled by default. On .NET Framework, when running inside testhost (the default for dotnet test and VS), MSTest re-enables AppDomains automatically. If you need to explicitly control AppDomain isolation, set it via .runsettings:
<RunSettings>
<RunConfiguration>
<DisableAppDomain>false</DisableAppDomain>
</RunConfiguration>
</RunSettings>
#### 4.2 TestContext throws when used incorrectly
MSTest v4 now throws when accessing test-specific properties in the wrong lifecycle stage:
TestContext.FullyQualifiedTestClassName-- cannot be accessed in[AssemblyInitialize]TestContext.TestName-- cannot be accessed in[AssemblyInitialize]or[ClassInitialize]
Fix: Move any code that accesses TestContext.TestName from [ClassInitialize] to [TestInitialize] or individual test methods, where per-test context is available. Do not replace TestName with FullyQualifiedTestClassName as a workaround -- they have different semantics.
#### 4.3 TestCase.Id generation changed
The generation algorithm for TestCase.Id has changed to fix long-standing bugs. This may affect Azure DevOps test result tracking (e.g., test failure tracking over time). There is no code fix needed, but be aware of test result history discontinuity.
#### 4.4 TreatDiscoveryWarningsAsErrors defaults to true
v4 uses stricter defaults. Discovery warnings are now treated as errors, which means tests that previously ran despite discovery issues may now fail entirely. If you see unexpected test failures after upgrading (not build errors, but tests not being discovered), check for discovery warnings. To restore v3 behavior while you investigate:
<RunSettings>
<MSTest>
<TreatDiscoveryWarningsAsErrors>false</TreatDiscoveryWarningsAsErrors>
</MSTest>
</RunSettings>
> Recommended: Fix the underlying discovery warnings rather than suppressing this setting.
#### 4.5 MSTest.Sdk and vstest.console compatibility
MSTest.Sdk defaults to Microsoft.Testing.Platform (MTP) mode. MSTest.Sdk v3 still added Microsoft.NET.Test.Sdk in that mode; v4 removes the unnecessary reference. A CI pipeline that separately invokes vstest.console can therefore drop to zero discovered tests immediately after the v4 upgrade.
Option A -- Preserve MTP and transitional VSTest discovery: Add the exact compatible Microsoft.NET.Test.Sdk package explicitly. This is the least disruptive fix when MTP remains the primary runner but an existing vstest.console job cannot be removed yet:
Use a direct PackageReference with the exact compatible version resolved from the configured feed. Under Central Package Management, add or update the Microsoft.NET.Test.Sdk PackageVersion in Directory.Packages.props and keep the project reference versionless. Do not copy a fixed example version.
Verify with the actual vstest.console command; a passing dotnet test MTP run does not prove VSTest discovery.
Option B -- Switch the project to VSTest mode: Set the UseVSTest property. MSTest.Sdk then adds Microsoft.NET.Test.Sdk:
<PropertyGroup>
<UseVSTest>true</UseVSTest>
</PropertyGroup>
Keep the resolved exact MSTest.Sdk v4 pin from Step 2; this option changes the runner, not the selected MSTest version or target framework.
Option C -- Switch CI to `dotnet test`: Replace vstest.console invocations in your CI pipeline with dotnet test. This works natively with MTP and is the recommended long-term approach for MSTest.Sdk projects.
Do not say this behavior predates v4: removal of the transitive Microsoft.NET.Test.Sdk reference in MTP mode is one of the v4 behavioral breaking changes.
#### 4.6 Analyzer severity changes
Multiple analyzers have been upgraded from Info to Warning by default:
- MSTEST0001, MSTEST0007, MSTEST0017, MSTEST0023, MSTEST0024, MSTEST0025
- MSTEST0030, MSTEST0031, MSTEST0032, MSTEST0035, MSTEST0037, MSTEST0045
Review and fix any new warnings, or suppress them in .editorconfig if intentional.
Step 5: Verify
- Run
dotnet build-- confirm zero errors and review any new warnings - Run
dotnet test-- confirm all tests pass - Compare test results (pass/fail counts) to the pre-migration baseline
- If using Azure DevOps test tracking, be aware that
TestCase.Idchanges may affect history continuity - Check that no tests were silently dropped due to stricter discovery
Related skills
Use this skill before answering, planning, or editing whenever .NET tests or CI are switching from VSTest to Microsoft.Testing.Platform (MTP), or an MTP migration behaves…
Convert .NET tests from xUnit.net v2/v3 to MSTest v4 while preserving VSTest or MTP.