PeriodicTimer
MockTimeSystem.PeriodicTimer wraps System.Threading.PeriodicTimer (IPeriodicTimer). It's the async pull-based counterpart to Timer: instead of registering a callback and waiting for it to fire, your code awaits WaitForNextTickAsync() inside a loop.
ITimeSystem.PeriodicTimer is only available on .NET 8 and later.
Typical use case: a hosted/background service that does work on a fixed cadence and needs to be testable without actually waiting that long, like a night guard who walks a round through the gallery every 30 minutes:
public class NightGuard(ITimeSystem timeSystem, IFileSystem fileSystem)
{
public async Task PatrolAsync(CancellationToken cancellationToken)
{
using IPeriodicTimer timer = timeSystem.PeriodicTimer.New(TimeSpan.FromMinutes(30));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
fileSystem.File.AppendAllText("guard-rounds.log", $"{timeSystem.DateTime.UtcNow:o}\n");
}
}
}
Driving the clock from a test
To assert exactly how often the loop ran, turn auto-advance off and advance the clock by hand. WaitForNextTickAsync then stays pending until the simulated time reaches the next tick.
On.PeriodicTimer.WaitingForNextTick() fires every time the guard calls WaitForNextTickAsync, so waiting for it tells the test that the guard finished the previous round and is waiting for the next one:
MockTimeSystem timeSystem = new(o => o.DisableAutoAdvance());
MockFileSystem fileSystem = new(o => o.UseTimeSystem(timeSystem));
NightGuard guard = new(timeSystem, fileSystem);
using CancellationTokenSource cts = new();
using IAwaitableCallback<IPeriodicTimer> waiting =
timeSystem.On.PeriodicTimer.WaitingForNextTick();
Task patrol = guard.PatrolAsync(cts.Token);
// The guard is waiting for the first round; nothing was logged yet
waiting.Wait();
await That(fileSystem.File.Exists("guard-rounds.log")).IsFalse();
timeSystem.TimeProvider.AdvanceBy(TimeSpan.FromMinutes(30));
waiting.Wait();
await That(fileSystem.File.ReadAllLines("guard-rounds.log")).HasCount(1);
cts.Cancel();
await That(async () => await patrol).Throws<OperationCanceledException>();
Like the real PeriodicTimer, advancing the clock by several periods at once results in a single tick, not one tick per elapsed period.
WaitingForNextTick accepts an optional predicate to filter, which is useful when multiple periodic timers run in the same test and you only want to wait for one specific period.
With auto-advance
With auto-advance on (the default), every WaitForNextTickAsync returns immediately and advances the simulated clock by one Period, so the loop runs as fast as it can until it is cancelled. That is useful to check that the loop does its work, but not to count iterations:
MockTimeSystem timeSystem = new();
MockFileSystem fileSystem = new(o => o.UseTimeSystem(timeSystem));
NightGuard guard = new(timeSystem, fileSystem);
using CancellationTokenSource cts = new();
using IAwaitableCallback<IPeriodicTimer> waiting =
timeSystem.On.PeriodicTimer.WaitingForNextTick();
Task patrol = guard.PatrolAsync(cts.Token);
waiting.Wait(count: 5); // the guard finished at least four rounds
cts.Cancel();
await That(async () => await patrol).Throws<OperationCanceledException>();
await That(fileSystem.File.ReadAllLines("guard-rounds.log").Length)
.IsGreaterThanOrEqualTo(4);
Changing the period
IPeriodicTimer.Period is settable; the next WaitForNextTickAsync uses the new value:
using IPeriodicTimer timer = timeSystem.PeriodicTimer.New(TimeSpan.FromSeconds(1));
await timer.WaitForNextTickAsync(); // waits 1s
timer.Period = TimeSpan.FromMilliseconds(100);
await timer.WaitForNextTickAsync(); // waits 100ms
Periods below 1 ms throw ArgumentOutOfRangeException, matching the base class library (BCL).
Stopping the loop
Dispose() ends the loop the next time around: WaitForNextTickAsync returns false instead of true, so the while (await …) exits cleanly. A CancellationToken passed into WaitForNextTickAsync makes it throw an OperationCanceledException when triggered.
// graceful: dispose ends the loop on the next tick
timer.Dispose();
// abrupt: cancellation throws inside the await
cts.Cancel();