TUnit

August 9, 2026 · View on GitHub

TUnit

TUnit

A modern .NET testing framework. Tests are discovered at compile time via source generators, run in parallel by default, and work under Native AOT — all built on Microsoft.Testing.Platform.

thomhurst%2FTUnit | Trendshift

GitHub Repo stars GitHub Issues or Pull Requests GitHub Sponsors nuget NuGet Downloads GitHub Workflow Status (with event) GitHub last commit (branch) License

What it looks like

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

When a test fails, TUnit tells you what happened — including the actual expression you wrote:

Expected to be 80
but found 100

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

Comparing objects? Instead of dumping two object graphs at you, TUnit pinpoints the difference:

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)

Why TUnit?

  • Compile-time test discovery — tests are wired up by a source generator at build time, not found via reflection at runtime. Faster startup, better IDE integration, and full Native AOT / trimming support.
  • Compile-time safety — a suite of Roslyn analyzers ships in the box, so mistakes like invalid hook signatures, broken data sources, and misused assertions fail your build, not your CI run.
  • Parallel by default, with real control — tests run concurrently out of the box; [DependsOn], [NotInParallel], and [ParallelLimiter<T>] give you precise ordering and throttling when you need it.
  • Batteries included — rich async assertions, shared fixtures with dependency injection, lifecycle hooks at every scope, and a source-generated mocking library — with first-class integrations for ASP.NET Core, Aspire, and Playwright.

Performance

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 — dramatically so under Native AOT. The same test suites, run on every framework:

ScenarioTUnit (AOT)TUnitxUnit v3NUnitMSTest
Data-driven tests17.74 ms308.59 ms534.14 ms547.52 ms552.18 ms
Async-heavy tests120.7 ms406.5 ms671.9 ms657.7 ms750.7 ms
Matrix combinations115.4 ms375.1 ms1,443.5 ms1,420.2 ms1,530.7 ms
Large suites (scale)15.92 ms272.51 ms500.67 ms502.50 ms494.11 ms
Massive parallelism217.8 ms469.3 ms2,926.8 ms1,073.9 ms2,981.5 ms
Setup/teardown lifecycle66.11 ms327.59 ms1,024.32 ms996.24 ms1,066.16 ms

Mean wall-clock time to run the same test suite. TUnit (AOT) 1.64.0 · TUnit 1.64.0 · xUnit v3 3.2.2 · NUnit 4.6.1 · MSTest 4.3.3. .NET SDK 10.0.302, .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v4. Updated 2026-08-09 — regenerated weekly by the Speed Comparison workflow. Full results and methodology: tunit.dev/docs/benchmarks.

Getting Started

dotnet new install TUnit.Templates
dotnet new TUnit -n "MyTestProject"
cd MyTestProject
dotnet run

Manual Installation

dotnet add package TUnit

Getting Started Guide · Migration Guides

A tour of the good parts

Data-driven tests

[Test]
[Arguments("user1@test.com", "ValidPassword123")]
[Arguments("admin@test.com", "AdminPass789")]
public async Task User_Login_Succeeds(string email, string password) { ... }

// Matrix — generates a test for every combination (9 total here)
[Test]
[MatrixDataSource]
public async Task Database_Operations_Work(
    [Matrix("Create", "Update", "Delete")] string operation,
    [Matrix("User", "Product", "Order")] string entity) { ... }

Need more? [MethodDataSource] pulls rows from a method, and custom DataSourceGenerator<T> attributes let you build your own sources.

Assertions that explain themselves

Assertions are async, chainable, and produce the focused failure messages shown above:

await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK)
    .Because("the health endpoint should always be up");

await Assert.That(order.Items)
    .Count().IsEqualTo(3)
    .And.Contains(item => item.Sku == "ABC-123");

Defining your own assertion is one attribute on a plain method — TUnit generates the fluent extension for you:

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

// Now available on Assert.That:
await Assert.That(account.Balance).IsPositive();

Shared fixtures without the ceremony

Inject anything into your test classes with [ClassDataSource<T>]. Implement IAsyncInitializer for async setup, IAsyncDisposable for teardown, and pick a sharing scope — None, PerClass, PerAssembly, PerTestSession, or Keyed:

public class PostgresContainer : IAsyncInitializer, IAsyncDisposable
{
    public Task InitializeAsync() { /* start container */ }
    public ValueTask DisposeAsync() { /* 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 */ }
}

Property injection keeps base test classes clean — subclasses inherit the fixture without re-threading constructor parameters. Prefer a constructor param? That works too. Disposal is reference-counted, so shared fixtures are torn down exactly when the last test using them finishes.

Parallelism you control

Everything runs in parallel by default. Opt out or sequence tests where it matters:

[Test]
public async Task Register_User() { ... }

[Test, DependsOn(nameof(Register_User))]
[Retry(3)]
public async Task Login_With_Registered_User() { ... } // runs after Register_User passes

[Test, NotInParallel("checkout-db")] // tests sharing a key never overlap
public async Task Migrates_Schema() { ... }

[Repeat(n)], [Timeout(ms)], and [ParallelLimiter<T>] round out the set.

Lifecycle hooks at every scope

[Before(Test)]        // also: Class, Assembly, TestSession
public async Task SetUp() { ... }

[After(Class)]
public static async Task TearDownDatabase(ClassHookContext context) { ... }

Mocking built in

TUnit.Mocks is a source-generated, Native AOT-compatible mocking library — no runtime proxies, no Castle.Core. It works with any test framework:

var gateway = IPaymentGateway.Mock();   // or Mock.Of<IPaymentGateway>()

gateway.ChargeAsync(Any<decimal>()).Returns(new ChargeResult(Success: true));

var checkout = new CheckoutService(gateway.Object);
await checkout.CompleteAsync(cart);

gateway.ChargeAsync(99.99m).WasCalled(Times.Once);

Companion packages mock the annoying stuff for you:

// TUnit.Mocks.Http — a real HttpClient backed by a scriptable handler
using var client = Mock.HttpClient("https://api.example.com");
client.Handler.OnGet("/users/1").RespondWithJson("""{ "id": 1 }""");

// TUnit.Mocks.Logging — capture and verify ILogger output
var logger = Mock.Logger<CheckoutService>();
logger.VerifyLog().AtLevel(LogLevel.Warning).ContainingMessage("retrying").WasCalled(Times.Once);

Custom attributes

Extend built-in base classes to create your own skip conditions, retry logic, and more:

public class WindowsOnlyAttribute : SkipAttribute
{
    public WindowsOnlyAttribute() : base("Windows only") { }

    public override Task<bool> ShouldSkip(TestContext testContext)
        => Task.FromResult(!OperatingSystem.IsWindows());
}

[Test, WindowsOnly]
public async Task Windows_Specific_Feature() { ... }

Integrations

ASP.NET Core

public class ApiFactory : TestWebApplicationFactory<Program>;

[ClassDataSource<ApiFactory>(Shared = SharedType.PerTestSession)]
public class HealthCheckTests(ApiFactory factory)
{
    [Test]
    public async Task Health_Endpoint_Responds()
    {
        using var client = factory.CreateClient();
        var response = await client.GetAsync("/health");

        await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
    }
}

Aspire

Spin up your whole distributed app once per test session, with resource log forwarding and OpenTelemetry capture built in:

public class AppFixture : AspireFixture<Projects.MyApp_AppHost>;

[ClassDataSource<AppFixture>(Shared = SharedType.PerTestSession)]
public class ApiServiceTests(AppFixture app)
{
    [Test]
    public async Task Api_Returns_Data()
    {
        var client = app.CreateHttpClient("apiservice");
        var response = await client.GetAsync("/weather");

        await Assert.That(response.IsSuccessStatusCode).IsTrue();
    }
}

Playwright

Inherit from PageTest and a browser page is waiting for you — lifecycle fully managed:

public class HomePageTests : PageTest
{
    [Test]
    public async Task Homepage_Loads()
    {
        await Page.GotoAsync("https://example.com");

        await Assert.That(await Page.TitleAsync()).Contains("Example");
    }
}

Property-based testing (FsCheck)

[Test, FsCheckProperty]
public bool Reversing_Twice_Returns_Original(int[] array) =>
    array.SequenceEqual(array.AsEnumerable().Reverse().Reverse());

More than C#

TUnit runs F# and VB.NET test projects too, and TUnit.Assertions.FSharp provides idiomatic F# assertion helpers.

IDE Support

IDENotes
Visual Studio 2022 (17.13+)Works out of the box
Visual Studio 2022 (earlier)Enable "Use testing platform server mode" in Tools > Manage Preview Features
JetBrains RiderEnable "Testing Platform support" in Settings > Build, Execution, Deployment > Unit Testing > Testing Platform
VS CodeInstall C# Dev Kit and enable "Use Testing Platform Protocol"
CLIWorks with dotnet test, dotnet run, and direct execution

Packages

PackagePurpose
TUnitStart here — the full framework (Core + Engine + Assertions)
TUnit.CoreShared test library components without an execution engine
TUnit.EngineExecution engine for test projects
TUnit.AssertionsStandalone assertions — works with other test frameworks too
TUnit.Assertions.ShouldOptional FluentAssertions-style value.Should().BeEqualTo(...) syntax over TUnit.Assertions (beta)
TUnit.MocksSource-generated, AOT-compatible mocking — works with any test runner
TUnit.Mocks.HttpHttpClient mocking helpers built on TUnit.Mocks
TUnit.Mocks.LoggingILogger capture/verification helpers built on TUnit.Mocks
TUnit.AspNetCoreASP.NET Core integration — WebApplicationFactory-based test fixtures
TUnit.AspireAspire integration — distributed app host fixtures with OpenTelemetry capture
TUnit.PlaywrightPlaywright integration with automatic browser lifecycle management
TUnit.FsCheckProperty-based testing via FsCheck
TUnit.OpenTelemetryOpenTelemetry instrumentation for test runs

Migrating from xUnit, NUnit, or MSTest?

The syntax will feel familiar. For example, xUnit's [Fact] becomes [Test], and [Theory] + [InlineData] becomes [Test] + [Arguments]. See the migration guides for full details: xUnit · NUnit · MSTest.

Repository layout

  • src/ — product libraries, analyzers, source generators, integrations, templates, and tools shipped as packages
  • tests/ — unit, integration, snapshot, fixture, and cross-language test projects
  • benchmarks/ — BenchmarkDotNet and profiling workloads
  • examples/ — sample applications and test projects
  • tools/ — repository automation and development utilities
  • eng/ — shared MSBuild imports, signing key, and engineering assets
  • scripts/ — local build, test, and profiling entry points

Solutions and repository-wide SDK, package, and build configuration remain at root.

Community