Write Your First Test

Install BackWave.Testing, build the harness from your real registry and DI, and run the define, enqueue, advance, assert loop every test follows — a complete passing test you can paste into a project.


A BackWave test enqueues work, advances a clock it controls, and asserts on the result. It never sleeps, polls, or races, so it gives the same answer on the first run and the ten-thousandth. This page gets you from an empty test project to one green test: install the package, build the harness, and follow the four-step loop with a complete example you can paste in.

You do not have to think about why this is reproducible to write the test. The short version is that the clock is a value your test moves explicitly, and the store underneath is real rather than a mock — the same In-Memory Store shipped for tests and local dev, implementing the same Storage Contract Postgres and SQL Server do, so the runtime behaves in a test exactly as it does in production. If you want the full account of how that determinism is built and exploited, it lives in The Determinism Boundary and How BackWave Is Tested.

Installing the Testing package#

The harness ships in BackWave.Testing. Add it to your test project.

Terminal
dotnet add package BackWave.Testing

It brings the In-Memory Store with it, so a test project needs no Storage Adapter of its own.

Building the harness#

BackWaveHarness is the public doorway. You build it from your real Job Registry and your real DI services, the same two things your application wires up in production.

Harness construction
var harness = new BackWaveHarness(BackWaveJobs.CreateRegistry(), services);

The registry is BackWaveJobs.CreateRegistry(), generated from the [Job] declarations in your solution exactly as shipped. The IServiceProvider resolves your handlers and their dependencies exactly as production would, which is where you swap real infrastructure for fakes: register the real handler and a fake gateway, and the handler under test never knows the difference.

A third BackWaveHarnessOptions argument is optional, and its defaults suit almost every test:

OptionDefaultMeaning
StartTimeMidnight UTC, 1 Jan 2026The fixed instant Virtual Time starts at, chosen for reproducibility rather than DateTimeOffset.UtcNow.
PolicyServes every Queue the registry declares, plus "default"The Dispatch Policy the single virtual Worker runs.
RetryPolicyRetryPolicy.DefaultThe retry ceiling and backoff applied to failing jobs.
RetentionOffRetention is off so assertions can see the whole job history; opt in only when testing retention itself.
BoundsIn-memory store defaultsPayload and store size bounds.

Once built, the harness exposes the surfaces a test reaches for:

MemberWhat it is
NowThe current Virtual Time instant. Starts at StartTime and only moves when you advance.
MonitorThe read-only Monitor API you assert through, identical to the one production observability and the dashboard use.
ClientThe BackWaveClient wired to the harness store and virtual clock, for enqueue overloads the harness does not surface directly.
StoreDirect access to the In-Memory Store for low-level assertions the Monitor does not expose.

The canonical loop#

Every BackWave test is the same four steps.

  1. Define the job and handler exactly as your application ships them. No test doubles for the job itself.
  2. Enqueue it through the harness with EnqueueAsync, at Now or Now + delay.
  3. Advance Virtual Time with AdvanceAsync, which runs everything that comes due in the interval.
  4. Assert through harness.Monitor, the same read surface production uses.

AdvanceAsync is the engine. It does not jump straight to the target instant. It stops at and fully processes every intervening due instant in order, a released Dependency, a retry backoff, a Lease expiry, a recurring tick, before moving on, and applies time-based effects like retention purges at the final instant. It only moves forward: a negative TimeSpan throws ArgumentOutOfRangeException. When you need to drain work that is due right now without moving the clock, RunDueAsync() is an advance by zero.

A complete example#

This test defines a real job and handler, builds the harness with a fake gateway, enqueues due two days out, advances three days, and asserts the job Succeeded, all with no waiting.

SendInvoiceTests.cs
// 1. Define the job -- the same record and handler your app ships.
[Job("send-invoice")]
public sealed record SendInvoice(string OrderId);
 
public sealed class SendInvoiceHandler(IInvoiceGateway gateway) : IJobHandler<SendInvoice>
{
    public Task HandleAsync(SendInvoice job, JobContext context, CancellationToken cancellationToken)
        => gateway.SendAsync(job.OrderId, cancellationToken);
}
 
[Fact]
public async Task InvoiceGoesOut_TwoDaysAfterTheOrder()
{
    // 2. Build the harness from your registry and DI services.
    var services = new ServiceCollection()
        .AddSingleton<IInvoiceGateway, FakeInvoiceGateway>()
        .AddTransient<IJobHandler<SendInvoice>, SendInvoiceHandler>()
        .BuildServiceProvider();
    var harness = new BackWaveHarness(BackWaveJobs.CreateRegistry(), services);
 
    // 3. Enqueue due in two days, then advance three -- everything due in between runs.
    var jobId = await harness.EnqueueAsync(new SendInvoice("order-42"), delay: TimeSpan.FromDays(2));
    await harness.AdvanceAsync(TimeSpan.FromDays(3));
 
    // 4. Assert through the Monitor API -- the same surface production observability uses.
    var job = await harness.Monitor.GetJobAsync(jobId);
    Assert.Equal(JobState.Succeeded, job!.State);
}

GetJobAsync returns the job's current JobSnapshot, or null if no job with that id exists. Succeeded is one of the terminal JobState values, alongside Cancelled, DeadLettered, and Quarantined; the non-terminal states are Scheduled, AwaitingParent, and Leased. Asserting on State is the simplest assertion; the Monitor also exposes queue depths, the Transition Log, schedules, and dependency edges for richer checks. See Assert on What Happened for the full assertion vocabulary.

Where to go next#

The rest of this section builds on the loop above.

  • Test Behavior Over Time: advancing the clock to drive delays, retries, backoff, recurring schedules, and dependency releases without waiting.
  • Assert on What Happened: the Monitor API in depth, from job state to the Transition Log to queue depths, plus verifying side effects with a fake.
  • Test Workflows: driving Dependencies and, with BackWave Pro, Workflows through the harness.
  • Integration Testing: graduating from the in-memory harness to a real database when you need to prove the adapter and real transactions.
  • Jobs & Handlers: the [Job] attribute, IJobHandler<T>, and the Wire Name your tests exercise.