This blog is presented as a part of C# Advent 2025. Follow the link to check out the rest of the excellent C# and .NET content coming out in 2 blogs per day between December 1 - 25.
For many years, the .NET ecosystem has relied on established testing frameworks such as xUnit.net, NUnit, and MSTest. These frameworks have served teams well for unit, integration, and even acceptance testing. However, as .NET has evolved with newer runtime versions, AOT compilation, cross-platform requirements, increasingly large test suites, and faster CI/CD pipelines the limitations of legacy frameworks have become more apparent.
TUnit aims to address many of these challenges, or at least claims to. In this blog I want to examine those claims and see whether they hold up in practice.
The Problem with Legacy Testing Frameworks
Before discussing TUnit, let’s acknowledge the elephants in the room the PAIN points.
1. Reflection Overhead
xUnit, NUnit, and MSTest rely heavily on reflection to discover tests, instantiate fixtures, and invoke test methods. Reflection is powerful, but it comes at a performance cost. In large test suites, this overhead can compound significantly especially in environments where startup time, test discovery speed, and overall throughput are critical.
2. Async-First Design Was an Afterthought
When async/await became mainstream, legacy frameworks added support rather than redesigning around it. The result is sometimes clunky assertions, inconsistent async handling, and occasional ergonomic friction.
[Fact]
public async Task TestAsync()
{
var result = await GetDataAsync();
Assert.Equal(13, result.Count); // No await here, clunky
}
3. No Native AOT Support
Native AOT is becoming essential for cloud-native applications, edge computing, and containerized workloads. Legacy frameworks heavy reflection usage makes AOT support difficult or impossible.
4. Limited Parallelization Control
Legacy frameworks offer basic parallelization, but fine-grained control over test execution ordering, dependency management, and resource allocation is minimal.
5. Test Discovery Bottlenecks
Reflection-based test discovery happens at runtime, forcing developers to wait before tests even start executing. This is particularly frustrating in CI/CD pipelines.
TUnit Claims
Now that we understand some of the pain points of “legacy frameworks” we can enter the world of TUnit.
TUnit’s design philosophy centers on being fast, modern, flexible, simple, and easy to adopt.
Key concepts:
- Performance first — using compile-time source generation instead of reflection, with parallel execution enabled by default.
- Modern .NET alignment — full support for .NET 8 and later, Native AOT, trimming, and new runtime capabilities.
- Control & flexibility — attribute- and configuration-based workflows, custom data sources, extensible behaviors, dependency chains, and fine-grained scheduling control.
- Broad applicability — suitable not only for unit tests but also integration, acceptance, and even end-to-end scenarios (including support for libraries like Playwright).
In doing so, TUnit aims to reimagine what testing should look like in modern .NET not just another clunky clone, and certainly not “yet another fork” as many tools tend to become in today’s ecosystem.
Before You Switch to the New and Shiny: A Feature Comparison
Before jumping into a new framework, it’s worth taking a closer look at how TUnit’s features compare to the established players
| Feature | TUnit | xUnit | NUnit | MSTest |
|---|---|---|---|---|
| Source Generated | ✅ | ❌ | ❌ | ❌ |
| Native AOT Support | ✅ | ❌ | ❌ | ❌ |
| Async-First Assertions | ✅ | ❌ | ❌ | ❌ |
| Fluent API | ✅ | ❌ | ❌ | ❌ |
| Data Sources | Arguments, Method, Class, Matrix | InlineData, MemberData, ClassData | TestCase, TestCaseSource, Values | DataRow, DynamicData |
| Setup/Teardown | Before/After hooks | Constructor/IDisposable | SetUp/TearDown | TestInitialize/TestCleanup |
| Attributes on Parameters | ✅ Matrix | ❌ | ✅ Values | ❌ |
| Compile-Time Diagnostics | ✅ | ⚠️ Minimal | ⚠️ Minimal | ⚠️ Minimal |
| Microsoft Testing Platform | ✅ | ❌ | ❌ | ✅ (v3+) |
| Performance vs Competition | Baseline (1.3x faster than xUnit) | 1.3x slower | 1.2x slower | 1.3x slower |
As you can see from the comparison table, TUnit supports many features that other testing frameworks do not. While the speed improvement around 1.3× faster at baseline may not seem dramatic, it is still a measurable gain, especially in large test suites. More importantly, performance is only one part of the story. TUnit introduces several modern capabilities that meaningfully enhance developer experience and test design.
Let’s take a closer look at the features TUnit brings to the table.
Note: For feature comparisons, I will primarily reference xUnit, since it is the most widely used modern framework and has been my personal go-to choice for years.
Native Async Support
TUnit’s most distinctive feature is truly async-first design. Assertions are awaitable, setup hooks are async, and the entire test lifecycle acknowledges that modern .NET is asynchronous.
xUnit approach:
[Fact]
public async Task TestAsync()
{
var result = await GetDataAsync();
Assert.NotNull(result);
Assert.Equal(5, result.Items.Count);
Assert.True(result.IsValid);
}
Notice the disconnect: we’re inside an async method, yet the assertions are synchronous calls. When an assertion fails, we lose the surrounding async context, which can obscure stack traces and make debugging more difficult.
TUnit approach:
[Test]
public async Task TestAsync()
{
var result = await GetDataAsync();
await Assert.That(result).IsNotNull();
await Assert.That(result.Items).Count().IsEqualTo(5);
await Assert.That(result.IsValid).IsTrue();
}
Each assertion is awaited. This enables TUnit to:
- Capture the async context accurately
- Chain assertions fluently
- Handle async assertions without special or custom syntax
- Provide clearer, more informative error messages with full execution context
Source Generation and Compile-Time Validation
TUnit uses C# source generators to discover and compile tests at build time, completely eliminating the need for runtime reflection. This approach allows many errors to be caught during compilation rather than at runtime, and it significantly improves test startup time by removing the reflection based discovery phase entirely. It also enhances the overall debugging experience, as the generated code provides clearer execution paths and more predictable behavior compared to traditional reflection driven frameworks.
[Test]
[Arguments(1, 2, 3)]
[Arguments(4, 5, 9)]
public async Task Add_WithVariousInputs(int a, int b, int expected)
{
var result = Add(a, b);
await Assert.That(result).IsEqualTo(expected);
}
// Source-generated equivalent (simplified):
// [CompilerGenerated]
// public async Task Add_WithVariousInputs_1()
// {
// var result = Add(1, 2);
// await Assert.That(result).IsEqualTo(3);
// }
// [CompilerGenerated]
// public async Task Add_WithVariousInputs_2() { ... }
Parallelization Model
// Run tests in parallel by default
[Test]
[Parallel] // Explicitly parallelizable
public async Task Test1() { }
// Force sequential execution when needed
[Test]
[Sequential] // Don't parallelize this test
public async Task Test2() { }
// Share resources across parallel tests
[ClassDataSource<DatabaseFixture>(Shared = SharedType.PerClass)]
public class DatabaseTests
{
[Test]
public async Task Test1(DatabaseFixture db) { }
[Test]
public async Task Test2(DatabaseFixture db) { }
}
Hence, xUnit can already run tests in parallel, so this is not a new concept. However, let’s give credit where credit is due.
Data-Driven Testing
Compile-time arguments
[Test]
[Arguments(1, 2, 3)]
[Arguments(4, 5, 9)]
public async Task Addition(int a, int b, int expected)
{
await Assert.That(a + b).IsEqualTo(expected);
}
Method data sources (dynamic data)
[Test]
[MethodDataSource(nameof(TestCases))]
public async Task WithDynamicData(int value, string expected)
{
var result = ProcessValue(value);
await Assert.That(result).IsEqualTo(expected);
}
public static IEnumerable<(int, string)> TestCases()
{
yield return (1, "one");
yield return (2, "two");
yield return (3, "three");
}
Matrix tests (combinatorial)
[Test]
public async Task MatrixCombinations(
[Matrix(1, 2, 3)] int x,
[Matrix("a", "b")] string y,
[Matrix(true, false)] bool z)
{
// Runs 3 × 2 × 2 = 12 combinations automatically
await Assert.That(Validate(x, y, z)).IsTrue();
}
Class data sources
[ClassDataSource<UserFixture>(Shared = SharedType.PerClass)]
public class UserServiceTests
{
[Test]
public async Task CreateUser(UserFixture fixture)
{
var user = await fixture.Service.CreateAsync("John");
await Assert.That(user.Name).IsEqualTo("John");
}
}
public class UserFixture
{
public IUserService Service { get; } = new UserService();
}
As you can see, TUnit supports nearly all the features offered by legacy frameworks, which is quite impressive. The creators clearly recognized that most developers rely on these familiar capabilities, so instead of removing them, they refined and modernized them. Features like Matrix Tests, for example, are something I use often yet setting them up in older frameworks was a real pain and definitely contributed to a few gray hairs on my head
Test Lifecycle and Fixtures
public class TestLifecycleExample
{
private DatabaseConnection _connection;
// Run once per test method
[Before(Test)]
public async Task SetupTest()
{
_connection = new DatabaseConnection();
await _connection.OpenAsync();
}
[Test]
public async Task DatabaseQuery()
{
var result = await _connection.QueryAsync("SELECT * FROM Users");
await Assert.That(result).IsNotNull();
}
// Run once per test method (cleanup)
[After(Test)]
public async Task CleanupTest()
{
await _connection.CloseAsync();
_connection?.Dispose();
}
// Run once per test class
[Before(Class)]
public static async Task SetupClass()
{
// Initialize shared resources
}
[After(Class)]
public static async Task CleanupClass()
{
// Tear down shared resources
}
// Run once per assembly
[Before(Assembly)]
public static async Task SetupAssembly()
{
// Application-wide initialization
}
}
Built-In Fluent Assertions (No Extra Packages Needed!)
TUnit supports fluent assertion chaining out of the box, one of the features I appreciate most about the framework. The team clearly listened to the community and delivered this capability natively, without requiring any additional packages or setup.
[Test]
public async Task FluentAssertions()
{
var user = new { Name = "Alice", Age = 30, Email = "alice@example.com" };
// Chain assertions naturally
await Assert.That(user.Name)
.IsNotNull()
.IsNotEmpty()
.StartsWith("Al");
// Use 'And' for logical grouping
await Assert.That(user.Age)
.IsGreaterThan(18)
.And.IsLessThan(65);
// Multiple assertions in one scope
using var scope = Assert.Multiple();
await Assert.That(user.Name).IsEqualTo("Alice");
await Assert.That(user.Age).IsEqualTo(30);
await Assert.That(user.Email).IsNotNull();
}
Native Dependency Injection Support
You don’t need any convoluted setup to use dependency injection, TUnit supports it out of the box, and that’s incredibly convenient. Having this kind of functionality available without extra configuration is pretty neat.
[Test]
public async Task WithDependencyInjection(IUserService userService, ILogger logger)
{
var user = await userService.GetUserAsync(1);
logger.LogInformation($"Loaded user: {user.Name}");
await Assert.That(user).IsNotNull();
}
// Configure via ServiceCollection
public void ConfigureServices(IServiceCollection services)
{
services.AddScoped<IUserService, UserService>();
services.AddLogging();
}
Conclusion
Should You Adopt TUnit?
TUnit represents a genuine advancement in .NET testing. This isn’t a marginal quality of life improvement, it’s a re-architecture for the modern .NET era. Legacy frameworks remain solid, reliable, and mature, but they reflect design decisions made long before AOT, source generators, or today’s performance expectations.
For developers starting new projects or optimizing performance-critical test suites, TUnit deserves serious consideration. The best time to adopt TUnit is in greenfield projects. The second best time is when your existing test suite becomes a performance bottleneck or when you need Native AOT support. For established codebases with significant test investment, your existing framework will most likely continue to work just fine, but you may still want to try TUnit and experience the difference firsthand.
Both TUnit and xUnit are powerful testing frameworks, each with strengths that suit different scenarios. Understanding their trade-offs helps you choose the right tool for your project’s requirements.
If you’re looking to modernize your .NET ecosystem or optimize performance across your testing and application layers, the Trailhead team can help. Contact Trailhead today if you’re ready to take your system’s reliability and speed to the next level with better testing.
References & Further Reading
TUnit — Official Website & Documentation
Converting an xUnit Test Project to TUnit — Andrew Lock


