Choosing a test framework in .NET used to be a question of taste. xUnit, NUnit, and MSTest all discovered tests by reflection, all ran on VSTest, and the differences that mattered were the attribute names and the assertion style. Performance was not part of the conversation because there was nothing much to compare.
Two things changed that. Every framework now ships a Microsoft.Testing.Platform runner, which builds the test project into a self contained executable instead of loading it into a host process. And three of the four moved test discovery from runtime reflection to build time source generators, so they can run under Native AOT. That moves cost from run time to compile time, which is a trade nobody was making five years ago.
TUnit publishes benchmarks showing it well ahead of the others. Those numbers are in the TUnit repository, which is a reason to check them rather than a reason to doubt them. This post measures the same thing independently, states the methodology in the open, and ships the harness so you can run it on your own machine.
#What is being measured
Four axes, on .NET 10:
- Wall clock of a test run, at 1, 1000, and 10000 tests
- The Microsoft.Testing.Platform executable against the legacy
dotnet test path - Native AOT against a normal build, including publish time and binary size
- Compilation time, cold and incremental, which is where source generators show up
Plus a suite where every test fails, and the usual lifecycle features: per test setup and teardown, class fixtures, assembly fixtures, inline data, and async tests.
#Versions and machine
| Version |
|---|
| xUnit.net v3 | 4.0.0 |
| NUnit | 4.6.1, with NUnit3TestAdapter 6.3.0 |
| MSTest | 4.4.0 |
| TUnit | 1.65.68 |
| .NET SDK | 10.0.400 |
Apple M5 Pro, 15 cores, macOS 26.6. Absolute numbers belong to this machine. The ratios are what travel.
#How the benchmark works
This is the part worth reading, because a test framework benchmark is unusually easy to get wrong.
Every framework is driven the same way. All four support Microsoft.Testing.Platform, so every project builds into an executable and the measurement is the wall clock of launching that process until it exits. No test host, no adapter discovery, no dotnet test in the middle. The legacy path is measured separately.
Tests are generated, not written. The harness emits a project per cell of the matrix: 4 frameworks, 3 sizes, 7 scenarios, 3 runners. Tests are spread over classes of 100, because several parallelism models key off the class and one giant class would silently serialize some frameworks.
Assertions are written by hand. Each test is a comparison and a throw, not a call into the framework's assertion library:
C#
[Fact]
public void Test42()
{
if (Compute(42) != 1765) throw new InvalidOperationException("Test 42 failed.");
}
That is deliberate. The comparison is about discovery and execution machinery, not assertion libraries. It also avoids an unfair asymmetry: TUnit's assertions are asynchronous, so using them would force TUnit's tests to be async Task while the other three stayed synchronous.
Restore is never counted as build time. It is timed separately. The cold build deletes the build outputs but keeps the restore assets, so no restore ever happens inside a timed build. The incremental build appends a line to one test file and rebuilds, which is what you actually wait for after an edit.
Ten runs per cell, first discarded, reporting the minimum. The minimum is the run least polluted by whatever else the machine was doing.
##Making sure the tests actually ran
The classic way this benchmark goes wrong is measuring a suite that runs nothing. A misconfigured test project compiles, runs, prints a summary, exits zero, and executes zero tests. It looks fast.
Two guards. The harness passes --minimum-expected-tests, a Microsoft.Testing.Platform option that makes the runner itself exit with an error when fewer tests ran than expected. It also parses the executed count out of the summary and refuses to record any measurement that does not match exactly.
This is not hypothetical. An MSTest project with PublishAot but without the source generator package builds cleanly, runs, and reports zero tests:
Test run summary: Zero tests ran
That is a real result from writing this post, and it is the reason the gate exists.
##Making sure parallelism is actually on
Three of the four frameworks run tests sequentially out of the box, and each turns it on differently. Comparing default settings would mostly measure which framework has the more aggressive default, so every framework is configured for maximum parallelism with the same worker count.
Configuration is not something to take on trust, so the harness has a scenario that only sleeps. 300 tests sleeping 20 ms each take 6 seconds if they are serialized:| Framework | 300 tests sleeping 20 ms |
|---|
| xUnit.net v3 | 652 ms |
| NUnit | 668 ms |
| MSTest | 609 ms |
| TUnit | 627 ms |
Serial execution would be 6000 ms. Benchmark project
All four land near 600 ms, about a tenth of the serial time on a 15 core machine. Parallelism is on everywhere, and the benchmark is comparing like with like.
#The same suite, four ways
The test itself is identical. What differs is the attributes and, more importantly, the configuration needed to get there.
##xUnit.net v3
C#
[Fact]
public void Test42() { }
Version 4.0 added full test case parallelization. Before it, a class was a collection and the tests inside one ran sequentially, so this attribute is what makes the comparison with TUnit meaningful at all:
C#
[assembly: Parallelization(Mode = ParallelMode.All, MaxThreads = 15)]
The packages are split by hosting model, which is easy to get wrong: xunit.v3.mtp-v2 for the platform runner, xunit.v3.mtp-off for VSTest, and xunit.v3.aot.mtp-v2 for Native AOT. UseMicrosoftTestingPlatformRunner matters too, because without it xUnit prints its own summary instead of the platform one.
XML
<PropertyGroup>
<OutputType>Exe</OutputType>
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3.mtp-v2" Version="4.0.0" />
</ItemGroup>
##NUnit
C#
[Test]
public void Test42() { }
NUnit runs nothing in parallel unless told to, and the assembly level configuration has two traps. LevelOfParallelism(0) does not mean "unlimited", it bypasses the dispatcher and runs single threaded. And NUnit's default is one fixture instance shared by every test in the class, where the other three create an instance per test, so matching their behaviour needs an explicit lifecycle:
C#
[assembly: Parallelizable(ParallelScope.Children)]
[assembly: LevelOfParallelism(15)]
[assembly: FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
ParallelScope.All also works at assembly level, but its Self bit has no meaning there, and NUnit's own documentation recommends Children. With InstancePerTestCase, one time setup and teardown must become static.
XML
<PropertyGroup>
<OutputType>Exe</OutputType>
<EnableNUnitRunner>true</EnableNUnitRunner>
</PropertyGroup>
##MSTest
C#
[TestClass]
public class Tests
{
[TestMethod]
public void Test42() { }
}
MSTest is sequential by default and parallelizes whole classes when you turn it on, so method level is an explicit choice:
C#
[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.MethodLevel)]
Workers = 0 means one worker per processor. MSTest is also the only one of the four that still requires a class level attribute.
XML
<PropertyGroup>
<OutputType>Exe</OutputType>
<EnableMSTestRunner>true</EnableMSTestRunner>
</PropertyGroup>
##TUnit
C#
[Test]
public void Test42() { }
Nothing to configure. TUnit runs everything in parallel by default, including different methods of the same class, and it was built on Microsoft.Testing.Platform rather than adapted to it:
XML
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="TUnit" Version="1.65.68" />
</ItemGroup>
That is the whole project file. It is also the only one of the four with no VSTest story at all.
#Startup: one test
The floor. One test method, one class, nothing else. This is the cost you pay before any of your code runs.| Framework | MTP executable | Native AOT | dotnet test |
|---|
| xUnit.net v3 | 178 ms | 24 ms | 715 ms |
| NUnit | 204 ms | not supported | 540 ms |
| MSTest | 151 ms | 28 ms | 469 ms |
| TUnit | 146 ms | 24 ms | not supported |
Benchmark project
The four frameworks are within 40% of each other, and every one of them is beaten by the choice of runner. Running the Microsoft.Testing.Platform executable instead of dotnet test is worth 3 to 4 times more than picking a different framework. Publishing with Native AOT is worth another 6 times on top.
At this size nothing framework-specific is being measured. 150 ms is process start, runtime initialization, and assembly loading. Your one test is lost in it.
The Native AOT column is the interesting one: 24 ms is fast enough that a test suite stops feeling like something you launch and starts feeling like something you call. It is also the column where NUnit is absent, which is the first real difference between these frameworks rather than a difference of degree.
#Scaling to 1000 and 10000 tests
Startup is a constant. What separates the frameworks is what each additional test costs, and that only becomes visible once there are thousands of them.| Framework | 1 test | 1000 tests | 10000 tests | Cost per test |
|---|
| xUnit.net v3 | 178 ms | 240 ms | 743 ms | 56 us |
| NUnit | 204 ms | 296 ms | 1.1 s | 85 us |
| MSTest | 151 ms | 172 ms | 311 ms | 15 us |
| TUnit | 146 ms | 197 ms | 575 ms | 42 us |
Benchmark project
Subtracting the fixed cost gives the marginal cost of a test, which is the last column. MSTest is the cheapest at 15 microseconds per test, then TUnit at 42, xUnit at 56, and NUnit at 85, roughly a factor of six between the fastest and the slowest.
That ordering is worth sitting with, because it is not the one the published comparisons lead you to expect. TUnit's own benchmarks show it far ahead of the field, and here it is second, behind MSTest. The likely reason is what is being measured: these tests contain a comparison and nothing else, with no assertion library, no data sources, and no fixtures. That isolates the framework's per test machinery, and it is exactly the case where MSTest's simple model has the least to do. It is not the case that most published benchmarks emphasise.
The practical reading is more boring than the ranking. Ten thousand tests, all of them doing nothing, cost between 0.3 and 1.1 seconds. If your suite takes minutes, essentially none of that is the framework.
#Microsoft.Testing.Platform against dotnet test
| Framework | Tests | MTP | dotnet test | Slower by |
|---|
| xUnit.net v3 | 1 | 178 ms | 715 ms | 4.0x |
| xUnit.net v3 | 1,000 | 240 ms | 975 ms | 4.1x |
| xUnit.net v3 | 10,000 | 743 ms | 3.6 s | 4.9x |
| NUnit | 1 | 204 ms | 540 ms | 2.6x |
| NUnit | 1,000 | 296 ms | 621 ms | 2.1x |
| NUnit | 10,000 | 1.1 s | 1.6 s | 1.5x |
| MSTest | 1 | 151 ms | 469 ms | 3.1x |
| MSTest | 1,000 | 172 ms | 541 ms | 3.1x |
| MSTest | 10,000 | 311 ms | 1.6 s | 5.1x |
| TUnit | 1 | 146 ms | not supported | no adapter |
| TUnit | 1,000 | 197 ms | not supported | no adapter |
| TUnit | 10,000 | 575 ms | not supported | no adapter |
Benchmark projectThis is the largest effect in the whole post, and it is the one that costs the least to act on.
dotnet test is 1.5 to 5 times slower than launching the test executable, and the gap widens with suite size for xUnit and MSTest. The work being paid for is the VSTest host: a separate process, adapter discovery, and marshalling results back across a pipe.
There is a deadline attached to this. On the .NET 10 SDK, VSTest and Microsoft.Testing.Platform are now mutually exclusive: a project referencing the platform packages fails outright with Testing with VSTest target is no longer supported by Microsoft.Testing.Platform on .NET 10 SDK and later. Measuring the legacy path at all required building against separate packages that explicitly exclude the platform, such as xunit.v3.mtp-off.
TUnit has no row here because it never shipped a VSTest adapter. In 2026 that reads less like a gap and more like it skipped a migration the others still have to finish.
#Native AOT
| Framework | Tests | JIT run | AOT run | AOT publish | Binary |
|---|
| xUnit.net v3 | 1 | 178 ms | 24 ms | 6.9 s | 12.7 MB |
| xUnit.net v3 | 1,000 | 240 ms | 45 ms | 8.1 s | 13.9 MB |
| xUnit.net v3 | 10,000 | 743 ms | 249 ms | 43.3 s | 25.1 MB |
| NUnit | 1 | 204 ms | not supported | not supported | not supported |
| NUnit | 1,000 | 296 ms | not supported | not supported | not supported |
| NUnit | 10,000 | 1.1 s | not supported | not supported | not supported |
| MSTest | 1 | 151 ms | 28 ms | 9.0 s | 16.6 MB |
| MSTest | 1,000 | 172 ms | 40 ms | 59.7 s | 17.3 MB |
| MSTest | 10,000 | 311 ms | 144 ms | 84 min | 25.3 MB |
| TUnit | 1 | 146 ms | 24 ms | 9.4 s | 16.0 MB |
| TUnit | 1,000 | 197 ms | 39 ms | 10.9 s | 16.7 MB |
| TUnit | 10,000 | 575 ms | 211 ms | 23.2 s | 23.1 MB |
Benchmark projectNative AOT makes a test run start in 24 ms instead of 150. For a suite that runs once, that saves a tenth of a second and is not worth thinking about. For a sharded CI pipeline that starts the same suite on 50 machines, or a watch loop that reruns on every save, it is the difference between waiting and not.
The cost is on the other side, and it is steep. A publish takes seconds to minutes where a normal build takes a few hundred milliseconds, and the binary is 13 to 25 MB.
One number deserves to be called out. MSTest publishing 10000 tests with Native AOT took 5031 seconds, about 84 minutes, against 43 seconds for xUnit and 23 seconds for TUnit on the same suite. This is an issue with ILLink.RoslynAnalyzer, not MSTest. This is fixed in .NET 11, but never backported to .NET 10. If you are on .NET 10 and care about Native AOT, MSTest is not a practical choice for large suites.
NUnit has no AOT row at all. Its discovery is entirely runtime reflection, and AOT support is still an open issue. If Native AOT matters to you, that is a decision already made for you.
#When every test fails
A green suite is the happy path. A suite where everything fails has to format messages, capture stack traces, and report each failure.| Framework | 1000 passing | 1000 failing | Slower by |
|---|
| xUnit.net v3 | 240 ms | 281 ms | 1.2x |
| NUnit | 296 ms | 359 ms | 1.2x |
| MSTest | 172 ms | 224 ms | 1.3x |
| TUnit | 197 ms | 233 ms | 1.2x |
Benchmark project
Failures cost something, but not much: 20 to 30% on a suite where every single test throws. Since a realistic suite is mostly green, the practical overhead is near zero.
This is worth measuring anyway, because it is the case people worry about when a CI run turns red and takes longer than usual. The measurement says the extra time is real but small, and that a red build is slow for other reasons.
#Compilation
Source generators are how three of these frameworks got Native AOT support, and they are not free. The cost lands on every build.| Framework | Tests | Cold build | Incremental build | Restore |
|---|
| xUnit.net v3 | 1 | 338 ms | 299 ms | 395 ms |
| xUnit.net v3 | 1,000 | 368 ms | 326 ms | 381 ms |
| xUnit.net v3 | 10,000 | 542 ms | 504 ms | 383 ms |
| NUnit | 1 | 353 ms | 312 ms | 380 ms |
| NUnit | 1,000 | 379 ms | 333 ms | 384 ms |
| NUnit | 10,000 | 510 ms | 461 ms | 388 ms |
| MSTest | 1 | 380 ms | 325 ms | 379 ms |
| MSTest | 1,000 | 419 ms | 360 ms | 387 ms |
| MSTest | 10,000 | 606 ms | 549 ms | 408 ms |
| TUnit | 1 | 352 ms | 308 ms | 375 ms |
| TUnit | 1,000 | 447 ms | 401 ms | 385 ms |
| TUnit | 10,000 | 1.2 s | 1.2 s | 942 ms |
Benchmark project
Nothing here is dramatic except one row. Three of the four frameworks build 10000 tests in about half a second, and adding tests barely moves the number, because Roslyn is fast at trivial code.
TUnit is the exception at roughly twice the cost, and the reason is the thing that makes it fast at run time. TUnit discovers tests with a source generator, always, not just for Native AOT. xUnit and MSTest only run a generator in their AOT configurations, so in a normal build they pay nothing.
The column that matters is the second one. C# compiles per assembly, so editing a single test file recompiles the whole test project. There is no cheaper incremental path: the cold and incremental numbers are almost identical for every framework. Whatever the generator costs, you pay it on every edit, not once.
At this scale it is under a second either way and nobody will notice. It is worth knowing about mainly because the trade is invisible: the framework that wins the execution benchmark is the one paying at build time, and build time is not what the execution benchmark measures.
#Lifecycle features
All four cover the same ground with different spellings.
| xUnit.net v3 | NUnit | MSTest | TUnit |
|---|
| Test | [Fact] | [Test] | [TestMethod] | [Test] |
| Inline data | [Theory] + [InlineData] | [TestCase] | [DataRow] | [Arguments] |
| Per test setup | constructor | [SetUp] | [TestInitialize] | [Before(Test)] |
| Per test teardown | IDisposable | [TearDown] | [TestCleanup] | [After(Test)] |
| Class one time | IClassFixture<T> | [OneTimeSetUp] | [ClassInitialize] | [Before(Class)] |
| Assembly one time | [assembly: AssemblyFixture] | [SetUpFixture] | [AssemblyInitialize] | [Before(Assembly)] |
The interesting question is what they cost. Each scenario below is the same 1000 tests with one feature added.| Framework | Bare | Per test hooks | Class fixture | Assembly fixture | Inline data | Async |
|---|
| xUnit.net v3 | 240 ms | 243 ms | 242 ms | 242 ms | 270 ms | 316 ms |
| NUnit | 296 ms | 295 ms | 297 ms | 297 ms | 366 ms | 352 ms |
| MSTest | 172 ms | 174 ms | 175 ms | 174 ms | 176 ms | 256 ms |
| TUnit | 197 ms | 213 ms | 210 ms | 203 ms | 304 ms | 273 ms |
Benchmark project
Setup and teardown, class fixtures, and assembly fixtures are all free, within noise of the bare suite for every framework. Whatever reason there is to prefer one lifecycle model, performance is not it.
Two columns do move. Inline data costs something for NUnit and TUnit, which is where those frameworks expand the data rows into individual test cases. And async tests cost 55 to 85 ms per thousand across the board, which is a thread pool round trip per test rather than anything framework-specific.
The support matrix above is the more useful output of this section. The features are the same everywhere; only the spelling differs.
#What to take away
- The runner matters more than the framework. Moving from
dotnet test to a Microsoft.Testing.Platform executable is a bigger win than switching frameworks, and it is a two line change. - Startup dominates small suites. Below a few hundred tests, you are measuring process start, not test execution.
- Per test cost is where the frameworks actually differ, and it only becomes visible in the thousands.
- Native AOT is a real option now, except for NUnit, and it trades a much slower publish for a much faster start.
- Source generators move the cost to the compiler. A framework that is faster to run can be slower to build, and you pay the build cost on every edit.
- Configuration is the biggest trap. Three of the four run sequentially by default, and one of them has an option that looks like "unlimited" and means "single threaded".
For most projects, none of this decides anything. A suite of a few hundred tests that touches a database will be dominated by the database. The numbers start to matter when the suite is large, when it runs on every commit, or when the fixed startup cost is paid many times over by sharding.
If performance really is the deciding factor, the answer depends on which cost you care about, and the measurements do not all point the same way. MSTest was the fastest to execute tests and the slowest to publish with Native AOT, by a margin that makes it unusable at 10000 tests. TUnit was the fastest to publish and the slowest to compile, because its source generator runs on every build. xUnit v3 was in the middle on all three. NUnit was the slowest to execute and is the only one with no Native AOT at all.
If you already have a large suite, the change worth making first is not the framework, it is the runner. Switching from dotnet test to the Microsoft.Testing.Platform executable is two lines of MSBuild and was worth more than any framework choice measured here.
#Additional resources
Do you have a question or a suggestion about this post? Contact me!