Back in 2015 I wrote a post on unit testing events and callbacks in C#. It covered a real problem: how do you test something that doesn’t return a value, but eventually fires a callback or raises an event?
11 years later, I’m fiddling with a little project at home that uses lots of programming languages, and I’m cringing at some of the things I wrote back than. In fairness, some of it was the best practice at the time. But some of it was just wrong, and I didn’t know it.
I figured a post that revisits the original and compares it to modern best practices would be useful.
The approach I used worked, but looking back at it now there are things that were outdated the moment they were written, and things that have since been overtaken by improvements in C# and the testing frameworks. This post covers both.
What Was Wrong From The Start
The original post used async void for the methods being tested.
public async void DoSomethingThatCallsBackEventually(string str, Action<string> callback) {
var s = await LongRunningOperation(str);
callback(s);
}This was a mistake that I didn’t flag. When an async void method throws an exception, there’s no way to catch it. It bypasses the test entirely and goes straight to the SynchronizationContext, which in most cases means it crashes the process. Your test won’t fail. It’ll just disappear.
The fix is simple. Use async Task instead.
public async Task DoSomethingThatCallsBackEventually(string str, Action<string> callback) {
var s = await LongRunningOperation(str);
callback(s);
}async void has one legitimate use: event handlers, where the signature is fixed. Everywhere else, it’s a trap.
The post also used this pattern to raise events.
if (SomethingHappened != null)
SomethingHappened(this, str + str);C# 6, released the same year as that post, gave us a better way.
SomethingHappened?.Invoke(this, str + str);The null-check version has a race condition. Between checking for null and invoking, another thread can unsubscribe. ?.Invoke is atomic and is the right pattern.
What Changed
The original post used AutoResetEvent to wait for async callbacks in tests.
[Test]
public void TestEventualCallback() {
AutoResetEvent _autoResetEvent = new AutoResetEvent(false);
var actual = string.Empty;
var aClass = new AClass();
aClass.DoSomethingThatCallsBackEventually("A", (s) => { actual = s; _autoResetEvent.Set(); });
Assert.IsTrue(_autoResetEvent.WaitOne());
Assert.AreEqual("DelayedA", actual);
}This wasn’t wrong exactly. At the time, NUnit didn’t support async test methods, so you had no way to await inside a test. AutoResetEvent was a reasonable workaround for that limitation.
There was one genuine bug though. WaitOne() with no timeout will block forever if the callback is never called. The test hangs. The build hangs. If the async void method threw an exception, you’d never know. You’d just be waiting. The fix was to pass a timeout: WaitOne(5000).
That limitation in NUnit was fixed long ago. NUnit 3, xUnit, and MSTest all support async test methods natively. You can await directly in a test. The AutoResetEvent workaround is no longer needed.
How To Do It Now
The modern replacement for AutoResetEvent in this context is TaskCompletionSource<T>. It bridges the gap between callback-style code and async/await.
[Test]
public async Task TestEventualCallback() {
var tcs = new TaskCompletionSource<string>();
var sw = Stopwatch.StartNew();
var aClass = new AClass();
var _ = aClass.DoSomethingThatCallsBackEventually("A", s => tcs.SetResult(s));
sw.Stop();
Assert.Less(sw.ElapsedMilliseconds, 500);
var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(5)));
Assert.That(completed == tcs.Task, "Callback was not called within 5 seconds");
Assert.AreEqual("DelayedA", await tcs.Task);
}TaskCompletionSource gives you a Task that you control. You resolve it from inside the callback by calling SetResult. The test can then await it like any other task.
The Task.WhenAny with a Task.Delay gives you the timeout that WaitOne() was missing. If the callback never fires, the test fails with a clear message rather than hanging forever.
The same approach works for events.
[Test]
public async Task TestEventualEvent() {
var tcs = new TaskCompletionSource<string>();
var sw = Stopwatch.StartNew();
var aClass = new AClass();
aClass.SomethingHappened += (_, s) => tcs.SetResult(s);
var _ = aClass.DoSomethingThatFiresAnEventEventually("A");
sw.Stop();
Assert.Less(sw.ElapsedMilliseconds, 500);
var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(5)));
Assert.That(completed == tcs.Task, "Event was not raised within 5 seconds");
Assert.AreEqual("DelayedA", await tcs.Task);
}And testing that something times out (where the original used WaitOne(1500)) becomes:
[Test]
public async Task TestEventualEventTimesOut() {
var tcs = new TaskCompletionSource<string>();
var aClass = new AClass();
aClass.SomethingHappened += (_, s) => tcs.SetResult(s);
var sw = Stopwatch.StartNew();
var _ = aClass.DoSomethingThatFiresAnEventEventually("A");
sw.Stop();
Assert.Less(sw.ElapsedMilliseconds, 500);
var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromMilliseconds(1500)));
Assert.That(completed != tcs.Task, "Expected event not to fire within 1500ms");
}The Short Version
If you’re writing code today:
- Use
async Task, neverasync void(outside of event handlers) - Use
?.Invoke()to raise events - Write your test methods as
async Task - Use
TaskCompletionSource<T>to bridge callbacks into the async world - Always add a timeout when waiting.
Task.WhenAnywith aTask.Delayis the cleanest way to do it.