Microsoft.Testing.Platform in .NET 10: What Changes for dotnet test and How to Migrate from VSTest

By Sergey Nosov

4 October 2026

For most of the last decade, running .NET tests meant VSTest. You wrote the tests with xUnit.net, NUnit, or MSTest, and dotnet test handed them to a separate runner. That runner found an adapter for your framework at run time, loaded your assembly in a test host process, and reported back over a serialized channel. It worked, so few teams thought about it. .NET 10, released on 11 November 2025, gives dotnet test a second mode built for Microsoft.Testing.Platform. The new platform lives inside the test project and turns it into an ordinary executable. The switch is three lines in one file. The consequences reach your project files, your CI scripts, your coverage tooling, and your IDE.

This article explains what the new platform changes and why, shows how to enable it with the .NET 10 SDK, walks through xUnit.net v3, MSTest, and NUnit, and ends with a migration checklist. I ran every command below on 4 October 2026 with .NET SDK 10.0.401, against a solution holding one xUnit.net project, one MSTest project, and one NUnit project. Where I repeat a performance figure or a statement of direction, it is Microsoft’s claim, and I say so.

Why VSTest Had to Be Replaced

VSTest is a platform, not a framework. Microsoft’s comparison page draws the line: “The test framework defines the test model you write against, such as MSTest, NUnit, xUnit.net, or TUnit,” while “The test platform runs tests, integrates with IDEs and CLI, and provides shared extension points.” In the VSTest design, dotnet test does not run your tests. The documentation describes the chain: “The process involves invoking the VSTest MSBuild target, which triggers other internal targets to run and ultimately runs vstest.console. All dotnet test command-line options are translated to their equivalents in vstest.console.” That console starts a test host, the host finds and loads an adapter, and the adapter loads your assembly.

Four properties of that design aged badly as .NET moved on.

Microsoft frames the motivation as a decade of friction. The platform overview says the new platform “aims to address the challenges encountered since the release of .NET Core in 2016.”

What Microsoft.Testing.Platform Changes

Microsoft.Testing.Platform, which Microsoft shortens to MTP, is in the overview’s words “a lightweight and portable alternative to VSTest for running tests in all contexts, including continuous integration (CI) pipelines, CLI, Visual Studio Test Explorer, and VS Code Test Explorer.” The central idea is that your test project compiles to an executable with the runner inside. Build it, and you can run Orders.Tests.exe on any machine that has the runtime, with no SDK and no vstest.console. In my project, the built executable reported three passing tests in about a fifth of a second, and dotnet run did the same.

The overview lists the design goals it calls pillars. The ones that change daily work are these:

Performance is where the claims need handling. The figures that circulate come from Microsoft’s January 2024 announcement of the MSTest runner. “In the internal Microsoft projects that switched to use the new MSTest runner, we saw massive savings in both CPU and memory. Some projects seen were able to complete their tests 3 times as fast, while using 4 times less memory when running with dotnet test.” Those are Microsoft’s own projects, measured by Microsoft, almost three years ago, and the savings are in runner overhead. A suite of thousands of fast unit tests spread over many assemblies has a lot of overhead to lose; a suite of a few hundred database tests has little. The current reference for dotnet test claims only that “With MTP, dotnet test operates faster than with VSTest.” I did not benchmark, and I would not put a multiplier in a business case.

Adoption is easier to pin down. In February 2025, Amaury Leve of Microsoft wrote that “Microsoft.Testing.Platform has now reached 20 Million+ downloads” and that Expecto, MSTest, NUnit, TUnit, and xUnit.net all support it. TUnit is built on nothing else. In August 2025, a .NET Blog post on the new dotnet test said that “MTP is designed to be the future of test execution in .NET.” The comparison page is more measured: VSTest “has the longest compatibility track record across existing products, tasks, and pipelines. MTP support is growing in the ecosystem, but some integrations may lag behind VSTest.” It also keeps VSTest for one case the new platform does not cover: “VSTest supports mixed-language adapter scenarios, while MTP is .NET-specific.” If your solution runs C++ or JavaScript tests through VSTest adapters, those stay where they are. And its closing line is fair to both: “If your specific use case isn’t listed, both platforms are valid choices.”

Turning It On in .NET 10

dotnet test now has two modes, and the default did not change. In the documentation’s words, VSTest mode “is the default mode for dotnet test and was the only mode available before the .NET 10 SDK,” while MTP mode, “Introduced with the .NET 10 SDK,” “exclusively supports test applications built with MTP.” You opt in through global.json at the repository root:

{
  "test": {
    "runner":
      "Microsoft.Testing.Platform"
  }
}

That is the whole switch. Two things follow from it.

First, the .NET 9 workaround goes away. Before .NET 10, MTP projects ran under VSTest mode only if you set TestingPlatformDotnetTestSupport to true and put the platform’s arguments after a -- separator, and the documentation now calls that path legacy. The migration notes say to remove that property, “as it’s no longer required,” and to remove TestingPlatformCaptureOutput and TestingPlatformShowTestsFailure “as they are no longer used by the new dotnet test.” The .NET 11 previews add an environment variable, DOTNET_TEST_RUNNER, as an alternative to the file.

Second, the switch is all or nothing. The reference states it directly: “When MTP is opted in via global.json, dotnet test expects all test projects to use MTP. It is an error if any of the test projects use VSTest.” I saw this on the first run, before I had converted the MSTest and NUnit projects. The build stopped with the message “global.json defines test runner to be Microsoft.Testing.Platform. All projects must use that test runner.” and listed the two offending project files. The comparison page is just as firm from the other side: “Don’t mix VSTest-based and MTP-based .NET test projects in the same solution or run configuration because that scenario isn’t supported.” If you want to pilot the platform on one project, give it a folder with its own global.json; the SDK resolves that file from the working directory upward.

What Changes on the Command Line

The August 2025 post, written during the previews, announced a breaking change: “The traditional syntax dotnet test <path> (e.g., dotnet test MyProject.csproj, dotnet test MySolution.sln, or dotnet test MyFolder) is no longer supported. You must now use explicit options to specify your target.” The shipped SDK is softer than the post. On 10.0.401, dotnet test MyProject.csproj and dotnet test MyProjectFolder still ran that project. A solution path did not. The reference explains the mechanism: “dotnet test forwards any token it doesn’t recognize to the test application.” My Lab.sln token was forwarded to all three test executables, each rejected it, and the run ended with zero tests and exit code 5, which means invalid command-line arguments. Scripts should use the explicit options, which are mutually exclusive:

dotnet test --project tests/Orders.Tests
dotnet test --solution Orders.sln
dotnet test --test-modules "**/bin/**/*.Tests.dll"

The third form is new. It runs assemblies that are already built, selected by a glob, and “Because this option doesn’t evaluate projects, use it to run already-built test applications when project restore state isn’t available.” That makes it the right tool for a container stage that has binaries but no obj folder. Two cautions from my run. The glob runs whatever matches: when a stale net8.0 build sat next to the net10.0 one, both ran. And --test-modules cannot be combined with --configuration, --framework, --arch, --runtime, or --os, because those need a project to evaluate.

The -- separator is now optional, and the documentation still recommends it: “Keep the separator to forward test application arguments unambiguously.” Everything before it belongs to dotnet test; everything after it goes to each test executable. A run of one test class in xUnit.net looks like this:

dotnet test -- --filter-class "Orders.PricingTests"

A few options earn a place in CI scripts. --output Detailed prints one line per test instead of one per assembly. --no-ansi and --no-progress keep build logs clean. --max-parallel-test-modules caps how many test assemblies run at once; the default is the processor count. The old VSTest /Parallel switch needs no replacement, because “MTP runs test modules in parallel by default.” --list-tests discovers without running.

Help is now dynamic, which surprised me. In MTP mode, dotnet test --help builds the projects, prints “Waiting for options and extensions...” and then lists the platform options followed by the extension options that your referenced packages contribute. Because my project referenced the code coverage extension, --coverage and its three companions appeared in the help. The flip side is that “Extension-specific options aren’t built into MTP. Each targeted test application must register the extension that provides an option.” Pass --report-trx to a project that does not reference the TRX report package, and the run fails with exit code 5.

Exit codes are stricter than VSTest’s, and one difference will bite CI pipelines. The migration guide is blunt: “If a test assembly ran zero tests, VSTest tolerates that and exits with success. However, MTP fails with exit code 8.” When I ran a filter that matched nothing, the output ended with “Test run completed with non-success exit code: 8” and the shell saw 8. A failing test returns 2. Adding --ignore-exit-code 8 turned the empty run into a success, and the migration guide offers the same escape through an MSBuild property or an environment variable. Before you reach for it, consider that a job which ran no tests is more often a broken filter than a passing build.

Framework by Framework

xUnit.net v3

xUnit.net v3 ships its MTP support inside the framework rather than through an adapter. Two project settings turn it on: the output type becomes an executable, and UseMicrosoftTestingPlatformRunner is set to true. This is the project file I tested with, and nothing in it is a test SDK or a Visual Studio runner package:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="xunit.v3" Version="4.0.1" />
  </ItemGroup>
</Project>

Do not expect the SDK’s template to give you this. On SDK 10.0.401, dotnet new xunit3 still produced a project that targets net8.0, references xunit.v3 3.2.1 together with Microsoft.NET.Test.Sdk and xunit.runner.visualstudio, and carries a comment saying it uses “VSTest by default when using ‘dotnet test’.” Under an MTP global.json, that fresh project fails the all-or-nothing check until you edit it.

Which package you reference matters more than it did a year ago. The xUnit.net documentation explains the choice: “If you are currently including the xunit.v3 NuGet package, you may choose among these alternatives: xunit.v3 (includes whatever the default version is), xunit.v3.mtp-v1 (explicitly chooses MTP v1, only available for version 3.x.y), xunit.v3.mtp-v2 (explicitly chooses MTP v2), xunit.v3.mtp-off (explicitly disables MTP support).” The default moved with xUnit.net v3 4.0.0, released on 14 August 2026: “With 4.0, we are discontinuing official support for Microsoft Testing Platform v1. The default version of Microsoft Testing Platform support now is v2 (currently at version 2.3.3).” The advice from late 2025, to reach for the mtp-v2 variant on .NET 10, is now the default behavior of the plain package, and the mtp-v1 package stops at the 3.x line.

Filters are the other adjustment. MSTest and NUnit keep the VSTest filter syntax under MTP, but, in the migration guide’s words, “xUnit.net, does not support the same filter format when running with MTP.” Its MTP options are --filter-class, --filter-method, --filter-namespace, --filter-trait, their --filter-not- counterparts, and --filter-query for the query language; the TRX report comes from --report-xunit-trx. One convenience is new: “With the Microsoft Testing Platform command line interface, multiple filters of the same kind can be specified with just a single switch.” I passed two method patterns after one --filter-method and both matched. If one solution mixes xUnit.net with MSTest or NUnit, a single --filter cannot satisfy both. The documentation’s answer is the TestingPlatformCommandLineArguments MSBuild property, set with a condition per framework.

MSTest

MSTest has the shortest path, because its project SDK chose the platform for you. The MSTest SDK documentation states that “By default, MSTest.Sdk uses the MSTest runner with MTP, including with dotnet test,” and that “you can switch to VSTest by adding the property <UseVSTest>true</UseVSTest>.” The entire project file I ran was this, plus the global using for the test attributes that the template adds:

<Project Sdk="MSTest.Sdk/4.4.1">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
  </PropertyGroup>
</Project>

The SDK’s default extension profile brings the Microsoft code coverage and TRX report extensions along, so --coverage and --report-trx work without extra references. If you stay on a classic Microsoft.NET.Sdk project, set EnableMSTestRunner to true and the output type to Exe instead. The mstest template in SDK 10.0.401 still produces that classic shape with the MSTest package, and it ran in VSTest mode until I changed it.

NUnit

NUnit’s support lives in its adapter. Microsoft’s page on the NUnit runner says “You can enable NUnit runner by adding the EnableNUnitRunner property and setting OutputType to Exe in your project file. You also need to ensure that you’re using NUnit3TestAdapter version 5.0 or newer.” NUnit’s own documentation gives the same floor and adds a caution: “It will take time to develop feature parity with NUnit’s current system.” The adapter is at 6.3.0 as of October 2026. This project ran for me:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <EnableNUnitRunner>true</EnableNUnitRunner>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="NUnit" Version="4.6.1" />
    <PackageReference Include="NUnit3TestAdapter" Version="6.3.0" />
  </ItemGroup>
</Project>

NUnit keeps the VSTest --filter syntax, and it still honors a .runsettings file through --settings. The migration guide notes that this is a courtesy of the framework, not of the platform: “RunSettings isn’t supported by the core MTP and was replaced by a more modern testconfig.json configuration file. However, MSTest and NUnit still support the old RunSettings when running MTP and --settings is still supported.” The nunit template on SDK 10.0.401 produces a VSTest project with the Coverlet collector, so it too needs editing.

Code Coverage

Coverage is the step most likely to go wrong, because the VSTest data collector model is gone. The familiar dotnet test --collect "XPlat Code Coverage" relied on coverlet.collector, which is a VSTest data collector and does nothing under MTP. There are two replacements, and both work.

The first is Microsoft’s own extension, Microsoft.Testing.Extensions.CodeCoverage. Reference the package, and the executable gains a --coverage option whose output format is one of coverage, xml, and cobertura, with the first as the default. This produced a Cobertura file under TestResults in my run:

dotnet test -- --coverage --coverage-output-format cobertura

Two notes on it. The coverage documentation says the package “is shipped with Microsoft .NET library closed-source free to use licensing model,” which some organizations will want to know. And a default changed: “The default value of IncludeTestAssembly in Microsoft.Testing.Extensions.CodeCoverage is false, while it used to be true in VSTest.” If your coverage number moves after migration, look there first. Keep the extension on the same release line as the platform: the documentation pairs 18.1.x with MTP 2.0.x, and the 17.14 line that late-2025 guidance named belongs to MTP 1.6.

The second replacement did not exist when most migration advice was written. Coverlet now ships coverlet.MTP, which the same documentation describes as “a native extension for MTP that implements coverlet.collector functionality.” It needs MTP 2.0 or newer, adds a --coverlet option, and writes JSON, LCOV, OpenCover, Cobertura, or TeamCity formats. Teams that chose Coverlet for its filters or its report formats no longer have to switch.

IDEs and CI

The migration guide gives the Visual Studio floor: “Visual Studio Test Explorer supports MTP starting with version 17.14.” Visual Studio Code supports it through the C# Dev Kit. JetBrains Rider runs these tests behind a setting on its Testing Platform options page. Its help for Rider 2026.2 adds a difference you will notice: “In contrast to test frameworks natively supported by JetBrains Rider, tests from Testing Platform adapters are only discovered after test projects are built.” Microsoft says the same about its own editors: “Automatic update of tests without building the project isn’t available.” Because each test project is an executable, Visual Studio and Visual Studio Code also let you set it as the startup project and press F5, which is the simplest way to debug a run.

In Azure DevOps, the VSTest task cannot run MTP projects. The migration guide says to replace it with the .NET Core task, and “Support for MTP in DotNetCoreCLI was added in 2.263.0 version of the task.” Any pipeline can also skip dotnet test and run the built executable as a script step, which is what the executable-first design was for. For GitHub Actions and Azure DevOps, Microsoft publishes reporter extensions that annotate failures in the job summary, enabled with --report-gh and --report-azdo once their packages are referenced.

Behavior Changes and Known Issues

Migration Checklist

  1. Update packages first. xUnit.net v3 4.x, MSTest 4.x or the MSTest.Sdk project SDK, NUnit3TestAdapter 6.x. Pin any Microsoft.Testing.Extensions.* packages to the same MTP release line.
  2. Make every test project an executable. Set OutputType to Exe and add the framework’s switch: UseMicrosoftTestingPlatformRunner, EnableMSTestRunner, or EnableNUnitRunner. A Directory.Build.props under the test folder does this once.
  3. Add the test section to global.json and delete TestingPlatformDotnetTestSupport, TestingPlatformCaptureOutput, and TestingPlatformShowTestsFailure wherever they appear.
  4. Rewrite every dotnet test call. Paths become --project, --solution, or --test-modules. Platform arguments go after --. --logger trx becomes --report-trx with the TRX package referenced. --collect becomes --coverage or --coverlet.
  5. Replace coverlet.collector with coverlet.MTP or Microsoft.Testing.Extensions.CodeCoverage, and check whether the test assembly should be included in the report.
  6. Convert xUnit.net filters from --filter to the --filter-* options. In mixed solutions, route per-framework arguments through TestingPlatformCommandLineArguments.
  7. Fix the pipeline. Replace the VSTest task with the .NET Core task at 2.263.0 or later, or run the executables. Decide what exit code 8 should mean for you.
  8. Check the editors. Visual Studio 17.14 or later, the C# Dev Kit in Visual Studio Code, or the Testing Platform setting in Rider. Tell the team that tests appear after a build.
  9. Set the telemetry variable on build agents if your policy requires it.
  10. Run locally before CI. dotnet test --help confirms which options each project accepts, and a folder with its own global.json lets you pilot one project without converting the rest.

Takeaways

How you organize the tests the runner executes is a separate question, which I covered in Unit, Integration, and End-to-End Tests.

Further Reading