Every .NET test framework discovers your tests the same way: it loads your assembly, scans for attributes via reflection, builds a test plan, and then executes it. This approach has worked for two decades across NUnit, xUnit, and MSTest. It's also the reason your test suite takes hundreds of milliseconds just to figure out what to run, why IDE test explorers lag on large solutions, and why Native AOT has been off the table for test projects entirely.

TUnit takes a different path. Instead of reflecting over your compiled assembly at runtime, it uses Roslyn source generators to discover and register every test at compile time. The result is a testing framework that starts faster, fails earlier (at build time rather than runtime), and runs natively ahead-of-time compiled. If you've been watching the source generator revolution reshape serialisation, dependency injection, and logging across .NET, this is the same idea applied to testing.

It's not a toy project, either. TUnit is built on Microsoft's own Microsoft.Testing.Platform — the modern replacement for VSTest — and it ships with its own assertion library, lifecycle hooks, data-driven testing, and a Playwright integration. It's approaching 4,000 GitHub stars and is seeing real adoption.

What source generation changes

In a traditional framework, test discovery is a runtime concern. Your test assembly compiles, the runner loads it, reflects over every public type and method looking for [Fact], [Test], or [TestMethod] attributes, and then builds an execution plan. This has a few consequences that developers rarely think about:

TUnit sidesteps all of this. During compilation, a source generator walks the syntax tree, finds every method decorated with [Test], validates its signature, and emits registration code. By the time the assembly is built, the test runner already knows every test that exists — no scanning required.

This is the same pattern System.Text.Json uses for serialisation and Microsoft.Extensions.Logging uses for high-performance log methods. TUnit applies it to testing.

Getting started

The quickest path is the project template:

terminal
dotnet new install TUnit.Templates
dotnet new TUnit -n "MyTestProject"
cd MyTestProject
dotnet test

Alternatively, add the metapackage to an existing project:

terminal
dotnet add package TUnit

The TUnit package bundles three components: TUnit.Core (attributes and test model), TUnit.Engine (the execution runtime), and TUnit.Assertions (the fluent assertion library). You can also use TUnit.Assertions standalone with another framework if you only want the assertion syntax.

A minimal test looks like this:

Tests/OrderServiceTests.cs
public class OrderServiceTests
{
    [Test]
    public async Task Calculating_total_applies_discount_correctly()
    {
        var service = new OrderService();

        var total = service.CalculateTotal(quantity: 5, unitPrice: 20.00m, discountPercent: 10);

        await Assert.That(total).IsEqualTo(90.00m);
    }
}

No [TestClass] attribute on the class. No base class to inherit. Just [Test] on the method. TUnit automatically configures global usings for TUnit.Core, TUnit.Assertions, and TUnit.Assertions.Extensions, so you rarely need explicit using statements.

Assertions that read like sentences

TUnit's assertion library uses a fluent, await-based syntax that chains naturally:

Tests/UserValidationTests.cs
[Test]
public async Task Validated_user_has_expected_properties()
{
    var user = await userService.GetValidatedUserAsync("jsmith");

    await Assert.That(user.Email).IsNotNull().And.Contains("@");
    await Assert.That(user.Age).IsGreaterThanOrEqualTo(18).And.IsLessThan(120);
    await Assert.That(user.Roles).Contains("member").And.DoesNotContain("admin");
}

The await on assertions is not optional. If you forget it, the assertion never executes, and your test passes silently — which sounds dangerous until you realise that TUnit ships with a Roslyn analyser that catches unawaited assertions at build time. It will not compile if you drop the await.

For scenarios where you want every assertion to run regardless of earlier failures, wrap them in Assert.Multiple:

Example.cs
[Test]
public async Task Order_response_contains_all_required_fields()
{
    var response = await client.GetOrderAsync(orderId: 42);

    await Assert.Multiple(() =>
    {
        Assert.That(response.Id).IsEqualTo(42);
        Assert.That(response.Status).IsEqualTo("confirmed");
        Assert.That(response.Items).IsNotEmpty();
        Assert.That(response.CreatedAt).IsAfter(DateTime.UtcNow.AddDays(-1));
    });
}

This collects all failures and reports them together, rather than stopping at the first.

Data-driven testing

TUnit provides three main approaches to parameterised tests, each fitting a different level of complexity.

Inline arguments for simple cases:

Tests/EmailValidatorTests.cs
[Test]
[Arguments("[email protected]", true)]
[Arguments("not-an-email", false)]
[Arguments("", false)]
[Arguments("[email protected]", true)]
public async Task Email_validation_handles_common_inputs(string email, bool expected)
{
    var result = EmailValidator.IsValid(email);

    await Assert.That(result).IsEqualTo(expected);
}

Matrix combinations when you need every permutation of multiple inputs:

Tests/PaymentProcessorTests.cs
[Test]
[MatrixDataSource]
public async Task Payment_processing_works_across_combinations(
    [Matrix("GBP", "USD", "EUR")] string currency,
    [Matrix("stripe", "paypal")] string provider,
    [Matrix(true, false)] bool recurring)
{
    var result = await processor.ChargeAsync(currency, provider, recurring, amount: 50.00m);

    await Assert.That(result.Succeeded).IsTrue();
}

This generates 12 test cases — every combination of 3 currencies, 2 providers, and 2 boolean values. Each runs as an independent test with its own name in the test explorer.

Method data sources for complex objects:

Tests/InvoiceTests.cs
public class InvoiceTests
{
    [Test]
    [MethodDataSource(nameof(GetTestInvoices))]
    public async Task Invoice_total_matches_line_items(Invoice invoice)
    {
        var expectedTotal = invoice.LineItems.Sum(li => li.Quantity * li.UnitPrice);

        await Assert.That(invoice.Total).IsEqualTo(expectedTotal);
    }

    public static IEnumerable<Invoice> GetTestInvoices()
    {
        yield return new Invoice { LineItems = [new(Quantity: 2, UnitPrice: 15.00m)], Total = 30.00m };
        yield return new Invoice { LineItems = [new(Quantity: 1, UnitPrice: 99.99m)], Total = 99.99m };
    }
}

You can also implement DataSourceGenerator<T> for fully custom data sources that pull from configuration, databases, or external files.

Lifecycle hooks without the ceremony

Most frameworks handle setup and teardown through constructors, IDisposable, or base class inheritance. TUnit uses explicit attributes at four scopes: test, class, assembly, and session.

Tests/DatabaseIntegrationTests.cs
public class DatabaseIntegrationTests
{
    private AppDbContext _db = null!;

    [Before(Test)]
    public async Task CreateDatabaseContext()
    {
        _db = await TestDatabaseFactory.CreateScopedContextAsync();
    }

    [After(Test)]
    public async Task DisposeDatabaseContext()
    {
        await _db.DisposeAsync();
    }

    [Test]
    public async Task Creating_a_customer_persists_to_database()
    {
        _db.Customers.Add(new Customer { Name = "Acme Ltd" });
        await _db.SaveChangesAsync();

        var count = await _db.Customers.CountAsync();
        await Assert.That(count).IsEqualTo(1);
    }
}

Multiple [After(Test)] methods are supported — each one runs regardless of whether earlier cleanup threw an exception. TUnit aggregates all exceptions, so you see every failure rather than just the first.

For one-time setup at the class or assembly level:

Example.cs
[Before(Class)]
public static async Task MigrateTestDatabase(ClassHookContext context)
{
    await TestDatabaseFactory.MigrateAsync();
}

[Before(Assembly)]
public static async Task StartTestContainers(AssemblyHookContext context)
{
    await TestInfrastructure.StartPostgresContainerAsync();
}

Parallelism done properly

TUnit runs every test in parallel by default, across classes and methods. This is more aggressive than xUnit's default (which parallelises across classes but runs tests within a class sequentially) and fundamentally different from NUnit's shared-instance model.

Each test gets its own class instance — there is no shared state between tests, and there is no way to opt out of this isolation. If your tests were accidentally relying on execution order or shared fields, TUnit will expose that immediately.

When you need to control concurrency — for example, when running Playwright browser tests that would overwhelm the system with 200 parallel browser instances — use [ParallelLimiter]:

Tests/BrowserTests.cs
[ParallelLimit<BrowserLimit>]
public class CheckoutFlowTests
{
    [Test]
    public async Task Guest_checkout_completes_successfully()
    {
        // Only N instances run simultaneously
    }
}

public class BrowserLimit : IParallelLimit
{
    public int Limit => 4;
}

For tests that genuinely depend on each other (integration flows where step B requires step A to pass), use [DependsOn]:

Example.cs
[Test]
public async Task Create_user_account() { /* ... */ }

[Test, DependsOn(nameof(Create_user_account))]
public async Task Verify_user_email() { /* ... */ }

[Test, DependsOn(nameof(Verify_user_email))]
public async Task User_can_log_in() { /* ... */ }

Dependent tests are excluded from the parallel pool — they wait for their dependency to pass before executing.

Compile-time safety

This is arguably TUnit's most underrated feature. The built-in Roslyn analysers catch mistakes that other frameworks only surface at runtime:

This shifts an entire class of test infrastructure bugs from "discovered during CI" to "discovered while typing."

Performance in practice

Benchmark results from TUnit's CI (generated April 2026, running .NET 10 on Ubuntu) tell a clear story for the ScaleTests scenario:

Framework Mean Median
TUnit 493 ms 493 ms
TUnit (AOT) 38 ms 38 ms
MSTest 468 ms 467 ms
NUnit 607 ms 606 ms
xUnit3 611 ms 612 ms

In standard managed execution, TUnit and MSTest are neck and neck — both meaningfully faster than NUnit and xUnit. The standout is TUnit with Native AOT compilation: 38 milliseconds. That's 12-16x faster than any other framework in the comparison.

For large test suites where startup overhead and discovery time matter, that difference compounds. A CI pipeline running thousands of tests across multiple assemblies benefits not just from faster execution but from eliminating the reflection-based discovery phase entirely.

Migrating from xUnit or NUnit

TUnit ships a migration analyser that automates the bulk of the conversion. For xUnit projects:

terminal
dotnet format analyzers --severity info --diagnostics TUXU0001

The core attribute mapping is straightforward:

xUnit NUnit TUnit
[Fact] [Test] [Test]
[Theory] [TestCase] [Test] + [Arguments]
[InlineData] [TestCase(args)] [Arguments(args)]
[MemberData] [TestCaseSource] [MethodDataSource]
[Collection] [Parallelizable] [ParallelLimit<T>]
Constructor [SetUp] [Before(Test)]
IDisposable [TearDown] [After(Test)]

Assertions need manual attention. The conceptual shift from Assert.Equal(expected, actual) to await Assert.That(actual).IsEqualTo(expected) is not purely mechanical — you're also moving from a synchronous, static-method style to an asynchronous, fluent chain. Plan for this to be the most time-consuming part of any migration.

IDE support

TUnit works out of the box in Visual Studio 2022 (17.13+), JetBrains Rider (with "Testing Platform support" enabled in settings), and VS Code (via C# Dev Kit with "Use Testing Platform Protocol" enabled). Earlier versions of Visual Studio 2022 require enabling "Use testing platform server mode" in preview features.

You can also run tests directly with dotnet test, dotnet run, or by executing the compiled test binary — no special runner required.

Common pitfalls

Forgetting await on assertions. Even with the analyser catching this at build time, it trips up every developer who comes from xUnit or NUnit, where assertions are synchronous. Muscle memory takes time to retrain.

Assuming sequential execution. TUnit parallelises everything by default. Tests that worked in xUnit because they happened to run in order will fail when they run simultaneously. This is by design — it forces you to write properly isolated tests — but it can make migration noisy.

Using async void methods. TUnit enforces async Task for async tests. async void triggers a build error rather than the subtle runtime failures you might have encountered in other frameworks.

Overusing [DependsOn]. Test dependencies are a code smell in unit tests. They have legitimate uses in integration test flows, but if you find yourself chaining five tests together, you probably want a single test with multiple assertions or a proper test fixture with lifecycle hooks.

Expecting constructor-based DI. TUnit does not use constructors for test setup the way xUnit does. Use [Before(Test)] methods instead. Data injection comes through attributes like [Arguments] and [MethodDataSource], not constructor parameters.

Summary