# Assert on What Happened

Once you have driven the harness forward in time (see [Test Behavior Over Time](/docs/testing/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.

```csharp title="SendInvoiceTests.cs" {5}
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](/docs/dashboard-operations/retention-and-purge) policy, so the `!` in a test is you asserting the job is still there. The snapshot carries the fields you assert on directly:

| Field | Type | What it tells you |
|---|---|---|
| `State` | `JobState` | Scheduled, AwaitingParent, Leased, Succeeded, Cancelled, DeadLettered, Quarantined |
| `Attempt` | `int` | Execution tries so far; starts at 1 and climbs with each retry |
| `Queue` | `string` | The Queue this job landed on |
| `WireName` | `string` | The job type's stable identity |
| `DueTime` | `DateTimeOffset` | When it is next eligible to run |
| `CancelRequested` | `bool` | Whether a cancel was requested |
| `TerminalAt` / `TerminalCause` | `DateTimeOffset?` / `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](/docs/core-concepts/job-lifecycle), 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.

```csharp title="DeadLetterTests.cs" {2,8}
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);
```

The harness honors a per-job-type `[Retry]` override exactly as production does. A Job that carries one ignores the harness `RetryPolicy` and dead-letters on its own ceiling. Read the ceiling off the attribute when you assert on a Job that has one.

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](/docs/reference/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.

```csharp title="RetryTests.cs" {7}
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](/docs/guides/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.

```csharp title="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.

```csharp title="DependencyTests.cs" {5,8}
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.

```csharp title="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](/docs/core-concepts/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.

```csharp title="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.

```csharp title="SideEffectTests.cs" {2,5}
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](/docs/core-concepts/jobs-and-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`.

```csharp title="RecordTests.cs" {1}
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](/docs/testing/behavior-over-time): advancing the clock to drive retries, schedules, and Lease expiry before you assert.
- [Write Your First Test](/docs/testing/your-first-test): the shape of a BackWave test and where the harness fits.
- [Monitor API](/docs/dashboard-operations/monitor-api): the same read surface as it appears in production operations.
- [Job Lifecycle](/docs/core-concepts/job-lifecycle): the states and retry loop behind the assertions on this page.
- [Dependencies](/docs/core-concepts/dependencies): how release and Dependency mode decide a child's outcome.
