TUnit: A .NET Testing Framework That Discovers Tests at Compile Time
A modern, fast and flexible .NET testing framework
At a glance
- What is it?
- TUnit replaces runtime reflection with source generators, runs tests in parallel by default, and supports Native AOT. Here is what that buys you, what it costs, and when xUnit or NUnit is still the better call.
- Who is it for?
- Adopt TUnit if you are starting a new .NET test suite, want parallel execution without extra configuration, or need Native AOT and trimming support that reflection-based runners cannot give you. Stay with xUnit, NUnit or MSTest if your suite depends on runner plugins or adapters that Microsoft.Testing.Platform does not cover, or if you cannot absorb a build-time cost on every compile.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository received new commits within the last day.
- What is it written in?
- Mainly C#, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on September 29, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The problem TUnit solves: reflection-based test discovery is slow and AOT-hostile
Most .NET test frameworks find your tests the same way: they load the compiled assembly, walk its types and methods with reflection, and build a test list at runtime. That works, but it puts work into every run that could have been done once at build time. It also conflicts with Native AOT and trimming, where reflection over test types is exactly the kind of thing the compiler cannot see through.
TUnit's answer is to move discovery to the compiler. Tests are wired up by a source generator during the build, so the test list exists as generated code rather than as a reflection scan. The README frames the payoff as "Faster startup, better IDE integration, and full Native AOT / trimming support." The framework itself is built on Microsoft.Testing.Platform, so the test project produces an executable rather than a library loaded by an external runner.
The audience is .NET teams that care about test suite wall-clock time, teams shipping AOT-compiled or trimmed applications who want their tests to run under the same constraints, and teams that want ordering and throttling controls without writing custom collections. If your suite is small and you never notice its startup cost, the compile-time machinery is overhead you did not ask for.
How source-generated discovery changes the data flow of a test run
In a reflection-based framework the sequence is: build, start runner, reflect over the assembly, filter, execute. In TUnit the reflection step is gone. The source generator emits the registration code at compile time, so the executable already knows its tests when it starts.
The README is explicit that this is a trade: "Source generation shifts work from run time to build time: you pay a little up front at build, and every test run after that starts faster." That sentence is the honest summary of the architecture. Incremental builds blunt the cost, clean builds pay it in full, and the more test cases the generator has to emit, the more code the compiler has to compile.
Parallelism is the default rather than an opt-in. The README states tests "run concurrently out of the box", with `[DependsOn]`, `[NotInParallel]` and `[ParallelLimiter<T>]` available when you need ordering or throttling. That default is a design position: most test suites are embarrassingly parallel and most frameworks make you configure that. The counter-position is that a suite with hidden shared state will start failing intermittently the first time it runs under TUnit, and the failure will look like a framework bug rather than a test design problem.
The README also describes a suite of Roslyn analyzers shipping in the box, so mistakes such as invalid hook signatures and broken data sources fail the build rather than the CI run. Combined with compile-time discovery, the practical effect is that a certain class of test error moves left by one stage.
Installing TUnit and writing a first test
The README recommends the project template. Installing the template package and scaffolding a project gives you a runnable test project in one step:
dotnet new install TUnit.Templates
dotnet new TUnit -n "MyTestProject"
cd MyTestProject
dotnet runThe template creates a project already wired to Microsoft.Testing.Platform, which is why `dotnet run` executes the tests directly. There is no separate `dotnet test` adapter step in this path. If you prefer to add TUnit to an existing project, the manual route is a single package reference:
dotnet add package TUnitA test is a method marked with `[Test]`. Inline data comes from `[Arguments]`, and the README's own example shows two rows feeding one parameterised method:
[Test]
[Arguments("GOLD", 100.00, 80.00)]
[Arguments("SILVER", 100.00, 90.00)]
public async Task Discount_Is_Applied(string tier, double subtotal, double expected)
{
var checkout = new CheckoutService();
var total = await checkout.ApplyDiscountAsync(tier, subtotal);
await Assert.That(total).IsEqualTo(expected);
}Assertions are async and chainable. The README shows a `.Because(...)` clause for failure context and a `.Count().IsEqualTo(3).And.Contains(...)` chain for collections. When a test fails, the framework reports the expression you wrote rather than a stack trace pointing into assertion internals:
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK)
.Because("the health endpoint should always be up");For matrix coverage, `[MatrixDataSource]` with `[Matrix(...)]` parameters generates one test per combination. The README's example declares three operations and three entities and notes that this produces nine tests. More data sources exist: `[MethodDataSource]` pulls rows from a method, and custom `DataSourceGenerator<T>` attributes let you write your own.
Shared fixtures, DI and lifecycle hooks without the collection ceremony
Fixture sharing is where test frameworks usually accumulate boilerplate. TUnit's approach is property injection with an explicit sharing scope. A class implementing `IAsyncInitializer` and `IAsyncDisposable` becomes a fixture, and `[ClassDataSource<T>]` injects it into the test class:
public class PostgresContainer : IAsyncInitializer, IAsyncDisposable
{
public Task InitializeAsync() => Task.CompletedTask; // start container
public ValueTask DisposeAsync() => ValueTask.CompletedTask; // stop container
}
public class OrderRepositoryTests
{
[ClassDataSource<PostgresContainer>(Shared = SharedType.PerTestSession)]
public required PostgresContainer Postgres { get; init; }
[Test]
public async Task Saves_Order() { /* Postgres is initialized and shared across the whole run */ }
}The `Shared` property takes `None`, `PerClass`, `PerAssembly`, `PerTestSession` or `Keyed`. That set covers the common cases: per-test isolation, per-class reuse, one instance for the whole assembly, one instance for the whole session, and keyed sharing where several fixtures are addressed by key. The README does not document what happens when a `PerTestSession` fixture throws during `InitializeAsync` in a suite running with high parallelism, and that is the kind of detail worth testing in a scratch project before you restructure an existing suite around it.
The README also mentions lifecycle hooks at every scope, first-class integrations for ASP.NET Core, Aspire and Playwright, and a source-generated mocking library. The repository carries an `examples/` directory with `TUnit.Example.Asp.Net.TestProject`, `TUnit.Example.FsCheck.TestProject` and a `CloudShop` sample, so the integration claims have runnable counterparts in the tree rather than only prose.
What the README does not tell you about compile-time discovery
The build-time cost is real and the README admits it in one sentence without quantifying it. A large suite with many `[Arguments]` rows and `[MatrixDataSource]` combinations generates a lot of code. Every clean build compiles that code. Whether this is a net win depends on your ratio of builds to test runs: a developer iterating with a fast inner loop rebuilds constantly and runs tests constantly, while CI may build once and run the suite a handful of times. The README's framing, "you pay a little up front at build, and every test run after that starts faster", assumes the second pattern more than the first.
Native AOT is the strongest differentiator and also the narrowest. The benchmark table lists a separate TUnit (AOT) column that is far ahead of everything else, but AOT is a deployment constraint, not a test-time convenience. If your application is not AOT-compiled, that column is not a reason to choose TUnit.
The benchmark table itself deserves scrutiny. It is regenerated weekly by a workflow in the repository and the README points to tunit.dev/docs/benchmarks for methodology, which is a better sign than a static table, but the numbers are the project's own and the comparison frameworks are pinned to specific versions listed in the table footnote. Treat them as the project's measurements, not as independent ones.
Finally, the README does not document rollback or how to run a TUnit suite under a VSTest adapter. If your CI pipeline or IDE plugin expects VSTest, that is a gap you have to resolve before adopting, and the README is silent on it.
TUnit vs xUnit, NUnit and MSTest: the difference is discovery, not assertions
The three established frameworks differ from TUnit in the same structural way, so the comparison is not three separate ones. xUnit, NUnit and MSTest all discover tests by reflection at runtime and all run under a runner that loads your test assembly. TUnit generates the test registration at compile time and produces a self-contained executable on Microsoft.Testing.Platform.
That single difference cascades. Startup is faster because there is no reflection scan. AOT and trimming work because the compiler can see the generated code. Analyzers can fail the build on invalid hook signatures because the generator knows the shape of hooks at compile time. Parallelism can be on by default because the framework owns the scheduling rather than delegating it to a runner's collection model.
The cost of that difference is ecosystem surface. xUnit and NUnit have years of runner plugins, adapters and third-party integrations built around assembly-loading runners. TUnit's model is newer and narrower. If your workflow depends on a specific VSTest-based adapter or a CI integration that assumes a test DLL, you are trading that dependency for the compile-time model.
On assertions, the gap is smaller than the discovery gap. TUnit assertions are async and chainable, and the failure messages point at the expression you wrote and at the differing member of a compared object. NUnit has constraint-based assertions with a long history; xUnit has its own assertion set. Teams rarely switch frameworks over assertion syntax. They switch over startup time, parallelism defaults and AOT support, which is exactly where TUnit's argument is strongest.
Licence, release cadence and the cost of keeping up
TUnit is MIT licensed, which permits commercial use, modification and redistribution provided the copyright notice and licence text are preserved. That is the same permissive family as the frameworks it competes with, so licence is unlikely to be the deciding factor. This is a description of the licence text, not legal advice; if your organisation has specific obligations around attribution or bundled dependencies, check the LICENSE file in the repository root.
The release cadence is fast. The three most recent releases listed are v1.69.0 on 2026-09-22, v1.68.17 on 2026-09-18 and v1.68.4 on 2026-09-16. Patch releases landing every few days means bug fixes arrive quickly and also means the surface moves underneath you. The repository carries a `renovate.json` and a `Directory.Packages.props` for central package management, which is consistent with a maintainer who keeps dependencies current.
The last push to the default branch was on 2026-09-23 and the repository is not archived, so this is a project under current development rather than one in maintenance mode. For adopters, the practical implication is to pin an exact package version in `Directory.Packages.props` or your `.csproj` rather than floating, and to budget time for reading release notes before bumping. The README links migration guides at tunit.dev/docs/migration/xunit, which suggests the maintainer expects people to move from xUnit and has written down the differences; whether equivalent guides exist for NUnit and MSTest is not stated in the README.
Editorial conclusion
Adopt TUnit if you are starting a new .NET test suite, want parallel execution without extra configuration, or need Native AOT and trimming support that reflection-based runners cannot give you. Stay with xUnit, NUnit or MSTest if your suite depends on runner plugins or adapters that Microsoft.Testing.Platform does not cover, or if you cannot absorb a build-time cost on every compile. Before committing, verify three things yourself: that your CI runner invokes the test executable rather than a VSTest adapter, that your shared fixtures map onto SharedType values rather than a custom collection model, and that your existing assertions have equivalents in the TUnit assertion API. The repository is not archived and the last push was on 2026-09-23, so the surface is still moving; pin a version and read the migration guide at tunit.dev/docs/migration/xunit before you port anything.
Frequently asked questions
What is TUnit?
TUnit is a .NET testing framework where tests are discovered at compile time by source generators rather than by reflection at runtime. It runs tests in parallel by default and is built on Microsoft.Testing.Platform, which gives it Native AOT and trimming support.
How does TUnit compare to xUnit?
The structural difference is discovery: xUnit finds tests by reflection at runtime, while TUnit generates test registration at compile time. That makes startup faster and enables Native AOT, at the cost of a build-time step and a narrower runner ecosystem than xUnit's.
How does TUnit compare to NUnit?
Both run the same kind of test methods, but NUnit discovers them by reflection and TUnit generates the wiring at build time. NUnit has a longer history of runner plugins and adapters; TUnit's advantage is compile-time safety through bundled Roslyn analyzers and support for Native AOT.
How does TUnit compare to MSTest?
MSTest discovers tests by reflection and runs them under a runner that loads the test assembly. TUnit generates the test list at compile time and produces a self-contained executable on Microsoft.Testing.Platform, which is why it can run under Native AOT.
Is TUnit faster than xUnit?
The README publishes a benchmark table comparing TUnit, TUnit under AOT, xUnit v3, NUnit and MSTest across six scenarios, regenerated weekly by a workflow in the repository. Those are the project's own measurements; the README points to tunit.dev/docs/benchmarks for full results and methodology.
Does TUnit work with Visual Studio Code?
The README claims better IDE integration as a benefit of compile-time discovery, and the repository contains a `.vscode/` directory, but it does not document a specific VS Code extension or setup procedure. Verify editor support against your own toolchain before adopting.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/thomhurst-tunit)