Unit Testing
async/await
NUnit
Software Testing
C# Programming

Unit Testing with async/wait methods using NUnit

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Async code should be tested asynchronously. In NUnit, that means test methods should normally return Task, await the code under test, and use async-aware assertion patterns instead of blocking with .Wait() or .Result.

This is not just style. Correct async tests avoid deadlocks, surface exceptions properly, and reduce the timing flakiness that appears when tests guess with Task.Delay instead of controlling completion explicitly.

The Basic NUnit Pattern

An async NUnit test should return Task, not void.

csharp
1using System.Threading.Tasks;
2using NUnit.Framework;
3
4public class MathService
5{
6    public async Task<int> AddAsync(int a, int b)
7    {
8        await Task.Delay(10);
9        return a + b;
10    }
11}
12
13[TestFixture]
14public class MathServiceTests
15{
16    [Test]
17    public async Task AddAsync_ReturnsSum()
18    {
19        var service = new MathService();
20
21        var result = await service.AddAsync(2, 3);
22
23        Assert.That(result, Is.EqualTo(5));
24    }
25}

Returning Task lets NUnit observe completion and capture failures correctly.

Test Exceptions With Assert.ThrowsAsync

If an async method is expected to fail, use NUnit’s async exception helper instead of blocking the task.

csharp
1using System;
2using System.Threading.Tasks;
3using NUnit.Framework;
4
5public class FailingService
6{
7    public async Task LoadAsync()
8    {
9        await Task.Delay(10);
10        throw new InvalidOperationException("boom");
11    }
12}
13
14[TestFixture]
15public class FailingServiceTests
16{
17    [Test]
18    public void LoadAsync_ThrowsInvalidOperationException()
19    {
20        var service = new FailingService();
21
22        Assert.ThrowsAsync<InvalidOperationException>(async () =>
23        {
24            await service.LoadAsync();
25        });
26    }
27}

That keeps the whole test aligned with the async control flow instead of forcing synchronous waiting into the path.

Avoid .Result and .Wait()

Blocking on async code is one of the easiest ways to create misleading tests.

Bad patterns:

csharp
// var result = service.AddAsync(2, 3).Result;
// service.AddAsync(2, 3).Wait();

These may appear to work on one machine and then hang or behave differently under another synchronization context. The right rule is simple: if the production method is async, let the test stay async all the way through.

Mock Async Dependencies Correctly

If the code under test depends on async collaborators, set up those collaborators to return tasks directly.

csharp
1using System.Threading.Tasks;
2using Moq;
3using NUnit.Framework;
4
5public interface IUserRepository
6{
7    Task<string> GetNameAsync(int id);
8}
9
10[TestFixture]
11public class RepositoryTests
12{
13    [Test]
14    public async Task RepositoryMock_ReturnsExpectedValue()
15    {
16        var repo = new Mock<IUserRepository>();
17        repo.Setup(r => r.GetNameAsync(42)).ReturnsAsync("Ada");
18
19        var result = await repo.Object.GetNameAsync(42);
20
21        Assert.That(result, Is.EqualTo("Ada"));
22    }
23}

This keeps the test arrangement consistent with the real method signatures.

Make Async Tests Deterministic

If a test depends on “wait 100 ms and hope the background work finished,” it is already fragile. Prefer explicit completion signals such as TaskCompletionSource or controlled fake dependencies.

csharp
1using System.Threading.Tasks;
2using NUnit.Framework;
3
4[TestFixture]
5public class CoordinationTests
6{
7    [Test]
8    public async Task TaskCompletionSource_CanDriveAsyncCompletion()
9    {
10        var tcs = new TaskCompletionSource<int>();
11
12        Task<int> work = Task.Run(async () =>
13        {
14            await Task.Yield();
15            return await tcs.Task;
16        });
17
18        tcs.SetResult(99);
19        var result = await work;
20
21        Assert.That(result, Is.EqualTo(99));
22    }
23}

That is much more robust than guessing with arbitrary delays.

Use Timeouts Carefully

Timeouts are useful as guardrails when a task might hang, but they should not be your primary synchronization strategy.

csharp
1using System.Threading.Tasks;
2using NUnit.Framework;
3
4[TestFixture]
5public class TimeoutTests
6{
7    [Test, Timeout(2000)]
8    public async Task SlowOperation_CompletesInTime()
9    {
10        await Task.Delay(50);
11        Assert.Pass();
12    }
13}

A timeout should catch abnormal hangs, not paper over uncertain test sequencing.

Common Pitfalls

A common mistake is writing async void tests. NUnit can handle Task, not arbitrary fire-and-forget async void behavior.

Another issue is using .Result or .Wait() and then blaming NUnit when the test hangs. The blocking pattern is the real problem.

Developers also often rely on Task.Delay to “let the background thread catch up.” That creates nondeterministic tests that fail intermittently on CI.

Finally, avoid sharing mutable fixtures across parallel async tests unless you have explicit synchronization. Async tests do not remove concurrency risks.

Summary

  • Write async NUnit tests as async Task, not async void.
  • Await the code under test instead of blocking with .Result or .Wait().
  • Use Assert.ThrowsAsync for exception paths.
  • Mock async dependencies with task-returning setups such as ReturnsAsync.
  • Prefer deterministic completion control over arbitrary sleeps.

Course illustration
Course illustration

All Rights Reserved.