Integration Testing
Graduate from the in-memory harness to the real hosted runtime against a real Storage Adapter, asserted through the Monitor on the real clock. The database provisioning is yours to choose; the BackWave surface is fixed.
The Virtual-Time harness is where most of your job tests belong: deterministic, fast, and driving the same Core that ships. But it runs against the In-Memory Store, and there is a class of correctness that the In-Memory Store structurally cannot prove, because it lives below the Determinism Boundary. A wrong Postgres claim query, a schema that never migrated, a transaction that did not roll back the way you assumed, none of that shows up until a real database is in the loop. An integration test puts one there: the real hosted runtime, a real Storage Adapter, an actual connection, and assertions through the same Monitor API you use everywhere else.
What only a real adapter can prove#
The harness pairs the In-Memory Store with a virtual clock, and that store is a real, deterministic implementation of the Storage Contract, not a mock. But it is not a database engine. Everything a database engine actually does, and every place a Storage Adapter's SQL could be wrong, sits on the far side of the Determinism Boundary and is invisible to Virtual Time. That is precisely the surface an integration test exists to cover.
| Concern | In-Memory harness | Real adapter integration test |
|---|---|---|
| Core decisions (retries, due time, Lease expiry, transitions) | Proven, deterministically | Also exercised, but non-deterministically |
Adapter SQL correctness (claim contention, FOR UPDATE SKIP LOCKED / UPDLOCK) | Out of reach | The whole point |
| Schema provisioning and version-skew guard | Not applicable | Exercised on a fresh database |
| Real ADO.NET transaction semantics for Transactional Enqueue | An in-memory transaction, not the real driver's | The real NpgsqlTransaction / SqlTransaction commit-or-rollback |
| Connection pooling, wake-up hint delivery, concurrency under real locks | Simulated in-process | Real, over the real connection |
BackWave proves the first row with its Simulator and proves the rest with the Conformance Suite that runs the real adapters against real databases, the same partition described in How BackWave Is Tested. Your integration tests are the consumer-side version of that Conformance shape: test the real adapter for real. You do not need many of them. A handful that assert an enqueued job actually runs to a terminal state against each database you deploy to is usually enough to catch the things the harness cannot. (If you wrote the adapter yourself, that suite ships as a package you can run against your own store — see The Conformance Suite.)
What you give up#
The trade is real, which is why this is the exception and not the default.
- No Virtual Time. A real WorkerGroup runs on the wall clock. There is no
AdvanceAsyncto jump a simulated year forward; a job due in the future is due in real seconds, and a Lease expires in real time. Schedule, retry, and lease-expiry tests become slow real-clock waits, so keep those on the harness and reserve the integration test for "does the real store actually run this." - Slower and less deterministic. You wait on real polling loops and real I/O, and the timing is not reproducible byte-for-byte the way a Seed is.
- Infrastructure. A networked adapter needs a real database process, which the test has to stand up and tear down. How you do that is your call — Testcontainers driving a container from inside the test, a
docker composeservice, a CI service container, or a shared throwaway instance. SQLite is the exception; as an in-process engine it needs no server at all.
The shape#
An integration test drives the exact registration a production host uses, just pointed at a throwaway database. There is no test-only entry point and no in-memory shortcut. Whatever tooling you reach for, the test has to satisfy four things:
- A real database reachable by a connection string. Where that database comes from is yours to choose; the BackWave part of the test does not care how it got there.
- The real runtime, registered the way production registers it:
AddBackWave, a real Storage Adapter viaUseStore, and at least oneAddWorkerGroupso jobs actually run. - An enqueue through
BackWaveClient, exactly as your application enqueues. - A bounded poll to a terminal state through
BackWaveMonitor. There is no clock to advance, so you wait on the real one — and you cap that wait so a job that never runs fails the test loudly instead of hanging.
Steps 2 through 4 are fixed: they are the surface this test exists to exercise, and the rest of this page is concrete about them. Step 1 is deliberately open.
Get a database#
The networked adapters, Postgres and SQL Server, each run as a separate server process, so a local integration test needs one stood up somewhere. Two common ways, both fine; pick whichever fits how your suite already runs.
Testcontainers manages the container's whole lifecycle from inside the test process and hands you the connection string, so there is no external file to run and nothing to tear down by hand.
await using var db = new PostgreSqlBuilder()
.WithImage("postgres:17-alpine")
.Build();
await db.StartAsync();
var connectionString = db.GetConnectionString();
// ... run the test against connectionString; the container is disposed with the test ...A compose service is just as valid when you would rather manage the database outside the test, say a shared local stack or a service container you already run in CI:
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: myapp
POSTGRES_PASSWORD: myapp
POSTGRES_DB: myapp_test
ports:
- "5499:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 2s
timeout: 5s
retries: 15Bring it up with docker compose up -d postgres before the suite and point the adapter at the published port. For SQL Server, the image is mcr.microsoft.com/mssql/server:2022-latest with an MSSQL_SA_PASSWORD; on Apple Silicon pin platform: linux/amd64 and expect emulation, slower but correct. SQLite sidesteps the whole question: it is the Embedded Adapter, an in-process engine reachable by a plain file path, so a SQLite integration test needs no container at all, just a temp-file connection string.
Whichever route you take, the rest of the test is identical. All it needs is the connection string.
Point the adapter at it#
However you got the connectionString — from the container object, an environment variable, a compose port — hand it to a real Storage Adapter. The one BackWave-specific thing to get right here is schema provisioning.
var store = new PostgresJobStore(new PostgresStoreOptions
{
ConnectionString = connectionString,
AutoMigrate = true, // let the fresh test database provision its own schema
});AutoMigrate defaults to false on every adapter, and the schema-version guard is lazy: it fires on the first store operation, not at registration or startup. A fresh database with AutoMigrate left off throws InvalidOperationException ("schema not found") the first time a job is stored. For a throwaway test database, either set AutoMigrate = true as above, or provision the schema deliberately before the test runs with the adapter's public migrator, PostgresMigrator.MigrateAsync against an NpgsqlDataSource (SQL Server's SqlServerMigrator.MigrateAsync takes a connection string instead, the one asymmetry between the two). Provisioning is covered in Schema and migrations.
Run the real runtime and assert through the Monitor#
Now register the runtime exactly as a production host would, start it, enqueue, and poll. Because a real WorkerGroup runs on the wall clock, PollInterval is the knob that governs how quickly a due job gets picked up; a short interval keeps the test snappy. The one piece the framework does not hand you is the wait: there is no Virtual Time outside the harness and no shipped "wait for terminal" helper, so the bounded poll is code you write. What it must guarantee is fixed — poll until a terminal state and cap the wait with a deadline — but the exact loop below is just one way to spell that.
var builder = WebApplication.CreateBuilder();
builder.Services.AddBackWave(bw => bw
.UseStore(new PostgresJobStore(new PostgresStoreOptions
{
ConnectionString = connectionString,
AutoMigrate = true,
}))
.UseJobs(BackWaveJobs.Module)
.AddWorkerGroup(new WorkerGroupOptions
{
Name = "default",
Policy = new DispatchPolicy.Strict("default"),
PollInterval = TimeSpan.FromMilliseconds(200),
}));
await using var app = builder.Build();
await app.StartAsync();
var client = app.Services.GetRequiredService<BackWaveClient>();
var monitor = app.Services.GetRequiredService<BackWaveMonitor>();
var jobId = await client.EnqueueAsync(new SendReceipt(orderId), DateTimeOffset.UtcNow);
// No Virtual Time here: poll the real Monitor on the real clock.
JobSnapshot? job = null;
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline)
{
job = await monitor.GetJobAsync(jobId);
if (job is { State: JobState.Succeeded or JobState.DeadLettered or JobState.Quarantined or JobState.Cancelled })
break;
await Task.Delay(50);
}
Assert.Equal(JobState.Succeeded, job!.State);
await app.StopAsync();The bound is the part that matters, not the polling style: wait for a terminal state (Succeeded, Dead-Lettered, Quarantined, or Cancelled) and cap the wait with a deadline so a job that never runs fails loudly instead of hanging the suite. GetJobAsync returning a snapshot whose State you inspect is the whole assertion surface; the Monitor API also exposes GetQueueDepthsAsync and the job history if you want to assert on the path a job took rather than just its endpoint. Nothing about the enqueue or the assertion is adapter-specific: swap PostgresJobStore for SqlServerJobStore or SqliteJobStore (with its own options record) and the same test asserts the same thing against a different engine.
Transactional enqueue is worth a real test#
The harness models Transactional Enqueue with an in-memory transaction, which proves your code path but not the database's rollback semantics. Against a real adapter the store enlists in the real driver's transaction, NpgsqlTransaction for Postgres, SqlTransaction for SQL Server, so an integration test can commit a business write and the job together, or roll both back, and then assert through the Monitor that a rolled-back enqueue left no job behind. That end-to-end atomicity is exactly the guarantee that only a real transaction can demonstrate. The EF Core storage integration is the cleanest way to enlist your own transaction; the capability itself is described in the storage contract.
Keep tests isolated#
Because a real database is durable, state survives between tests, and a job left over from one test will skew the next. Reset the database to a known state between tests, truncating BackWave's tables or recreating the schema, so each test starts clean. If you provision with Testcontainers you can also lean on a fresh container per test class instead of resetting by hand; either way the goal is the same, every test starting from a known state. This is the one place an integration suite asks for bookkeeping the harness gives you for free, since every harness test starts from an empty In-Memory Store.
Where to go next#
- Test Behavior Over Time: the deterministic harness that should carry most of your job tests.
- Assert on What Happened: the Monitor-based assertions you reuse here, on the real clock.
- Choosing an adapter: where Postgres, SQL Server, and SQLite each fit.
- Schema and migrations: auto-migrate versus the explicit migrator and the version-skew guard.
- Transactional enqueue with EF Core: enlisting a job in your own database transaction.
- How BackWave Is Tested: what the simulation proves versus what only a real adapter test can.
- The Conformance Suite: the published package that certifies an adapter you wrote against the Storage Contract.