Framework
thomhurst/TUnit avatar
thomhurst/TUnit

TUnit finds tests at build time and prints the assertion you wrote when one fails

A modern, fast and flexible .NET testing framework

3,991 stars143 forksC#MIT

At a glance

What is it?
thomhurst/TUnit is a .NET testing framework that discovers tests with a source generator instead of reflection, runs everything in parallel by default, and works under Native AOT on Microsoft.Testing.Platform. The interesting parts are the failure messages, which quote your own expression back at you, and the weekly benchmark table, whose widest margins come from the AOT column rather than from the framework comparison.
Who is it for?
TUnit is worth adopting if your suite is large enough that reflection based startup shows up in your build pipeline, if you are publishing Native AOT, and if you value a failure message that names the member and the expression instead of dumping two object graphs.
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 November 14, 2026, and from our analysis. They are not legal advice.

From daily snapshots

Star history

  • thomhurst/TUnit3,991 ★ · +29
Star history
Repository2026-09-242026-11-14
thomhurst/TUnit4k4k

29 Stars in the last 51 days, measured from the daily GitHub snapshot. Compare with other projects

Editorial analysis

Discovery is a source generator, so a broken test fails the build

The central design decision is that tests are wired up at build time rather than found by reflection at run time, and the example on the page shows what that means in practice. A test is a plain async method with attributes on it:

csharp
[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);
}

The generator turns those attributes into the registration code the runner needs, which is what makes start up cheap and what makes Native AOT and trimming support possible, since there is no reflection surface to trim away. The second claim on the page is the one that changes day to day work: a suite of Roslyn analyzers ships in the box, so an invalid hook signature, a broken data source or a misused assertion is a compile error rather than a CI failure discovered twenty minutes later. The tradeoff is stated plainly on the same page, that source generation moves work from run time to build time and you pay a little up front.

The failure message quotes your own expression back at you

The claim that sells the framework is in the output, not the feature list. When an assertion fails, the message includes the assertion expression as it was written, so the reader sees the line they wrote rather than a variable name:

code
Expected to be 80
but found 100

at Assert.That(total).IsEqualTo(expected)

The object comparison case is the more interesting one, because the stated behaviour is that TUnit pinpoints the difference instead of dumping two object graphs at you:

code
Expected to be equal to Employee { FirstName = "Victoria", LastName = "Apanii", Age = 30 }
but differs at member FirstName: expected "Victoria" but found "ictoria"

at Assert.That(actualEmployee).IsEqualTo(expectedEmployee)

That second example is worth reading closely. A missing leading character in a string is the kind of failure that a graph dump hides behind hundreds of identical lines, and here it is reported as a named member with both values. Assertions are async and chainable, a failure reason can be attached with `.Because("the health endpoint should always be up")`, and counts compose: `.Count().IsEqualTo(3).And.Contains(item => item.Sku == "ABC-123")`.

The benchmark table's widest margins come from the AOT column

The performance section publishes mean wall clock time for the same suite across five targets, and it is regenerated weekly by a workflow, with the page recording the exact versions it compared: TUnit 1.72.16, xUnit v3 4.0.1, NUnit 5.0.0 and MSTest 4.4.1, on .NET SDK 10.0.401 and .NET 10.0.12 with the x64 RyuJIT targeting x86-64-v4, updated 2026-10-04. Read the two TUnit columns separately, because they are not the same comparison. On data driven tests the AOT column is 20.22 ms against 355.91 ms for the same framework without AOT, so most of that row's headline is the compilation mode, not the framework. Across frameworks the picture is steadier but uneven: matrix combinations put TUnit at 410.6 ms against 969.5 ms for xUnit v3 and about 1,604 ms for both NUnit and MSTest, while large suites narrow to 410.28 ms against 602.94 ms for MSTest. The row where MSTest fares worst is massive parallelism at 3,163.5 ms against TUnit's 493.2 ms. So the fair statement is that TUnit leads everywhere in this table and leads by very different factors depending on the scenario.

Parallel by default, with three attributes to take control back

Everything runs concurrently out of the box, which is the right default for a suite of independent tests and the wrong one the moment tests share state. The opt outs are attributes rather than configuration: `DependsOn` sequences a test after another one has passed, `NotInParallel` takes a single test out of the concurrent pool, and `ParallelLimiter<T>` throttles how many run at once, with the generic parameter carrying the key so the limit can be scoped. Retries are an attribute too. The page's own sequence shows the combination, with a register test, then a login test marked as depending on it and given a retry count, annotated so it runs after the first one passes. The important property is that parallelism is something you subtract from, not something you switch on, which means a suite that inherited order dependent behaviour will usually run green and then fail intermittently once adopted, and the fix is per test annotation rather than a global setting.

Fixtures are injected as properties and torn down by reference count

Shared fixtures arrive through an attribute rather than a constructor, and the scopes are named. `ClassDataSource<T>` injects a type into the test class, the sharing level is chosen from `None`, `PerClass`, `PerAssembly`, `PerTestSession` or `Keyed`, and async setup and teardown come from implementing two interfaces rather than from overriding anything.

csharp
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 */ }
}

Property injection is what keeps base test classes clean, since a subclass inherits the fixture without re threading constructor parameters, and a constructor parameter on the test class works too if you prefer it. Two constraints are stated and easy to trip over: types created by `ClassDataSource<T>` need a public parameterless constructor, and nested dependencies of those types should use property injection. Disposal is reference counted, so a fixture shared across a session is torn down exactly when the last test using it finishes, which is the behaviour a container backed fixture needs.

Custom assertions are one attribute on a plain method

Extending the assertion surface does not mean writing an assertion class. A `GenerateAssertion` attribute on a static extension method is enough, and the generator produces the fluent method on the assertion entry point:

csharp
[GenerateAssertion]
public static bool IsPositive(this int value) => value > 0;

// Now available on Assert.That:
var account = new { Balance = 1 };
await Assert.That(account.Balance).IsPositive();

The domain rule therefore lives next to the domain type, as an ordinary method someone would unit test anyway, and the framework derives the assertion from its signature. This is the same source generation idea applied to assertions rather than to discovery, and it is why the page can promise that the failure message will still quote the call site: the generated method knows the expression it came from. The rest of the assertion surface composes the same way, with chainable async steps, an optional reason attached to a specific step, and collection checks that combine a count assertion with a predicate.

Three shapes of data driven test, from arguments to a matrix

Data driven tests come in a small number of forms. Repeated `Arguments` attributes cover the simple case, one attribute per row, with the values matched to the method's parameters in order. A matrix data source generates a test per combination instead, and the example marks a method with the matrix attribute and decorates two of its parameters, producing nine tests from three by three values. Beyond that, `MethodDataSource` pulls rows from a method, and if neither fits you can write your own source by implementing the generic data source generator interface and exposing it as an attribute. The matrix form is the one with a cost worth naming: the count is the product of the dimensions, so a four by four by four matrix is sixty four test cases from one method body, and there is no partial expansion. The same generator that registers these tests is what lets a type level attribute like `ClassDataSource` feed fixtures from the same mechanism.

Three solution files, central package versions, and a version derived from git

The repository layout shows how the project is built. Three solution files sit at the root, `TUnit.slnx`, `TUnit.CI.slnx` and `TUnit.Dev.slnx`, in the newer solution format, which separates what CI compiles from what a developer opens. Package versions are managed centrally through `Directory.Packages.props`, with `Directory.Build.props` and `Directory.Build.targets` for shared build settings, and `global.json` pinning the SDK. The version number is not written by hand: `GitVersion.yml` derives it from git history, which is why release tags like 1.72.16 matter to consumers of the NuGet package. Dependency updates are handled by a Renovate configuration. Alongside that sit `benchmarks/`, which is where the comparison table comes from, an `eng/` directory for engineering scripts, `scripts/`, `tools/`, `docs/`, `skills/`, and an `examples/` tree holding a sample shop application, an ASP.NET Core project, a project testing that ASP.NET Core application, and an example wired to FsCheck for property based testing. The repository is MIT licensed with 3968 stars, 138 forks and 13 open issues.

Editorial conclusion

TUnit is worth adopting if your suite is large enough that reflection based startup shows up in your build pipeline, if you are publishing Native AOT, and if you value a failure message that names the member and the expression instead of dumping two object graphs. It is a bigger commitment than adding a test package, because the source generator and its analyzers sit in your build, the fixture model is property injection with reference counted disposal rather than constructor injection, and the ecosystem integrations for ASP.NET Core, Aspire and Playwright become dependencies you opt into. Before you commit, check four things. Which test platform you are on, since TUnit is built on Microsoft.Testing.Platform rather than VSTest, and the tree carries separate solutions for CI and for development. Which version of each framework the benchmark table last compared, because the table records TUnit 1.72.16 against xUnit v3 4.0.1, NUnit 5.0.0 and MSTest 4.4.1 on a specific SDK and runtime. Whether your tests rely on shared state, since everything runs in parallel until you opt out with the ordering attributes. And whether the framework version, which is derived from git tags rather than hand edited, matches what your build expects.

Frequently asked questions

How does TUnit compare with xUnit?

The project's own benchmark table reports mean wall clock time for the same suite at TUnit 1.72.16 against xUnit v3 4.0.1, for example 410.6 ms against 969.5 ms on matrix combinations and 493.2 ms against 1,372.8 ms on massive parallelism. It is regenerated weekly by a workflow, with methodology published at tunit.dev/docs/benchmarks.

Does TUnit work with VS Code?

The page attributes better IDE integration to compile-time discovery through a source generator rather than runtime reflection, and lists full Native AOT and trimming support as a consequence of the same mechanism. The repository itself carries a .vscode directory alongside a .devcontainer and an .editorconfig.

How do I install TUnit into a new .NET test project?

The recommended route is the project template: dotnet new install TUnit.Templates, then dotnet new TUnit -n "MyTestProject", then cd into the folder and dotnet run. Manual installation is a single command, dotnet add package TUnit.

How does TUnit decide which tests run in parallel?

Everything runs in parallel by default. You opt out per test with DependsOn to sequence after another test passes, NotInParallel to leave the concurrent pool, and ParallelLimiter to throttle, with Retry available for a flaky case.

What is a TUnit?

TUnit is a .NET testing framework where tests are discovered at compile time by a source generator rather than by reflection, they run in parallel by default, and it supports Native AOT. It is built on Microsoft.Testing.Platform and is published on NuGet under the MIT license.

Official sources

  1. License: MIT
  2. Project website
  3. README
  4. Releases
  5. thomhurst/TUnit on GitHub
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.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/thomhurst-tunit.svg)](https://hysenlabs.com/projects/thomhurst-tunit)

Star history chart for your README

markdown
[![Star history](https://hysenlabs.com/chart/thomhurst-tunit.svg)](https://hysenlabs.com/projects/thomhurst-tunit)

Was this useful?