Migrate MSTest v1 or v2 test project to MSTest v3.
xUnit -> MSTest Migration
Migrate .NET test projects from xUnit.net (v2 or v3) to MSTest v4. USE FOR: convert/migrate xUnit tests to MSTest, replace xunit/xunit.v3 packages, port [Fact]/[Theory]/[InlineData]/[MemberData]/[ClassData] to [TestMethod]/[DataRow]/[DynamicData], port Assert.Equal/True/Throws/ThrowsAsync to Assert.AreEqual/IsTrue/ThrowsExactly/ThrowsExactlyAsync, port IClassFixture/ ICollectionFixture/IDisposable/IAsyncLifetime/ITestOutputHelper/[Trait]/[Fact(Skip)] to MSTest equivalents, preserve xUnit parallel-class default via [assembly: Parallelize(Scope = ClassLevel)], remove xunit.runner.json. DO NOT USE FOR: xUnit v2 -> v3 upgrade (use migrate-xunit-to-xunit-v3); MSTest -> xUnit, NUnit/TUnit -> MSTest (no skills exist); MSTest version upgrades (use migrate-mstest-v1v2-to-v3 or migrate-mstest-v3-to-v4); VSTest <-> MTP only (use migrate-vstest-to-mtp); general .NET upgrades.
Workflow
> Commit strategy: Commit after Step 2 (packages updated, builds broken), after Step 6 (attributes converted, asserts fixed), and after Step 8 (fixtures/lifecycle rewritten, tests pass). Commit before fixing follow-up cleanup so reviewers can bisect.
Step 1: Assess the project
- Locate every test project. Read
.csproj,Directory.Build.props,Directory.Packages.props, andglobal.json. - Identify the xUnit version:
- xunit 2.x (+ xunit.assert / xunit.core / xunit.abstractions) -> xUnit v2 - xunit.v3 / xunit.v3.* -> xUnit v3
- Identify the current test platform (this dictates what to keep, not what to change) by invoking the
platform-detectionskill. The xUnit/MTP matrix is nuanced -- xunit.v3 inside Test Explorer is MTP by default unless opted out, while xunit.v3 insidedotnet testdepends on thexunit.v3.mtp-v*packages -- so do not try to inline a shortcut here. Quick signals to feed into that skill:xunit.runner.visualstudio(v2) usually means VSTest;xunit.v3.mtp-v*/xunit.v3.core.mtp-v*packages orYTest.MTP.XUnit2(v2 MTP shim) usually mean MTP.<UseMicrosoftTestingPlatformRunner>only affectsdotnet runand is not a reliable VSTest-vs-MTP signal on its own. - Verify the
TargetFrameworkis supported by MSTest v4:
- Supported: net8.0, net9.0, net462+, netstandard2.0 (test library only), uap10.0.16299, net8.0-windows10.0.18362.0 (WinUI), net9.0-windows10.0.17763.0 (modern UWP). - Unsupported: .NET Core 3.1, net5.0-net7.0. STOP and ask the user to upgrade the TFM first, or migrate to MSTest v3 (then use migrate-mstest-v3-to-v4 after a TFM bump).
- Inventory high-risk patterns -- scan for these and flag them now so you can plan judgement steps later:
- Parallelization differences (Step 11) -- xUnit parallelizes test classes by default; MSTest does not. This is the single most common source of post-migration regressions: tests that depended on isolation by parallel scheduling, on the lack of it, or on shared static state can pass differently. Decide the target parallelization model now -- do not leave it as the MSTest default by accident. - ICollectionFixture<T> / [CollectionDefinition] (scope concern -- see Step 8) - Custom DataAttribute / custom FactAttribute / custom TheoryAttribute subclasses (manual conversion to ITestDataSource / TestMethodAttribute -- see Step 5) - Assert.Throws<T> (xUnit semantics = exact type; maps to Assert.ThrowsExactly<T>, not Assert.Throws<T>) - Record.Exception / Record.ExceptionAsync (manual conversion) - Assert.Raises* / event assertions (no MSTest equivalent -- manual) - xUnit v3: [assembly: CaptureConsole] and other v3-only assembly attributes
- Inventory state shared between tests -- static fields/properties, singletons, file paths, well-known ports, in-memory caches, database connection strings pointing at a single shared DB, environment variables. Whether parallelization is on or off, switching frameworks changes the *order* and *concurrency* in which these are touched. List them now so you can decide in Step 11 whether to enable parallelism, serialize specific classes with
[DoNotParallelize], or refactor the shared state. - Run a baseline build + test to record the current pass/fail count for parity check at Step 13. Re-run a second time -- if the xUnit run is flaky today, those flakes are almost certainly caused by parallel scheduling and will manifest differently after migration. Flag any flaky tests now.
Step 2: Replace packages
> Choose the package option that matches what the project uses today. When the user says "preserve VSTest" -- or the existing project uses explicit `PackageReference`s -- default to Option A (`MSTest` metapackage). Reach for Option B (MSTest.Sdk) only when the user explicitly asks to modernize the SDK or already uses MSTest.Sdk elsewhere in the solution; if you adopt it, you must preserve the platform from Step 1.
Remove every xUnit package reference (from .csproj, Directory.Build.props, Directory.Packages.props):
xunit,xunit.abstractions,xunit.assert,xunit.corexunit.extensibility.core,xunit.extensibility.executionxunit.runner.visualstudioxunit.v3,xunit.v3.assert,xunit.v3.core,xunit.v3.extensibility.corexunit.v3.mtp-v1,xunit.v3.mtp-v2,xunit.v3.core.mtp-v1,xunit.v3.core.mtp-v2YTest.MTP.XUnit2(xUnit v2 MTP shim)- Companion packages:
Xunit.SkippableFact,Xunit.Combinatorial,Xunit.StaFact(see Step 10)
Add MSTest v4. Two options -- both correct.
Option A -- `MSTest` metapackage (recommended for incremental migrations):
<ItemGroup>
<PackageReference Include="MSTest" Version="4.1.0" />
</ItemGroup>
The MSTest metapackage pulls in MSTest.TestFramework, MSTest.TestAdapter, MSTest.Analyzers, and Microsoft.NET.Test.Sdk -- so VSTest discovery (vstest.console, classic dotnet test) still works.
> MTP code-coverage caveat for Option A: Microsoft.NET.Test.Sdk pulls VSTest's Microsoft.CodeCoverage transitively. If the project from Step 1 is on MTP and uses code coverage, that transitive dependency can interfere with MTP's collector (Microsoft.Testing.Extensions.CodeCoverage). Prefer Option B (MSTest.Sdk without UseVSTest) for MTP projects -- the SDK omits Microsoft.NET.Test.Sdk and wires the MTP coverage collector instead. If you must stay on Option A for an MTP project, verify coverage works on a representative test run before merging.
Option B -- `MSTest.Sdk`:
<Project Sdk="MSTest.Sdk/4.1.0">
<PropertyGroup>
<!-- Keep the project's existing TargetFramework -- do NOT retarget during migration. -->
<TargetFramework>$(ExistingTargetFramework)</TargetFramework>
</PropertyGroup>
</Project>
MSTest.Sdk defaults to MTP. To preserve a VSTest project, opt back in with <UseVSTest>true</UseVSTest> -- the SDK then pulls in Microsoft.NET.Test.Sdk automatically (no extra PackageReference needed):
<PropertyGroup>
<UseVSTest>true</UseVSTest>
</PropertyGroup>
For solutions with several test projects, prefer pinning the MSTest.Sdk version in global.json so it lives in one place:
{
"msbuild-sdks": {
"MSTest.Sdk": "4.1.0"
}
}
With the pin in global.json, the project line simplifies to <Project Sdk="MSTest.Sdk">.
When switching to MSTest.Sdk, also remove now-redundant properties: <OutputType>Exe</OutputType>, <IsPackable>false</IsPackable>, <IsTestProject>true</IsTestProject>, <EnableMSTestRunner>.
MSTest.Sdk also adds Microsoft.VisualStudio.TestTools.UnitTesting as an implicit global using. Do not add <Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" /> to the project (it's noise) and skip the per-file using Microsoft.VisualStudio.TestTools.UnitTesting; in Step 4 -- you only need it for projects on Option A (the MSTest metapackage).
Step 3: Update project configuration
- Preserve the runner. Confirm the platform decision from Step 1 still holds after Step 2. Common mistakes:
- Switching to MSTest.Sdk without UseVSTest=true silently flips a VSTest project to MTP. Add <UseVSTest>true</UseVSTest> to the project (the SDK pulls in Microsoft.NET.Test.Sdk automatically -- no manual PackageReference needed). - <UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner> only affects the dotnet run entry point and is not a runner switch in Test Explorer or dotnet test. Do not infer the platform from this property in either direction -- defer to the platform-detection skill (see Step 1).
- Delete
xunit.runner.jsonand port any settings you need (parallelization,[CollectionBehavior],appDomain) per Step 11's "xunit.runner.json -> MSTest" sub-table. The settings have no direct MSBuild-property mapping. - Remove
using Xunit;andusing Xunit.Abstractions;from C# files. For Option A (MSTestmetapackage), addusing Microsoft.VisualStudio.TestTools.UnitTesting;per file (Step 4 covers this alongside the other rewrites). For Option B (MSTest.Sdk), skip the per-file using -- the SDK provides it as an implicit global using.
Step 4: Convert test classes and methods
Apply these rewrites to every C# test file. Class-level first, then method-level.
Class:
- Add
[TestClass]to every class that contained xUnit[Fact]/[Theory]methods (xUnit had no class-level requirement). - Preserve the original class hierarchy. xUnit projects often use base/derived test classes (shared setup, helper assertions, generic base fixtures); marking classes
sealedwould break that pattern. Sealing is an optional follow-up handled bywriting-mstest-tests, not part of the mechanical migration. - Replace
using Xunit;/using Xunit.Abstractions;withusing Microsoft.VisualStudio.TestTools.UnitTesting;. On Option B (`MSTest.Sdk`), skip adding the MSTest using -- the SDK provides it as an implicit global using, so just remove theusing Xunit;/using Xunit.Abstractions;lines (Step 2 and Step 3 cover this).
Methods:
> `[Ignore]` and `[Timeout]` are modifiers, not discovery attributes. Always emit [TestMethod] *alongside* them -- a method with [Ignore] but no [TestMethod] is silently skipped by the test runner (no error, no skip count). Same for [Timeout].
| xUnit | MSTest | |---|---| | [Fact] | [TestMethod] | | [Theory] | [TestMethod] (parameterized; MSTest 3+ no longer needs [DataTestMethod]) | | [Fact(DisplayName = "x")] | [TestMethod("x")] (v3 of MSTest) or [TestMethod(DisplayName = "x")] (v4) | | [Fact(Skip = "reason")] | [TestMethod] + [Ignore("reason")] (both attributes required) | | [Fact(Timeout = 5000)] | [TestMethod] + [Timeout(5000)] (both attributes required) | | [Trait("Category", "Unit")] | [TestCategory("Unit")] | | [Trait("Owner", "alice")] | [TestProperty("Owner", "alice")] |
> Both [TestCategory] and [TestProperty] are filterable at runtime (--filter "TestCategory=Unit" / --filter "Owner=alice"). [TestCategory] targets Assembly, Class, and Method, so an xUnit [assembly: Trait("Category", ...)] keeps its assembly scope under MSTest as [assembly: TestCategory(...)]. `[TestProperty]` targets only `Class` and `Method` — there is no AttributeTargets.Assembly, so an assembly-level xUnit trait with an arbitrary key must collapse to [assembly: TestCategory(...)] (or be pushed down to every class). Use [TestCategory] for the conventional category trait; use [TestProperty] for arbitrary key/value metadata at class/method scope. For environmental skips (OS-specific, CI-only), MSTest 3.10+'s [OSCondition] / [CICondition] are usually a better fit than overloading a trait -- see Step 6 / cheatsheet §3.9.
Step 5: Convert data-driven tests
| xUnit | MSTest | |---|---| | [InlineData(1, 2)] | [DataRow(1, 2)] | | [InlineData(1, DisplayName = "case 1")] | [DataRow(1, DisplayName = "case 1")] | | [MemberData(nameof(Cases))] returning IEnumerable<object[]> | [DynamicData(nameof(Cases))] returning IEnumerable<object[]> | | [MemberData(nameof(Cases), MemberType = typeof(X))] | [DynamicData(nameof(Cases), typeof(X))] | | [MemberData(nameof(Method), arg1, arg2)] (parameterized member) | Manual: convert to a parameterless property or compute the inputs inside the test | | [ClassData(typeof(MyData))] (class implementing IEnumerable<object[]>) | Add a static property => new MyData() on the test class, then [DynamicData(nameof(Cases))] | | TheoryData<int, string> | IEnumerable<object[]>, IEnumerable<(int, string)> (MSTest 3.7+ ValueTuple), or IEnumerable<TestDataRow<(int, string)>> (strongly-typed with per-row metadata) | | Custom DataAttribute subclass | Manual: implement ITestDataSource (GetData, GetDisplayName) |
Prefer ValueTuple data sources for new MSTest tests (see writing-mstest-tests), but for migration keep IEnumerable<object[]> -- it minimizes diff churn and works in both MSTest 3 and 4.
Step 6: Convert assertions
Most common cases inline. For the full table including string/collection/type/numeric and event/equivalence assertions, see `references/mapping-cheatsheet.md` §3.
| xUnit | MSTest | |---|---| | Assert.Equal(expected, actual) | Assert.AreEqual(expected, actual) | | Assert.NotEqual(a, b) | Assert.AreNotEqual(a, b) | | Assert.True(x) / Assert.False(x) | Assert.IsTrue(x) / Assert.IsFalse(x) | | Assert.Null(x) / Assert.NotNull(x) | Assert.IsNull(x) / Assert.IsNotNull(x) | | Assert.Same(a, b) / Assert.NotSame(a, b) | Assert.AreSame(a, b) / Assert.AreNotSame(a, b) | | Assert.Throws<T>(() => ...) | `Assert.ThrowsExactly<T>(() => ...)` (see trap below) | | Assert.ThrowsAny<T>(() => ...) | `Assert.Throws<T>(() => ...)` | | await Assert.ThrowsAsync<T>(...) | await Assert.ThrowsExactlyAsync<T>(...) | | Assert.IsType<T>(x) (exact-type check, returns T) | Assert.IsExactInstanceOfType<T>(x) (MSTest 4.1+, returns T) -- not Assert.IsInstanceOfType<T>, which is assignable/is-a and silently weakens the assertion | | Assert.IsNotType<T>(x) (exact-type check) | Assert.IsNotExactInstanceOfType<T>(x) (MSTest 4.1+) | | Assert.IsAssignableFrom<T>(x) | Assert.IsInstanceOfType<T>(x) (MSTest v4 returns the typed value) | | Assert.Empty(coll) / Assert.NotEmpty(coll) | Assert.IsEmpty(coll) / Assert.IsNotEmpty(coll) | | Assert.Single(coll) | var item = Assert.ContainsSingle(coll); | | Assert.Contains(item, coll) / Assert.DoesNotContain(...) | Same -- Assert.Contains / Assert.DoesNotContain | | Assert.Contains("sub", str) / StartsWith / EndsWith / Matches | Same (MSTest 3.8+) or StringAssert.* | | Assert.Skip("reason") (v3 runtime) | Assert.Inconclusive("reason") | | Assert.SkipWhen(cond, "reason") (v3) | If cond is environmental: [OSCondition] / [CICondition] (MSTest 3.10+); otherwise if (cond) Assert.Inconclusive("reason"); | | Assert.SkipUnless(cond, "reason") (v3) | Same -- prefer a condition attribute when the predicate is environmental; otherwise if (!cond) Assert.Inconclusive("reason"); |
Critical semantic trap -- exception assertions:
- xUnit
Assert.Throws<T>= exact type match -> MSTestAssert.ThrowsExactly<T>. - xUnit
Assert.ThrowsAny<T>= derived types also match -> MSTestAssert.Throws<T>.
Reversing these flips the assertion semantics silently. Verify by name, not by visual similarity.
No-equivalent assertions -- convert manually (see cheatsheet §3.11):
Assert.Collection(items, e1 => ..., e2 => ...)-> assert count, then per-elementAssert.All(items, x => ...)->foreachAssert.Equivalent(expected, actual)-> deep equality manually, or a third-party libraryAssert.Raises<T>/Assert.PropertyChanged-> manual event subscription + flag checkRecord.Exception/Record.ExceptionAsync->try/catchreturning the exception (orAssert.ThrowsExactly<T>if you know the type)
Step 7: Convert lifecycle
Constructor / `IDisposable` / `IAsyncDisposable` / `IAsyncLifetime`:
| xUnit | MSTest | |---|---| | Constructor (sync setup) | Keep constructor (MSTest also instantiates per test). Drop xUnit-only ITestOutputHelper param -- see Step 9 | | Dispose() (sync teardown) | Keep Dispose() (MSTest supports IDisposable) or rewrite as [TestCleanup] public void Cleanup() { ... } | | DisposeAsync() (async teardown) | Keep IAsyncDisposable.DisposeAsync() or rewrite as [TestCleanup] public async Task CleanupAsync() { ... } | | IAsyncLifetime.InitializeAsync | [TestInitialize] public async Task InitAsync() { ... } | | IAsyncLifetime.DisposeAsync | [TestCleanup] public async Task CleanupAsync() { ... } |
> Per writing-mstest-tests: prefer the constructor for sync init (it allows readonly fields). Use [TestInitialize] only for async setup or when you need TestContext.
Step 8: Convert fixtures (high-risk -- read carefully)
`IClassFixture<T>` -- class-level shared state (mechanical):
// xUnit v2/v3
public class DbFixture : IDisposable
{
public string ConnectionString { get; } = "...";
public void Dispose() { /* cleanup */ }
}
public class OrderTests : IClassFixture<DbFixture>
{
private readonly DbFixture _fixture;
public OrderTests(DbFixture fixture) => _fixture = fixture;
}
// MSTest equivalent
[TestClass]
public sealed class OrderTests
{
private static DbFixture? s_fixture;
[ClassInitialize]
public static void ClassInit(TestContext context) => s_fixture = new DbFixture();
[ClassCleanup]
public static void ClassCleanup() => s_fixture?.Dispose();
}
`ICollectionFixture<T>` / `[CollectionDefinition]` -- shared by tests in the same collection (judgement call):
xUnit collections do two things simultaneously: (1) share a fixture instance across multiple test classes, and (2) serialize those classes (no parallel execution within a collection). MSTest does not have a built-in equivalent that preserves both semantics. Pick one -- do not silently map to [AssemblyInitialize]:
- Few classes, narrow scope: copy the fixture initialization into each class's
[ClassInitialize], OR introduce a staticLazy<T>shared helper. Add[DoNotParallelize]on each class to preserve serialization. - Many classes, fixture is genuinely assembly-wide (e.g., process-wide TestServer): hoist to
[AssemblyInitialize]/[AssemblyCleanup]in a dedicatedAssemblySetupclass and confirm with the user that widening the scope is acceptable. Note that this changes parallelization semantics. - Custom collection behavior or test-collection-orderer: stop and flag for manual review.
> REQUIRED -- communicate the scope decision before applying it. Silently widening fixture scope across the assembly is the most common way this migration regresses tests. Use this template (replace bracketed text): > > "The xUnit [Collection(\"<name>\")] shared a <Fixture> between \<N\> classes and serialized them. I am mapping that to: a static Lazy<<Fixture>> shared by each class's [ClassInitialize] (scope: per-class, shared via static -- not widened to assembly), plus [DoNotParallelize] on <ClassA> and <ClassB> to preserve the serialization. The alternative -- [AssemblyInitialize] -- would widen the fixture to every test in the assembly, which I rejected because \<reason\>."
Step 9: Convert output and TestContext
`ITestOutputHelper` -> `TestContext`:
// xUnit (v2 and v3)
public class MyTests
{
private readonly ITestOutputHelper _output;
public MyTests(ITestOutputHelper output) => _output = output;
[Fact]
public void Test() => _output.WriteLine("...");
}
// MSTest (v3.6+ supports TestContext in constructor)
[TestClass]
public sealed class MyTests
{
private readonly TestContext _testContext;
public MyTests(TestContext testContext) => _testContext = testContext;
[TestMethod]
public void Test() => _testContext.WriteLine("...");
}
If the project pins MSTest < 3.6 (rare after Step 2), use property injection instead:
public TestContext TestContext { get; set; } = null!;
xUnit v3 `TestContext.Current` (TestContext.Current is static in xUnit v3; in MSTest you must use the instance TestContext obtained via the same constructor or property injection shown above):
TestContext.Current.CancellationToken->_testContext.CancellationToken(MSTest 3.6+)TestContext.Current.AddAttachment(name, path)->_testContext.AddResultFile(path)TestContext.Current.TestOutputHelper.WriteLine(...)->_testContext.WriteLine(...)
> REQUIRED for CancellationToken: Add the constructor injection from above (or property injection if pinned to MSTest < 3.6) even if the class only uses TestContext.Current.CancellationToken (no ITestOutputHelper). Do NOT replace TestContext.Current.CancellationToken with CancellationToken.None or a freshly-constructed CancellationTokenSource -- both lose the test-host's cancellation linkage and change behavior under timeouts.
// xUnit v3
[Fact]
public async Task WorkRespectsCancellation()
{
var ct = TestContext.Current.CancellationToken;
await Task.Delay(1, ct);
Assert.False(ct.IsCancellationRequested);
}
// MSTest (note: Assert.False -> Assert.IsFalse from Step 6)
[TestClass]
public sealed class MyTests
{
private readonly TestContext _testContext;
public MyTests(TestContext testContext) => _testContext = testContext;
[TestMethod]
public async Task WorkRespectsCancellation()
{
var ct = _testContext.CancellationToken;
await Task.Delay(1, ct);
Assert.IsFalse(ct.IsCancellationRequested);
}
}
Step 10: Convert companion packages
| xUnit companion | MSTest equivalent | |---|---| | Xunit.SkippableFact ([SkippableFact], Skip.If, Skip.IfNot) | For environmental predicates (OS/CI/arch): MSTest 3.10+ condition attributes ([OSCondition], [CICondition], etc.). Otherwise: [Ignore] (compile-time) or Assert.Inconclusive("reason") (runtime). Remove the package | | Xunit.Combinatorial ([CombinatorialData], [CombinatorialValues]) | `Combinatorial.MSTest` (community port; attribute surface matches xUnit.Combinatorial). Or expand combinations into explicit [DataRow]s / [DynamicData] | | Xunit.StaFact ([StaFact], [WpfFact]) | [TestMethod] + manual STA thread. No MSTest equivalent for [WpfFact]; flag for manual conversion | | Verify.Xunit | Verify.MSTest -- swap the package; usage is similar | | FluentAssertions / Shouldly / AwesomeAssertions | Keep -- assertion library is framework-agnostic | | Moq / NSubstitute / FakeItEasy | Keep -- mocking library is framework-agnostic |
Step 11: Handle parallelization (defaults differ -- read carefully)
> This is the most common source of post-migration regressions. xUnit and MSTest have opposite defaults. Do not skip this step even if Step 1 said tests passed cleanly.
#### How each framework parallelizes by default
| Framework | Across test classes | Within a test class | Test-class instance lifetime | |---|---|---|---| | xUnit v2 | Parallel (one class per worker thread) | Serial (one test method at a time) | New instance per test method | | xUnit v3 | Parallel (same as v2) | Serial (same as v2) | New instance per test method | | MSTest (default) | Serial (one class at a time) | Serial (one test method at a time) | New instance per test method | | MSTest + [assembly: Parallelize(Scope = ClassLevel)] | Parallel | Serial | Same | | MSTest + [assembly: Parallelize(Scope = MethodLevel)] | Parallel | Parallel -- more aggressive than xUnit | Same |
Workers = 0 means "use all available logical cores" (MSTest's recommended default for parallel runs); any positive integer caps the worker count.
#### Pick a target model -- there are three reasonable choices
Choice A -- Match xUnit's behaviour exactly (recommended default):
// Place in any .cs file at assembly scope (often AssemblyInfo.cs or GlobalUsings.cs)
[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.ClassLevel)]
Use this when the suite was healthy on xUnit and you want zero behavioural change. It preserves "parallel across classes, serial within a class" exactly.
> REQUIRED -- explicitly tell the user why this attribute is needed. When applying Choice A, include this sentence (verbatim or near-verbatim) in your final summary: > > "MSTest defaults to serial execution across classes (unlike xUnit, which parallelizes classes by default), so this [assembly: Parallelize(Workers = 0, Scope = ExecutionScope.ClassLevel)] is required to match the project's previous xUnit parallel-class behaviour. Without it, the suite would still pass but run roughly one-class-at-a-time and CI throughput would drop." > > The user must understand this is opt-in under MSTest -- a silent omission looks like a no-op but is actually a behavioural regression.
Choice B -- Adopt MSTest's serial default:
// No [assembly: Parallelize] needed -- this is the default
Use this only when the suite has known shared-state issues (Step 1.6) that you intend to leave unfixed for now, or when wall-clock time is not a concern. Expect significantly slower CI.
Choice C -- Selective parallelization:
[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.ClassLevel)]
Plus per-class opt-out for the classes that genuinely cannot run concurrently:
[TestClass]
[DoNotParallelize]
public sealed class DatabaseIntegrationTests { /* ... */ }
Use this when most of the suite is isolated but a few classes touch shared state (one DB, fixed ports, file system locations). This is usually the right answer when migrating from xUnit collections.
> Do not pick `ExecutionScope.MethodLevel` to "match xUnit" -- it parallelizes test methods *within* a single class, which xUnit never does. It is more aggressive than xUnit and will surface latent intra-class state issues.
#### Translate xUnit parallelization opt-outs
| xUnit pattern | MSTest equivalent | |---|---| | [assembly: CollectionBehavior(DisableTestParallelization = true)] | Omit [assembly: Parallelize] (or use Choice B above) | | [assembly: CollectionBehavior(MaxParallelThreads = N)] | [assembly: Parallelize(Workers = N, Scope = ExecutionScope.ClassLevel)] | | [Collection("Db")] on multiple classes (forces those classes to share a fixture and run serially) | [DoNotParallelize] on each of those classes (preserves serialization) + Step 8 fixture handling (preserves sharing) | | [CollectionDefinition("Db", DisableParallelization = true)] | Same as above -- [DoNotParallelize] on each member class | | [Collection("Foo")] used only for fixture sharing (no parallelization concern) | Step 8 fixture handling; do not add [DoNotParallelize] |
The distinction in the last two rows matters: xUnit collections conflate "share state" with "serialize". MSTest decouples them. Read the original [CollectionDefinition] carefully -- if DisableParallelization is false (or omitted), only the fixture sharing semantic needs to migrate, not the serialization.
#### Verify after Step 13
If pass/fail counts diverge from the baseline after migration, parallelization is the first place to look:
- More failures than baseline: tests are now running concurrently and stomping shared state. Either add
[DoNotParallelize]to the offending classes, or fix the shared state. - Fewer failures than baseline (tests previously flaky now green): probably means a race condition that xUnit's scheduling exposed is now hidden by serial execution. Note it in a follow-up issue -- do not declare victory.
- Same count but tests take much longer: you forgot
[assembly: Parallelize]. Add Choice A. - Same count but tests take much less time and occasionally fail: you picked
MethodLevelinstead ofClassLevel. Switch toClassLevel.
#### Other runner config: xunit.runner.json migration
Delete xunit.runner.json. Port relevant settings:
| xunit.runner.json | MSTest equivalent | |---|---| | "parallelizeAssembly": false | Default in MSTest -- no action | | "parallelizeTestCollections": false | Omit [assembly: Parallelize] (Choice B) | | "maxParallelThreads": N | [assembly: Parallelize(Workers = N, Scope = ExecutionScope.ClassLevel)] | | "methodDisplay": "method" / "classAndMethod" | No equivalent (MSTest always uses class + method) | | "diagnosticMessages": true | Use --diagnostic on the CLI, or set verbosity in .runsettings | | "preEnumerateTheories": false | No equivalent (MSTest enumerates [DataRow]/[DynamicData] eagerly) | | "longRunningTestSeconds": N | Use [Timeout(N * 1000)] per test | | "appDomain": "denied" / "ifAvailable" | No equivalent (MSTest uses no app domains on modern .NET) |
If the project uses xUnit traits in CI filter expressions (e.g., --filter "Category=Unit" with xUnit), the equivalent MSTest filter is --filter "TestCategory=Unit" (VSTest) or --filter-trait "TestCategory=Unit" (MTP). Update CI pipelines accordingly.
Step 12: Convert xUnit assembly attributes
Some xUnit assembly attributes have direct MSTest equivalents at assembly scope; others must be removed (and re-applied per class/method) or reimplemented against MSTest extensibility.
Convert (assembly scope preserved):
[assembly: Xunit.Trait("Category", "v")]->[assembly: TestCategory("v")]--TestCategoryAttributetargetsAssembly,Class, andMethod; assembly application propagates to every test.
Convert (assembly scope NOT preserved):
[assembly: Xunit.Trait("k", "v")](non-category key) -> collapse to[assembly: TestCategory("v")]if the value alone is sufficient as a filter, or move the trait down to every test class as[TestProperty("k", "v")].TestPropertyAttributeonly targetsClassandMethod(noAttributeTargets.Assembly) --[assembly: TestProperty(...)]will not compile.
Delete (no MSTest equivalent or now handled elsewhere):
[assembly: CollectionBehavior(...)]-- replaced by[assembly: Parallelize(...)](Step 11)[assembly: TestCaseOrderer(...)]-- reimplement against MSTest extensibility; flag for manual conversion[assembly: TestCollectionOrderer(...)]-- flag for manual conversion[assembly: TestFramework(...)][assembly: CaptureConsole](xUnit v3) -- MSTest does not capture console by default
Custom orderers/test framework hooks must be reimplemented against MSTest's extensibility model (TestMethodAttribute subclasses, ITestDataSource, etc.) -- stop and flag for manual conversion if present.
Step 13: Build and verify parity
dotnet build-- must succeed with zero errors. Address remaining errors using the mapping reference.dotnet test-- run with the same filter/runner combination as before migration.- Compare pass/fail counts to the baseline from Step 1.7. Investigate any deltas:
- New failures on shared-state tests -- you enabled parallelization (Choice A/C in Step 11) and tests are now stomping each other. Add [DoNotParallelize] to the specific class(es), or fix the shared state. - Tests previously parallel now serial (wall-clock much longer) -- you forgot [assembly: Parallelize]. See Step 11 Choice A. - Tests previously flaky now consistently green -- almost certainly a race condition hidden by MSTest's serial default. Open a follow-up issue; do not declare victory. - Tests now skipped ([Ignore]) that used to run via Assert.SkipWhen? Convert to runtime Assert.Inconclusive if you want them to execute when the condition is false. - Theory cases dropped? Check [DataRow] literal types (1 int vs 1L long -- MSTest enforces exact match unlike xUnit). - Tests passing but executing 0 assertions? Likely an Assert.Collection or Assert.All was dropped -- restore manually.
- After parity is confirmed, run the test-quality skills (
test-anti-patterns,assertion-quality) to identify follow-up improvements -- e.g., replacingAssert.IsTrue(x.Count() == 3)withAssert.HasCount(3, x).
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).