Test-clock control APIs (testing)
PrimeTestClock exposes the same control verbs in both stacks — they differ only in the time types each one uses (BCL DateTimeOffset / TimeSpan vs NodaTime Instant / Duration).
| Verb |
Purpose |
SetTime / SetInstant |
Set virtual UTC time; forward targets march through due delays, expiries, and timers; backward targets follow the rules below. |
SetLocalTime |
Set virtual time from a local wall-clock value (lenient DST mapping); same forward/backward semantics as SetTime after resolving to UTC. |
Advance |
Step virtual time forward by marching to each due instant; callbacks see that instant in UtcNow / NowInstant. |
RunFor |
Start a bounded automatic runner that advances virtual time until a stop horizon, then stops and raises lifecycle events. |
Start / Stop |
Toggle the deadline-driven automatic runner; IsRunning reflects the state. |
System Clock stack
/// <summary>
/// Verifies <see cref="IPrimeTestClock.SetTime(System.DateTimeOffset)"/> moves the virtual UTC
/// timeline used by <see cref="IPrimeClock.UtcNowDateTimeOffset"/>.
/// </summary>
[Fact]
public void TestClock_SetTime_Advance_UpdatesUtcNow ()
{
DateTimeOffset start = new(2025, 1, 1, 12, 0, 0, TimeSpan.Zero);
IPrimeTestClock clock = new PrimeTestClock(start);
clock.SetTime(new DateTimeOffset(2025, 6, 1, 8, 0, 0, TimeSpan.Zero));
clock.Advance(TimeSpan.FromHours(3));
clock.UtcNowDateTimeOffset.Should().Be(new DateTimeOffset(2025, 6, 1, 11, 0, 0, TimeSpan.Zero));
}
/// <summary>
/// Verifies <see cref="IPrimeTestClock.RunFor(System.TimeSpan,System.TimeSpan)"/> starts a bounded
/// automatic run and returns immediately, with the caller waiting for completion by observing
/// <see cref="IPrimeTestClock.ClockEvents"/> for <see cref="PrimeTestClockEventType.ClockStopped"/>.
/// </summary>
[Fact]
public void TestClock_RunFor_TimeSpan_WaitsForBoundedCompletion ()
{
DateTimeOffset start = new(2025, 2, 1, 0, 0, 0, TimeSpan.Zero);
IPrimeTestClock clock = new PrimeTestClock(start);
TimeSpan step = TimeSpan.FromMinutes(40);
using ManualResetEventSlim stoppedSignal = new(initialState: false);
clock.ClockEvents += OnClockEvent;
try
{
bool started = clock.RunFor(step, TimeSpan.FromHours(1));
started.Should().BeTrue();
bool stopped = stoppedSignal.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
stopped.Should().BeTrue("bounded RunFor should publish ClockStopped before timeout");
clock.IsRunning.Should().BeFalse("clock should not remain running after ClockStopped");
}
finally
{
clock.ClockEvents -= OnClockEvent;
}
clock.UtcNowDateTimeOffset.Should().Be(start + step);
return;
void OnClockEvent (object? _, PrimeTestClockEvent clockEvent)
{
if (clockEvent.EventType == PrimeTestClockEventType.ClockStopped)
// ReSharper disable once AccessToDisposedClosure
stoppedSignal.Set();
}
}
/// <summary>
/// Verifies <see cref="IPrimeTestClock.Start(System.TimeSpan?)"/> toggles
/// <see cref="IPrimeTestTime.IsRunning"/> and <see cref="IPrimeTestClock.Stop"/> stops automatic advancement.
/// </summary>
[Fact]
public void TestClock_StartStop_TogglesIsRunning ()
{
IPrimeTestClock clock = new PrimeTestClock(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
clock.IsRunning.Should().BeFalse();
clock.Start(TimeSpan.FromSeconds(1));
clock.IsRunning.Should().BeTrue();
clock.Stop().Should().BeTrue();
clock.IsRunning.Should().BeFalse();
}
PrimeTime (NodaTime) stack
/// <summary>
/// Verifies <see cref="IPrimeTestClock.SetInstant"/> moves the virtual UTC timeline used by
/// <see cref="IPrimeClock.NowInstant"/>.
/// </summary>
[Fact]
public void TestClock_SetInstant_Advance_UpdatesNowInstant ()
{
Instant start = Instant.FromUtc(2025, 1, 1, 12, 0, 0);
IPrimeTestClock clock = new PrimeTestClock(start);
clock.SetInstant(Instant.FromUtc(2025, 6, 1, 8, 0, 0));
clock.Advance(Duration.FromHours(3));
clock.NowInstant.Should().Be(Instant.FromUtc(2025, 6, 1, 11, 0, 0));
}
/// <summary>
/// Verifies <see cref="IPrimeTestClock.SetLocalTime"/> resolves wall-clock times in the clock's zone,
/// including lenient handling when the wall clock falls in a spring-forward gap.
/// </summary>
[Fact]
public void TestClock_SetLocalTime_LenientSpringGap_ResolvesToValidZonedTime ()
{
DateTimeZone eastern = NodaDstScenarioFixture.UsEastern;
LocalDateTime gapWall = new(2024, 3, 10, 2, 30);
ZonedDateTime lenient = eastern.AtLeniently(gapWall);
IPrimeTestClock clock = new PrimeTestClock(lenient.ToInstant(), eastern);
clock.SetLocalTime(gapWall);
clock.LocalZonedNowInstant.LocalDateTime.Should().Be(lenient.LocalDateTime);
}
/// <summary>
/// Verifies <see cref="IPrimeTestClock.RunFor(Duration,Duration)"/> starts a bounded automatic run and
/// returns immediately, with the caller waiting for completion by observing
/// <see cref="IPrimeTestClock.ClockEvents"/> for <see cref="PrimeTestClockEventType.ClockStopped"/>.
/// </summary>
[Fact]
public void TestClock_RunFor_Duration_WaitsForBoundedCompletion ()
{
Instant start = Instant.FromUtc(2025, 2, 1, 0, 0, 0);
IPrimeTestClock clock = new PrimeTestClock(start);
Duration step = Duration.FromMinutes(40);
using ManualResetEventSlim stoppedSignal = new(initialState: false);
clock.ClockEvents += OnClockEvent;
try
{
bool started = clock.RunFor(step, Duration.FromHours(1));
started.Should().BeTrue();
bool stopped = stoppedSignal.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
stopped.Should().BeTrue();
}
finally
{
clock.ClockEvents -= OnClockEvent;
}
clock.NowInstant.Should().Be(start + step);
return;
void OnClockEvent (object? _, PrimeTestClockEvent clockEvent)
{
if (clockEvent.EventType == PrimeTestClockEventType.ClockStopped)
// ReSharper disable once AccessToDisposedClosure
stoppedSignal.Set();
}
}
/// <summary>
/// Verifies <see cref="IPrimeTestClock.Start(NodaTime.Duration?)"/> toggles
/// <see cref="IPrimeTestTime.IsRunning"/> and <see cref="IPrimeTestClock.Stop"/> stops automatic advancement.
/// </summary>
[Fact]
public void TestClock_StartStop_TogglesIsRunning ()
{
IPrimeTestClock clock = new PrimeTestClock(Instant.FromUtc(2025, 1, 1, 0, 0, 0));
clock.IsRunning.Should().BeFalse();
clock.Start(Duration.FromSeconds(1));
clock.IsRunning.Should().BeTrue();
clock.Stop().Should().BeTrue();
clock.IsRunning.Should().BeFalse();
}
Forward marching (Advance, forward SetTime / SetInstant / SetLocalTime)
- Virtual time moves to the earliest due instant in the open interval toward the target, dispatches all work due at that instant (delays complete, time expiries cancel, interval and day-time timers fire), then repeats until the horizon is reached.
- Timer callbacks observe
UtcNowDateTimeOffset / NowInstant equal to that firing instant, not only the final horizon.
ClockEvents fires once per distinct virtual instant visited during the march.
- Negative
Advance duration is treated as zero (no backward move).
Automatic runner (Start / Stop / RunFor)
Start(perSecondRate) runs a background loop that waits for the next virtual deadline among pending delays, time expiries, timers, and a virtual one-minute ClockEvents heartbeat when no other work is due. Virtual delay is mapped to real time using perSecondRate (virtual time per real second).
- Run rate must be between 100 ms and 1 hour of virtual time per real second (inclusive), or
ArgumentOutOfRangeException is thrown. null means 1:1.
- The runner does not poll every real second. Intended real sleeps shorter than 15 ms are skipped in favor of in-process virtual bursts until the next sleep would be at least 15 ms.
- After a real wait, virtual catch-up uses measured anchor elapsed time × rate (oversleep produces extra virtual progress), not only the intended timeout.
- Registering new delays, timers, or time expiries, cancelling work,
Stop, or a persist-on-read from a "now" getter can wake the runner so sooner deadlines are not missed.
Stop() stops the runner, clears the anchor, and returns true if the clock was running. "Now" getters then return the persisted instant only (no projection).
RunFor(duration) starts a bounded run at a default 1:1 rate and returns immediately.
RunFor(duration, perSecondRate) starts a bounded run at the supplied rate and returns true when the run starts, or false when the clock is already running.
RunFor uses virtual duration (negative treated as zero); completion raises ClockStopped when the stop horizon is reached.
- To wait for bounded completion in tests, observe
IsRunning and/or subscribe to ClockStopped.
"Now" while running (projection and persist-on-read)
- While
Start() is active, UtcNowDateTimeOffset, LocalNow*, and Noda NowInstant / related members project linearly from the last committed virtual instant and a shared Stopwatch anchor at the run rate.
- Reading "now" also persists: due work up to the projected instant is marched, the committed instant is stored, and the anchor restarts (the runner is signaled to recompute). Frequent reads can be expensive — prefer explicit
Advance / RunFor when you do not need projection.
- See also Timers, daylight saving, and testing.
Backward moves
| State |
Backward SetTime / SetInstant / SetLocalTime |
| Running |
InvalidOperationException |
| Stopped, any active interval timer |
InvalidOperationException |
| Stopped, no active interval timers |
Instant jump, one ClockEvents, no replay of skipped delay/timer work; day-time timers recompute NextDueUtc (at most one callback per discrete time-of-day occurrence) |
What to notice (both stacks)
- Use
Advance or forward SetTime when you need intermediate timer and delay callbacks on the path to a target instant.
SetLocalTime maps a local wall time in the clock's zone; spring-forward gaps and fall-back overlaps use lenient mapping consistent with production day-time scheduling.
Advance is a synchronous deterministic march, while RunFor is a non-blocking bounded automatic runner.