End-to-End Integration Testing for Agent-Based Systems with .NET Aspire

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:

EndpointPurpose
/health/liveProcess is alive
/health/readyAll 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

Website Powered by WordPress.com.

Up ↑