VSLive! Blog

Industry Insights, Information, and Developer News

Blog archive

Wait...Who Moved My dotnet test?

I've typed dotnet test probably 100 zillion times. It's muscle memory to build the thing, run the tests and look for the fails. Nothing interesting to see here.

And then I created a brand-new xUnit test project on the .NET 10 SDK and noticed something weird: The template dropped a global.json file into my solution directory. I didn't ask for that. Then I tried running my tests with one of my usual command-line switches and got told "unknown option." Hmm. OK. Something changed.

What changed is that dotnet test now has a whole second engine under the hood. It's called Microsoft.Testing.Platform -- MTP for short -- and if you're using xUnit v3, you're probably already using it whether you realize it or not. So let's take a tour.

The Old Way: VSTest
For basically the entire history of .NET Core, dotnet test has been powered by VSTest. VSTest is the same test platform that's been behind Visual Studio's Test Explorer forever. The way it works is that your test project gets built as a library, then VSTest spins up a test host process, loads your assembly and uses a test adapter (like xunit.runner.visualstudio) to go find and run your tests.

It works. It's worked for a long time. But there are a lot of moving parts in there, and your test project is kind of a passenger. It doesn't really know how to run itself -- it needs VSTest to come pick it up and drive.

The New Way: Your Test Project Is Just an App
Microsoft.Testing.Platform flips that around. With MTP, the test runner gets compiled into your test project. Your test project becomes an executable. It knows how to discover and run its own tests. No separate host, no adapter.

Here's what the xUnit v3 template gives you on .NET 10 (I bumped the target framework to net10.0 and the package to the latest):

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
  </PropertyGroup>
  <ItemGroup>
    <Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
  </ItemGroup>
  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>
  <ItemGroup>
    <PackageReference Include="xunit.v3.mtp-v2" Version="4.0.2" />
  </ItemGroup>
</Project>

Notice a couple of things. First, <OutputType>Exe</OutputType>. That's a test project that's an app. Second, there's no Microsoft.NET.Test.Sdk and no xunit.runner.visualstudio. If you've been doing xUnit v2 for years like I have, those two packages were basically mandatory boilerplate. They're gone.

Third -- look at that package name: xunit.v3.mtp-v2. xUnit v3 ships a little family of packages that let you pick which version of MTP you want:

Package What You Get
xunit.v3The default. As of package version 4.0.0, that means MTP v2.
xunit.v3.mtp-v2Explicitly MTP v2.
xunit.v3.mtp-offNo MTP at all.
xunit.v3.mtp-v1Explicitly MTP v1. Only exists for the 3.x packages.

(And yes, the xunit.v3 package is now on version 4.x. Naming things is hard.)

Since your test project is an app, you can just...run it:

$ ./test/Demo.Tests/bin/Debug/net10.0/Demo.Tests
xUnit.net v3 In-Process Runner v4.0.2+0c7aa4991f (64-bit .NET 10.0.10)
Discovering: Demo.Tests
Discovered:  Demo.Tests
Starting:    Demo.Tests
Finished:    Demo.Tests
=== TEST EXECUTION SUMMARY ===
  Demo.Tests  Total: 5, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0, Time: 0.056s

No dotnet test. No test host. Just an executable that runs tests. That's the whole idea.

So Where Does global.json Come In?
OK, so if the test project can run itself, what does dotnet test do now?

On the .NET 10 SDK, dotnet test has two modes: the old VSTest mode and a new native MTP mode. VSTest mode is still the default. To switch to MTP mode, you put this in a global.json file at the root of your repo:

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

That's the file the xUnit v3 template quietly dropped next to my solution.

You might be used to global.json as "the file that pins my SDK version." It still does that. It just has a new test section now. And it makes sense that this lives in global.json and not in a .csproj: dotnet test needs to know which engine to use before it starts looking at your projects. It's a repo-wide decision, not a per-project one.

So what happens if you delete it? I tried. Here's what you get:

error : Testing with VSTest target is no longer supported by Microsoft.Testing.Platform
on .NET 10 SDK and later. If you use dotnet test, you should opt in to the new
dotnet test experience. For more information, see https://aka.ms/dotnet-test-mtp-error

That's the MTP v2 package refusing to be run by the old VSTest engine. On .NET 8 and 9, that <TestingPlatformDotnetTestSupport> property in the .csproj was how you bridged MTP projects into VSTest-mode dotnet test. On .NET 10 with MTP v2, that bridge is closed. The global.json setting is how you get in.

The flip side: Because this is a repo-wide switch, every test project that dotnet test picks up has to speak MTP. According to the docs, if one of your projects only supports VSTest, you'll get an error. So if you've got a big solution with a mix of old xUnit v2 projects and new v3 projects, plan on migrating them together.

What It Looks Like When You Run It
Here's a run with all tests passing:

$ dotnet test
Running tests from ~/code/MtpDemo/test/Demo.Tests/bin/Debug/net10.0/Demo.Tests.dll (net10.0|arm64)
~/code/MtpDemo/test/Demo.Tests/bin/Debug/net10.0/Demo.Tests.dll (net10.0|arm64) passed (332ms)
Test run summary: Passed!  total: 5  failed: 0  succeeded: 5  skipped: 0  duration: 604ms

And here's what a failure looks like. I broke one of my test's expected values on purpose:

$ dotnet test
Running tests from ~/code/MtpDemo/test/Demo.Tests/bin/Debug/net10.0/Demo.Tests.dll (net10.0|arm64)
~/code/MtpDemo/test/Demo.Tests/bin/Debug/net10.0/Demo.Tests.dll (net10.0|arm64)
failed Demo.Tests.ShippingCalculatorTests.SmallOrder_PaysStandardShipping (1ms)
  Assert.Equal() Failure: Values differ
  Expected: 6.99
  Actual:   5.99
    at Demo.Tests.ShippingCalculatorTests.SmallOrder_PaysStandardShipping() in ~/code/MtpDemo/test/Demo.Tests/ShippingCalculatorTests.cs:12
Test run summary: Failed!  total: 5  failed: 1  succeeded: 4  skipped: 0  duration: 547ms
Test run completed with non-success exit code: 2 (see: https://aka.ms/testingplatform/exitcodes)

It's tidier than the old output. And notice the exit code at the bottom. MTP has documented, specific exit codes -- 2 means "tests failed," 5 means "zero tests ran," and so on. That's handy when you're scripting.

You'll also want to know about --list-tests:

$ dotnet test --list-tests
Discovered 5 tests in assembly - .../Demo.Tests.dll (net10.0|arm64)
  Demo.Tests.ShippingCalculatorTests.SmallOrder_PaysStandardShipping
  Demo.Tests.ShippingCalculatorTests.LargeOrder_ShipsFree
  Demo.Tests.ShippingCalculatorTests.Express_AddsSurcharge(orderTotal: 20, expected: 17.99)
  Demo.Tests.ShippingCalculatorTests.Express_AddsSurcharge(orderTotal: 75, expected: 12)
  Demo.Tests.ShippingCalculatorTests.NegativeTotal_Throws

The Muscle Memory Gotchas
Here's where I tripped. MTP has its own command-line options, and some of the VSTest ones you've been typing for years don't exist anymore. The worst part is how it fails: When I ran dotnet test --logger trx, I didn't get a friendly "Hey, that's the old syntax" message. I got this:

Test run summary: Zero tests ran
error: 1
...
Test run completed with non-success exit code: 5

Zero tests ran. If you run the test executable directly with the same switch, you get the real story: error: unknown option: --logger. So if your CI pipeline suddenly reports zero tests, check your switches.

Here's my cheat sheet of the things I actually use, all tried against the xUnit v3 / MTP v2 project:

What You Want Old VSTest Way MTP + xUnit v3 Way
TRX results file--logger trx--report-xunit-trx
Filter by name--filter "FullyQualifiedName~Express"Still works! xUnit supports the VSTest filter syntax via --filter
Filter by method--filter "Name=..."--filter-method "*ShipsFree"
Filter by trait--filter "Category=Express"--filter-trait "Category=Express"
Code coverage--collect "XPlat Code Coverage" (Coverlet)Add Microsoft.Testing.Extensions.CodeCoverage, then --coverage
Pass args to the runnerdotnet test -- <args>Just pass them. The -- is optional now.

A few notes about that table.

The filters are actually nicer. xUnit gives you --filter-class, --filter-method, --filter-namespace, --filter-trait and the "not" versions of all of those (--filter-not-trait, etc.). No more trying to remember the VSTest filter expression syntax. And if you've got a bunch of old filter strings in scripts, the plain --filter option still understands them.

The reports are a fun surprise. TRX is there, but so are JUnit, NUnit, CTRF, HTML and...Markdown. Here's dotnet test --report-xunit-markdown --report-xunit-markdown-filename results.md:

### Test Results
⌚ 66ms total run time
🧪 5 tests
✅ 5 passed

That's perfect for dropping into a GitHub Actions job summary.

Code coverage needs a package now. The Coverlet --collect approach isn't supported with MTP. Instead, you add a package and turn it on from the command line:

$ dotnet add package Microsoft.Testing.Extensions.CodeCoverage
$ dotnet test --coverage --coverage-output-format cobertura

In process file artifacts produced:
  - ~/code/MtpDemo/TestResults/c89c2dd7-a922-46c4-804e-4570cae2a94b.cobertura.xml

This is the pattern with MTP in general: Extra capabilities come from extension packages that you add to the test project, and those extensions add their own command-line options. Run dotnet test --help in an MTP repo and you'll see the platform options, then a whole section of xUnit-specific options, then options from any extensions you've added.

One More Knob: UseMicrosoftTestingPlatformRunner
Remember running the test executable directly earlier? That output was xUnit's own native console runner. If you'd rather the executable behave exactly like dotnet test -- same options, same output -- add this to the .csproj:

<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>

Now running the executable directly gives you the MTP experience:

$ ./test/Demo.Tests/bin/Debug/net10.0/Demo.Tests
Test run summary: Passed! - .../Demo.Tests.dll (net10.0|arm64)
  total: 5  failed: 0  succeeded: 5  skipped: 0  duration: 147ms

What About CI?
The good news: As long as global.json is committed at the root of your repo, a plain dotnet test step in your build does the right thing. Here's a GitHub Actions step that also produces a TRX file:

- name: Run tests
  run: dotnet test --report-xunit-trx --results-directory ./TestResults

If you're on Azure Pipelines using the DotNetCoreCLI@2 task with command: test, MTP support was added in version 2.263.0 of that task, and it also relies on that global.json setting. And whatever CI you're on, go check every place you pass --logger or --collect. Those are the ones that'll bite you.

Should You Switch?
If you're starting a new test project with xUnit v3, you already did -- the template made the decision for you. For existing VSTest-based solutions, I'd move to MTP when you move to xUnit v3. It's not a giant migration, but because it's repo-wide, you want to do it on purpose: Update the packages, add the global.json setting, then grep your scripts and pipelines for --logger, --collect and --filter.

What I like about this is the same thing I liked when I finally figured out how top-level statements and AddScoped() work. Once you see what's actually going on, it's simpler than it looked. Your test project is just an app. It runs its own tests. dotnet test is now mostly a friendly way to build and run a bunch of those apps. And global.json is just the switch that tells it to do that.

-Ben

About the Author

Benjamin Day is a consultant, trainer and author specializing in software development, project management and leadership.

Posted by Benjamin Day on 10/21/2026


Keep Up-to-Date with Visual Studio Live!

Email Address*Country*
Please type the letters/numbers you see above.