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:

Example.cs
[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:

Example.cs
// 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:

Example.cs
[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

Example.cs
[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)

Example.cs
[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:

Example.cs
[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:

Example.cs
[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:

Example.cs
[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:

Example.cs
[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:

Example.cs
[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:

Example.cs
[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:

Example.cs
[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:

Example.cs
// 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