Test an Aspire distributed application
Use XBullet.EasyTesting.Aspire when a test must start the complete AppHost topology, discover
resources and endpoints, wait for health, and exercise services across process boundaries. For a
single ASP.NET Core service, prefer the faster in-memory host described in
test-host selection.
Prerequisites and installation
Reference the Aspire AppHost project from the test project and install:
dotnet add package XBullet.EasyTesting.Aspire
Import XBullet.EasyTesting.Aspire. The AppHost and every resource it launches must be runnable on
the test machine; container resources also require their normal container runtime.
Start, wait, and call a resource
RunAsync owns startup and shutdown. Declare every resource whose health is required before the
test callback runs:
await AspireTestHost.Create<Projects.Orders_AppHost>()
.WithArguments("--environment=Testing")
.WaitForResource("api")
.WaitForResource("frontend")
.WithStartupTimeout(TimeSpan.FromMinutes(2))
.RunAsync(async (application, cancellationToken) =>
{
using var client = application.CreateHttpClient("frontend");
using var response = await client.GetAsync("/orders/42", cancellationToken);
response.EnsureSuccessStatusCode();
}, cancellationToken);
WithArguments configures AppHost command-line arguments. ConfigureAppHost exposes the native
IDistributedApplicationTestingBuilder for test-only service or resource configuration. Builder
configuration is frozen after startup begins.
The startup timeout covers AppHost startup and configured health waits. Caller cancellation remains
an OperationCanceledException; it is not rewritten as a timeout.
Discover resources and endpoints
After startup, use:
ResourceNamesto inspect discovered logical resources.WaitForResourceAsyncfor an additional health wait.GetEndpoint(resourceName, endpointName?)for an assigned URI.CreateHttpClient(resourceName, endpointName?)for an endpoint-aware client.GetConnectionStringAsyncfor resources that publish a connection string.
Use manual lifetime control when setup or assertions must occur outside one callback:
[Fact(Explicit = true)]
public async Task Distributed_app_exposes_all_resource_operations_and_disposes_idempotently()
{
var builder = AspireTestHost.Create<testapphost::TestAppHost.AppHostMarker>()
.ConfigureAppHost(_ => { })
.WaitForResource("api")
.WithMaximumDiagnosticLinesPerResource(1);
var application = await builder.StartAsync(TestContext.Current.CancellationToken);
Assert.Throws<InvalidOperationException>(() => builder.WithArguments("--late"));
Assert.Throws<InvalidOperationException>(() => builder.ConfigureAppHost(_ => { }));
Assert.Throws<InvalidOperationException>(() => builder.WaitForResource("api"));
Assert.Throws<InvalidOperationException>(() =>
builder.WithStartupTimeout(TimeSpan.FromSeconds(1)));
Assert.Throws<InvalidOperationException>(() =>
builder.WithMaximumDiagnosticLinesPerResource(1));
Assert.Contains("api", application.ResourceNames);
Assert.NotNull(application.GetEndpoint("api"));
Assert.NotNull(application.GetEndpoint("api", "http"));
Assert.Throws<ArgumentException>(() => application.GetEndpoint("api", " "));
await application.WaitForResourceAsync("api", TestContext.Current.CancellationToken);
await Assert.ThrowsAsync<ArgumentException>(async () =>
await application.GetConnectionStringAsync(
"api",
TestContext.Current.CancellationToken));
using (var client = application.CreateHttpClient("api"))
using (var response = await client.GetAsync("/health", TestContext.Current.CancellationToken))
{
response.EnsureSuccessStatusCode();
}
using (var client = application.CreateHttpClient("api", "http"))
using (var response = await client.GetAsync("/health", TestContext.Current.CancellationToken))
{
response.EnsureSuccessStatusCode();
}
var diagnostics = await application.CaptureDiagnosticsAsync(
TestContext.Current.CancellationToken);
Assert.All(diagnostics.Resources, resource => Assert.True(resource.Logs.Count <= 1));
await application.DisposeAsync();
await application.DisposeAsync();
Assert.Throws<ObjectDisposedException>(() => application.GetEndpoint("api"));
}
Always dispose the returned application asynchronously. Disposal is idempotent and shuts down the distributed application even after a test failure.
Failure diagnostics and logs
When the callback supplied to RunAsync throws, the host captures diagnostics before shutdown and
attaches them to exception.Data[AspireApplicationDiagnostics.ExceptionDataKey]:
[Fact(Explicit = true)]
public async Task Distributed_app_waits_for_health_serves_http_and_attaches_failure_diagnostics()
{
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
AspireTestHost.Create<testapphost::TestAppHost.AppHostMarker>()
.WithArguments("--environment=Testing")
.ConfigureAppHost(_ => { })
.WaitForResource("api")
.WithStartupTimeout(TimeSpan.FromMinutes(2))
.RunAsync(
async (application, cancellationToken) =>
{
Assert.Contains("api", application.ResourceNames);
Assert.NotNull(application.GetEndpoint("api"));
using var client = application.CreateHttpClient("api");
using var response = await client.GetAsync("/health", cancellationToken);
response.EnsureSuccessStatusCode();
Assert.Contains(
"Healthy",
await response.Content.ReadAsStringAsync(cancellationToken));
throw new InvalidOperationException("Expected distributed test failure.");
},
TestContext.Current.CancellationToken));
var diagnostics = Assert.IsType<AspireApplicationDiagnostics>(
exception.Data[AspireApplicationDiagnostics.ExceptionDataKey]);
var api = Assert.Single(diagnostics.Resources, resource => resource.Name == "api");
Assert.Equal("Healthy", api.HealthStatus);
Assert.NotEmpty(api.Logs);
}
Each resource snapshot contains its logical and instance names, state, health, exit code, and a
bounded tail of stdout and stderr with stream identity. Endpoint values and connection strings are
excluded. Configure the bound with WithMaximumDiagnosticLinesPerResource; use zero only when log
capture is intentionally disabled.
Call CaptureDiagnosticsAsync explicitly when a test needs to inspect current state without
failing. Captured diagnostics are a snapshot and remain available after shutdown.
Troubleshooting startup and readiness
A startup deadline produces TimeoutException and cleans partially created state:
[Fact(Explicit = true)]
public async Task Startup_timeout_is_reported_as_timeout_and_cleans_partial_state()
{
await Assert.ThrowsAsync<TimeoutException>(() =>
AspireTestHost.Create<testapphost::TestAppHost.AppHostMarker>()
.WithStartupTimeout(TimeSpan.FromMilliseconds(1))
.StartAsync(TestContext.Current.CancellationToken));
}
When startup or readiness fails:
- Verify the AppHost project reference and the marker type passed to
Create<TAppHost>(). - Confirm the resource name matches the AppHost logical name exactly.
- Inspect attached resource state, health, exit code, and recent logs.
- Check prerequisites for child processes and containers.
- Increase the startup timeout only after determining that the resource is progressing normally.
An unavailable resource fails its health wait rather than consuming the entire timeout. Missing endpoint or connection-string errors usually mean the selected resource does not publish that kind of value or the endpoint name is wrong.
These tests exercise the packaged topology, service discovery, and real inter-process networking.
They are slower and more environment-dependent than in-memory controller tests, so keep focused
unit and service-level tests as the default feedback loop. Canonical behavior lives in
AspireTests; real distributed tests are
explicit and currently run for the AppHost target framework.
Browse the Aspire API reference.