Best Practices
This guide covers best practices for writing clean, maintainable, and robust tests with TUnit. Following these patterns will help you create a test suite that's easy to understand and maintain over time.
Test Namingâ
Use Descriptive Namesâ
Good test names clearly describe what's being tested and what's expected. A common pattern is Method_Scenario_ExpectedBehavior:
// â
Good: Clearly describes what's being tested
[Test]
public async Task CalculateTotal_WithDiscount_ReturnsReducedPrice()
{
var calculator = new PriceCalculator();
var result = calculator.CalculateTotal(100, discount: 0.2);
await Assert.That(result).IsEqualTo(80);
}
// â Bad: Vague and unclear
[Test]
public async Task Test1()
{
var calculator = new PriceCalculator();
var result = calculator.CalculateTotal(100, 0.2);
await Assert.That(result).IsEqualTo(80);
}
Alternative Naming Patternsâ
You can also use sentence-like names that read naturally:
[Test]
public async Task When_discount_is_applied_total_is_reduced()
{
// Test implementation
}
[Test]
public async Task Should_return_reduced_price_when_discount_applied()
{
// Test implementation
}
Pick a naming convention and stick to it throughout your project for consistency.
Test Organizationâ
One Test Class Per Production Classâ
Organize your tests to mirror your production code structure:
MyApp/
Services/
OrderService.cs
PaymentService.cs
MyApp.Tests/
Services/
OrderServiceTests.cs
PaymentServiceTests.cs
This makes it easy to find tests and keeps your test suite organized as your codebase grows.
Group Related Testsâ
Use nested classes or separate test classes to group related test scenarios:
public class OrderServiceTests
{
public class CreateOrder
{
[Test]
public async Task Creates_order_with_valid_data()
{
// Test implementation
}
[Test]
public async Task Throws_exception_when_user_not_found()
{
// Test implementation
}
}
public class CancelOrder
{
[Test]
public async Task Cancels_order_successfully()
{
// Test implementation
}
[Test]
public async Task Throws_when_order_already_shipped()
{
// Test implementation
}
}
}
Keep Test Files Focusedâ
Each test file should focus on testing a single class or component. If your test file is getting large (>500 lines), consider splitting it into multiple files or using nested classes.
Assertion Best Practicesâ
Prefer Specific Assertionsâ
Use the most specific assertion available for better failure messages:
// â
Good: Specific assertion with clear failure message
await Assert.That(result).IsEqualTo(5);
// Failure: Expected 5 but was 3
// â Okay but less helpful: Generic boolean assertion
await Assert.That(result == 5).IsTrue();
// Failure: Expected true but was false
One Logical Assertion Per Testâ
Each test should verify one specific behavior. Multiple assertions are fine if they're testing different aspects of the same behavior:
// â
Good: Multiple assertions testing one behavior (user creation)
[Test]
public async Task CreateUser_SetsAllProperties()
{
var user = await userService.CreateUser("john@example.com", "John Doe");
await Assert.That(user.Email).IsEqualTo("john@example.com");
await Assert.That(user.Name).IsEqualTo("John Doe");
await Assert.That(user.CreatedAt).IsNotEqualTo(default(DateTime));
}
// â Bad: Testing multiple unrelated behaviors
[Test]
public async Task UserService_Works()
{
var user = await userService.CreateUser("john@example.com", "John");
await Assert.That(user.Email).IsEqualTo("john@example.com");
await userService.DeleteUser(user.Id);
var deleted = await userService.GetUser(user.Id);
await Assert.That(deleted).IsNull();
}
Always Await Assertionsâ
TUnit assertions are async and must be awaited. Forgetting await means the assertion never runs:
// â Wrong: Assertion returns Task that's never awaited
[Test]
public async Task MyTest()
{
Assert.That(result).IsEqualTo(5); // Test passes without checking!
}
// â
Correct: Assertion is awaited and executed
[Test]
public async Task MyTest()
{
await Assert.That(result).IsEqualTo(5);
}
The compiler will warn you about unawaited tasks, but watch for this common mistake.
Test Lifecycle Managementâ
Use Hooks for Setup and Cleanupâ
TUnit provides several hooks for test lifecycle management. Use them to keep your test logic clean:
public class DatabaseTests
{
private TestDatabase? _database;
[Before(Test)]
public async Task SetupDatabase()
{
_database = await TestDatabase.CreateAsync();
}
[After(Test)]
public async Task CleanupDatabase()
{
if (_database != null)
await _database.DisposeAsync();
}
[Test]
public async Task Can_insert_record()
{
// Database is ready to use
await _database!.InsertAsync(new Record { Id = 1 });
var result = await _database.GetAsync(1);
await Assert.That(result).IsNotNull();
}
}
Choose the Right Hook Levelâ
[Before(Test)]/[After(Test)]: Runs before/after each test (most common)[Before(Class)]/[After(Class)]: Runs once per test class[Before(Assembly)]/[After(Assembly)]: Runs once per test assembly
Sharing Expensive Resourcesâ
For expensive setup that needs to be shared across tests (like web servers, databases, or containers), use [ClassDataSource<>] with shared types and IAsyncInitializer/IAsyncDisposable:
// â
Best: Shared resource with ClassDataSource
public class TestWebServer : IAsyncInitializer, IAsyncDisposable
{
public WebApplicationFactory<Program>? Factory { get; private set; }
public async Task InitializeAsync()
{
Factory = new WebApplicationFactory<Program>();
await Task.CompletedTask;
}
public async ValueTask DisposeAsync()
{
if (Factory != null)
await Factory.DisposeAsync();
}
}
[ClassDataSource<TestWebServer>(Shared = SharedType.PerTestSession)]
public class ApiTests(TestWebServer server)
{
[Test]
public async Task Can_call_endpoint()
{
var client = server.Factory!.CreateClient();
var response = await client.GetAsync("/api/health");
await Assert.That(response.IsSuccessStatusCode).IsTrue();
}
[Test]
public async Task Can_get_users()
{
var client = server.Factory!.CreateClient();
var response = await client.GetAsync("/api/users");
await Assert.That(response.IsSuccessStatusCode).IsTrue();
}
}
Why this is better:
- Keeps test files simpler (no static fields or Before/After hooks)
- Shared resources work across multiple test classes
- Can share across assemblies using
SharedType.PerTestSession - Cleaner lifecycle management with
IAsyncInitializer/IAsyncDisposable - Type-safe dependency injection into test constructors
Shared Type Options:
SharedType.PerTestSession: One instance for entire test run, shared across assemblies (best for expensive resources)SharedType.PerClass: One instance per test classSharedType.None: New instance per test (default)
You can also use hooks, but they're less flexible:
// â Less flexible: Using hooks for shared setup
public class ApiTests
{
private static WebApplicationFactory<Program>? _factory;
[Before(Class)]
public static async Task StartServer()
{
_factory = new WebApplicationFactory<Program>();
}
[After(Class)]
public static async Task StopServer()
{
_factory?.Dispose();
}
// Tests use static _factory field
}
Avoid Complex Setup Logicâ
Keep your setup code simple and focused. If setup is complex, extract it to helper methods:
// â
Good: Simple setup with extracted helpers
[Before(Test)]
public async Task Setup()
{
_database = await CreateTestDatabase();
_testUser = await CreateTestUser();
}
private async Task<TestDatabase> CreateTestDatabase()
{
var db = await TestDatabase.CreateAsync();
await db.SeedDefaultData();
return db;
}
// â Bad: Complex setup logic in hook
[Before(Test)]
public async Task Setup()
{
_database = await TestDatabase.CreateAsync();
await _database.ExecuteAsync("CREATE TABLE Users (...)");
await _database.ExecuteAsync("INSERT INTO Users VALUES (...)");
await _database.ExecuteAsync("CREATE TABLE Orders (...)");
// ... lots more setup code
}
Parallelism Guidanceâ
Tests Run in Parallel By Defaultâ
TUnit runs tests in parallel for better performance. Write your tests to be independent:
// â
Good: Test is self-contained and independent
[Test]
public async Task Can_create_order()
{
var orderId = Guid.NewGuid(); // Unique ID
var order = new Order { Id = orderId, Total = 100 };
await orderService.CreateAsync(order);
var result = await orderService.GetAsync(orderId);
await Assert.That(result).IsNotNull();
}
Use NotInParallel When Neededâ
Some tests can't run in parallel (database tests, file system tests). Use [NotInParallel]:
// Tests that modify shared state
[Test, NotInParallel]
public async Task Updates_configuration_file()
{
await ConfigurationManager.SetAsync("key", "value");
var result = await ConfigurationManager.GetAsync("key");
await Assert.That(result).IsEqualTo("value");
}
Control Execution Orderâ
When tests need to run in a specific order, use [DependsOn] instead of NotInParallel with Order:
// â
Good: Use DependsOn for ordering while maintaining parallelism
[Test]
public async Task Step1_CreateUser()
{
// Runs first
}
[Test]
[DependsOn(nameof(Step1_CreateUser))]
public async Task Step2_UpdateUser()
{
// Runs after Step1_CreateUser completes
// Other unrelated tests can still run in parallel
}
[Test]
[DependsOn(nameof(Step2_UpdateUser))]
public async Task Step3_DeleteUser()
{
// Runs after Step2_UpdateUser completes
}
Why [DependsOn] is better:
- More intuitive: explicitly declares dependencies between tests
- More flexible: tests can depend on multiple other tests
- Maintains parallelism: unrelated tests still run in parallel
- Better for complex workflows: clear dependency chains
You can also use NotInParallel with Order, but this forces sequential execution:
// â Less flexible: Forces all tests to run sequentially
[Test, NotInParallel(Order = 1)]
public async Task Step1_CreateUser()
{
// Runs first
}
[Test, NotInParallel(Order = 2)]
public async Task Step2_UpdateUser()
{
// Runs second, but blocks all other tests
}
Important: If tests need ordering, they might be too tightly coupled. Consider:
- Refactoring into a single test
- Using proper setup/teardown
- Making tests truly independent
Use Parallel Groupsâ
Group related tests that can't run in parallel with each other but can run in parallel with other groups:
public class FileSystemTests
{
// These tests can't run in parallel with each other
// but can run in parallel with DatabaseTests
[Test, NotInParallel("FileGroup")]
public async Task Test1_WritesFile()
{
// Test implementation
}
[Test, NotInParallel("FileGroup")]
public async Task Test2_ReadsFile()
{
// Test implementation
}
}
public class DatabaseTests
{
[Test, NotInParallel("DbGroup")]
public async Task Test1_InsertsRecord()
{
// Runs in parallel with FileSystemTests
}
}
Common Anti-Patterns to Avoidâ
Avoid Test Interdependenceâ
Each test should be completely independent and not rely on other tests:
// â Bad: Tests depend on execution order
private static User? _user;
[Test]
public async Task Test1_CreateUser()
{
_user = await userService.CreateAsync("john@example.com");
}
[Test]
public async Task Test2_UpdateUser()
{
// Assumes Test1 ran first!
_user!.Name = "Jane Doe";
await userService.UpdateAsync(_user);
}
// â
Good: Each test is independent
[Test]
public async Task Can_create_user()
{
var user = await userService.CreateAsync("john@example.com");
await Assert.That(user.Email).IsEqualTo("john@example.com");
}
[Test]
public async Task Can_update_user()
{
var user = await userService.CreateAsync("jane@example.com");
user.Name = "Jane Doe";
await userService.UpdateAsync(user);
var updated = await userService.GetAsync(user.Id);
await Assert.That(updated.Name).IsEqualTo("Jane Doe");
}
Avoid Shared Instance Stateâ
Important: TUnit creates a new instance of your test class for each test method. Don't rely on instance fields to share state:
// â Bad: Trying to share instance state between tests
public class MyTests
{
private int _value; // Different instance per test!
[Test, NotInParallel]
public void Test1()
{
_value = 99;
}
[Test, NotInParallel]
public async Task Test2()
{
await Assert.That(_value).IsEqualTo(99); // Fails! _value is 0
}
}
// â
Good: Use static fields if you really need shared state
public class MyTests
{
private static int _value; // Shared across all tests
[Test, NotInParallel]
public void Test1()
{
_value = 99;
}
[Test, NotInParallel]
public async Task Test2()
{
await Assert.That(_value).IsEqualTo(99); // Works!
}
}
But seriously: if tests need to share state, reconsider your design. It's usually better to make tests independent.
Avoid Complex Test Logicâ
Tests should be simple and easy to understand. Avoid complex conditionals, loops, or calculations:
// â Bad: Complex logic in test
[Test]
public async Task CalculatesTotals()
{
var items = await GetItems();
decimal expected = 0;
foreach (var item in items)
{
if (item.IsDiscounted)
expected += item.Price * 0.8m;
else
expected += item.Price;
}
var result = calculator.CalculateTotal(items);
await Assert.That(result).IsEqualTo(expected);
}
// â
Good: Simple, explicit test
[Test]
public async Task CalculateTotal_WithMixedItems()
{
var items = new[]
{
new Item { Price = 100, IsDiscounted = false }, // 100
new Item { Price = 50, IsDiscounted = true } // 40
};
var result = calculator.CalculateTotal(items);
await Assert.That(result).IsEqualTo(140);
}
If your test has complex logic, you're essentially writing code to test code. Keep it simple!
Avoid Over-Mockingâ
Don't mock everything. Use real implementations when they're fast and reliable:
// â Bad: Mocking things that don't need mocking
[Test]
public async Task ProcessOrder()
{
var mockLogger = new Mock<ILogger>();
var mockValidator = new Mock<IValidator>();
var mockCalculator = new Mock<IPriceCalculator>();
var mockRepository = new Mock<IOrderRepository>();
// So much setup...
}
// â
Good: Only mock expensive or external dependencies
[Test]
public async Task ProcessOrder()
{
var logger = new NullLogger(); // Real lightweight implementation
var validator = new OrderValidator(); // Real validator is fast
var calculator = new PriceCalculator(); // Simple calculations
var mockRepository = new Mock<IOrderRepository>(); // Mock database
// Much simpler!
}
Mock external dependencies (databases, APIs, file systems) but use real implementations for simple logic.
Avoid Testing Implementation Detailsâ
Test behavior, not implementation. Your tests should verify what the code does, not how it does it:
// â Bad: Testing internal implementation
[Test]
public async Task ProcessOrder_CallsRepositorySaveMethod()
{
var mockRepository = new Mock<IOrderRepository>();
var service = new OrderService(mockRepository.Object);
await service.ProcessOrder(order);
// Verifying method calls instead of behavior
mockRepository.Verify(r => r.Save(It.IsAny<Order>()), Times.Once);
}
// â
Good: Testing actual behavior
[Test]
public async Task ProcessOrder_SavesOrderToDatabase()
{
var repository = new InMemoryOrderRepository();
var service = new OrderService(repository);
await service.ProcessOrder(order);
// Verifying the result
var saved = await repository.GetAsync(order.Id);
await Assert.That(saved).IsNotNull();
await Assert.That(saved.Status).IsEqualTo(OrderStatus.Processed);
}
Tests that verify implementation details are brittle and break when you refactor.
Performance Considerationsâ
TUnit is designed for performance at scale. Follow these guidelines to keep your test suite fast:
Optimize Test Discoveryâ
- Use AOT mode for faster test discovery and lower memory usage
- Keep data sources lightweight (see Performance Best Practices)
- Limit matrix test combinations to avoid test explosion
Optimize Test Executionâ
- Let tests run in parallel (it's fast!)
- Only use
[NotInParallel]when absolutely necessary - Configure parallelism based on your CPU:
[assembly: MaxParallelTests(Environment.ProcessorCount)] - Avoid expensive setup in
[Before(Test)]hooks - use class or assembly-level hooks for shared resources
Avoid Slow Operations in Testsâ
Tests should be fast. If a test takes more than a few seconds, look for optimization opportunities:
// â Slow: Real HTTP calls
[Test]
public async Task GetUserData()
{
var client = new HttpClient();
var response = await client.GetAsync("https://api.example.com/users");
// Slow and unreliable
}
// â
Fast: Use in-memory test doubles
[Test]
public async Task GetUserData()
{
var client = new TestHttpClient(); // In-memory fake
var response = await client.GetAsync("/users");
// Fast and reliable
}
For detailed performance guidance, see Performance Best Practices.
Summaryâ
Following these best practices will help you:
- Write tests that are easy to understand and maintain
- Create a fast, reliable test suite that scales
- Catch bugs without introducing brittle tests
- Make your codebase more maintainable over time
Remember: good tests are simple, focused, independent, and fast. When in doubt, ask yourself: "Will someone else understand what this test is doing and why it might fail?"