Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions docs/articles/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,15 @@ Key features:
the test runs on a per-test copy of the host with that service replaced. Register under the type the application
resolves (`Use<IWeatherClient>(stub)`, not `Use(stub)`). A test that registers nothing runs on the shared host;
registering after the host is built throws, so a test never silently runs against the real service. Outbound HTTP
has its own mock, see [below](#mocking-outbound-http-dependencies).
has its own mock, see [below](#mocking-outbound-http-dependencies). The per-test host uses the class's databases and
virtual hosts, so while it runs, the shared host pauses its restartable hosted services (see the reset below) and
resumes them after the test: the two hosts never compete for the same queue or the same database rows.
- **Automatic database reset** – the database is reset with Respawn after each test, so tests sharing a factory
start from a clean state. Hosted services implementing `IRestartableHostedService` (from
`Vulthil.Extensions.Hosting`) are stopped around the reset and restarted afterwards, so a database-polling relay such
as the outbox background service never contends with it, and the message consumers stop consuming until the reset
is done. Every stop, reset and restart step is bounded by its own
`Vulthil.Extensions.Hosting`) on every live host of the class are stopped around the reset and restarted afterwards,
so a database-polling relay such as the outbox background service never contends with it, and the message consumers
stop consuming until the reset is done. Only the newest live host runs those services at any time: a per-test host
pauses the hosts built before it until it stops. Every stop, reset and restart step is bounded by its own
30-second timeout rather than the test's cancellation token, a failing step never skips the remaining ones, and all
failures are reported together — a test that timed out still leaves a clean fixture for the next one.
- **Log capture** – application logs are routed to the currently running test automatically (via `TestContext`). The
Expand All @@ -136,7 +139,7 @@ Key features:
`Vulthil.xUnit` ships fixture base classes (in the `Vulthil.xUnit.Fixtures` namespace) that wrap [Testcontainers](https://testcontainers.com/) containers so you can spin up databases, message brokers, and other dependencies as Docker containers. There are three levels, depending on what the container needs to expose:

- `TestContainerFixture<TBuilderEntity, TContainerEntity>` – a plain container with a managed lifecycle (`ITestContainer`).
- `TestContainerFixtureWithConnectionString<TBuilderEntity, TContainerEntity>` – adds a connection string that is injected into the host's configuration under `ConnectionStrings:{ConnectionStringKey}` (`ITestContainerWithConnectionString`). Give `ConnectionStringKey` the bare name (e.g. `"AppDb"`); the factory adds the `ConnectionStrings:` prefix.
- `TestContainerFixtureWithConnectionString<TBuilderEntity, TContainerEntity>` – adds a connection string that is injected into the host's configuration under `ConnectionStrings:{ConnectionStringKey}` (`ITestContainerWithConnectionString`). Give `ConnectionStringKey` the bare name (e.g. `"AppDb"`); the factory adds the `ConnectionStrings:` prefix. Every container a factory consumes needs its own key: when two consumed containers use the same key (compared case-insensitively, like configuration keys), the factory's `InitializeAsync` throws instead of letting one connection string overwrite the other.
- `TestDatabaseContainerFixture<TDbContext, TBuilderEntity, TContainerEntity>` – adds EF Core migrations and Respawn-based data reset between tests (`ITestDatabaseContainer`).

None of them needs constructor arguments. Pass an `IMessageSink` to route Testcontainers' own log output somewhere
Expand Down Expand Up @@ -230,6 +233,8 @@ Every container on the host is consumed automatically, so containers are managed

The scope identifier defaults to the factory type name plus a random suffix (override `CreateScopeId()` to change it), so two classes using the same factory type still get distinct databases and virtual hosts.

A scope lives as long as its factory: one test class. All tests of the class — and the per-test hosts they build — share its database and virtual host. The database is reset after each test, but the queues are not purged, so a message that one test leaves in a queue is delivered during the next test of the class. Wait for the messages a test publishes before the test ends.

### Mocking outbound HTTP dependencies

For a service that calls an external API through an `HttpClient` from `IHttpClientFactory`, register an in-process HTTP mock on the factory. It replaces that client's primary message handler, so the real client code runs (URL building, serialization, the delegating-handler pipeline) and only the wire is faked. Both **typed** clients (`AddHttpClient<TClient, ...>()`) and **named** clients (`AddHttpClient("name")`) are supported; for typed clients the implementation type does not need to be accessible:
Expand Down
16 changes: 8 additions & 8 deletions src/Vulthil.xUnit/BaseIntegrationTestCase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ namespace Vulthil.xUnit;
/// Supply the factory as an <see cref="IClassFixture{TFixture}"/> (or collection fixture) so its containers are
/// started once and shared across the tests in that scope; database state is reset after each test. Tests that
/// register no services share the fixture's test host; a test that registers services runs on a derived host built
/// through <see cref="WebApplicationFactory{TEntryPoint}.WithWebHostBuilder"/>, disposed after the test. Application
/// logs reach the currently running test through the factory's TestContext-routed logger.
/// through <see cref="WebApplicationFactory{TEntryPoint}.WithWebHostBuilder"/>, disposed after the test. Both hosts use
/// the fixture's databases and virtual hosts, so while the derived host runs, the shared host pauses its restartable
/// hosted services (see <see cref="BaseWebApplicationFactory{TEntryPoint}"/>). Application logs reach the currently
/// running test through the factory's TestContext-routed logger.
/// </remarks>
/// <typeparam name="TEntryPoint">The application's entry point type, typically <c>Program</c>.</typeparam>
public abstract class BaseIntegrationTestCase<TEntryPoint> : BaseUnitTestCase
Expand Down Expand Up @@ -123,7 +125,7 @@ protected HttpClient Client
/// yourself (for example <c>FactoryFixture.WithWebHostBuilder(...)</c>) when the tests need other per-test host
/// configuration; call <see cref="ConfigureTestCaseServices"/> from the builder's <c>ConfigureTestServices</c> to
/// keep the registered doubles. A derived factory is disposed automatically after each test, and the post-test
/// reset always targets whichever factory this method returns, never an unrelated, never-built host.
/// reset covers every live host of the fixture without building one.
/// </summary>
/// <returns>The factory the current test should run against.</returns>
protected virtual WebApplicationFactory<TEntryPoint> CreateFactory()
Expand Down Expand Up @@ -229,7 +231,7 @@ public override async ValueTask InitializeAsync()
}

/// <summary>
/// Resets the host this test ran on (restartable services paused, resettable resources cleared), disposes the
/// Resets the fixture's live hosts (restartable services paused, resettable resources cleared), disposes the
/// scope, the client and any per-test derived factory, then disposes everything the auto-mocker holds. Override
/// (calling the base implementation) for further cleanup.
/// </summary>
Expand All @@ -238,12 +240,10 @@ protected override async ValueTask Dispose()
{
try
{
// Only the factory this test actually ran on (Factory, e.g. a WithWebHostBuilder(...) clone of
// FactoryFixture) has the running host whose restartable services need pausing around the reset; a test
// that never touched Factory never built any host, so there is nothing to reset.
// A test that never touched Factory changed nothing, so there is nothing to reset.
if (_lazyFactory.IsValueCreated)
{
await FactoryFixture.ResetAsync(_lazyFactory.Value.Services).ConfigureAwait(false);
await FactoryFixture.ResetAsync().ConfigureAwait(false);
}
}
finally
Expand Down
124 changes: 27 additions & 97 deletions src/Vulthil.xUnit/BaseWebApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,33 @@ namespace Vulthil.xUnit;
/// ensures EF Core migrations are applied during host startup.
/// </summary>
/// <remarks>
/// <para>
/// Register the containers once on a <see cref="ContainerHost"/> assembly fixture and pass it to the constructor; the
/// factory then consumes every host container (filter with <see cref="ShouldUseContainer"/>) through a per-factory
/// scope view — an isolated database, virtual host, ... — so test classes run in parallel against shared containers
/// without interfering. Use a derived factory as an <see cref="IClassFixture{TFixture}"/> (or collection fixture) so
/// its scopes are provisioned once and shared across the tests in that scope, while
/// <see cref="BaseIntegrationTestCase{TEntryPoint}"/> resets database state between tests.
/// </para>
/// <para>
/// Migrations run from an <see cref="IHostedService"/> registered at the front of the host's hosted-service list, so the
/// schema exists before the application's own background services start. The migration step only applies migrations that
/// are still pending and tolerates a concurrent migrator, so an application that migrates itself on startup keeps
/// ownership and the factory never interferes with the application's own migration logic.
/// </para>
/// <para>
/// Every host the factory builds — its own and each one derived through <c>WithWebHostBuilder</c> — uses the same scope
/// views, so the same databases and virtual hosts. Only the newest live host runs its
/// <c>IRestartableHostedService</c>s: while a derived host runs, the hosts before it pause their restartable services
/// (for example the outbox relay and the message consumers), and they resume when the derived host stops.
/// </para>
/// </remarks>
public abstract class BaseWebApplicationFactory<TEntryPoint> : WebApplicationFactory<TEntryPoint>, IAsyncLifetime, ITestHostMigrator
public abstract class BaseWebApplicationFactory<TEntryPoint> : WebApplicationFactory<TEntryPoint>, IAsyncLifetime
where TEntryPoint : class
{
private readonly ContainerHost _containerHost;
private readonly HashSet<ITestContainer> _containers = [];
private readonly TestHostScope _scope;
private readonly Dictionary<string, HttpMock> _httpMocks = [];
private readonly List<Action<IServiceCollection>> _httpClientConfigurations = [];
private readonly TestHostReset _reset = new(TimeProvider.System);
private bool _initialized;

/// <summary>
/// Initializes a factory that consumes the shared containers of <paramref name="containerHost"/>.
Expand All @@ -45,21 +52,9 @@ public abstract class BaseWebApplicationFactory<TEntryPoint> : WebApplicationFac
protected BaseWebApplicationFactory(ContainerHost containerHost)
{
ArgumentNullException.ThrowIfNull(containerHost);
_containerHost = containerHost;
_scope = new TestHostScope(containerHost, ShouldUseContainer, CreateScopeId, TimeProvider.System);
}

private IEnumerable<ITestContainerWithConnectionString> ContainersWithConnectionStrings => _containers
.OfType<ITestContainerWithConnectionString>();

private IEnumerable<ITestDatabaseContainer> DatabaseContainers => _containers
.OfType<ITestDatabaseContainer>();

private IEnumerable<IResettableResource> ResettableResources => _containers
.OfType<IResettableResource>()
.Concat(_httpMocks.Values);

private IEnumerable<IStartupResource> StartupResources => _containers.OfType<IStartupResource>();

/// <summary>
/// Registers an in-process HTTP mock for the named <see cref="HttpClient"/> registered with
/// <c>AddHttpClient("<paramref name="name"/>")</c>, and routes that client's outbound calls through it by replacing
Expand Down Expand Up @@ -162,20 +157,19 @@ protected override sealed void ConfigureWebHost(IWebHostBuilder builder)
IncludeScopes = true,
})));

foreach (var container in ContainersWithConnectionStrings)
foreach (var (key, connectionString) in _scope.ConnectionStrings)
{
var connectionString = container.ConnectionString;
builder.UseSetting($"ConnectionStrings:{container.ConnectionStringKey}", connectionString);
builder.UseSetting(key, connectionString);
}

foreach (var container in _containers)
foreach (var container in _scope.Containers)
{
container.ConfigureWebHost(builder);
}

builder.ConfigureTestServices(services =>
{
foreach (var container in _containers)
foreach (var container in _scope.Containers)
{
container.ConfigureServices(services);
}
Expand All @@ -185,8 +179,7 @@ protected override sealed void ConfigureWebHost(IWebHostBuilder builder)

builder.ConfigureServices(services =>
{
services.Insert(0, ServiceDescriptor.Singleton<IHostedService>(
sp => new TestMigrationHostedService(this, sp)));
services.Insert(0, ServiceDescriptor.Singleton<IHostedService>(_scope.CreateHostLifecycle));

foreach (var configureHttpClient in _httpClientConfigurations)
{
Expand All @@ -200,38 +193,8 @@ protected override sealed void ConfigureWebHost(IWebHostBuilder builder)
/// each of them in parallel. Invoked once by xUnit before the tests in scope run.
/// </summary>
/// <returns>A task representing the asynchronous startup work.</returns>
public async ValueTask InitializeAsync()
{
if (_initialized)
{
return;
}

await AcquireHostContainers().ConfigureAwait(false);
await Parallel.ForEachAsync(_containers, (container, ct) => container.InitializeAsync()).ConfigureAwait(false);
_initialized = true;
}

private async Task AcquireHostContainers()
{
var consumedContainers = _containerHost.Containers.Where(ShouldUseContainer).ToList();
await Parallel.ForEachAsync(consumedContainers, async (container, ct) => await _containerHost.EnsureStartedAsync(container).ConfigureAwait(false)).ConfigureAwait(false);

var scopeId = CreateScopeId();
foreach (var container in consumedContainers)
{
#pragma warning disable CA2000 // Ownership transfers to _containers; scope views are disposed in DisposeAsync.
_containers.Add(CreateScopeView(container, scopeId));
#pragma warning restore CA2000
}
}

private static ITestContainer CreateScopeView(ITestContainer container, string scopeId) => container switch
{
ITestContainerScopeProvider scopeProvider => scopeProvider.CreateScope(scopeId),
ITestContainerWithConnectionString withConnectionString => new TestContainerWithConnectionStringScope(withConnectionString),
_ => new TestContainerScope(container),
};
/// <exception cref="InvalidOperationException">Two consumed containers use the same connection string key.</exception>
public async ValueTask InitializeAsync() => await _scope.InitializeAsync().ConfigureAwait(false);

/// <inheritdoc />
public override async ValueTask DisposeAsync()
Expand All @@ -242,48 +205,15 @@ public override async ValueTask DisposeAsync()
// per-factory scopes. The containers themselves are owned by the ContainerHost and outlive the factory.
await base.DisposeAsync().ConfigureAwait(false);

await Parallel.ForEachAsync(_containers, (container, ct) => container.DisposeAsync()).ConfigureAwait(false);
}

async Task ITestHostMigrator.PrepareAsync(IServiceProvider serviceProvider)
{
var scope = serviceProvider.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
await Parallel.ForEachAsync(DatabaseContainers, (container, ct) => container.MigrateDatabase(scope.ServiceProvider)).ConfigureAwait(false);
await Parallel.ForEachAsync(StartupResources, (resource, ct) => resource.InitializeAsync(serviceProvider)).ConfigureAwait(false);
await _scope.DisposeAsync().ConfigureAwait(false);
}

/// <summary>
/// Resets the given host's restartable services and this factory's registered resources. Accepts the host's
/// service provider explicitly so a caller running against a derived factory (for example one produced by
/// <c>WithWebHostBuilder(...)</c>) can pass that host's own <see cref="IServiceProvider"/> — resetting always
/// targets the host the caller actually ran the test against, never an unrelated, never-built host.
/// Resets the factory's scope between tests: pauses the restartable services of every running host built by this
/// factory (its own and any derived through <c>WithWebHostBuilder</c>), resets the scope's resources and the HTTP
/// mocks, then resumes the paused services. Does nothing when no host is live, so a test that built no host never
/// builds one just to reset it.
/// </summary>
/// <param name="hostServices">The service provider of the host the test ran against.</param>
internal Task ResetAsync(IServiceProvider hostServices) => _reset.ResetAsync(hostServices, [.. ResettableResources]);
}

internal interface ITestHostMigrator
{
Task PrepareAsync(IServiceProvider serviceProvider);
}

internal sealed class TestMigrationHostedService(ITestHostMigrator migrator, IServiceProvider serviceProvider) : IHostedService
{
private bool _completed;

/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
if (_completed)
{
return;
}

await migrator.PrepareAsync(serviceProvider).ConfigureAwait(false);
_completed = true;
}

/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <returns>A task that completes when every step has run.</returns>
internal Task ResetAsync() => _scope.ResetAsync([.. _httpMocks.Values]);
}
Loading
Loading