Testing

Microsoft.Testing.Platform (MTP) in the Test Explorer: dotnet test, xUnit v3, MSTest and NUnit for C# and F#

A dotnet test module listing xUnit v3, MSTest and NUnit tests for C# and F# into the SharpLsp Test Explorer

dotnet test is changing underneath .NET developers. Microsoft.Testing.Platform (MTP) is the new test platform that replaces VSTest. With it, a test project builds into an executable test module that discovers and runs its own tests. There is no separate testhost process and no adapter in between. MSTest, NUnit and xUnit all ship MTP runners, and xUnit v3 4.0 supports MTP v2 only: it has no VSTest adapter at all.

So an editor whose Test Explorer only speaks VSTest shows an empty tree for a modern xUnit v3 project. The tests are there, dotnet test runs them, and the editor sees nothing.

SharpLsp's Test Explorer now speaks both. The support is on main today and ships in the next release. It discovers, runs, debugs and collects code coverage for MTP test modules: xUnit v3, MSTest and NUnit, in C# and in F#. It does this side by side with VSTest projects, down to one multi-root workspace holding both. This post covers:

Thank you, Valentin Dide

This feature exists because Valentin Dide (@validide) hit exactly that empty tree, filed issue #249, "Microsoft.Testing.Platform (MTP) support in Test Explorer", and then did the hard part too. Pull request #250, "Microsoft.Testing.Platform support in the Test Explorer" added:

Everything in this post builds on that PR. Thank you, Valentin: this is what open-source .NET tooling looks like when it works.

MTP vs VSTest in one paragraph

Under VSTest, dotnet test builds your project and hands the assembly to a test host, which loads a framework adapter (xunit.runner.visualstudio, NUnit3TestAdapter, MSTest.TestAdapter). The adapter finds and runs the tests.

Under Microsoft.Testing.Platform, your test project is itself the runner. It builds to an executable module with its own command line:

Features like TRX and coverage are extensions: NuGet packages the module references and registers, not flags the platform always has. That one detail explains most of what follows.

How to opt in: C# and F#

In the .NET 10 SDK, dotnet test picks its mode from global.json:

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

That opt-in is also the first thing SharpLsp reads. A project with no global.json is still found. SharpLsp asks MSBuild whether the project is a Testing Platform application, so an MTP project in a VSTest repository still gets its tests.

A C# xUnit v3 test project on MTP:

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

An F# xUnit v3 test project is the same project file plus the compile order F# always needs:

<ItemGroup>
  <Compile Include="Tests.fs" />
</ItemGroup>
module MyApp.Tests.Calculator

open Xunit

[<Fact>]
let ``adds two numbers`` () = Assert.Equal(3, 1 + 2)

[<Theory>]
[<InlineData(1, 2, 3)>]
[<InlineData(2, 2, 4)>]
let ``adds rows`` (a: int) (b: int) (expected: int) = Assert.Equal(expected, a + b)

For MSTest on MTP, reference MSTest (4.x) and set <EnableMSTestRunner>true</EnableMSTestRunner>. For NUnit on MTP, reference NUnit and NUnit3TestAdapter 6.x and set <EnableNUnitRunner>true</EnableNUnitRunner>. All three need <OutputType>Exe</OutputType>, because the module is the executable.

Reference Microsoft.Testing.Extensions.TrxReport for xUnit v3 and NUnit (MSTest carries it already). Without it the module rejects --report-trx and cannot report per-test results. The Test Explorer then tells you exactly which package to add, instead of showing a wall of "No result reported".

What the Test Explorer does with an MTP module

Discovery. SharpLsp builds the discovery target, then asks each built module directly with dotnet exec <module> --list-tests json. It does not go through dotnet test, because dotnet test does not forward the json argument (dotnet/sdk#49754). The text listing it does forward holds display names, and MSTest renders those as the bare method name.

The JSON listing carries each test's namespace, type, method and source location, so an MTP test row opens at the line it is written on. The tree is Assembly β†’ Namespace β†’ Class β†’ Test, the same shape as VSTest.

A module on a Testing Platform older than 2.3 can't list as JSON. It gets a message telling you to update the framework package, in the log and in the tree.

Running (β–Ά). One invocation per module for the whole selection, never one per test. A class of twenty tests does not pay twenty process starts. The selection goes out as --filter-uid values. This is the one dotnet test filter every MTP framework in our matrix accepts, and it takes literal values with no escaping, which matters for NUnit's Adds_Case(2,2,4)-style uids.

Results come back through --report-trx, read by the same TRX reader as the VSTest path. Pass, fail and skip are real, and the assertion text is the framework's own. A data-driven test (an xUnit [Theory], an MSTest [DataRow] or an NUnit [TestCase]) is one row in the tree, judged by its worst row.

Code coverage. Run with Coverage passes --coverage --coverage-output-format cobertura when the module references Microsoft.Testing.Extensions.CodeCoverage. MTP writes <guid>.cobertura.xml directly into the results directory, not one folder down where coverlet's coverage.cobertura.xml goes. The reader looks at the depth each collector really writes to.

If you're searching for "dotnet test code coverage" on MTP, that package plus that flag pair is the answer. A module without the package gets a message naming it.

Debugging. The Debug profile starts the module with TESTINGPLATFORM_WAIT_ATTACH_DEBUGGER=1. The module then prints its pid and waits. SharpLsp attaches the netcoredbg-based debugger to that exact process, and your breakpoint in the test body binds and stops. Just My Code holds, locals and watches work, and a debug run never writes a fabricated result into the tree.

VSTest and MTP side by side. The runner is chosen per discovery target, not per workspace. A multi-root workspace with a VSTest folder and an MTP folder routes every test to the runner that discovered it. Each MTP module is rebuilt from its own folder before it runs.

F# is not an afterthought

SharpLsp treats F# as a first-class citizen, so every MTP case is proven in F# as well as C#:

The bugs we found making it trustworthy

Getting MTP to "work on the demo project" is easy. Getting it to be right on your project is not. Reviewing and hardening the feature turned up real defects, each fixed at its root with a failing end-to-end test written first:

xUnit vs MSTest vs NUnit on MTP

If you're weighing xUnit vs MSTest or NUnit vs xUnit for a new project, this is how each behaves under Microsoft.Testing.Platform from an editor's point of view:

xUnit v3 MSTest NUnit
VSTest adapter none from 4.0 (MTP only) yes yes
TRX report add Microsoft.Testing.Extensions.TrxReport built in add Microsoft.Testing.Extensions.TrxReport
Source location in --list-tests json yes yes no
Data-row uid hash of the row's data keyed by the row's position decorated name, e.g. Adds_Case(2,2,4)
Display name fully qualified name bare method name method name with its arguments

None of these is a reason to pick one framework over another. They're reasons a Test Explorer must never build a test id from a display name, and must never trust yesterday's uids.

Tested against real projects

The MTP support is covered by end-to-end suites that build real .csproj and .fsproj files inside a real VS Code extension host:

They run on Windows and Linux in CI, in their own jobs, next to the VSTest suites.

FAQ

How do I run dotnet test with Microsoft.Testing.Platform? On the .NET 10 SDK, add "test": { "runner": "Microsoft.Testing.Platform" } to global.json and use an MTP-enabled framework package (xUnit v3, MSTest with EnableMSTestRunner, or NUnit with EnableNUnitRunner).

Does xUnit v3 work with VSTest? Not from 4.0: xunit.v3 4.x supports MTP v2 only and carries no VSTest adapter.

How do I filter tests under MTP? Each framework adds its own filter options. --filter-uid is the platform's own, and it's what SharpLsp uses for β–Ά, so a selection means exactly the tests you picked.

How do I get code coverage from dotnet test on MTP? Reference Microsoft.Testing.Extensions.CodeCoverage and pass --coverage --coverage-output-format cobertura. The SharpLsp Test Explorer's Run with Coverage does that for you.

Can one repository mix VSTest and MTP projects? Yes. SharpLsp picks the runner per discovery target and routes each test to the runner that found it.

Try it

MTP support ships in the next SharpLsp release after 0.21.0. Watch the releases page or build the VS Code extension from main today. Then open a solution with an xUnit v3, MSTest or NUnit project on Microsoft.Testing.Platform, and open the Testing view. If your MTP project shows an empty tree, a wrong outcome or a debugger that never attaches, open an issue. That's how this feature started.