DEV Community

Cover image for Microsoft.Testing.Platform: How .NET Testing Evolved
Gunjit Chhatwal
Gunjit Chhatwal

Posted on

Microsoft.Testing.Platform: How .NET Testing Evolved

For most .NET developers, running tests has long appeared simple: add MSTest, NUnit, or xUnit.net, write a few test methods, and run dotnet test. Under that command, however, sits a test platform responsible for discovering tests, loading adapters, starting processes, collecting results, and integrating with editors and CI systems.

That distinction matters:

  • A test framework defines how tests are written. Examples include MSTest, NUnit, xUnit.net, and TUnit.
  • A test platform discovers and executes those tests and connects them to command-line tools, IDEs, and build pipelines.

For many years, VSTest was the standard platform behind .NET testing. Microsoft.Testing.Platform (MTP) is its modern, lightweight alternative. It does not replace test frameworks; instead, it changes how their tests are hosted and executed.

How .NET testing reached this point

The earliest .NET test runners were closely associated with their frameworks or development environments. Tests often ran through a framework-specific console runner or directly inside Visual Studio. This worked well when applications primarily targeted the .NET Framework and ran on Windows.

VSTest brought valuable consistency. It gave Visual Studio, command-line tools, CI systems, and third-party frameworks a common execution platform. Test adapters allowed MSTest, NUnit, xUnit.net, and other frameworks to participate in the same workflow.

The arrival of .NET Core in 2016 changed the environment. .NET became cross-platform, side-by-side versioned, container-friendly, and increasingly optimized for deployment models such as trimming and Native AOT. Test projects could now target many runtimes and architectures, while runners had to handle more combinations of SDKs, hosts, adapters, extensions, and process boundaries.

VSTest continued to evolve, but its architecture came from a period when an external runner loading test adapters was the natural model. Supporting modern .NET form factors within that model added complexity. The result could be subtle differences between local, IDE, and CI execution, along with difficult assembly-loading or extension-discovery problems.

Microsoft introduced MTP through the MSTest Runner work and expanded it into a platform supported by the major .NET test frameworks. In .NET 10, dotnet test gained native MTP-aware orchestration, making the new platform feel increasingly like a first-class part of the SDK rather than a separate experiment.

evolution of testing in dotnet

The architectural shift: the test project becomes an application

The most important MTP idea is surprisingly simple: a test project is built as an executable test application.

With the traditional VSTest model, an external runner discovers an adapter, loads the test assembly, and coordinates execution. With MTP, the platform and framework runner are embedded into the test project. The resulting application can participate in dotnet test, but it can also be started with dotnet run or executed directly.

test framework architecture

This design reduces the number of hidden decisions made at run time. Frameworks and extensions are normally registered at build time, so the application knows what it contains before execution begins. The same test artifact can run locally or in CI without depending on a separately installed console runner.

What improves with Microsoft.Testing.Platform?

1. More deterministic execution

MTP emphasizes compile-time registration instead of discovering arbitrary plugins through reflection at run time. This makes configuration errors visible earlier and reduces environment-dependent behavior. The test application carries its intended runner and extensions with it, improving the chance that the same build behaves consistently on a developer machine and a build agent.

2. A smaller and more transparent runtime

The MTP core is a single assembly with no dependencies beyond supported .NET runtimes. It avoids creating isolation mechanisms such as AppDomain or AssemblyLoadContext simply to coordinate a run, and it does not rely on custom assembly resolution for its core operation. That reduces interference between the platform and the framework executing the tests.

3. Executable-first workflows

Because an MTP test project behaves like a normal .NET application, it works naturally with familiar application workflows:

dotnet test
dotnet run --project tests/MyApp.Tests
./tests/MyApp.Tests/bin/Release/net8.0/MyApp.Tests
Enter fullscreen mode Exit fullscreen mode

This is useful for containers, specialized devices, browser-like hosts, startup-project debugging, and environments where installing or locating an external runner is undesirable.

4. Better alignment with modern .NET

MTP was designed for current and future .NET form factors, including trimming and Native AOT scenarios. Its hostable architecture also allows the test runtime to run inside different application types rather than assuming every test must be controlled by a conventional console host.

5. Modular capabilities instead of a large default surface

The platform focuses on orchestration and adds capabilities through extensions. Available extensions cover TRX, HTML, JUnit, CTRF and CI-native reporting, code coverage, crash and hang dumps, retries, Hot Reload, OpenTelemetry, Microsoft Fakes, and more.

When standard MSBuild integration is enabled, installing an extension package is generally enough for it to be detected and registered. Teams can therefore include only the capabilities they need instead of paying for every feature in every test process.

6. Framework choice remains open

MTP is not limited to MSTest. MSTest, NUnit, xUnit.net, and TUnit all support it, while TUnit is built directly on top of MTP. This lets teams modernize test execution without necessarily rewriting their test suites or abandoning their preferred assertion and test-authoring model.

A practical before-and-after example

Consider a small pricing service. The production code applies a percentage discount:

public static class PriceCalculator
{
    public static decimal ApplyDiscount(decimal price, decimal percentage)
    {
        if (percentage is < 0 or > 100)
        {
            throw new ArgumentOutOfRangeException(nameof(percentage));
        }

        return price * (1 - percentage / 100);
    }
}
Enter fullscreen mode Exit fullscreen mode

Its MSTest test is intentionally ordinary:

using Microsoft.VisualStudio.TestTools.UnitTesting;

[TestClass]
public sealed class PriceCalculatorTests
{
    [TestMethod]
    public void ApplyDiscount_ReducesPrice()
    {
        decimal result = PriceCalculator.ApplyDiscount(200m, 15m);

        Assert.AreEqual(170m, result);
    }
}
Enter fullscreen mode Exit fullscreen mode

The test itself does not need to change when moving from VSTest to MTP. The difference is in how the test project is assembled and executed.

Before: a conventional VSTest project

A representative VSTest-based project explicitly combines the .NET SDK, the test platform package, the framework, and its adapter:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <IsTestProject>true</IsTestProject>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
    <PackageReference Include="MSTest.TestAdapter" Version="4.1.0" />
    <PackageReference Include="MSTest.TestFramework" Version="4.1.0" />
  </ItemGroup>
</Project>
Enter fullscreen mode Exit fullscreen mode

The typical CI command asks VSTest to load a logger and a data collector:

dotnet test --configuration Release \
  --logger trx \
  --collect "Code Coverage;Format=cobertura"
Enter fullscreen mode Exit fullscreen mode

This is a mature and widely supported workflow. However, the test artifact is primarily a library intended to be understood and launched by an external test platform. The runner must locate the adapter, interpret VSTest options, start a test host, and coordinate collection and reporting.

After: the same suite as an MTP application

MSTest.Sdk bundles the recommended MSTest setup and uses MTP by default:

<Project Sdk="MSTest.Sdk/4.1.0">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
  </PropertyGroup>
</Project>
Enter fullscreen mode Exit fullscreen mode

For native MTP orchestration in .NET 10 or later, the repository selects the runner in global.json:

{
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}
Enter fullscreen mode Exit fullscreen mode

The equivalent CI command becomes:

dotnet test --configuration Release \
  --report-trx \
  --coverage \
  --coverage-output-format cobertura
Enter fullscreen mode Exit fullscreen mode

The options now describe capabilities owned by the test application: TRX reporting and code coverage. With the default MSTest.Sdk profile, the required Microsoft extensions are already included. If a team uses a minimal profile, it can add only the extension packages it wants.

More importantly, the compiled test application is not limited to dotnet test:

# Familiar solution or project orchestration
dotnet test --project tests/Pricing.Tests/Pricing.Tests.csproj

# Launch it like another .NET application
dotnet run --project tests/Pricing.Tests/Pricing.Tests.csproj

# Build once, then run the produced test executable
dotnet build tests/Pricing.Tests -c Release
./tests/Pricing.Tests/bin/Release/net10.0/Pricing.Tests
Enter fullscreen mode Exit fullscreen mode

On Windows, the final command uses Pricing.Tests.exe.

executable test artifacts

What improved in this example?

First, the project configuration has fewer independently managed test packages. The SDK supplies a coherent runner, framework, and default extension profile. Second, the test output is self-describing: the runner and registered extensions travel with the build instead of being discovered from a machine-specific adapter setup. Third, local debugging and CI execution are closer because both launch the same test application. Finally, stricter defaults expose mistakes sooner—for example, MTP returns a non-success exit code when a test application executes zero tests, while VSTest normally treats that situation as successful.

The improvement is primarily architectural and operational. A single Assert.AreEqual is not inherently faster because it runs on MTP. The gains appear around startup composition, predictable extension loading, artifact portability, parallel module orchestration, diagnostics, and reduced differences between execution environments. Actual performance still depends on the number of projects, framework behavior, test setup, I/O, and enabled extensions, so teams should benchmark their own suites rather than rely on a universal speed claim.

Retrying individual tests in a test pipeline

Consider a long-running test pipeline that exercises a deployed service, including authentication, data import, background processing, and persistence. Such pipelines are expensive to repeat because each test can involve network calls, external resources, and significant setup.

Before the migration, a task-level retry was used when a test failed. If one test failed near the end of the run, the pipeline repeated the entire task—including tests that had already passed:

individual test retry

After moving the test project to MTP, the suite can add the retry extension:

<PackageReference Include="Microsoft.Testing.Extensions.Retry" />
Enter fullscreen mode Exit fullscreen mode

The pipeline could then request up to two retries for failed tests:

dotnet test \
  --retry-failed-tests 2 \
  --retry-failed-tests-max-percentage 10
Enter fullscreen mode Exit fullscreen mode

The percentage limit prevents a broadly broken suite from repeatedly retrying a large number of failures. Smaller suites can choose a different policy because one failed test might already exceed a 10% threshold.

This changes the retry boundary from the pipeline task to the individual test. A transient failure no longer requires every successful test to run again, reducing wasted agent time and repeated calls to deployed resources. A pipeline can also merge the per-attempt TRX files into a final report, retaining the final outcome while identifying tests that failed initially and passed only on retry.

Per-test retries should not be used to normalize flaky tests. A test that passes on retry is still a reliability signal that should be visible and investigated. The value of the MTP extension is that retry behavior becomes an explicit, configurable part of the test application instead of a coarse pipeline-level rerun.

This improvement is specifically attributable to adopting the MTP retry extension. By contrast, building test projects once and reusing their artifacts was already possible with VSTest; MTP makes those artifacts directly executable and more self-describing, but it did not introduce the concept of prebuilt tests.

MTP compared with the common alternatives

Area VSTest Microsoft.Testing.Platform Framework-specific/custom runner
Architecture External platform plus adapters Platform embedded in an executable test app Varies by framework or vendor
Ecosystem maturity Long-established and broadly integrated Rapidly growing, with major .NET frameworks supported Often strong within a narrower ecosystem
Runtime behavior Compatibility-oriented and dynamic Explicit, modular, and build-time registered Depends on implementation
Modern deployment Limited for Native AOT and trimming scenarios Designed for modern .NET form factors Varies
Mixed-language testing Supports .NET and non-.NET adapter scenarios Focused on .NET Usually specialized
Direct execution Normally driven by VSTest tooling dotnet run or direct executable supported Often supported by dedicated commands
CI migration cost Usually lowest for existing pipelines Requires checking arguments, reports, and extensions May require vendor-specific integration

Tools such as ReSharper, Rider, NCrunch, and IDE test explorers should not always be viewed as competing platforms. They are developer experiences that may integrate with one or more underlying test technologies. The practical question is not only “Which button runs my tests?” but also “Which execution model produces and controls the test process?”

Should every project migrate now?

Not automatically. VSTest remains a sound choice when a repository depends on mature integrations, custom VSTest adapters, existing .runsettings behavior, or mixed-language test execution such as C++ and JavaScript alongside .NET. Some third-party tooling still has deeper VSTest integration.

MTP is especially compelling for new .NET test projects, teams targeting Native AOT or trimming, containerized test artifacts, or repositories experiencing VSTest-specific loading and extension problems. It is also attractive when reproducibility and explicit composition are more important than maximum backward compatibility.

Migration should be deliberate. Choose one platform consistently for a solution or run configuration, verify framework support, map command-line arguments, and replace VSTest loggers or collectors with the corresponding MTP extensions. Do not assume every VSTest switch has an identical MTP equivalent.

The larger improvement

Microsoft.Testing.Platform is not merely a faster command or a new adapter. Its real contribution is making test execution look like the rest of modern .NET development: build an application with known dependencies, run the same artifact in different environments, and add capabilities through explicit packages.

VSTest solved the earlier problem of unifying a fragmented testing ecosystem. MTP addresses the next problem: keeping that ecosystem predictable, portable, and extensible as .NET itself moves into more runtimes, deployment models, and development environments.

For most teams, that means evolution rather than revolution. Existing test frameworks remain useful, dotnet test remains familiar, and migration can happen project by project. What changes is the foundation underneath—and that foundation is now much closer to the application model .NET developers already understand.

Top comments (0)