Performance Best Practices
This guide provides recommendations for optimizing test performance and ensuring your TUnit test suite runs efficiently.
Performance
Want to see how fast TUnit can be? Check out the performance benchmarks showing real-world speed comparisons.
Test Discovery Performanceâ
Use AOT Modeâ
TUnit's AOT (Ahead-of-Time) compilation mode provides the best performance for test discovery:
<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
</PropertyGroup>
Native AOT Performance
TUnit with Native AOT compilation delivers significant speed improvements compared to regular JIT. See the benchmarks for detailed measurements.
Benefits:
- Faster test discovery
- Lower memory usage
- Better performance in CI/CD pipelines
Optimize Data Sourcesâ
Keep Data Generation Lightweightâ
// â Bad: Heavy computation during discovery
public static IEnumerable<User> GetTestUsers()
{
// This runs during test discovery!
var users = DatabaseQuery.GetAllUsers();
return users.Where(u => u.IsActive);
}
// â
Good: Lightweight data generation
public static IEnumerable<User> GetTestUsers()
{
yield return new User { Id = 1, Name = "Test User 1" };
yield return new User { Id = 2, Name = "Test User 2" };
}
Use Lazy Data Loadingâ
// â
Good: Defer expensive operations until test execution
[Test]
[MethodDataSource<LazyDataProvider>(nameof(LazyDataProvider.GetIds))]
public async Task TestWithLazyData(int id)
{
// Load full data only during test execution
var user = await LoadUserAsync(id);
await Assert.That(user).IsNotNull();
}
public class LazyDataProvider
{
public static IEnumerable<int> GetIds()
{
// Return only IDs during discovery
yield return 1;
yield return 2;
yield return 3;
}
}
Limit Matrix Test Combinationsâ
// â Bad: Exponential test explosion
[Test]
[Arguments(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)]
[Arguments("a", "b", "c", "d", "e")]
[Arguments(true, false)]
// Creates 10 Ã 5 Ã 2 = 100 tests!
// â
Good: Targeted test combinations
[Test]
[Arguments(1, "a", true)]
[Arguments(5, "c", false)]
[Arguments(10, "e", true)]
// Only 3 specific test cases
Test Execution Performanceâ
Optimize Parallel Executionâ
Configure Appropriate Parallelismâ
using TUnit.Core;
using TUnit.Core.Interfaces;
// Set maximum parallel test execution using an assembly-level parallel limiter
[assembly: ParallelLimiter<ProcessorCountLimit>]
public class ProcessorCountLimit : IParallelLimit
{
public int Limit => Environment.ProcessorCount;
}
Alternatively, use the command line flag:
dotnet test -- --maximum-parallel-tests 8
Or set an environment variable:
export TUNIT_MAX_PARALLEL_TESTS=8
Group Related Testsâ
// Tests in the same group run sequentially but different groups run in parallel
[ParallelGroup("DatabaseTests")]
public class UserRepositoryTests
{
// These tests share database resources
}
[ParallelGroup("DatabaseTests")]
public class OrderRepositoryTests
{
// These also share database resources
}
[ParallelGroup("ApiTests")]
public class ApiIntegrationTests
{
// These can run in parallel with database tests
}
Use Parallel Limiters Wiselyâ
public class DatabaseConnectionLimit : IParallelLimit
{
public int Limit => 5; // Max 5 concurrent database connections
}
[ParallelLimiter<DatabaseConnectionLimit>]
public class DatabaseIntegrationTests
{
// All tests here respect the connection limit
}
Minimize Test Setup Overheadâ
Share Expensive Setupâ
// â Bad: Expensive setup per test
public class ExpensiveTests
{
[Before(HookType.Test)]
public async Task SetupEachTest()
{
await StartDatabaseContainer();
await MigrateDatabase();
}
}
// â
Good: Share setup across tests
public class EfficientTests
{
private static DatabaseContainer? _container;
[Before(HookType.Class)]
public static async Task SetupOnce()
{
_container = await StartDatabaseContainer();
await MigrateDatabase();
}
[After(HookType.Class)]
public static async Task CleanupOnce()
{
if (_container != null)
{
await _container.DisposeAsync();
}
}
}
Use Lazy Initializationâ
public class PerformantTests
{
private static readonly Lazy<ExpensiveResource> _resource =
new(() => new ExpensiveResource(), LazyThreadSafetyMode.ExecutionAndPublication);
[Test]
public async Task TestUsingResource()
{
var resource = _resource.Value; // Only created on first access
await resource.DoSomethingAsync();
}
}
Optimize Assertionsâ
Avoid Expensive Operations in Assertionsâ
// â Bad: Expensive operation in assertion
await Assert.That(await GetAllUsersFromDatabase())
.Count()
.IsEqualTo(1000);
// â
Good: Use efficient queries
var userCount = await GetUserCountFromDatabase();
await Assert.That(userCount).IsEqualTo(1000);
Use Early Exit Patternsâ
[Test]
public async Task EfficientValidation()
{
var result = await GetResultAsync();
// Quick checks first
if (result == null)
{
await Assert.That(result).IsNotNull();
return; // Exit early
}
// More expensive validations only if needed
await Assert.That(result.Items).Count().IsGreaterThan(0);
}
Memory Managementâ
Dispose Resources Properlyâ
public class MemoryEfficientTests : IAsyncDisposable
{
private readonly List<IDisposable> _disposables = new();
[Test]
public async Task TestWithResources()
{
var resource = new LargeResource();
_disposables.Add(resource);
// Use resource
await resource.ProcessAsync();
}
public async ValueTask DisposeAsync()
{
foreach (var disposable in _disposables)
{
disposable.Dispose();
}
_disposables.Clear();
// Force garbage collection if needed
GC.Collect();
GC.WaitForPendingFinalizers();
}
}
Avoid Memory Leaks in Static Fieldsâ
// â Bad: Static collections that grow indefinitely
public class LeakyTests
{
private static readonly List<TestResult> _allResults = new();
[After(HookType.Test)]
public void StoreResult()
{
_allResults.Add(GetCurrentResult()); // Memory leak!
}
}
// â
Good: Proper cleanup or bounded collections
public class EfficientTests
{
private static readonly Queue<TestResult> _recentResults = new();
private const int MaxResults = 100;
[After(HookType.Test)]
public void StoreResult()
{
_recentResults.Enqueue(GetCurrentResult());
while (_recentResults.Count > MaxResults)
{
_recentResults.Dequeue();
}
}
}
Use ValueTask for High-Frequency Operationsâ
// For operations called many times, use ValueTask to reduce allocations
public async ValueTask<bool> FastCheckAsync(int id)
{
if (_cache.TryGetValue(id, out var cached))
{
return cached; // No allocation for cached path
}
var result = await LoadFromDatabaseAsync(id);
_cache[id] = result;
return result;
}
[Test]
[Arguments(1, 2, 3, 4, 5)] // Many invocations
public async Task HighFrequencyTest(int id)
{
var result = await FastCheckAsync(id);
await Assert.That(result).IsTrue();
}
I/O Performanceâ
Batch Operationsâ
// â Bad: Individual operations
[Test]
public async Task SlowIOTest()
{
foreach (var id in Enumerable.Range(1, 100))
{
await SaveUserAsync(new User { Id = id });
}
}
// â
Good: Batch operations
[Test]
public async Task FastIOTest()
{
var users = Enumerable.Range(1, 100)
.Select(id => new User { Id = id })
.ToList();
await SaveUsersBatchAsync(users);
}
Use Async I/Oâ
// â Bad: Synchronous I/O
[Test]
public void SyncIOTest()
{
var content = File.ReadAllText("large-file.txt");
ProcessContent(content);
}
// â
Good: Asynchronous I/O
[Test]
public async Task AsyncIOTest()
{
var content = await File.ReadAllTextAsync("large-file.txt");
await ProcessContentAsync(content);
}
Cache File Contentsâ
public class FileTestsWithCache
{
private static readonly ConcurrentDictionary<string, string> _fileCache = new();
private async Task<string> GetFileContentAsync(string path)
{
return await _fileCache.GetOrAddAsync(path,
async p => await File.ReadAllTextAsync(p));
}
[Test]
[Arguments("config1.json")]
[Arguments("config2.json")]
public async Task TestWithCachedFiles(string filename)
{
var content = await GetFileContentAsync(filename);
await Assert.That(content).IsNotEmpty();
}
}
Database Testing Performanceâ
Use Transaction Rollbackâ
public class FastDatabaseTests
{
[Test]
public async Task TransactionalTest()
{
using var transaction = await BeginTransactionAsync();
try
{
// Perform database operations
await CreateUserAsync("test@example.com");
// Verify
var user = await GetUserAsync("test@example.com");
await Assert.That(user).IsNotNull();
// Rollback instead of cleanup
await transaction.RollbackAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
}
Use In-Memory Databases for Unit Testsâ
public class InMemoryDatabaseTests
{
private DbContext CreateInMemoryContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new AppDbContext(options);
}
[Test]
public async Task FastDatabaseTest()
{
using var context = CreateInMemoryContext();
// Test runs entirely in memory
context.Users.Add(new User { Name = "Test" });
await context.SaveChangesAsync();
var count = await context.Users.CountAsync();
await Assert.That(count).IsEqualTo(1);
}
}
CI/CD Optimizationâ
Split Test Suitesâ
# Run fast unit tests first
dotnet test --no-build -- --treenode-filter "/*/*/*/*[Category=Unit]"
# Run slower integration tests separately
dotnet test --no-build -- --treenode-filter "/*/*/*/*[Category=Integration]"
# Run expensive E2E tests last
dotnet test --no-build -- --treenode-filter "/*/*/*/*[Category=E2E]"
Note: With .NET 10 SDK or newer, you can use the simpler syntax:
dotnet test --no-build --treenode-filter "/**[Category=Unit]"
Use Test Result Cachingâ
<!-- Cache test results in CI -->
<PropertyGroup>
<TUnitCacheTestResults>true</TUnitCacheTestResults>
<TUnitTestResultsCachePath>$(Build.StagingDirectory)/testcache</TUnitTestResultsCachePath>
</PropertyGroup>
Fail Fast in CIâ
# Stop on first failure to save CI time
dotnet test -- --fail-fast
Monitoring and Profilingâ
Add Performance Loggingâ
public class PerformanceAwareExecutor : ITestExecutor
{
private readonly ILogger<PerformanceAwareExecutor> _logger;
public async Task ExecuteAsync(TestContext context, Func<Task> testBody)
{
var stopwatch = Stopwatch.StartNew();
try
{
await testBody();
}
finally
{
stopwatch.Stop();
if (stopwatch.ElapsedMilliseconds > 1000)
{
_logger.LogWarning(
"Slow test detected: {TestName} took {ElapsedMs}ms",
context.Metadata.TestName,
stopwatch.ElapsedMilliseconds);
}
}
}
}
Track Test Metricsâ
[After(HookType.Test)]
public static void RecordTestMetrics()
{
var context = TestContext.Current;
if (context?.Execution.Result != null)
{
TelemetryClient.TrackMetric(
"TestDuration",
context.Execution.Result.Duration.TotalMilliseconds,
new Dictionary<string, string>
{
["TestName"] = context.Metadata.TestName,
["TestClass"] = context.Metadata.TestDetails.TestClass,
["Result"] = context.Execution.Result.State.ToString()
});
}
}
Summaryâ
Key performance principles:
- Optimize Discovery: Keep data sources lightweight and limit test combinations
- Parallelize Wisely: Use appropriate parallel limits and grouping
- Manage Resources: Dispose properly and avoid memory leaks
- Cache Aggressively: Cache expensive operations and file I/O
- Batch Operations: Group database and I/O operations
- Monitor Performance: Track and alert on slow tests
- Use AOT Mode: Enable AOT for best performance
- Fail Fast: Stop early on failures in CI
By following these practices, you can maintain a fast, efficient test suite that scales with your codebase.