Is it time to break up with your Test Framework? A look at TUnit
For years, .NET testing has mostly meant xUnit, NUnit or MSTest. They’ve done the job, they’re battle-tested, and most of us never really questioned if there was anything fundamentally better out there. Until TUnit arrived. It’s built as a modern test framework from the ground up.
I’ve been experimenting with TUnit recently 🧪, and in this post I want to go through what I see as its main benefits, what stands out compared to the other frameworks, and share my personal take on the big question: should you switch to TUnit, or not (yet)?
The big game changer
To start off, let’s write a simple unit test using TUnit:
[Test]
public async Task Add_AddsNumbersCorrectly()
{
var calculator = new Calculator();
var result = calculator.Add(1, 2);
await Assert.That(result).IsEqualTo(3);
}
You might have spotted it already, await on an assertion. Kinda weird at first glance, right? In TUnit, that’s totally on purpose. The whole framework is async-first.
Do I love having to specify
awaiton every assertion? Honestly, not really. But hey, you can’t have everything, right? For me this was actually the only thing I liked less about the framework.
If you write out a TUnit-test inside code, the moment you slap a [Test] attribute on there, you might have noticed the test now has 1 usage. If your first thought was “this smells like source generation”, gold star for you ⭐. TUnit uses a source generator to wire up your test-methods, instead of using reflection when you run your tests. This has several benefits, but the big one here is startup time.
Traditional test frameworks do runtime discovery. When the runner starts, it has to load your test assembly, walk every type and method via reflection, check for attributes like [Test], build an in-memory test tree, and only then can it start executing. That means lots of metadata inspection, allocations, and attribute lookups, which gets noticeably slower as your solution and test suite grow.
With TUnit, that work already happened at compile time. The Roslyn source generator scans your code during compilation, finds things like your [Test] methods, fixtures, data sources, etc., and then generates real C# code that registers those tests explicitly with the TUnit engine / Microsoft.Testing.Platform. In other words, it generates a strongly typed “test manifest”, code that knows “here are all the tests in this assembly and how to construct and run them”, so when you run your tests, the engine can jump straight into that generated registration code instead of reflecting over everything. So, no big reflection sweep, no dynamic type probing at startup, just calling pre-baked methods. That is why source-generated mode is faster.
And because TUnit sits directly on top of the Microsoft.Testing.Platform, you also get cleaner runner integration and a more future-proof testing foundation out of the box. It’s essentially the “modern” implementation designed to take advantage of this platform, while the older frameworks still rely on legacy adapters rather than using this new platform natively.
Hooks all the way
In all testing frameworks, you can run code before and after each test, once per class, etc. These are lifecycle hooks you can use to execute code at a specific point in time.
TUnit takes this a step further, it lets you define a rich set of lifecycle hooks at different levels:
public class LifeCycleTests
{
[Before(TestDiscovery)]
public static void BeforeTestDiscovery() => Console.WriteLine("Before(TestDiscovery)");
[Before(TestSession)]
public static void BeforeTestSession() => Console.WriteLine("Before(TestSession)");
[Before(Assembly)]
public static void BeforeAssembly() => Console.WriteLine("Before(Assembly)");
[Before(Class)]
public static void BeforeClass() => Console.WriteLine("Before(Class)");
[Before(Test)]
public void BeforeTest() => Console.WriteLine("Before(Test)");
}
All of these also have corresponding
[After]variants.
There’s even a lifecycle level that runs before TestDiscovery itself, which is pretty wild.
On top of that, you have the BeforeEach variant. These are typically defined as static lifecycle hooks in a separate file and run for every test within that scope. For example, a [BeforeEach(Assembly)] hook executes before every test in the assembly in which it’s defined.
So in short:
[Before]&[After]→ Runs only once for the first test that is executed in a given scope[BeforeEvery]&[AfterEvery]→ Runs once for every test in a given scope
Retrying tests when it makes sense
TUnit gives us a way to retry failed tests, you can enable this by specifying the [Retry] attribute:
public class RetryTests
{
[Test]
[Retry(3)]
public async Task EndToEndTest()
{
await Assert.That(true).IsFalse();
}
}
This code would run our test a maximum of 4 times ☝🏻
One thing to immediately point out here: Don’t use this to cover up flaky unit tests! This feature was introduced to allow extra attempts when for example end-to-end or screenshot tests fail due to timing issues. In these cases, a test may still pass after a few retries, which helps prevent the entire pipeline from failing just because one of these tests ran a bit slower than expected. And let’s be honest, we’ve all experienced this at least once before, and it can be pretty frustrating.
Dependencies between tests
Another niche, but cool feature, is a way to allow your tests to be dependent on each other. This can be done by using the [DependsOn] attribute:
public class DependantTests
{
[Test]
[DependsOn(nameof(TestTwo))]
public async Task TestOne()
{
await Assert.That(true).IsTrue();
}
[Test]
public async Task TestTwo()
{
await Assert.That(false).IsFalse();
}
}
Again, this shouldn’t be something we use for unit testing, but for integration testing, this could be useful.
Imagine most of your integration tests assume certain data already exists in the database, while you also have a dedicated test that creates that data. With [DependsOn], you can let the creation test run first and have the rest execute only after the data is created.
This is certainly not a silver bullet, but chaining related CRUD-style integration tests this way can be practical in specific situations.
There you have it, a few of the major selling points for TUnit.
Now the big question: Should you replace your current testing framework with TUnit? Probably not. The migration cost won’t justify the gains. But for new projects or greenfield work, TUnit is absolutely worth trying. It offers a clean, modern approach to writing tests, making it a strong option to consider the next time you start something new.