Modern agent-based systems are rarely a single executable. They typically consist of multiple cooperating components: agent hosts, orchestrators, background workers, and external dependencies such as Redis, search engines, or AI services.
Testing such systems effectively requires more than unit tests—it requires repeatable, automated, end-to-end integration tests that reflect real runtime behavior.
In this post, I’ll walk through how we implemented stable, fully automated Aspire-based integration tests for an agent system using xUnit and .NET Aspire, without exposing domain-specific details.
Why Traditional Integration Tests Fall Short
In distributed agent architectures, common testing approaches often break down:
- Running services manually (
dotnet run) before tests is error-prone - Static ports and connection strings cause conflicts
- “Is the service ready?” becomes guesswork
- CI behavior diverges from local development
What we wanted instead was:
- A single command to run tests
- The same topology locally and in CI
- Deterministic startup and shutdown
- Explicit readiness signaling
This is exactly what .NET Aspire’s testing infrastructure is designed for.
The Aspire Testing Model
Aspire introduces a powerful concept:
tests can bootstrap the entire distributed application.
Using Aspire.Hosting.Testing, an xUnit test can:
- Start the AppHost
- Launch all dependent services (agent host, Redis, etc.)
- Discover dynamically assigned ports
- Communicate via real HTTP endpoints
- Tear everything down automatically
In other words, the test becomes the orchestrator.
Test Architecture Overview
At a high level, our setup looks like this:
xUnit Test
└─ Aspire AppHost
├─ AgentHost (HTTP)
├─ Redis (dynamic port)
└─ Other infrastructure resources
The test does not mock the system.
It runs the system.
Managing the AppHost Lifecycle in xUnit
The first rule of Aspire integration testing is simple:
Start the AppHost once per test collection.
We achieve this using an xUnit collection fixture.
public sealed class AspireAppFixture : IAsyncLifetime
{
public DistributedApplication App { get; private set; } = default!;
public async Task InitializeAsync()
{
var builder =
await DistributedApplicationTestingBuilder
.CreateAsync<Projects.My_AppHost>();
builder.WithEnvironment("ASPNETCORE_ENVIRONMENT", "Testing");
builder.WithEnvironment("TEST_MODE", "true");
App = builder.Build();
await App.StartAsync();
}
public async Task DisposeAsync()
{
await App.DisposeAsync();
}
}
This ensures:
- Services start only once
- Tests remain fast
- Startup behavior matches production topology
The Importance of Explicit Health Endpoints
One of the most important improvements we made was introducing explicit health endpoints on the AgentHost.
Why “ready” Is Not the Same as “running”
A process can be running while still being unusable:
- Redis not connected
- Agents not registered
- Background initialization incomplete
To address this, we implemented two endpoints:
| Endpoint | Purpose |
|---|---|
/health/live | Process is alive |
/health/ready | All required subsystems are operational |
Implementing Health Checks
Liveness
hc.AddCheck("self", () => HealthCheckResult.Healthy());
Readiness Checks
We tagged readiness checks explicitly:
- Redis connectivity (using
IConnectionMultiplexer.Ping) - Agent subsystem initialized (at least one agent registered)
hc.AddCheck<RedisPingHealthCheck>("redis", tags: new[] { "ready" });
hc.AddCheck<AgentSubsystemHealthCheck>("agents", tags: new[] { "ready" });
Endpoint Mapping
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = r => r.Name == "self"
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = r => r.Name == "self" || r.Tags.Contains("ready")
});
This separation proved critical for reliable test startup.
Waiting for Readiness in Tests
Instead of guessing delays or using fixed sleeps, the test actively waits for readiness:
await WaitForReadyAsync(httpClient, "/health/ready", TimeSpan.FromSeconds(30));
Only after readiness is confirmed do we construct higher-level clients and execute assertions.
This eliminated flakiness caused by:
- Slow machines
- Cold starts
- CI variability
Smoke Testing the Agent System
Once the system is ready, tests interact with it through real HTTP calls:
var client = app.CreateHttpClient("agenthost");
var response = await client.PostAsJsonAsync(
"/api/agent/execute",
new { input = "sample request" });
response.EnsureSuccessStatusCode();
From the test’s perspective, this is indistinguishable from a real consumer.
Lessons Learned
1. Health endpoints are not optional
They are the foundation of reliable integration tests.
2. Aspire expressions matter
Passing deferred Aspire expressions (instead of static host:port strings) ensures services receive the published ports, not defaults.
3. Avoid mutating HttpClient after first use
Timeouts and handlers must be configured before the first request to avoid subtle runtime issues.
4. Let tests own the environment
dotnet test should be the only command required—locally and in CI.
The Result
With this approach, we achieved:
- Fully automated end-to-end tests
- Identical behavior locally and in CI
- Deterministic startup and shutdown
- Clear diagnostics when readiness fails
Most importantly, we gained confidence that our agent system behaves correctly as a system, not just as isolated components.
Closing Thoughts
.NET Aspire provides more than orchestration—it provides a testable model for distributed applications.
For agent-based systems, where coordination and readiness matter as much as correctness, Aspire-based integration testing has proven to be a robust and scalable solution.
If you’re building distributed systems in .NET, I strongly recommend treating Aspire tests as first-class citizens, not an afterthought.
That’s all folks!
Cheers!
Gašper Rupnik
{End.}

Leave a comment