Test with containerized infrastructure
Use XBullet.EasyTesting.Testcontainers when behavior depends on a real database, broker, cache,
or storage protocol. Each container is a scenario environment resource: it starts before the
application host, publishes configuration only after readiness, contributes bounded diagnostics on
failure, and is disposed after the host.
Prerequisites and installation
- A running Docker-compatible container runtime.
- Permission to pull the selected images and bind local ports.
- Explicit acceptance of the Service Bus emulator license when that module is used.
dotnet add package XBullet.EasyTesting.Testcontainers
Import XBullet.EasyTesting.Testcontainers and XBullet.EasyTesting.Hosting. Native container
types come from the corresponding Testcontainers modules.
Lifecycle and configuration
Add modules to an EasyTestHost or TestScenarioScopeBuilder, then create and dispose a scenario:
using var factory = EasyTestHost.Create<Program>()
.UsePostgreSql(options =>
{
options.ResourceName = "orders-db";
options.ConfigurationKey = "ConnectionStrings:Orders";
options.ConfigureBuilder(builder => builder.WithDatabase("orders"));
})
.UseRedis()
.Build();
await using var scope = await factory.CreateTestScenarioScopeAsync(cancellationToken);
var postgres = scope.GetTestcontainer<Program, PostgreSqlContainer>("orders-db");
Builder configuration is lazy; no container is created until the scenario starts. Registered
resources start in order, the application starts after all resources are ready, and cleanup occurs
in reverse registration order. Never cache a container endpoint before StartAsync: mapped ports
are known only after startup.
Built-in modules
| Module | Registration | Default resource | Published configuration |
|---|---|---|---|
| PostgreSQL | UsePostgreSql() |
PostgreSql |
ConnectionStrings:PostgreSql |
| SQL Server | UseSqlServer() |
SqlServer |
ConnectionStrings:SqlServer |
| Kafka | UseKafka() |
Kafka |
Kafka:BootstrapServers |
| Redis | UseRedis() |
Redis |
ConnectionStrings:Redis |
| RabbitMQ | UseRabbitMq() |
RabbitMq |
ConnectionStrings:RabbitMq |
| Azurite | UseAzurite() |
Azurite |
ConnectionStrings:AzureStorage |
| Service Bus emulator | UseServiceBusEmulator(true) |
ServiceBus |
ConnectionStrings:ServiceBus |
Each option object supports ResourceName, ConfigurationKey, and one or more
ConfigureBuilder callbacks for native Testcontainers configuration.
PostgreSQL
[Fact(Explicit = true)]
public async Task PostgreSql_is_ready_and_accepts_queries()
{
using var factory = EasyTestHost.Create<Program>()
.UsePostgreSql()
.Build();
await using var scope = await CreateScopeAsync(factory);
var container = scope.GetTestcontainer<Program, PostgreSqlContainer>("PostgreSql");
await using var connection = new NpgsqlConnection(container.GetConnectionString());
await connection.OpenAsync(TestContext.Current.CancellationToken);
await using var command = new NpgsqlCommand("SELECT 42", connection);
var result = await command.ExecuteScalarAsync(TestContext.Current.CancellationToken);
Assert.Equal(42L, Convert.ToInt64(result, System.Globalization.CultureInfo.InvariantCulture));
AssertConfiguration(scope, "ConnectionStrings:PostgreSql", container.GetConnectionString());
}
SQL Server
[Fact(Explicit = true)]
public async Task SqlServer_is_ready_and_accepts_queries()
{
using var factory = EasyTestHost.Create<Program>()
.UseSqlServer()
.Build();
await using var scope = await CreateScopeAsync(factory);
var container = scope.GetTestcontainer<Program, MsSqlContainer>("SqlServer");
await using var connection = new SqlConnection(container.GetConnectionString());
await connection.OpenAsync(TestContext.Current.CancellationToken);
await using var command = new SqlCommand("SELECT 42", connection);
var result = await command.ExecuteScalarAsync(TestContext.Current.CancellationToken);
Assert.Equal(42, Convert.ToInt32(result, System.Globalization.CultureInfo.InvariantCulture));
AssertConfiguration(scope, "ConnectionStrings:SqlServer", container.GetConnectionString());
}
Kafka
[Fact(Explicit = true)]
public async Task Kafka_is_ready_and_persists_a_message()
{
using var factory = EasyTestHost.Create<Program>()
.UseKafka()
.Build();
await using var scope = await CreateScopeAsync(factory);
var container = scope.GetTestcontainer<Program, KafkaContainer>("Kafka");
var topic = $"xbullet-{Guid.NewGuid():N}";
using var producer = new ProducerBuilder<Null, string>(new ProducerConfig
{
BootstrapServers = container.GetBootstrapAddress(),
MessageTimeoutMs = 30_000
}).Build();
var result = await producer.ProduceAsync(
topic,
new Message<Null, string> { Value = "ready" },
TestContext.Current.CancellationToken);
Assert.Equal(PersistenceStatus.Persisted, result.Status);
AssertConfiguration(scope, "Kafka:BootstrapServers", container.GetBootstrapAddress());
}
Redis
[Fact(Explicit = true)]
public async Task Redis_is_ready_and_round_trips_a_value()
{
using var factory = EasyTestHost.Create<Program>()
.UseRedis()
.Build();
await using var scope = await CreateScopeAsync(factory);
var container = scope.GetTestcontainer<Program, RedisContainer>("Redis");
await using var connection = await ConnectionMultiplexer.ConnectAsync(
container.GetConnectionString());
var database = connection.GetDatabase();
var key = $"xbullet:{Guid.NewGuid():N}";
Assert.True(await database.StringSetAsync(key, "ready"));
Assert.Equal("ready", await database.StringGetAsync(key));
AssertConfiguration(scope, "ConnectionStrings:Redis", container.GetConnectionString());
}
RabbitMQ
[Fact(Explicit = true)]
public async Task RabbitMq_is_ready_and_round_trips_a_message()
{
using var factory = EasyTestHost.Create<Program>()
.UseRabbitMq()
.Build();
await using var scope = await CreateScopeAsync(factory);
var container = scope.GetTestcontainer<Program, RabbitMqContainer>("RabbitMq");
var connectionFactory = new ConnectionFactory
{
Uri = new Uri(container.GetConnectionString())
};
await using var connection = await connectionFactory.CreateConnectionAsync(
TestContext.Current.CancellationToken);
await using var channel = await connection.CreateChannelAsync(
cancellationToken: TestContext.Current.CancellationToken);
var queue = await channel.QueueDeclareAsync(
queue: string.Empty,
durable: false,
exclusive: true,
autoDelete: true,
cancellationToken: TestContext.Current.CancellationToken);
await channel.BasicPublishAsync(
exchange: string.Empty,
routingKey: queue.QueueName,
body: Encoding.UTF8.GetBytes("ready"),
cancellationToken: TestContext.Current.CancellationToken);
var delivery = await channel.BasicGetAsync(
queue.QueueName,
autoAck: true,
TestContext.Current.CancellationToken);
Assert.NotNull(delivery);
Assert.Equal("ready", Encoding.UTF8.GetString(delivery.Body.Span));
AssertConfiguration(scope, "ConnectionStrings:RabbitMq", container.GetConnectionString());
}
Azurite
[Fact(Explicit = true)]
public async Task Azurite_is_ready_and_round_trips_a_blob()
{
using var factory = EasyTestHost.Create<Program>()
.UseAzurite()
.Build();
await using var scope = await CreateScopeAsync(factory);
var container = scope.GetTestcontainer<Program, AzuriteContainer>("Azurite");
var service = new BlobServiceClient(container.GetConnectionString());
var blobContainer = service.GetBlobContainerClient($"xbullet-{Guid.NewGuid():N}");
await blobContainer.CreateAsync(cancellationToken: TestContext.Current.CancellationToken);
var blob = blobContainer.GetBlobClient("ready.txt");
await blob.UploadAsync(
BinaryData.FromString("ready"),
TestContext.Current.CancellationToken);
var download = await blob.DownloadContentAsync(TestContext.Current.CancellationToken);
Assert.Equal("ready", download.Value.Content.ToString());
AssertConfiguration(scope, "ConnectionStrings:AzureStorage", container.GetConnectionString());
}
Service Bus emulator
The emulator module rejects registration unless acceptLicenseAgreement: true is supplied. The
default emulator configuration provides queue.1 for a minimal round trip:
[Fact(Explicit = true)]
public async Task ServiceBus_emulator_is_ready_and_round_trips_a_message()
{
using var factory = EasyTestHost.Create<Program>()
.UseServiceBusEmulator(acceptLicenseAgreement: true)
.Build();
await using var scope = await CreateScopeAsync(factory);
var container = scope.GetTestcontainer<Program, ServiceBusContainer>("ServiceBus");
await using var client = new ServiceBusClient(container.GetConnectionString());
await using var sender = client.CreateSender("queue.1");
await using var receiver = client.CreateReceiver("queue.1");
await sender.SendMessageAsync(
new ServiceBusMessage("ready"),
TestContext.Current.CancellationToken);
var received = await receiver.ReceiveMessageAsync(
maxWaitTime: TimeSpan.FromSeconds(30),
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(received);
Assert.Equal("ready", received.Body.ToString());
await receiver.CompleteMessageAsync(received, TestContext.Current.CancellationToken);
AssertConfiguration(scope, "ConnectionStrings:ServiceBus", container.GetConnectionString());
}
Register an arbitrary container
Use UseTestcontainer for a native Testcontainers module or an IContainer. Resolve mapped ports
inside the configuration callback, which runs after readiness:
builder.UseTestcontainer(
"search",
_ => new ContainerBuilder("getmeili/meilisearch:v1.12.8")
.WithPortBinding(7700, assignRandomHostPort: true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(
request => request.ForPort(7700).ForPath("/health")))
.Build(),
container => new Dictionary<string, string?>
{
["Search:Endpoint"] =
$"http://{container.Hostname}:{container.GetMappedPublicPort(7700)}"
},
configureServices: (container, services) => services.AddSingleton(container),
maximumDiagnosticCharacters: 20_000);
The generic registration integrates with host configuration, typed lookup, and automatic cleanup:
[Fact]
public async Task Generic_registration_integrates_with_scenario_host_and_typed_lookup()
{
var container = CreateContainer(out var proxy);
using var factory = TestApiHostSettings.CreateBuilder()
.UseTestcontainer(
"dependency",
_ => container,
_ => new Dictionary<string, string?>
{
["Dependency:Endpoint"] = "localhost:54321"
},
configureServices: null,
maximumDiagnosticCharacters: 100)
.Build();
await using (var scope = await factory.CreateTestScenarioScopeAsync(
cancellationToken: TestContext.Current.CancellationToken))
{
Assert.Equal(
"localhost:54321",
scope.Services.GetRequiredService<IConfiguration>()["Dependency:Endpoint"]);
Assert.Same(
container,
scope.GetTestcontainer<Program, IContainer>("dependency"));
Assert.Equal(1, proxy.StartCount);
Assert.Equal(0, proxy.DisposeCount);
}
Assert.Equal(1, proxy.DisposeCount);
}
Use a native wait strategy that proves the service is usable, not merely that its process started. Readiness failures stop scenario startup and still dispose resources that started successfully.
Resource sharing and isolation
The default model is one container set per scenario. This gives strong isolation and predictable cleanup but costs startup time. To share infrastructure, keep a factory alive across tests and make application data unique per scenario—for example, separate schemas, database names, topic names, queue names, or key prefixes. Serialize scenarios that mutate the same shared resource.
Do not share a mutable container implicitly through static state. If sharing is intentional, document ownership and ensure the final fixture disposes it even when setup fails.
Diagnostics and failure behavior
Container diagnostics include identity, image, state, health, mapped ports, and bounded stdout and stderr. Published configuration values are excluded because connection strings may contain credentials. The resource cannot publish configuration before startup and disposal is idempotent:
[Fact]
public async Task Resource_waits_for_start_before_publishing_configuration_and_services()
{
var container = CreateContainer(out var proxy);
var resource = new TestcontainerResource<IContainer>(
container,
_ => new Dictionary<string, string?>
{
["ConnectionStrings:Dependency"] = "server=secret"
},
(_, services) => services.AddSingleton<Marker>(),
maximumDiagnosticCharacters: 5);
var configurationBuilder = new ConfigurationBuilder();
Assert.Throws<InvalidOperationException>(() =>
resource.ConfigureConfiguration(configurationBuilder));
await resource.StartAsync(TestContext.Current.CancellationToken);
resource.ConfigureConfiguration(configurationBuilder);
var services = new ServiceCollection();
resource.ConfigureServices(services);
Assert.Equal("server=secret", configurationBuilder.Build()["ConnectionStrings:Dependency"]);
await using var serviceProvider = services.BuildServiceProvider();
Assert.Same(container, serviceProvider.GetRequiredService<IContainer>());
Assert.NotNull(serviceProvider.GetService<Marker>());
var diagnostics = JsonSerializer.Serialize(
await resource.CaptureDiagnosticsAsync(TestContext.Current.CancellationToken));
Assert.Contains("container-id", diagnostics);
Assert.Contains("defgh", diagnostics);
Assert.DoesNotContain("abcdefgh", diagnostics);
Assert.DoesNotContain("server=secret", diagnostics);
await resource.DisposeAsync();
await resource.DisposeAsync();
Assert.Equal(1, proxy.StartCount);
Assert.Equal(1, proxy.DisposeCount);
}
If startup times out, first inspect the container runtime, image pull permissions, port availability, and the configured wait strategy. If the application starts but cannot connect, confirm that it uses the published configuration key rather than a development value. Increase diagnostic bounds only when the missing log tail is needed; avoid exposing secrets in container output.
Run the real-service smoke tests
The seven smoke tests are explicit so an ordinary test run and CI do not require Docker:
dotnet test tests/XBullet.EasyTesting.ContainerTests/XBullet.EasyTesting.ContainerTests.csproj \
--configuration Release --explicit only
Run them locally or in a dedicated infrastructure job. See
ContainerModuleSmokeTests
for the canonical client round trips and
TestcontainerTests for lifecycle and
failure coverage.
Browse the Testcontainers API reference.