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.
Typical use case: a hosted/background service that does work on a fixed cadence - every 30 seconds, every 5 minutes - and needs to be testable without actually sleeping for that long.
public class HeartbeatWorker(ITimeSystem timeSystem, IFileSystem fileSystem)
{
public async Task RunAsync(CancellationToken cancellationToken)
{
using IPeriodicTimer timer = timeSystem.PeriodicTimer.New(TimeSpan.FromSeconds(30));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
fileSystem.File.AppendAllText("heartbeat.log", $"{timeSystem.DateTime.UtcNow:o}\n");
}
}
}
Driving the loop in a test
With auto-advance on (the default), every await timer.WaitForNextTickAsync() returns immediately and advances the simulated clock by exactly one Period. A four-iteration loop runs in microseconds and ends two minutes ahead on the simulated clock:
MockTimeSystem timeSystem = new(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
MockFileSystem fileSystem = new(o => o.UseTimeSystem(timeSystem));
HeartbeatWorker worker = new(timeSystem, fileSystem);
using CancellationTokenSource cts = new();
Task run = worker.RunAsync(cts.Token);
// Let the worker iterate four times, then stop it
await Task.Delay(50);
cts.Cancel();
try { await run; } catch (OperationCanceledException) { }
string[] lines = fileSystem.File.ReadAllLines("heartbeat.log");
await Expect.That(lines).HasCount(4);
await Expect.That(timeSystem.DateTime.UtcNow)
.IsEqualTo(new DateTime(2026, 1, 1, 0, 2, 0, DateTimeKind.Utc));
Asserting iterations with On.PeriodicTimer.WaitingForNextTick
The polling pattern above is brittle - it depends on Task.Delay letting the worker run far enough. The deterministic alternative is to subscribe to the periodic-timer notification, which fires on every WaitForNextTickAsync call:
MockTimeSystem timeSystem = new();
using IAwaitableCallback<IPeriodicTimer> ticks =
timeSystem.On.PeriodicTimer.WaitingForNextTick();
Task run = worker.RunAsync(cts.Token);
ticks.Wait(count: 4); // block until the worker has looped four times
cts.Cancel();
WaitingForNextTick accepts an optional predicate to filter - useful when multiple periodic timers run in the same test and you only want to wait for one specific period.
DisableAutoAdvance - drive the clock yourself
When you want to assert the worker actually waits between iterations (not just that it iterated), turn auto-advance off and advance the clock by hand. With auto-advance disabled, WaitForNextTickAsync blocks until the simulated time crosses lastTick + Period:
MockTimeSystem timeSystem = new(o => o.DisableAutoAdvance());
Task run = worker.RunAsync(cts.Token);
// Worker is blocked inside WaitForNextTickAsync - clock has not moved
await Expect.That(fileSystem.File.Exists("heartbeat.log")).IsFalse();
// Push the clock forward → exactly one tick fires
timeSystem.TimeProvider.AdvanceBy(TimeSpan.FromSeconds(30));
await Task.Yield();
await Expect.That(fileSystem.File.ReadAllLines("heartbeat.log")).HasCount(1);
// Push it forward by two periods → two more ticks fire
timeSystem.TimeProvider.AdvanceBy(TimeSpan.FromSeconds(60));
await Task.Yield();
await Expect.That(fileSystem.File.ReadAllLines("heartbeat.log")).HasCount(3);
Mutating the period mid-loop
Unlike the BCL on older runtimes, IPeriodicTimer.Period is settable on every framework version. 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 BCL contract.
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 raises TaskCanceledException when triggered.
// graceful: dispose ends the loop on the next tick
timer.Dispose();
// abrupt: cancellation throws inside the await
cts.Cancel();