| name | tunit-best-practices |
| description | Best practices for writing clean, maintainable, and robust tests with TUnit. |
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:
[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);
}
[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]
public async Task Should_return_reduced_price_when_discount_applied()
{
}
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]
public async Task Throws_exception_when_user_not_found()
{
}
}
public class CancelOrder
{
[Test]
public async Task Cancels_order_successfully()
{
}
[Test]
public async Task Throws_when_order_already_shipped()
{
}
}
}
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:
await Assert.That(result).IsEqualTo(5);
await Assert.That(result == 5).IsTrue();
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:
[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));
}
[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:
[Test]
public async Task MyTest()
{
Assert.That(result).IsEqualTo(5);
}
[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()
{
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:
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 class
SharedType.None: New instance per test (default)
You can also use hooks, but they're less flexible:
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();
}
}
Avoid Complex Setup Logic
Keep your setup code simple and focused. If setup is complex, extract it to helper methods:
[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;
}
[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 (...)");
}
Parallelism Guidance
Tests Run in Parallel By Default
TUnit runs tests in parallel for better performance. Write your tests to be independent:
[Test]
public async Task Can_create_order()
{
var orderId = Guid.NewGuid();
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]:
[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:
[Test]
public async Task Step1_CreateUser()
{
}
[Test]
[DependsOn(nameof(Step1_CreateUser))]
public async Task Step2_UpdateUser()
{
}
[Test]
[DependsOn(nameof(Step2_UpdateUser))]
public async Task Step3_DeleteUser()
{
}
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:
[Test, NotInParallel(Order = 1)]
public async Task Step1_CreateUser()
{
}
[Test, NotInParallel(Order = 2)]
public async Task Step2_UpdateUser()
{
}
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
{
[Test, NotInParallel("FileGroup")]
public async Task Test1_WritesFile()
{
}
[Test, NotInParallel("FileGroup")]
public async Task Test2_ReadsFile()
{
}
}
public class DatabaseTests
{
[Test, NotInParallel("DbGroup")]
public async Task Test1_InsertsRecord()
{
}
}
Common Anti-Patterns to Avoid
Avoid Test Interdependence
Each test should be completely independent and not rely on other tests:
private static User? _user;
[Test]
public async Task Test1_CreateUser()
{
_user = await userService.CreateAsync("john@example.com");
}
[Test]
public async Task Test2_UpdateUser()
{
_user!.Name = "Jane Doe";
await userService.UpdateAsync(_user);
}
[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:
public class MyTests
{
private int _value;
[Test, NotInParallel]
public void Test1()
{
_value = 99;
}
[Test, NotInParallel]
public async Task Test2()
{
await Assert.That(_value).IsEqualTo(99);
}
}
public class MyTests
{
private static int _value;
[Test, NotInParallel]
public void Test1()
{
_value = 99;
}
[Test, NotInParallel]
public async Task Test2()
{
await Assert.That(_value).IsEqualTo(99);
}
}
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:
[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);
}
[Test]
public async Task CalculateTotal_WithMixedItems()
{
var items = new[]
{
new Item { Price = 100, IsDiscounted = false },
new Item { Price = 50, IsDiscounted = true }
};
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:
[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>();
}
[Test]
public async Task ProcessOrder()
{
var logger = new NullLogger();
var validator = new OrderValidator();
var calculator = new PriceCalculator();
var mockRepository = new Mock<IOrderRepository>();
}
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:
[Test]
public async Task ProcessOrder_CallsRepositorySaveMethod()
{
var mockRepository = new Mock<IOrderRepository>();
var service = new OrderService(mockRepository.Object);
await service.ProcessOrder(order);
mockRepository.Verify(r => r.Save(It.IsAny<Order>()), Times.Once);
}
[Test]
public async Task ProcessOrder_SavesOrderToDatabase()
{
var repository = new InMemoryOrderRepository();
var service = new OrderService(repository);
await service.ProcessOrder(order);
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 using the
--maximum-parallel-tests CLI flag or [assembly: ParallelLimiter<T>] attribute
- 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:
[Test]
public async Task GetUserData()
{
var client = new HttpClient();
var response = await client.GetAsync("https://api.example.com/users");
}
[Test]
public async Task GetUserData()
{
var client = new TestHttpClient();
var response = await client.GetAsync("/users");
}
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?"