Testing Async Code in .NET: Patterns and Pitfalls
Almost everything in modern .NET is asynchronous. HTTP calls, database queries, file I/O, message publishing — they all return Task or Task<T>. Testing async code is mostly straightforward, but there are specific patterns to follow and pitfalls that can lead to tests that pass when they shouldn't.
The Basics: Async Test Methods
All three major test frameworks support async Task test methods. This is the correct way to test async code:
[Fact]
public async Task GetUser_ExistingUser_ReturnsUser()
{
var user = await _repository.GetByIdAsync(1);
Assert.NotNull(user);
Assert.Equal("Alice", user.Name);
}
Never write async void test methods. The test runner cannot observe exceptions from async void methods — they'll crash the process instead of failing the test.
Pitfall: Forgetting to Await
The most dangerous mistake in async testing:
// WRONG - this test always passes!
[Fact]
public void GetUser_NonExistentUser_ThrowsNotFoundException()
{
Assert.Throws<NotFoundException>(
() => _repository.GetByIdAsync(999));
}
This test passes because Assert.Throws catches exceptions thrown synchronously. The GetByIdAsync method returns a Task without throwing — the exception is inside the task. Since nobody awaits the task, the exception is never observed.
The correct version:
[Fact]
public async Task GetUser_NonExistentUser_ThrowsNotFoundException()
{
await Assert.ThrowsAsync<NotFoundException>(
() => _repository.GetByIdAsync(999));
}
Note ThrowsAsync instead of Throws, and the test method is async Task.
Testing Exception Messages
[Fact]
public async Task TransferFunds_InsufficientBalance_ThrowsWithMessage()
{
var exception = await Assert.ThrowsAsync<InsufficientFundsException>(
() => _service.TransferAsync("acc-1", "acc-2", 1000m));
Assert.Contains("insufficient balance", exception.Message, StringComparison.OrdinalIgnoreCase);
}
ThrowsAsync returns the caught exception, so you can assert on its properties.
Testing Methods That Return Task (No Result)
[Fact]
public async Task SendNotification_ValidUser_CompletesSuccessfully()
{
// Arrange
var notification = new Notification("[email protected]", "Hello");
// Act & Assert - this will throw if the method fails
await _sender.SendAsync(notification);
// If we reach here, the method completed without throwing
}
For void-returning async methods, simply awaiting them is often sufficient — if the method throws, the test fails. If you need to verify side effects, combine with a mock:
[Fact]
public async Task SendNotification_ValidUser_CallsEmailService()
{
var emailService = Substitute.For<IEmailService>();
var sender = new NotificationSender(emailService);
await sender.SendAsync(new Notification("[email protected]", "Hello"));
await emailService.Received(1)
.SendEmailAsync("[email protected]", Arg.Any<string>(), Arg.Any<string>());
}
Testing Timeouts and Cancellation
Production code should respect CancellationToken. Test that it does:
[Fact]
public async Task LongRunningOperation_CancellationRequested_ThrowsOperationCancelled()
{
using var cts = new CancellationTokenSource();
cts.Cancel(); // Cancel immediately
await Assert.ThrowsAsync<OperationCanceledException>(
() => _service.ProcessAsync(cts.Token));
}
To test that an operation respects a timeout:
[Fact]
public async Task SlowOperation_Timeout_ThrowsWithinReasonableTime()
{
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => _service.ProcessLargeDatasetAsync(cts.Token));
}
ThrowsAnyAsync catches both OperationCanceledException and TaskCanceledException — the framework may throw either.
Testing Concurrent Behaviour
When you need to verify that code handles concurrent access correctly:
[Fact]
public async Task IncrementCounter_ConcurrentAccess_ProducesCorrectResult()
{
var counter = new ThreadSafeCounter();
var tasks = new List<Task>();
for (int i = 0; i < 100; i++)
{
tasks.Add(Task.Run(() => counter.IncrementAsync()));
}
await Task.WhenAll(tasks);
Assert.Equal(100, counter.Value);
}
Testing IAsyncEnumerable
.NET's async streams need specific patterns:
[Fact]
public async Task GetProducts_ReturnsAllProducts()
{
var products = new List<Product>();
await foreach (var product in _repository.GetAllAsync())
{
products.Add(product);
}
Assert.Equal(5, products.Count);
}
Or more concisely with LINQ:
[Fact]
public async Task GetProducts_ReturnsAllProducts()
{
var products = await _repository.GetAllAsync().ToListAsync();
Assert.Equal(5, products.Count);
}
Testing Events and Callbacks
When async code triggers events or callbacks:
[Fact]
public async Task FileProcessor_CompletesProcessing_RaisesCompletedEvent()
{
var tcs = new TaskCompletionSource<bool>();
_processor.Completed += (sender, args) =>
{
tcs.SetResult(true);
};
await _processor.ProcessAsync("test-file.csv");
var completed = await Task.WhenAny(tcs.Task, Task.Delay(5000));
Assert.Equal(tcs.Task, completed); // Ensure the event fired, not the timeout
}
The TaskCompletionSource pattern bridges callback-based APIs with async/await, letting you await an event with a timeout.
Mocking Async Methods
When setting up mocks for async interfaces:
// NSubstitute
var repo = Substitute.For<IUserRepository>();
repo.GetByIdAsync(1).Returns(new User { Id = 1, Name = "Alice" });
repo.GetByIdAsync(999).ThrowsAsync(new NotFoundException());
// Moq
var mock = new Mock<IUserRepository>();
mock.Setup(r => r.GetByIdAsync(1))
.ReturnsAsync(new User { Id = 1, Name = "Alice" });
mock.Setup(r => r.GetByIdAsync(999))
.ThrowsAsync(new NotFoundException());
Both ReturnsAsync and ThrowsAsync create a completed task with the specified result, so the mock responds synchronously — which is fine for unit tests and keeps them fast.
Key Takeaways
- Always use
async Tasktest methods, neverasync void - Use
ThrowsAsyncfor exception assertions, notThrows - Test cancellation token support explicitly
- Use
TaskCompletionSourceto bridge events and callbacks - Keep timeouts in tests reasonable — a test that waits 30 seconds for a timeout is a slow test