Start Debugging

Migrate from VSTest to Microsoft.Testing.Platform on the .NET 11 SDK

A step-by-step migration from VSTest to Microsoft.Testing.Platform 2.3.3: the OutputType Exe opt-in, the global.json runner switch, loggers becoming reporters, .runsettings becoming testconfig.json, and the exit codes that turn a green CI job red.

Moving a solution from VSTest to Microsoft.Testing.Platform (MTP) is a half-day job for the project files and a full day for CI. The project-side work is three lines per test project: <OutputType>Exe</OutputType>, one opt-in property for your test framework, and a global.json that sets "runner": "Microsoft.Testing.Platform". What actually costs the time is everything downstream: every --logger, --collect, and --blame flag in your pipeline maps to a different option that only exists if you also add a NuGet package, your .runsettings file loses most of its meaning, and a test project that runs zero tests now fails the build with exit code 8 instead of passing. This guide is written against the .NET 11 SDK (Preview 7, August 2026), Microsoft.Testing.Platform 2.3.3, MSTest 4.3.3, NUnit3TestAdapter 6.3.0, and xunit.v3 4.0.0.

Why the swap is worth doing now

What breaks

AreaChangeSeverity
Project shapeTest projects must set <OutputType>Exe</OutputType>high
Solution consistencyWith MTP enabled in global.json, every test project must use MTP. A mixed solution is an error, not a warninghigh
--loggerRenamed to “reporters”. --logger trx becomes --report-trx and requires Microsoft.Testing.Extensions.TrxReporthigh
--collect "Code Coverage"Becomes --coverage, requires Microsoft.Testing.Extensions.CodeCoverage, and IncludeTestAssembly now defaults to falsehigh
--blame-crash / --blame-hangBecome --crashdump / --hangdump from separate packages. --blame-crash-collect-always has no equivalentmedium
Zero tests executedVSTest returns 0. MTP returns exit code 8high
.runsettingsSupported only through the MSTest and NUnit VSTest bridges. The platform itself reads testconfig.jsonmedium
dotnet test MyTests.csprojPositional project paths are gone. Use --project, --solution, or --test-modulesmedium
xUnit filters--filter is not implemented. Use --filter-class, --filter-method, --filter-namespace, --filter-trait, --filter-queryhigh (xUnit only)
RunConfiguration.TargetPlatform=x86Becomes --arch x86low
Console encodingMTP always sets UTF-8. VSTest’s default isolation mode did notlow

The two rows that decide your timeline are the solution-consistency one and the --logger one. The rest the tooling tells you about.

Pre-flight checklist

Migration steps

  1. Pin the SDK and select the runner in global.json.

    Runner selection is a repo-level decision, not a per-project one.

    // global.json - .NET 11 SDK
    {
      "sdk": {
        "version": "11.0.100",
        "rollForward": "latestFeature"
      },
      "test": {
        "runner": "Microsoft.Testing.Platform"
      }
    }

    VSTest is the other valid value and remains the default when the test section is absent. On the .NET 11 SDK you can also override this per shell with the DOTNET_TEST_RUNNER environment variable, which is the fastest way to A/B a CI job without editing a tracked file.

    Verify: dotnet test --help now lists --project, --solution, and --test-modules. If it still lists --logger and --collect, the runner switch did not take effect.

  2. Make every test project an executable.

    This is the universal opt-in, regardless of framework. Put it in Directory.Build.props next to your test projects rather than repeating it.

    <!-- tests/Directory.Build.props - .NET 11 SDK, MTP 2.3.3 -->
    <Project>
      <PropertyGroup>
        <OutputType>Exe</OutputType>
      </PropertyGroup>
    </Project>

    You do not write a Main. Microsoft.Testing.Platform.MSBuild, which every MTP-capable framework brings in transitively, generates a TestingPlatformEntryPoint for you.

    Verify: dotnet build produces a MyApp.Tests executable (or .exe) in the output folder, and running it directly executes the suite.

  3. Turn on the runner for your test framework.

    Each framework has its own property, and the minimum versions differ.

    <!-- tests/Directory.Build.props - pick the one that matches your framework -->
    <PropertyGroup>
      <!-- MSTest 3.2.0+, current 4.3.3 -->
      <EnableMSTestRunner>true</EnableMSTestRunner>
    
      <!-- NUnit3TestAdapter 5.0.0+, current 6.3.0 -->
      <EnableNUnitRunner>true</EnableNUnitRunner>
    
      <!-- xunit.v3 1.0.1+, current 4.0.0 -->
      <UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
    </PropertyGroup>

    MSTest projects can skip the property entirely by switching the project SDK to MSTest.Sdk, where MTP is on by default. xUnit v3 4.0.0 resolves to the MTP v2 package variant; the 3.x line defaulted to MTP v1, which 4.0.0 dropped. If you are still on xUnit v2, there is no first-party MTP path, so do the v2 to v3 migration first.

    Verify: run the test executable with --help. You should see the platform options (--filter-uid, --timeout, --list-tests) plus whatever your framework registers.

  4. Delete the .NET 9 era bridge properties.

    A lot of blog posts and even parts of the MS Learn MSTest page still show these. On the .NET 10 or .NET 11 SDK with global.json runner selection, they are obsolete and should be removed:

    <!-- delete these from every test project and Directory.Build.props -->
    <TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
    <TestingPlatformShowTestsFailure>true</TestingPlatformShowTestsFailure>

    The -- separator they required also becomes optional, though it is still worth keeping in CI for a reason covered in step 6.

    Verify: dotnet test still runs and the console output shows the MTP terminal reporter rather than the VSTest one.

  5. Re-add the loggers and collectors as extension packages.

    MTP core ships none of these. If your pipeline passes an option whose package is missing, the run fails with exit code 5 because the option is unrecognized.

    <!-- tests/Directory.Build.props - MTP 2.3.3 extensions -->
    <ItemGroup>
      <PackageReference Include="Microsoft.Testing.Extensions.TrxReport" Version="2.3.3" />
      <PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.10.0" />
      <PackageReference Include="Microsoft.Testing.Extensions.HangDump" Version="2.3.3" />
      <PackageReference Include="Microsoft.Testing.Extensions.CrashDump" Version="2.3.3" />
    </ItemGroup>

    The code coverage extension versions independently of the platform: it tracks the Visual Studio test platform numbering, so the current release is 18.10.0 while the rest sit at 2.3.3. The documented compatibility table pairs the 18.1.x line with MTP 2.0.x, 18.0.x with 1.8.x, and 17.14.x with 1.6.2, and the guidance is to keep both on their latest. If you are on Central Package Management, these belong in Directory.Packages.props, which is one more argument for moving the solution to Directory.Packages.props before you start.

    Verify: dotnet test --help lists --report-trx, --coverage, --hangdump, and --crashdump.

  6. Translate the CI command line.

    This is the bulk of the work. The mapping:

    # before - VSTest, .NET 9 SDK
    dotnet test MyApp.sln \
      --logger "trx;LogFileName=results.trx" \
      --collect "Code Coverage" \
      --blame-hang-timeout 5m \
      --results-directory ./artifacts/tests \
      --filter "TestCategory=Integration"
    # after - MTP 2.3.3, .NET 11 SDK
    dotnet test --solution MyApp.sln \
      --results-directory ./artifacts/tests \
      -- --report-trx --report-trx-filename results.trx \
         --coverage --coverage-output-format cobertura \
         --hangdump --hangdump-timeout 5m \
         --filter "TestCategory=Integration"

    Three things to notice. The positional MyApp.sln became --solution, because dotnet test in MTP mode no longer accepts a bare path. The -- is technically optional on the .NET 10 SDK and later, but dotnet test forwards unrecognized tokens to the test application, and a recognized SDK option sitting between an unrecognized option name and its value changes how the leftover tokens bind. Put test application arguments after -- and the ambiguity disappears. Finally, --results-directory is understood by both the SDK and the platform, so it can sit on either side.

    For a solution that mixes frameworks or extension sets, route arguments per project instead of globally:

    <!-- only the projects that reference HangDump get the option -->
    <PropertyGroup Condition="'$(MSBuildProjectName)' == 'MyApp.Integration.Tests'">
      <TestingPlatformCommandLineArguments>
        $(TestingPlatformCommandLineArguments) --hangdump --hangdump-timeout 5m
      </TestingPlatformCommandLineArguments>
    </PropertyGroup>

    Verify: the run produces results.trx and a Cobertura file under ./artifacts/tests, and the exit code is 0.

  7. Replace .runsettings with testconfig.json.

    MSTest and NUnit keep honoring --settings config.runsettings through their VSTest bridges, so you can defer this. xUnit v3 does not, and the platform itself never reads runsettings. The replacement:

    // testconfig.json at the repo root - MTP 2.3.3
    {
      "platformOptions": {
        "resultDirectory": "./artifacts/tests",
        "exitProcessOnUnhandledException": false
      },
      "environmentVariables": {
        "DOTNET_ENVIRONMENT": "Testing"
      },
      "mstest": {
        "parallelism": { "enabled": true, "workers": 4, "scope": "method" },
        "timeout": { "test": 30000 }
      }
    }

    The mapping is not one-to-one. RunConfiguration/ResultsDirectory becomes platformOptions.resultDirectory. RunConfiguration/MaxCpuCount has no equivalent, because process-level parallelism is now --max-parallel-test-modules. LoggerRunSettings/Loggers and everything under DataCollectionRunSettings become CLI options from step 5. TestRunParameters becomes --test-parameter key=value. Starting with MTP 2.3.0 you can also put CLI options themselves in testconfig.json, extension options included, which is how you keep --coverage-output-format cobertura out of every pipeline file; the environmentVariables section is also 2.3.0 or later.

    Point every project at one shared file from Directory.Build.props:

    <PropertyGroup>
      <TestingPlatformCommandLineArguments>
        $(TestingPlatformCommandLineArguments) --config-file $(MSBuildThisFileDirectory)testconfig.json
      </TestingPlatformCommandLineArguments>
    </PropertyGroup>

    Verify: delete the .runsettings reference from CI and confirm results still land in the configured directory.

  8. Swap the CI task itself.

    On Azure DevOps, replace the VSTest@2 task with DotNetCoreCLI@2. It is a dotnet test invocation like any other, so the step 6 rules apply verbatim:

    # azure-pipelines.yml - .NET 11 SDK, MTP 2.3.3
    - task: DotNetCoreCLI@2
      inputs:
        command: 'test'
        arguments: '--solution MyApp.sln -- --report-trx --results-directory $(Agent.TempDirectory)'

    On GitHub Actions, Microsoft.Testing.Extensions.GitHubActionsReport plus --report-gh puts failures directly in the pull request diff, which is the reporting story that went stable in MTP 2.3. Note the near-miss: the third-party GitHubActionsTestLogger package uses --report-github, one character apart from the first-party option.

    Verify: a deliberately failing test produces a red job with the failure visible in the run summary, not just in the raw log.

Verify the migration

Run this list against one project before rolling the change across the solution:

Rollback

This migration is reversible in one commit for as long as you keep Microsoft.NET.Test.Sdk and your framework’s VSTest adapter package referenced. Delete the test section from global.json and the runner falls back to VSTest; OutputType=Exe and the opt-in properties are inert under VSTest. That is exactly why you should not delete xunit.runner.visualstudio or Microsoft.NET.Test.Sdk in the same pull request. Do the cleanup pass a week later, once CI and every developer’s IDE have run on MTP.

Gotchas worth knowing before you start

Exit code 8 turns a green job red. A project that runs zero tests exits with 8 under MTP and 0 under VSTest. This bites solutions with a placeholder test project or a filter that matches nothing. Either fix the filter or opt out explicitly:

<PropertyGroup>
  <TestingPlatformCommandLineArguments>
    $(TestingPlatformCommandLineArguments) --ignore-exit-code 8
  </TestingPlatformCommandLineArguments>
</PropertyGroup>

--ignore-exit-code takes a semicolon-separated list (--ignore-exit-code 2;8), and TESTINGPLATFORM_EXITCODE_IGNORE does the same from the environment. Separately, MTP 2.3.0 changed the all-skipped case: a run where every test was skipped now succeeds by default, and --zero-tests-policy strict restores the pre-2.3.0 failure.

A mixed solution is an error, not a warning. Once global.json selects MTP, dotnet test expects every test project in the graph to be an MTP project. One straggler on VSTest fails the whole run. Migrate the leaf projects first and flip global.json last.

Exit code 5 means a missing package, not a typo. If half your projects reference Microsoft.Testing.Extensions.HangDump and half do not, --hangdump is valid for some and unrecognized for others, and the run dies with 5. Use the per-project TestingPlatformCommandLineArguments conditions from step 6.

xUnit ignores --filter. MSTest and NUnit keep the VSTest expression syntax (FullyQualifiedName~UnitTest1|TestCategory=CategoryA) under MTP. xUnit v3 does not implement it at all: you need --filter-class, --filter-method, --filter-namespace, --filter-trait, or --filter-query, plus their negated variants. A CI filter that silently matches nothing then trips exit code 8, which is how this shows up in practice. The same class of silent-filter problem is worth understanding if you are also weighing xUnit v3 against NUnit and MSTest.

Coverage numbers move. IncludeTestAssembly defaults to false in Microsoft.Testing.Extensions.CodeCoverage and defaulted to true in VSTest. Your total coverage percentage will change on the migration commit for reasons unrelated to your code. Tell whoever watches the coverage gate before you push.

The generated entry point produces two odd compiler errors. Microsoft.Testing.Platform.MSBuild emits TestingPlatformEntryPoint and SelfRegisteredExtensions into $(RootNamespace), which defaults to the project name. A project named Contoso.Serialization.Tests that also references a Contoso.Serialization package can produce CS0118: 'Serialization' is a namespace but is used like a type; set <RootNamespace>Contoso.SerializationTests</RootNamespace> or clear it with <RootNamespace />. Separately, a non-test project that references a test project hits CS8892 because the generated entry point collides with its Main; set <IsTestingPlatformApplication>false</IsTestingPlatformApplication> on the referencing project, or <GenerateTestingPlatformEntryPoint>false</GenerateTestingPlatformEntryPoint> on the test project.

Test Explorer weirdness has its own switch. If discovery misbehaves in an IDE, <DisableTestingPlatformServerCapability>true</DisableTestingPlatformServerCapability> turns off MTP’s server mode so the IDE falls back to the VSTest adapter. That is a workaround, not a fix, and it is a different problem from Test Explorer hanging while dotnet test passes.

The .NET 11 SDK makes the timing good: run-level --timeout and --maximum-failed-tests, --no-dependencies, --use-current-runtime, !-prefixed exclusion patterns for --test-modules, Microsoft.Build.Traversal support, and a live in-flight test display in interactive terminals. None of it exists on the VSTest path.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back