Resrcify.SharedKernel.IntegrationTesting
4.7.0
dotnet add package Resrcify.SharedKernel.IntegrationTesting --version 4.7.0
NuGet\Install-Package Resrcify.SharedKernel.IntegrationTesting -Version 4.7.0
<PackageReference Include="Resrcify.SharedKernel.IntegrationTesting" Version="4.7.0" />
<PackageVersion Include="Resrcify.SharedKernel.IntegrationTesting" Version="4.7.0" />
<PackageReference Include="Resrcify.SharedKernel.IntegrationTesting" />
paket add Resrcify.SharedKernel.IntegrationTesting --version 4.7.0
#r "nuget: Resrcify.SharedKernel.IntegrationTesting, 4.7.0"
#:package Resrcify.SharedKernel.IntegrationTesting@4.7.0
#addin nuget:?package=Resrcify.SharedKernel.IntegrationTesting&version=4.7.0
#tool nuget:?package=Resrcify.SharedKernel.IntegrationTesting&version=4.7.0
Resrcify.SharedKernel.IntegrationTesting
Resrcify.SharedKernel.IntegrationTesting holds the integration-test fixtures the services share: PostgreSQL and
RabbitMQ in Testcontainers, and a host fixture that runs a service's Program in-process on them.
Table of Contents
- What you get
- Prerequisites
- Install
- Hosting a service:
ServiceHostFixture<TProgram>- Settings
- A shared network
- Starting more:
StartDependenciesAsync - Getting the started host ready:
OnHostStartedAsync - A service without a database
- What a service changes
- A Program with its own configuration builder
- What the service logged:
Logs,RenderedLogs - The message bus on the test harness:
UsesMessageBusTestHarness
- Waiting for what a test expects:
Waiting/ - Container fixtures
- Another service's image:
ImageUnderTest
What you get
ServiceHostFixture<TProgram>(Factories/): aWebApplicationFactory<TProgram>and xUnit fixture that starts PostgreSQL (unless the service has none), RabbitMQ when asked and whatever else the service starts inStartDependenciesAsync, hands the host the conventional settings through its configuration, starts the host and lets the service get it ready inOnHostStartedAsync(pause its jobs, seed data).IntegrationFactoryBase<TProgram>(Factories/): aWebApplicationFactorywhose subclass supplies settings, for hosts that own no containers. The settings win overappsettings*.jsonand environment variables, withWebApplication.CreateBuilderandHost.CreateDefaultBuilderalike.ServiceHostFixture.Logs: what the host logged, through the logging abstraction and Serilog, in Microsoft'sFakeLogCollector;RenderedLogs: its Serilog lines as production's console prints them;UsesMessageBusTestHarness: the service's buses on the message bus test harness, in memory.PostgresContainerFixture,RabbitMqContainerFixture(Fixtures/): one container each, on the images production runs,postgres:18andrabbitmq:4.3.4-management(the RabbitMQ Cluster Operator v2.23.0's default) (RESRCIFY_TEST_POSTGRES_IMAGE/RESRCIFY_TEST_RABBITMQ_IMAGEreplace the image for every fixture), their database and credentials set with init properties (no subclass), a readiness probe,JoinNetwork(network, aliases), and for RabbitMQ production's queue defaults (ProductionQueueDefaults),QueuesAsync,AddQuorumVirtualHostAsync,ConnectionandWaitForExchangePublish.ImageUnderTest,ImageAcquisition(Containers/): another service's image in a system or end-to-end test, pulled fresh (or pinned by digest), and a[tested against]line saying exactly which build ran.HangGuard,AwaitableLog<T>,CallCounter(Waiting/): waiting for a signal instead of a deadline, with one limit that only catches a hang.- With Resrcify.SharedKernel.MessageBus.Testing (which this references):
PublishedEventListener, a subscriber to the integration events a service publishes on a real broker.
Prerequisites
- .NET 10 SDK and Docker on the machine running the tests.
Install
<PackageReference Include="Resrcify.SharedKernel.IntegrationTesting" Version="<latest>" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
Hosting a service: ServiceHostFixture<TProgram>
public sealed class ShardHostFixture
: ServiceHostFixture<Program>
{
// The service's database and credentials; production's image. null: no database.
protected override PostgresContainerFixture CreatePostgres()
=> new() { Database = "ShardDb", Username = "ShardUser", Password = "K33pTr4ck" };
// null (the default): no broker. Production's quorum queues and delivery limit.
protected override RabbitMqContainerFixture? CreateRabbitMq()
=> new() { ProductionQueueDefaults = true };
protected override bool UsesNetwork => true; // see below
protected override void ConfigureSettings(IDictionary<string, string?> settings)
=> settings["Patreon:Token"] = "test";
protected override void ConfigureTestServices(IServiceCollection services)
=> services.Replace(ServiceDescriptor.Singleton<IClock>(new FixedClock()));
}
[CollectionDefinition(Name)]
public sealed class ShardIntegrationCollection
: ICollectionFixture<ShardHostFixture>
{
public const string Name = nameof(ShardIntegrationCollection);
}
When it starts (InitializeAsync) it starts the containers (at once), adds the conventional settings, runs
StartDependenciesAsync, then ConfigureSettings, then builds and starts the host (a plain WebApplicationFactory
would wait for the first CreateClient() or Services), then runs OnHostStartedAsync. Postgres, RabbitMq,
Network and Settings are there for the tests. Disposing it stops the host, then what was handed to
DisposeWithFixture (last first), then the containers, then deletes the network. A start that fails (a container,
StartDependenciesAsync, ConfigureSettings, the host's own start, OnHostStartedAsync) tears down what it had
started the same way before it throws, so nothing is left running, and the failure is the fixture's, not the first
test's.
Settings
| Key | Value |
|---|---|
Database:Host, Database:Port |
the container's host and mapped port |
Database:Database, Database:Username, Database:Password |
what the container was created with |
MessageBus:Host, MessageBus:Port |
the broker's host and mapped AMQP port (with RabbitMQ only) |
MessageBus:Username, MessageBus:Password |
the broker's credentials (with RabbitMQ only) |
Migrations:Run |
true (with PostgreSQL only) |
ConfigureSettings adds to them or changes them; the environment is Development (override Environment). They
reach the host with UseSetting, so Program sees them in builder.Configuration while it registers its services
(where AddPostgresDbContext binds Database and the services bind MessageBus), and as the last configuration
source, so they win over appsettings*.json, environment variables and the Program's own sources on the generic
host (Host.CreateDefaultBuilder + Startup) too, where UseSetting alone lost to appsettings.json. They belong
to that host alone: nothing is
written to the process' environment, so two fixtures in two collections can run at once, each on its own containers.
The old fixtures' Environment.SetEnvironmentVariable("Database__Host", ...) were process-wide: they leaked into every
other collection and forced all tests into one.
The credentials come from the container's connection string, which Testcontainers doesn't quote: keep ; out of a
test database's password.
A shared network
With UsesNetwork, the fixture creates a network and puts PostgreSQL on it as postgres and RabbitMQ as rabbitmq
(PostgresNetworkAlias, RabbitMqNetworkAlias). A container a test starts on Network (another service, say)
reaches them at postgres:5432 and rabbitmq:5672, while the in-process host keeps using the mapped ports.
Starting more: StartDependenciesAsync
A service that needs another container (SwgohApi's image on the fixture's network, say, whose URL goes into the
settings) starts it in StartDependenciesAsync. It runs once PostgreSQL, RabbitMQ and the network are up, with the
conventional Settings in place, and before ConfigureSettings and the host:
public sealed class SentinelHostFixture
: ServiceHostFixture<Program>
{
private SwgohApiPod _swgohApi = default!;
protected override PostgresContainerFixture? CreatePostgres() => new PostgresFixture();
protected override RabbitMqContainerFixture? CreateRabbitMq() => new RabbitMqFixture();
protected override bool UsesNetwork => true; // the pod reaches rabbitmq:5672
protected override async Task StartDependenciesAsync(CancellationToken cancellationToken)
=> _swgohApi = DisposeWithFixture(await SwgohApiPod.StartAsync(RabbitMq!, Network!, cancellationToken));
protected override void ConfigureSettings(IDictionary<string, string?> settings)
=> settings["SwgohApiService:Url"] = _swgohApi.Url.ToString();
}
DisposeWithFixture(x) has the fixture dispose x when it is torn down: after the host has stopped, before RabbitMQ,
PostgreSQL and the network, the last one handed over first. It takes an IAsyncDisposable (a Testcontainers
container, returned for chaining) or an IAsyncLifetime (a container fixture of this package), and may be called at
any time (a test that starts another pod later hands it over too). Hand a container over before starting it
(var c = DisposeWithFixture(builder.Build()); await c.StartAsync(cancellationToken);), so it is removed even if its
own start fails. The token is cancelled when the fixture is torn down.
Getting the started host ready: OnHostStartedAsync
OnHostStartedAsync(services, cancellationToken) runs once, at the end of InitializeAsync: after the host has
started (its hosted services too, so Quartz's scheduler is running) and before the first test. services is the host's
root provider (Services); create a scope for scoped services. Sentinel's system tests pause the scheduled jobs, which
the tests then run on demand, and leave the outbox's on its schedule:
public sealed class SentinelSystemHostFixture
: ServiceHostFixture<Program>
{
private static readonly string[] ScheduledJobs = ["UpdateGuildsJob", "UpdateTrackedPlayersJob", /* … */];
protected override async Task OnHostStartedAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var scheduler = await services.GetRequiredService<ISchedulerFactory>().GetScheduler(cancellationToken);
foreach (var job in ScheduledJobs)
await scheduler.PauseJob(new JobKey(job), cancellationToken);
// or every job, the outbox's included: await scheduler.PauseAll(cancellationToken);
await using var scope = services.CreateAsyncScope();
await SeedAsync(scope.ServiceProvider, cancellationToken); // data every test reads
}
}
A failure in it tears down the host and everything started before it, like any failed start; it doesn't run when the start failed earlier (the host's own start included). A job whose trigger fires as the scheduler starts may run once before the hook pauses it: give such a job a start delay if a test can't tolerate that run.
There is no built-in "pause Quartz" switch: this package doesn't depend on Quartz (a service without jobs shouldn't ship it), and which jobs to hold differs per service (Sentinel keeps the outbox running). The hook is the three lines above.
A service without a database
CreatePostgres() returning null starts no PostgreSQL and gives no Database:* or Migrations:Run settings
(Discord's bot has no database); reading Postgres then throws InvalidOperationException.
protected override PostgresContainerFixture? CreatePostgres() => null;
What a service changes
Before (~55 lines per service):
public sealed class ShardHostFixture : WebApplicationFactory<Program>, IAsyncLifetime
{
public PostgresFixture Postgres { get; } = new();
public RabbitMqFixture RabbitMq { get; } = new();
public async Task InitializeAsync()
{
await Postgres.InitializeAsync();
await RabbitMq.InitializeAsync();
Environment.SetEnvironmentVariable("Database__Host", Postgres.Host);
// ... nine more
}
async Task IAsyncLifetime.DisposeAsync() { ... }
protected override void ConfigureWebHost(IWebHostBuilder builder) => builder.UseEnvironment("Development");
}
After: the class above. The service's PostgresFixture / RabbitMqFixture subclasses, which only set credentials
(and an image), go: their values move into CreatePostgres() / CreateRabbitMq() as init properties
(Database, Username, Password), and the image stays production's. A RabbitMqFixture that built its own network
can drop it for UsesNetwork (its alias is the same, rabbitmq). Tests reach the containers as host.Postgres /
host.RabbitMq, the broker as the bus connects to it as host.RabbitMq.Connection, and its user as
host.RabbitMq.BrokerUsername / BrokerPassword.
What the service logged: Logs, RenderedLogs
Logs is Microsoft's FakeLogCollector (Microsoft.Extensions.Diagnostics.Testing): every line the host logged that
its own level filters let through, with its category, level, message, exception and structured state.
await client.PostAsJsonAsync("/shards", request);
var warning = host.Logs.GetSnapshot().ShouldHaveSingleItem(record => record.Level == LogLevel.Warning);
It hears both ways a service logs: through a provider of the logging abstraction, and, for a service on Serilog,
through a sink registered in the container, which AddServiceTelemetry's Serilog reads (ReadFrom.Services). A line
arrives once (Serilog bypasses the abstraction's providers). A service setting up Serilog itself without
ReadFrom.Services(provider) is heard through the providers only, i.e. not at all. Logs.Clear() between tests in a
shared fixture.
RenderedLogs holds what the host logged through Serilog as production's console prints it: each line's level,
template and message as RenderedCompactJsonFormatter (the formatter the services' appsettings.Production.json
configures) renders @m. Serilog quotes a string property there unless the template says :l, which Logs, rendered
as the logging abstraction does, never shows:
var line = host.RenderedLogs.Lines.ShouldHaveSingleItem(line => line.Template == "Sync of {Shard} failed");
line.Message.ShouldBe("Sync of \"alpha\" failed"); // production's console, quotes included
It is a sink registered in the container next to Logs' (ILogEventSink), so it hears a service whose Serilog reads
them (AddServiceTelemetry, ReadFrom.Services), and nothing otherwise. RenderedLogs.Clear() between tests.
The message bus on the test harness: UsesMessageBusTestHarness
public sealed class HarnessShardHostFixture : ServiceHostFixture<Program>
{
protected override bool UsesMessageBusTestHarness => true; // no broker: CreateRabbitMq stays null
protected override PostgresContainerFixture CreatePostgres()
=> new() { Database = "ShardDb", Username = "ShardUser", Password = "K33pTr4ck" };
}
// In a test:
await host.Harness.Published.WaitForAsync<ShardCreated>(e => e.Name == "Main");
await using var players = await host.BusNetwork.StartResponderAsync<GetPlayer, PlayerProfile>(...);
The fixture calls AddMessageBusTestHarness(BusNetwork) after the service's registrations, so every bus of the
service runs in memory on BusNetwork, whatever transport it configured (no MessageBus:* settings are needed),
recorded by Harness (the service's MessageBusTestHarness; reading it without the switch throws). It replaces the
BusNetwork property and the ConfigureTestServices(services => services.AddMessageBusTestHarness(BusNetwork)) each
service's Harness*HostFixture wrote.
A Program with its own configuration builder
A Program that reads a configuration it builds itself never sees the fixture's settings:
var configuration = new ConfigurationBuilder() // Tournament's Program.cs
.AddJsonFile("appsettings.json")
.AddEnvironmentVariables()
.Build();
builder.Services.AddPersistenceServices(configuration);
builder.Configuration already has the same JSON files, environment variables and command line: pass it instead
(AddPersistenceServices(builder.Configuration)) before moving such a service to this fixture.
Waiting for what a test expects: Waiting/
A test waits for its condition itself, never for a time: a correct but slow run (a loaded CI runner) then passes, and a fast run doesn't wait for nothing.
AwaitableLog<T>: what a recorder or spy saw.Add(entry)records;WaitForAsync(matches)completes with the first matching entry, at once if it is already there, or as soon as it is added.Entrieslists them in order.CallCounter: calls a stand-in received.Increment()counts one;ReachedAsync(n)completes oncenwere made.HangGuard: the only limit.AwaitAsync(signal, "what it waits for", diagnostics)awaits a signal and fails with aTimeoutExceptionnaming what it waited for (and the diagnostics: a container's log, the last state seen) only afterHangGuard.Limit, 10 minutes, far beyond any correct run.UntilAsync(holds, …)checks state every 250 ms until it holds: only for state another process knows with no signal to wait on (a container's readiness, a row another service writes).
var calls = new CallCounter();
var replies = new AwaitableLog<PingReply>();
// … a stand-in increments calls, a consumer adds replies …
await HangGuard.AwaitAsync(calls.ReachedAsync(3), "three calls to SwgohApi");
var reply = await HangGuard.AwaitAsync(replies.WaitForAsync(r => r.Id == id), $"the reply to {id}");
The limit is a hang detector, not an assertion: a passing run never comes near it, so a generous one costs nothing but
the time a broken run takes to fail. Ten minutes covers the slowest correct waits the services have (a cold image pull
on a CI runner, a large download through a stand-in); the services' copies used 5 and 10 minutes, and the longer one is
kept so no correct wait is cut short. Pass limit: to fail a broken run sooner. A deterministic test of the guard
itself passes limit: TimeSpan.Zero (and UntilAsync checkEvery: TimeSpan.Zero).
Container fixtures
Use them on their own (IClassFixture<PostgresContainerFixture>) when a test needs a database but no host;
CreateIsolatedConnectionString() gives each test class a database of its own. Set the database and credentials with
init properties (new PostgresContainerFixture { Database = "ShardDb", Username = "ShardUser", Password = "..." },
new RabbitMqContainerFixture { Username = "messageUser", Password = "..." }), or in a subclass' constructor when xUnit
needs a type of its own (public ShardPostgres() => Database = "ShardDb";). Both default to the images production
runs: leave Image alone (a test on another version proves less), and try another version for a whole run with
RESRCIFY_TEST_POSTGRES_IMAGE / RESRCIFY_TEST_RABBITMQ_IMAGE. Configure (a subclass) still sets any other builder
option.
A production-like broker
Production's RabbitMQ declares a queue without a type as a quorum queue and gives every queue a delivery limit of a
million (RabbitMQ 4 limits a quorum queue to 20 redeliveries otherwise): Resrcify.Kubernetes' additionalConfig.
new RabbitMqContainerFixture { ProductionQueueDefaults = true } // the whole broker, as production
var quorum = await rabbitMq.AddQuorumVirtualHostAsync(); // or one virtual host of a shared broker
await host.StartAsync(quorum); // a RabbitMqConnection in that virtual host
var queues = await rabbitMq.QueuesAsync(quorum.VirtualHost); // name, type (classic, quorum), exclusive
queues.ShouldContain(new RabbitMqQueue("shardmanagement", "quorum", Exclusive: false));
ProductionQueueConfig is the configuration it mounts (/etc/rabbitmq/conf.d/90-production-queues.conf).
Events on a real broker: PublishedEventListener
PublishedEventListener (Resrcify.SharedKernel.MessageBus.Testing) subscribes to a service's integration events on a
real broker as another service would: a bus of its own with an input queue of its own, bound to each event's topic
(its wire name), recording what arrives.
await using var events = await PublishedEventListener.StartAsync(
host.RabbitMq!.Connection,
listen => listen.Listen<ShardCreated>().Listen<ShardRenamed>("ShardRenamedV2")); // a wire name of its own
await client.PostAsJsonAsync("/shards", request);
var created = await events.Received.WaitForAsync<ShardCreated>(e => e.Name == "Main");
Start it before the publish (a publish before its queue is bound has nowhere to go); disposing it unsubscribes and
stops it. It replaces Shard's TopicListener (raw RabbitMQ on RebusTopics) and Sentinel's EventSubscriber.
Another service's image: ImageUnderTest
A system or end-to-end test that runs another service's image needs to know it ran the release it meant to. One
ImageUnderTest per image per run (a static of the fixture) starts every container of that image:
private static readonly ImageUnderTest Orders = new(
service: "Orders",
reference: Environment.GetEnvironmentVariable("ORDERS_IMAGE") ?? "ghcr.io/acme/orders:latest",
origin: Environment.GetEnvironmentVariable("ORDERS_IMAGE") is null ? "the default" : "ORDERS_IMAGE",
acquisition: Environment.GetEnvironmentVariable("ORDERS_IMAGE_LOCAL") is "1" ? ImageAcquisition.LocalOnly : ImageAcquisition.PullFresh,
overrides: "set ORDERS_IMAGE (a tag or a digest), or ORDERS_IMAGE_LOCAL=1 to run the local copy");
var container = await Orders.StartAsync(builder => builder
.WithNetwork(network)
.WithEnvironment("ConnectionStrings__Database", connectionString)
.WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPath("/health/ready"))));
ImageAcquisition.PullFresh(for CI): the run's first container pulls the image (Testcontainers'PullPolicy.Always), so a stale local copy of a moving tag likelatestnever stands in for a newer release; a failed pull fails the run, naming the image, its registry (docker login …) andoverrides, and never falls back to the local copy.ImageAcquisition.LocalOnly(offline, or an image built on this machine): the local copy, pulled only when there is none.- Pinned by digest (
ghcr.io/acme/orders@sha256:…): exactly that build, never re-pulled when present. - After the first start, the run names the image by its registry digest, so every later container runs the same
build even if the tag moves meanwhile, and
Description(also written to the console once, for the CI log) says what it was:
[tested against] Orders: ghcr.io/acme/orders:latest (the default; pulled fresh) = version 2.4.0, revision 1f3c9e2, ghcr.io/acme/orders@sha256:…, image sha256:…, created 2026-10-18T09:12:44Z
The version and revision come from the image's org.opencontainers.image.version / .revision labels
((unlabelled) without them).
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.AspNetCore.Mvc.Testing (>= 10.0.13)
- Microsoft.Extensions.Diagnostics.Testing (>= 10.11.0)
- Npgsql (>= 10.0.3)
- Resrcify.SharedKernel.MessageBus.Testing (>= 4.7.0)
- Serilog (>= 4.3.0)
- Serilog.Formatting.Compact (>= 3.0.0)
- Testcontainers.PostgreSql (>= 4.16.0)
- Testcontainers.RabbitMq (>= 4.16.0)
- xunit (>= 2.9.3)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 4.7.0 | 8 | 10/19/2026 |
| 4.6.0 | 44 | 10/19/2026 |
| 4.5.0 | 136 | 10/18/2026 |
| 4.4.1 | 79 | 10/17/2026 |
| 4.4.0 | 65 | 10/16/2026 |
| 4.3.0 | 53 | 10/16/2026 |
| 4.2.0 | 87 | 10/14/2026 |
| 4.1.0 | 88 | 10/14/2026 |
| 4.0.0 | 103 | 10/13/2026 |
| 3.0.0 | 150 | 10/3/2026 |
| 2.2.0 | 142 | 9/13/2026 |
| 2.1.1 | 379 | 6/8/2026 |
| 2.1.0 | 125 | 5/24/2026 |
| 2.0.0 | 125 | 5/22/2026 |
| 1.16.3 | 187 | 5/7/2026 |
| 1.16.2 | 116 | 5/7/2026 |
| 1.16.1 | 113 | 5/7/2026 |