Integration Testing with .NET Aspire

Unit tests verify individual components in isolation. Integration tests verify that your services work correctly when wired together with real databases, caches, and message brokers. Aspire's testing support makes writing these tests straightforward by reusing the same AppHost configuration you use for development.

The Testing Package

Install the Aspire testing package in your test project:

dotnet add package Aspire.Hosting.Testing

This package provides DistributedApplicationTestingBuilder, which creates a testable version of your AppHost.

Writing Your First Integration Test

Here is a complete integration test using xUnit:

Example.cs
public class CatalogApiTests : IAsyncLifetime
{
    private DistributedApplication _app = null!;
    private HttpClient _httpClient = null!;

    public async Task InitializeAsync()
    {
        var appHost = await DistributedApplicationTestingBuilder
            .CreateAsync<Projects.MyApp_AppHost>();

        _app = await appHost.BuildAsync();
        await _app.StartAsync();

        _httpClient = _app.CreateHttpClient("catalog-api");
    }

    [Fact]
    public async Task GetProducts_ReturnsSuccessStatusCode()
    {
        var response = await _httpClient.GetAsync("/products");

        response.EnsureSuccessStatusCode();
    }

    [Fact]
    public async Task GetProduct_WithInvalidId_ReturnsNotFound()
    {
        var response = await _httpClient.GetAsync("/products/99999");

        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    }

    public async Task DisposeAsync()
    {
        await _app.DisposeAsync();
    }
}

The key line is _app.CreateHttpClient("catalog-api"). This creates an HttpClient that is preconfigured to call the catalog-api service by the name you gave it in the AppHost. Aspire resolves the actual host and port automatically.

What Happens Behind the Scenes

When you call DistributedApplicationTestingBuilder.CreateAsync, Aspire:

  1. Reads your AppHost's Program.cs configuration
  2. Starts all container resources (PostgreSQL, Redis, RabbitMQ, etc.)
  3. Starts all .NET project resources
  4. Waits for health checks to pass
  5. Makes service endpoints available through CreateHttpClient

Your tests run against the same topology as your development environment. There is no mocking of infrastructure — the database queries hit a real PostgreSQL instance, and the cache operations go to a real Redis container.

Customising the Test Environment

You can modify the AppHost configuration for testing. For example, you might want to disable a resource or change settings:

Example.cs
var appHost = await DistributedApplicationTestingBuilder
    .CreateAsync<Projects.MyApp_AppHost>();

appHost.Services.ConfigureHttpClientDefaults(http =>
{
    http.ConfigureHttpClient(client =>
    {
        client.Timeout = TimeSpan.FromSeconds(30);
    });
});

_app = await appHost.BuildAsync();

Testing with Resource Wait Conditions

If your tests depend on a migration running before the API accepts requests, Aspire respects the WaitFor and WaitForCompletion conditions you defined in the AppHost. The test infrastructure will not start sending requests until all dependencies are ready.

Testing Specific Scenarios

Here is a more complete test that verifies database integration:

Example.cs
[Fact]
public async Task CreateProduct_PersistsToDatabase()
{
    var product = new { Name = "Test Widget", Price = 9.99m };
    var content = new StringContent(
        JsonSerializer.Serialize(product),
        Encoding.UTF8,
        "application/json");

    var createResponse = await _httpClient.PostAsync("/products", content);
    createResponse.EnsureSuccessStatusCode();

    var location = createResponse.Headers.Location;
    var getResponse = await _httpClient.GetAsync(location);
    getResponse.EnsureSuccessStatusCode();

    var created = await getResponse.Content
        .ReadFromJsonAsync<ProductDto>();

    Assert.Equal("Test Widget", created?.Name);
    Assert.Equal(9.99m, created?.Price);
}

This test creates a product via the API, then fetches it to verify it was persisted. The database is real — the product was actually written to PostgreSQL and read back.

Parallel Test Execution

Because each test class can create its own DistributedApplication instance, tests are naturally isolated. However, container startup adds overhead. For faster test runs, share the application instance across tests in the same class using IAsyncLifetime as shown above.

If you need complete isolation between tests (e.g., a clean database for each test), consider running migrations and seeding data in each test method, or using transactions that roll back:

Example.cs
[Fact]
public async Task TransactionalTest()
{
    // The API call runs in a real transaction
    var response = await _httpClient.PostAsync("/products",
        JsonContent.Create(new { Name = "Temp", Price = 1.00m }));

    response.EnsureSuccessStatusCode();

    // Verify and clean up
    var id = await response.Content.ReadFromJsonAsync<int>();
    await _httpClient.DeleteAsync($"/products/{id}");
}

CI/CD Considerations

Aspire integration tests require Docker to be available in your CI environment. Most modern CI systems (GitHub Actions, Azure DevOps, GitLab CI) support Docker. Ensure your CI agent has Docker installed and running.

The tests will pull container images on first run, which can be slow. Use image caching in your CI configuration to speed up subsequent runs.

Aspire's integration testing support bridges the gap between unit tests and production. You test against real infrastructure with minimal setup, catching issues that mocks would never reveal.