Assert on What Happened

How to assert on what a job did: read state, attempt count, queue depth, dependency outcomes, and transition history through the Monitor API, and inject fakes to verify side effects.


Once you have driven the harness forward in time (see Test Behavior Over Time), you need to check what actually happened. BackWave gives you one read surface for that, the Monitor API, and it is the same surface production dashboards and admin endpoints read. A harness test asserts through harness.Monitor, so the code path your assertion exercises is the code path an operator sees in production. Nothing test-only sits underneath it.

This page covers the assertions you will write most: a job Succeeded or Dead-Lettered, the Attempt count after retries, queue depth, a Dependency's released outcome, and the Transition history. It then covers the two things the Monitor deliberately does not do for you, verifying a side effect and reading a low-level record field, and where you drop down for each.

The Monitor is the assertion surface#

BackWaveHarness constructs a BackWaveMonitor over its In-Memory Store and exposes it as harness.Monitor. Every read returns a stable public shape (a JobSnapshot, a JobTransition, a QueueStateCount), and storage internals never leak through. The canonical assertion is: enqueue, advance past everything due, then read the snapshot off the Monitor.

SendInvoiceTests.cs
var jobId = await harness.EnqueueAsync(new SendInvoice("order-1"));
await harness.AdvanceAsync(TimeSpan.FromDays(3));
 
var job = await harness.Monitor.GetJobAsync(jobId);
Assert.Equal(JobState.Succeeded, job!.State);

GetJobAsync returns a nullable JobSnapshot. It is null when no job with that id exists, which includes a job that has been purged under a Retention policy, so the ! in a test is you asserting the job is still there. The snapshot carries the fields you assert on directly:

FieldTypeWhat it tells you
StateJobStateScheduled, AwaitingParent, Leased, Succeeded, Cancelled, DeadLettered, Quarantined
AttemptintExecution tries so far; starts at 1 and climbs with each retry
QueuestringThe Queue this job landed on
WireNamestringThe job type's stable identity
DueTimeDateTimeOffsetWhen it is next eligible to run
CancelRequestedboolWhether a cancel was requested
TerminalAt / TerminalCauseDateTimeOffset? / string?When and why it reached a terminal state

JobSnapshot never carries payload or output bytes by design. Those are separate Monitor calls, or a drop to the Store, both covered below.

Asserting Succeeded or Dead-Lettered#

A terminal state is the outcome most tests assert on. Succeeded is the happy path above. To assert a job is Dead-Lettered, you need a handler that fails every Attempt and a retry ceiling low enough that the test does not wait through ten backoffs. The harness defaults to RetryPolicy.Default (MaxAttempts = 10); override it in the options with a small MaxAttempts so the job dead-letters after one failure.

DeadLetterTests.cs
var harness = new BackWaveHarness(BackWaveJobs.CreateRegistry(), services,
    new BackWaveHarnessOptions { RetryPolicy = new RetryPolicy { MaxAttempts = 1 } });
 
var jobId = await harness.EnqueueAsync(new SendInvoice("doomed-order"));
await harness.AdvanceAsync(TimeSpan.FromMinutes(1));
 
var job = await harness.Monitor.GetJobAsync(jobId);
Assert.Equal(JobState.DeadLettered, job!.State);

Keep Dead-Lettered distinct from Quarantined. Dead-Lettered is a job that ran and exhausted its Attempt ceiling; Quarantined is a job that could not be routed or decoded at all, so it never ran. Both are terminal. See Job States for the full set.

Asserting the attempt count after retries#

Retries are where Virtual Time earns its keep, and Attempt is how you prove a retry happened. Claiming a job increments its Attempt, so a job that failed once and then succeeded reports Attempt == 2. To exercise this, inject a fake configured to throw on the first try only, advance past the retry backoff, and read the count.

RetryTests.cs
log.FailFirstAttempt = true;
var jobId = await harness.EnqueueAsync(new SendInvoice("flaky-order"));
await harness.AdvanceAsync(TimeSpan.FromMinutes(1));
 
var job = await harness.Monitor.GetJobAsync(jobId);
Assert.Equal(JobState.Succeeded, job!.State);
Assert.Equal(2, job.Attempt);

A lapsed Lease counts as an Attempt the same as a thrown exception, so Attempt is the try counter, not the failure counter. Retry backoff and the Attempt ceiling are configured through Configure Retries and Error Handling.

Asserting queue depth#

GetQueueDepthsAsync returns one QueueStateCount row per (Queue, State) pair that currently has jobs. It is the assertion for "the backlog fully drained" or "N jobs landed in this state on this Queue." QueueStateCount is a positional record, (string Queue, JobState State, int Count), so you can construct the expected row inline.

BacklogTests.cs
var depths = await harness.Monitor.GetQueueDepthsAsync();
Assert.Equal(new QueueStateCount("default", JobState.Succeeded, 365),
    Assert.Single(depths));

Because only non-empty combinations produce a row, Assert.Single doubles as a claim that every job ended on the same Queue in the same state. If work spread across Queues or states, you will get more than one row and the assertion fails loudly.

Asserting a Dependency's released outcome#

A dependent job enqueued with EnqueueDependencyAsync sits in AwaitingParent until its parent reaches a terminal state, then releases to Scheduled and runs. Under the default DependencyMode.OnSuccess, it releases only if every parent Succeeded; if a parent did not succeed, the dependency is Cancelled instead. Asserting the released outcome means reading the dependent's State before and after you advance past the parent's terminal instant.

DependencyTests.cs
var parent = await harness.EnqueueAsync(new SendInvoice("parent"), delay: TimeSpan.FromHours(1));
var child = await harness.EnqueueDependencyAsync(new SendInvoice("child"), parent);
 
await harness.AdvanceAsync(TimeSpan.FromMinutes(30));
Assert.Equal(JobState.AwaitingParent, (await harness.Monitor.GetJobAsync(child))!.State);
 
await harness.AdvanceAsync(TimeSpan.FromHours(1));
Assert.Equal(JobState.Succeeded, (await harness.Monitor.GetJobAsync(child))!.State);

GetDependencyEdgesAsync gives you the live graph as a DependencyEdges(IReadOnlyList<Guid> GatingParents, IReadOnlyList<Guid> Children). It confirms a release: once the parent goes terminal, it drops out of GatingParents, so an empty GatingParents on the child means nothing is still holding it back.

DependencyTests.cs
var edges = await harness.Monitor.GetDependencyEdgesAsync(child);
Assert.Empty(edges.GatingParents);

GatingParents is the still-gating set, not the child's full original parent list, so it empties as parents resolve and cannot be used to recover the history of dependencies that have already fired. For the outcome itself, read the child's State. Dependency mechanics are covered in Dependencies.

Asserting the transition history#

GetJobHistoryAsync returns the full oldest-first timeline of JobTransition rows, (long Ordinal, DateTimeOffset Timestamp, JobState State, int Attempt, string? FailureDetail). This is how you assert the whole path a job took to get there, which matters for a job that failed and retried before succeeding.

HistoryTests.cs
var history = await harness.Monitor.GetJobHistoryAsync(jobId);
Assert.Equal(
    new[] { JobState.Scheduled, JobState.Leased, JobState.Scheduled, JobState.Leased, JobState.Succeeded },
    history.Select(t => t.State));

FailureDetail is non-null only on a failing transition, so you can pick out the failing rows to inspect them. GetJobHistoryAsync returns an empty list both for an unknown job and for a known job whose store has history recording turned off, so treat an empty timeline as "no record kept," not proof that nothing happened. The harness's In-Memory Store records the full timeline by default, so in tests an empty history normally means the job id is wrong.

Verifying a side effect with an injected fake#

The Monitor tells you what BackWave recorded about a job. It cannot tell you whether your handler charged a card or called a gateway, because those effects live inside the handler body and BackWave never looks inside a handler. To assert on a side effect, you inject a fake for the handler's dependency through the same IServiceProvider you hand the harness. The real, unmodified handler still runs, resolved through normal DI, but its dependency is a test double you keep a reference to and assert against afterward.

SideEffectTests.cs
var services = new ServiceCollection()
    .AddSingleton<InvoiceLog>()
    .AddTransient<IJobHandler<SendInvoice>, SendInvoiceHandler>()
    .BuildServiceProvider();
var log = services.GetRequiredService<InvoiceLog>();
 
var harness = new BackWaveHarness(BackWaveJobs.CreateRegistry(), services);
var jobId = await harness.EnqueueAsync(new SendInvoice("order-1"));
await harness.AdvanceAsync(TimeSpan.FromDays(3));
 
Assert.Equal(new[] { ("order-1", 1) }, log.Sent);

The real SendInvoiceHandler resolves its InvoiceLog from the same provider the harness uses to run jobs. Registering it as a singleton and pulling it back out with GetRequiredService before you build the harness gives you the exact instance the handler will record on. This is a spy pattern, not a mocking framework: the fake is an ordinary class you write, and the assertion is against the state it accumulated. It is also how the retry test above configures its first-attempt failure, the fake decides to throw, and the handler's real logic runs against it.

This is the same DI wiring production uses. A handler that injects a gateway interface in production injects a FakeInvoiceGateway implementation of that same interface in the test, and the assertion is against the fake. See Jobs & Handlers for how handlers resolve their dependencies per Attempt.

Dropping to the Store for a low-level field#

JobSnapshot is deliberately narrow. It never carries the raw Payload bytes, the Output blob, the count of parents a dependent is still waiting on, the Dependency mode, or the trace context. When a test needs one of those, drop to harness.Store, the public InMemoryJobStore, which implements the Storage Contract directly and returns the fuller JobRecord.

RecordTests.cs
var record = await harness.Store.GetJobAsync(jobId);
Assert.Equal(0, record!.ParentsRemaining);
Assert.Equal(DependencyMode.OnSuccess, record.Mode);

JobRecord is a superset of JobSnapshot: everything the snapshot has plus Payload, Output, ParentsRemaining, Mode, and TraceContext. Those five fields are the concrete reason to reach for the Store rather than the Monitor. Prefer the Monitor for anything it exposes, because that keeps your assertion on the same public surface production reads; reach for the Store only when you need a field the Monitor does not carry.

For raw Output specifically, the Monitor also offers GetJobOutputAsync, which returns the exact bytes a dependent job would read, without going through the Store. Use it when you are asserting on what a downstream job consumes rather than on the storage record itself.

Where to go next#

  • Test Behavior Over Time: advancing the clock to drive retries, schedules, and Lease expiry before you assert.
  • Write Your First Test: the shape of a BackWave test and where the harness fits.
  • Monitor API: the same read surface as it appears in production operations.
  • Job Lifecycle: the states and retry loop behind the assertions on this page.
  • Dependencies: how release and Dependency mode decide a child's outcome.