Skill v1.0.0
currentAutomated scan100/100version: "1.0.0" name: dotnet-test-platforms description: Use when deciding or explaining how dotnet test actually runs a C#/.NET test project — VSTest vs Microsoft.Testing.Platform (MTP), which one a given project uses, why a flag lands after -- or not, TRX/coverage/exit-code differences, or migrating a project from VSTest to MTP. This is the authority on the platforms themselves; framework-specific test-writing flags live in csharp-tunit, csharp-nunit.
.NET test execution: VSTest vs Microsoft.Testing.Platform (MTP)
dotnet test is a thin driver over one of two unrelated test platforms. They differ in process architecture, extension model, CLI options, and exit codes — mixing up which one a project uses is the source of most "why doesn't --filter work" / "why is there no testhost" confusion. This covers the .NET 10.0.401 and .NET 11.0.100-rc.1 SDKs.
The two platforms, in one paragraph each
VSTest is the original architecture (Microsoft.NET.Test.Sdk). The test project is an ordinary library (OutputType implicitly Library). dotnet test builds it, then launches a separate vstest.console process, which spawns a testhost process, which loads your test DLL through a test adapter (xunit.runner.visualstudio, NUnit3TestAdapter, MSTest's adapter). Discovery and execution happen through that adapter layer. Config comes from a .runsettings XML file; results and coverage come from loggers and data collectors running inside the vstest process tree.
Microsoft.Testing.Platform (MTP) removes that whole chain. The test project builds to a self-contained executable (OutputType=Exe, Microsoft.Testing.Platform.MSBuild generates the Main/entry point). There's no vstest.console, no testhost, no adapter — dotnet <yourtests>.dll (or the native apphost) is the test run. Capabilities that VSTest bundled centrally (TRX output, coverage, crash/hang dumps, retry) are opt-in NuGet extensions (Microsoft.Testing.Extensions.*) that each test app references directly, so the command-line surface is whatever extensions that specific app happens to have — there's no fixed global flag set the way VSTest has.
Do: check IsTestingPlatformApplication (or look for OutputType=Exe + no adapter package) before assuming a project's flagsDon't: assume every test project takes --filter, --logger, --collect — those are VSTest-onlyDon't: assume every test project takes --treenode-filter or MTP exit codes — those are MTP-only
Which one is a project actually using?
Check with MSBuild directly — this is unambiguous where reading the .csproj isn't:
dotnet msbuild -getProperty:IsTestingPlatformApplication,EnableMSTestRunner,IsTestProject,OutputType
IsTestingPlatformApplication | IsTestProject | Classification | |
|---|---|---|---|
true | any | MTP test application | |
false/empty | true | VSTest test project | |
false/empty | false/empty | not a test project |
Signals in the .csproj:
- VSTest:
<PackageReference Include="Microsoft.NET.Test.Sdk">+ a*.runner.visualstudio/NUnit3TestAdapter/ MSTest adapter package, noOutputType.dotnet new xunit(v2, default) and a plaindotnet new mstest(no--test-runner) produce this. Running it prints the VSTest banner:Test run for ....dll (.NETCoreApp,Version=v10.0)/A total of N test files matched the specified pattern. - MTP:
OutputType=Exe,EnableMSTestRunner=true(MSTest ≥ 3.2) /EnableNUnitRunner=true(NUnit3TestAdapter≥ 5.0) /UseMicrosoftTestingPlatformRunner=true(xUnit.v3), TUnit always. Running it printsRunning tests from ....dll (tfm|arch)/Test run summary: Passed!. - Gotcha:
dotnet new mstest --sdk/--test-runner MSTest(as templated by the 10.0.401 SDK) setsEnableMSTestRunner=trueandTestingPlatformDotnetTestSupport=true— but also pins an oldMSTestpackage version (3.6.4) whereIsTestingPlatformApplicationdoesn't get wired toEnableMSTestRunner, sodotnet msbuild -getProperty:IsTestingPlatformApplicationstill reportsfalseand native-MTPdotnet test(global.json opted in) refuses the project ("The following test projects are using VSTest test runner"). BumpingMSTestto 4.0.2 (and dropping the now-unneededMicrosoft.NET.Test.Sdk/pinned extension versions the template also added) fixes it. Don't trust the property values in a template-generated `.csproj` at face value — verify with `-getProperty` after restore.
How dotnet test picks a runner
Runner selection was VSTest-only through .NET 9. Starting with .NET 10 SDK (MTP 1.7+ required), dotnet test reads global.json:
{"test": { "runner": "Microsoft.Testing.Platform" }}
"VSTest" is the explicit (and default, if the key is absent) value. Starting with .NET 11 Preview 6, DOTNET_TEST_RUNNER=Microsoft.Testing.Platform (or VSTest) overrides global.json for one invocation without editing the file.
Once global.json opts into MTP, every test project touched by that dotnet test invocation must be an MTP app — a VSTest project in the mix is a hard error: "global.json defines test runner to be Microsoft.Testing.Platform. All projects must use that test runner. The following test projects are using VSTest test runner: <project>". There's no per-project override once you're in native-MTP mode.
Native MTP mode vs the VSTest-mode bridge — this is the confusing part
An MTP test app (IsTestingPlatformApplication=true) can be run by dotnet test in two different ways, and they look and behave differently:
- Native MTP mode (
global.jsonopts in):dotnet testtalks to the app's own MTP protocol directly. Output looks likeRunning tests from .../X.dll (tfm|arch)/Test run summary: Passed!. Options are whatever the app's registered extensions expose (see below); there is no fixed VSTest-style flag set. - VSTest-mode bridge (
global.jsonstill says VSTest, project hasTestingPlatformDotnetTestSupport=true):dotnet teststill drives a VSTest-shaped flow, but instead of a testhost+adapter it launches the MTP exe directly. Output looks likeRun tests: '.../X.dll' [tfm|arch]/Tests succeeded:— visually distinct from both the native-MTP output and plain-VSTest output. This is theTestingPlatformDotnetTestSupportMSBuild property's whole job: let an MTP app keep working underdotnet testbefore a repo (or its CI) has opted intoglobal.json's MTP mode.
The exact same EnableMSTestRunner=true project produces three different console banners across the three modes: plain-VSTest testhost banner when misclassified, VSTest-bridge banner with TestingPlatformDotnetTestSupport=true, native-MTP banner once global.json opted in and the project actually classified as MTP. If output format looks unfamiliar, that's the tell for which mode you're in — don't guess from the flags alone.
Why arguments go after -- in one mode but not the other
- VSTest (
dotnet test [options] -- <RunSettings NAME=VALUE ...>): everything after--is inline.runsettingskey/value pairs (dotnet test -- MSTest.MapInconclusiveToFailed=True), not arbitrary CLI flags. - MTP (
dotnet test [dotnet-test options] -- <args passed verbatim to the test app>):dotnet testonly recognizes a fixed, small set of orchestration options (--results-directory,--minimum-expected-tests,--list-tests, etc. — see the synopsis below). Anything it doesn't recognize is forwarded to the test application as a token, and a recognized option sitting between two of your tokens gets stripped before forwarding — which can silently reassociate values. Putting extension-specific args (--report-trx,--coverage,--treenode-filter) after a literal--removes that ambiguity:dotnet test --results-directory TestResults -- --report-trx --report-trx-filename A.trx. Some options (--minimum-expected-tests) are recognized in both positions with different scope — before--it's a whole-run policy, after--it's forwarded to each module and applied per-module.
.NET 11 changes (11.0.100-rc.1)
dotnet new xunit --xunit-version v3generates an xUnit v3 project that is MTP by default (OutputType=Exe,UseMicrosoftTestingPlatformRunner=true,xunit.v3.mtp-v2package) — and the template itself writes `"test": {"runner": "Microsoft.Testing.Platform"}` into `global.json` if one exists in the directory tree.--test-runner VSTestopts back into the old shape.dotnet new nunit --test-runner Microsoft.Testing.Platformsimilarly opts an NUnit project into MTP (EnableNUnitRunner=true).- New
dotnet testflags for MTP mode:--no-dependencies,--use-current-runtime/--ucr,--timeout,--maximum-failed-tests,--results-directory-layout per-module,--test-modulesexclusion via!pattern,--device/--list-devicesfor mobile targets,--collect-test-map/--affected-tests(experimental). DOTNET_TEST_RUNNERenv var (Preview 6+) overridesglobal.jsonwithout editing it.
VSTest in depth
Project shape: library, Microsoft.NET.Test.Sdk, a test-adapter package, no OutputType.
dotnet test # exit 0 pass / 1 fail — the only two VSTest exit codesdotnet test --filter "FullyQualifiedName~Foo" # property=value syntax; see table belowdotnet test -t # --list-tests, short form -tdotnet test --logger trx # writes TestResults/<host>_<date>.trxdotnet test --collect "Code Coverage" # Microsoft-format coverage (needs the VS-hosted collector)dotnet test --collect:"XPlat Code Coverage" # coverlet.collector -> TestResults/<guid>/coverage.cobertura.xmldotnet test -s my.runsettings # .runsettings drives adapter config, blame/data-collector options, TargetPlatformdotnet test --blame-hang --blame-hang-timeout 60s # hang/crash dumps via the blame collector (testhost-process based)
--filter property support is framework-specific: MSTest (FullyQualifiedName, Name, ClassName, Priority, TestCategory), xUnit (FullyQualifiedName, DisplayName, Traits), NUnit (FullyQualifiedName, Name, Priority, TestCategory/Category, Property). Operators: =, !=, ~ (contains), !~; combine with &/| and parens. A bare value is FullyQualifiedName~value.
Exit codes: pass → 0, one failing [Fact] → 1; VSTest never returns anything else. A run that discovers zero tests still returns 0 unless .runsettings sets RunConfiguration.TreatNoTestsAsError=true.
MTP in depth
Project shape: OutputType=Exe, IsTestingPlatformApplication=true (transitively set by EnableMSTestRunner/EnableNUnitRunner/UseMicrosoftTestingPlatformRunner/TUnit), no VSTest adapter package. It runs three interchangeable ways:
dotnet run --project tests/X -- --treenode-filter "/*/*/MyClass/*" # fastest inner loop, TUnit's filter syntax./bin/Debug/net9.0/X # the built apphost, directly — no dotnet neededdotnet bin/Debug/net9.0/X.dll --list-tests # via dotnet exec, same processdotnet test # orchestrated, with global.json opted into MTP
dotnet test --list-tests # discovers without running; prints "Discovered N tests."dotnet test -- --filter "TestMethod1" # MSTest/NUnit/xUnit v3 on MTP: --filter, not --treenode-filterdotnet test -- --treenode-filter "..." # TUnit only — hierarchical /Assembly/Namespace/Class/Method path syntaxdotnet test -- --report-trx # needs Microsoft.Testing.Extensions.TrxReport referenced; writes bin/.../TestResults/*.trxdotnet test --coverage # needs Microsoft.Testing.Extensions.CodeCoverage referenced; writes a .coverage filedotnet test --minimum-expected-tests 5 # whole-run floor; add "-- --minimum-expected-tests 2" for a per-module floor too
Extensions aren't built in. Pass --report-trx to an app that never referenced Microsoft.Testing.Extensions.TrxReport and MTP fails with exit code 5 (unrecognized option) rather than silently ignoring it. dotnet test --help only shows the options the targeted app actually registers.
Exit codes (10.0.401 + MSTest 4.0.2)
| Code | Meaning | |
|---|---|---|
0 | success | |
2 | at least one test failed | |
3 | aborted / timed out (--timeout, double Ctrl+C) | |
5 | unrecognized option — extension not referenced | |
8 | zero tests ran — --filter matching nothing gives Test run completed with non-success exit code: 8 | |
9 | --minimum-expected-tests not met — e.g. --minimum-expected-tests 5 against a 1-test project gives exit code: 9 | |
13 | --maximum-failed-tests limit reached (.NET 11 Preview 7+, MTP 2.4+) |
--ignore-exit-code <N> (or a comma list) maps a specific code back to 0 — e.g. --ignore-exit-code 2 turns a failing run's process exit code from 2 to 0 while the summary still reports the failure. Use it deliberately (e.g. suppressing 8 for an intentionally test-less scaffold project), not to paper over real failures.
Config file, not runsettings
MTP has no .runsettings equivalent in the VSTest sense. Per-run configuration is testconfig.json (pointed at with --config-file) plus whatever command-line options the referenced extensions expose; there's no XML data-collector pipeline.
Which frameworks support what
| Framework | VSTest | MTP | How to get MTP | |
|---|---|---|---|---|
| TUnit | no | only | always — TUnit has no VSTest adapter | |
| MSTest | yes (default via dotnet new mstest) | yes | EnableMSTestRunner=true, or the MSTest.Sdk project SDK (sets it automatically); MSTest ≥ 3.2 | |
| NUnit | yes (default) | yes | EnableNUnitRunner=true; NUnit3TestAdapter ≥ 5.0, or dotnet new nunit --test-runner Microsoft.Testing.Platform | |
| xUnit v3 | possible but atypical | default (11 SDK templates) | native — UseMicrosoftTestingPlatformRunner=true, or dotnet new xunit --xunit-version v3 | |
| xUnit v2 | only | no | not supported; migrate to xUnit v3 for MTP |
See csharp-tunit and csharp-nunit for the framework-level testing conventions; this skill only covers the platform underneath them.
Migrating a project from VSTest to MTP
- Add the runner-enabling property (
EnableMSTestRunner/EnableNUnitRunner/UseMicrosoftTestingPlatformRunner) or switch to the framework's MTP-first SDK/template. - Set (or let the package set)
OutputType=Exe. The project now produces a real executable, not just a library — anything that referenced the test DLL as a library dependency needsIsTestingPlatformApplication=falseset on that project instead, or it will pick up an unwanted transitive entry point (seeerror CS8892in MTP troubleshooting docs). - Replace
.runsettingsusage withtestconfig.json/ command-line options per extension. - Replace
coverlet.collector+--collect:"XPlat Code Coverage"withMicrosoft.Testing.Extensions.CodeCoverage+--coverage(Microsoft's MTP-native coverage extension), orcoverlet.MTPif you need coverlet specifically on MTP. - Replace
--logger trx/-l trxwithMicrosoft.Testing.Extensions.TrxReport+--report-trx. - Drop
--collect "Code Coverage"/--blame-*/--diag— those are VSTest-specific; MTP's equivalents areMicrosoft.Testing.Extensions.CrashDump/HangDump(--crashdump,--hangdump,--hangdump-timeout) and--diagnostic/--diagnostic-output-directory. - Update CI: exit-code handling changes (0/1 → the MTP table above), TRX/coverage output paths move under
bin/<config>/<tfm>/TestResults/(or<ArtifactsPath>/test/<project>/<pivot>with the SDK artifacts layout) instead of the project-rootTestResults/. - Opt the whole repo in via
global.json's"test": {"runner": "Microsoft.Testing.Platform"}only once every test project in every solution touched bydotnet testhas migrated — a straggler VSTest project makes the wholedotnet testinvocation fail outright.
For solutions that mix frameworks (e.g. MSTest and xUnit) that are all individually MTP, but need different extension args, set TestingPlatformCommandLineArguments per-project instead of passing shared args after dotnet test --.
Gotchas
- A solution can't mix MTP and VSTest projects under one `dotnet test` invocation. Not "supported but weird" — it's rejected outright once
global.jsonopts into MTP, and even the VSTest-mode bridge (TestingPlatformDotnetTestSupport) docs explicitly warn against solutions with both. - `--no-build` is risky in both platforms for the same reason
csharp-tunit/csharp-verificationcall out: it skips the build that would otherwise catch a stale binary, and for MTP specifically--no-buildalso implies--no-restore, so a project whose classification depends on a fresh restore (IsTestingPlatformApplication) can silently misclassify. - Running a single test fast: for an MTP project, skip
dotnet test's orchestration entirely —dotnet run --project tests/X -- --treenode-filter "..."(TUnit) or-- --filter "..."(MSTest/NUnit/xUnit v3) rebuilds only that project and executes it directly. For VSTest,dotnet test --filter ...still goes through the full vstest.console/testhost spin-up; there's no equivalent shortcut because there's no standalone executable to run. - Parallelism across assemblies is unrelated to parallelism within a test. For solution-level or multi-targeted runs,
TestTfmsInParallel(VSTest, defaulttruesince .NET 9) and--max-parallel-test-modules(MTP, defaults toEnvironment.ProcessorCount) control how many test assemblies run concurrently; each framework's own in-process parallelism (e.g. TUnit's per-test parallel-by-default model, seecsharp-tunit) is a separate, per-assembly setting. - Template-generated `.csproj` files can lie about their own classification (see the MSTest 3.6.4 example above) — verify with
dotnet msbuild -getProperty:IsTestingPlatformApplicationafter restore rather than trusting the presence ofEnableMSTestRunner=truealone. - Output format is a reliable tell for which of the three modes you're in (plain VSTest testhost banner / VSTest-bridge banner / native-MTP banner) when a flag mysteriously doesn't work — check which one you're actually looking at before assuming the flag is wrong.
Checklist
- [ ] Confirmed the project's platform with
dotnet msbuild -getProperty:IsTestingPlatformApplication,IsTestProject— not by reading the.csprojalone - [ ] Confirmed which
dotnet testmode applies (native MTP / VSTest-bridge / plain VSTest) by matching the console banner, if a flag isn't behaving as expected - [ ] Framework-appropriate filter syntax used:
--filter(VSTest/MSTest/NUnit/xUnit-on-MTP) vs--treenode-filter(TUnit) - [ ] Test-app-specific args placed after a literal
--in MTP mode; runsettingsNAME=VALUEpairs after--in VSTest mode - [ ] Extension packages referenced for any flag used (
Microsoft.Testing.Extensions.TrxReport/CodeCoverage/CrashDump/HangDump) — an unregistered option is exit code 5, not a silent no-op - [ ] CI exit-code handling matches the platform in use (0/1 for VSTest; 0/2/3/5/8/9/13 for MTP) rather than assuming pass/fail is always 0/1
- [ ] No VSTest and MTP projects mixed under one
dotnet testinvocation onceglobal.jsonopts into MTP - [ ]
--no-buildavoided when classification or freshness matters (seecsharp-verification)