Testing Background Services and Loops with AutoResetEvent
Updates:
-
April 2022: I no longer recommend this as a good way to test background services due to its complexity and invasiveness. These days I recommend waiting for side effects to occur from the outside. I’m leaving this post up as a record of what I did do at one point. Thanks for reading!
-
February 2019 - Fixed a bug with ManualResetAwaiter in which it wouldn’t accurately wait for loops to wrap around. I’ve reworked it slightly to reduce the number of cases it needs to handle.
A common use case for for .NET Core 2.1’s BackgroundService
(or its IHostedService interface) is to run a loop that waits for some work to do, and then sleeps for a little while. It might look something like this, using a contrived example of incrementing numbers:
While complex work should usually be kept somewhere else outside of the loop, it’s possible that things might get hairy enough that some tests might help us out.
This presents a problem, as there’s no way to control when the loop continues. It might be possible to control the wait duration, but that could lead to unpredictable tests. What we really want is a way to control when the loop continues from the outside.
Enter AutoResetEvent
The .NET Framework has a synchronisation primitive called AutoResetEvent
that can act as a handle on a loop. Instead of calling await Task.Delay(TimeSpan)
, we can call _autoResetEvent.WaitOne()
, which then allows us to decide when to continue the loop by calling _autoResetEvent.Set()
:
This could be useful for our tests, but first lets put it behind an interface so we can switch the behaviour and make it a bit nicer.
Next lets implement a timed version that will run for reals with Task.Delay
:
And then a version that we’ll use for testing that allows us to await
until the service continues execution and reaches the Wait
call again. The Progress(TimeSpan)
method will return false if the work doesn’t complete before the timeout, which we can assert on.
We can now update our worker implementation to use this new abstraction:
At runtime, a TimedResetAwaiter can be passed in as a constructor argument via DI or new
, and for testing we can pass in a ManualResetAwaiter that gives us control over the loop operation.
The Test
And now we can write a test that looks like this:
Conclusion
This technique can be useful for controlling any loop that depends on waiting between periods of work.
Pulling out the wait to its own abstraction means that you could also substitute more advanced wait strategies without affecting the accompanying tests.