Test Behavior Over Time
Test what your job does across time — delays, retries and backoff, recurring schedules, and dependency releases — by advancing a clock you control instead of waiting on the wall clock.
Most of what you want to prove about a background job is a question about time: does it fire two days from now, does it retry after a failure, does the nightly schedule mint a job every night, does the child run once its parent finishes. Testing any of these by waiting on a real clock would be slow and flaky. Instead you advance a clock your test owns, and years of schedule activity, retry backoff, and Lease expiry collapse into milliseconds, deterministically. This page is the recipe book for those time-based behaviors. It builds on the loop from Write Your First Test; start there if you have not.
The clock only moves when you move it#
BackWaveHarness exposes the current virtual instant as Now. It starts at the harness's configured start time and stays there until you call AdvanceAsync. Nothing in the runtime ever consults the wall clock, so Now is the single source of "when" for the whole test: enqueues are stamped against it, due times are measured from it, and every assertion you make reads a world frozen at whatever instant you last advanced to.
var harness = new BackWaveHarness(BackWaveJobs.CreateRegistry(), services);
// Now sits at the configured StartTime and does not move on its own.
var jobId = await harness.EnqueueAsync(new SendInvoice("order-42"), delay: TimeSpan.FromHours(6));
Assert.Equal(harness.Now, harness.Now); // still StartTime; nothing has advancedBecause Now is frozen between advances, a due time you compute in the test is exact. EnqueueAsync's delay is measured relative to the harness's current virtual Now, not the real clock, so the job above becomes due at exactly StartTime + 6h. Enqueue again after an AdvanceAsync and the new delay is relative to the already-advanced clock, not the original start.
StartTime#
The instant the clock starts at is BackWaveHarnessOptions.StartTime, and it defaults to midnight UTC on 1 January 2026. A fixed instant is what keeps tests reproducible: a schedule that fires "daily at 3am" lands on the same computable instants every run, so you can assert against them directly instead of against a moving DateTimeOffset.UtcNow.
var harness = new BackWaveHarness(BackWaveJobs.CreateRegistry(), services, new BackWaveHarnessOptions
{
StartTime = new DateTimeOffset(2026, 3, 1, 0, 0, 0, TimeSpan.Zero),
});What "due" means, and what gets processed#
When the clock reaches an instant, the harness's internal Shell loop drains everything that is due at that instant before the clock is allowed to move any further. "Due" is a small, closed set of kinds:
| Due kind | What becomes runnable |
|---|---|
| Scheduled enqueue | A job whose due time (immediate or delayed) has arrived. |
| Retry backoff | A failed Attempt re-scheduled to run again once its backoff has elapsed. This is just a job back in the Scheduled state with a future due time. |
| Lease expiry | A job whose worker Lease lapsed, making it claimable again. |
| Recurring tick | The next cron occurrence of a Recurring Schedule, which mints a fresh Scheduled job. |
| Released Dependency | A child whose parent set reached a terminal state, releasing its Dependency latch. |
The first four are instants the clock actually jumps to. The fifth is different, and the distinction matters when you reason about ordering. A released Dependency is not an instant of its own that the harness watches for. A child job's latch is released as a synchronous side effect of applying its parent's terminal outcome, at whatever instant the parent's own due time or Lease expiry already brought the clock to. So the ordered set of "things that make an instant interesting" is due time, Lease expiry, and Recurring tick; a retry's re-scheduling and a Dependency's release are consequences computed and applied within the processing of one of those instants, not separate stops. See Dependencies and Job Lifecycle for the state machine behind those two.
Everything due at one instant is fully processed, in order, before the clock advances. That ordering is what makes the harness deterministic: a retry that comes due at the same instant as a scheduled enqueue is resolved against a defined sequence, not a race.
RunDueAsync vs AdvanceAsync#
Two methods drive time, and the difference between them is whether the clock moves at all.
RunDueAsync() is an "advance by zero". It drains everything already due at the current Now without moving the clock. Use it right after enqueuing due-now work, when you want that work to run before you assert but you have no reason to move time forward yet.
var jobId = await harness.EnqueueAsync(new SendInvoice("welcome"));
await harness.RunDueAsync(); // runs it at the current instant; harness.Now is unchanged
var job = await harness.Monitor.GetJobAsync(jobId);
Assert.Equal(JobState.Succeeded, job!.State);AdvanceAsync(duration) moves the clock forward and stops at every due instant in between. Given a duration, it:
- Drains anything already due at the current
Now. - Computes a target of
Now + duration. - Repeatedly jumps
Nowto the next due instant, as long as that instant is at or before the target, fully draining each one before looking for the next. - Sets
Nowto the target exactly and drains once more, so any time-based effect scheduled for precisely the target instant (a retention purge, say) still fires even if nothing else is due there.
You never hand AdvanceAsync the exact instant of the thing you are testing. You give it a window large enough to contain it, and the loop discovers the intervening due instants itself. Advancing by a negative duration throws ArgumentOutOfRangeException: Virtual Time only moves forward.
Testing a retry without waiting for the backoff#
A failing handler is re-scheduled by the RetryPolicy. The default backs a job off 2^attempt seconds (capped at five minutes) before the next Attempt, up to ten attempts, after which the job is Dead-Lettered instead of retried. In real time you would wait seconds or minutes between attempts. In Virtual Time you advance a window that contains the backoff and let the harness step to the retry instant for you.
var jobId = await harness.EnqueueAsync(new SendInvoice("flaky-order"));
// The handler throws on attempt 1; the default RetryPolicy backs it off two
// seconds before attempt 2. One AdvanceAsync call steps the clock to that
// backoff instant, runs the retry, and lands past it, with no real waiting.
await harness.AdvanceAsync(TimeSpan.FromMinutes(1));
var job = await harness.Monitor.GetJobAsync(jobId);
Assert.Equal(JobState.Succeeded, job!.State);
Assert.Equal(2, job.Attempt);The advance was a full minute, but the retry's backoff instant sat two seconds in. AdvanceAsync found that instant, ran the retry there, and continued on to the target. The size of the window does not change when the retry runs, only that it is contained.
To make retry timing short and predictable, or to force a Dead-Letter on the first failure, override the policy through BackWaveHarnessOptions:
var harness = new BackWaveHarness(BackWaveJobs.CreateRegistry(), services, new BackWaveHarnessOptions
{
RetryPolicy = new RetryPolicy { MaxAttempts = 1, Backoff = _ => TimeSpan.FromMinutes(1) },
});A one-attempt policy means the first thrown Attempt exhausts the ceiling and the job Dead-Letters immediately, which is the fast way to reach a terminal failure state without stepping through the default ten-attempt ladder.
Ticking a Recurring Schedule across a year in one call#
A Recurring Schedule mints a Scheduled job at each cron occurrence. Its first tick is computed from the current virtual instant, so only future ticks mint; advancing the clock runs each intervening tick in order. Because every tick is an instant AdvanceAsync stops at, a single call can run a year of daily ticks, each one discovered, minted, and executed, in well under a second of real wall-clock time.
await harness.UpsertRecurringAsync("nightly-invoice", Cron.Daily(hour: 3), new SendInvoice("nightly"));
// A year of daily 3am ticks, each discovered and minted in order, in one call.
await harness.AdvanceAsync(TimeSpan.FromDays(365));
var depths = await harness.Monitor.GetQueueDepthsAsync();
Assert.Equal(new QueueStateCount("default", JobState.Succeeded, 365), Assert.Single(depths));
var schedule = Assert.Single(await harness.Monitor.ListSchedulesAsync());
Assert.Equal(harness.Now.Date.AddHours(3), schedule.NextDue!.Value.UtcDateTime);Cron.Daily, Cron.Hourly, Cron.Weekly, Cron.Monthly, Cron.EveryMinute, and Cron.EveryMinutes are convenience builders; CronExpression.Parse takes a raw 5- or 6-field cron string when you need one. After the advance, ScheduleStatus.NextDue from the Monitor's ListSchedulesAsync is the next tick that will mint past the current Now, the field to assert against to prove the schedule is positioned where you expect. The Recurring Schedules guide covers Catch-Up and No-Overlap policies.
Stepping across a Dependency release#
Because a Dependency latch releases inside the same instant its parent goes terminal, a partial advance that does not reach the parent's due time leaves the child untouched. Only the advance that reaches the parent runs the child.
var parent = await harness.EnqueueAsync(new SendInvoice("parent"), delay: TimeSpan.FromHours(1));
var child = await harness.EnqueueDependencyAsync(new SendInvoice("child"), parent);
// Half an hour in, the parent is not due yet, so the child is still waiting.
await harness.AdvanceAsync(TimeSpan.FromMinutes(30));
Assert.Equal(JobState.AwaitingParent, (await harness.Monitor.GetJobAsync(child))!.State);
// Reaching the parent's due time runs it; its terminal outcome releases the latch
// in the same instant, and the child runs before the clock moves on.
await harness.AdvanceAsync(TimeSpan.FromHours(1));
Assert.Equal(JobState.Succeeded, (await harness.Monitor.GetJobAsync(child))!.State);The child stays AwaitingParent through the first, partial advance and only becomes due once the second advance reaches the parent. That is why the release is a same-instant consequence rather than a separate stop the clock has to find.
Retention and the terminal clock#
Retention is off by default so your assertions can see a job's whole history. Turn it on through BackWaveHarnessOptions.Retention to test purge timing, and remember that the retention clock for a job starts at the instant it became terminal, not at enqueue time.
var harness = new BackWaveHarness(BackWaveJobs.CreateRegistry(), services, new BackWaveHarnessOptions
{
Retention = RetentionPolicy.Default, // keeps Succeeded 24h, Dead-Lettered 14 days
});Purges are one of the time-based effects the final drain of AdvanceAsync guarantees: even if a purge instant is exactly the advance target and nothing else is due there, the trailing drain applies it. So a job that becomes terminal, then sits past its keep window as you advance, is gone by the time you assert, with no separate call needed. The operational side of this lives in Retention & Purge.
Where to go next#
- Write Your First Test: the harness, the In-Memory Store, and how a test is wired up.
- Assert on What Happened: reading job state, attempt count, history, and outputs through the Monitor API.
- Job Lifecycle: the states behind Scheduled, Leased, AwaitingParent, and Dead-Lettered.
- Scheduling: how Recurring Schedules mint jobs and how cursors advance.
- Recurring Schedules guide: defining schedules, Catch-Up, and No-Overlap end to end.