๐Ÿš€ New: The .NET Senior PlaybookSave 20% with launch discountย 

newsletter

.NET Aspire Integration Testing Best Practices for Distributed Applications

Download source code
7 min read

Newsletter Sponsors

Copied

Vibe Coding Is Fun โ€” Until You Need It in Production (Sponsored)

Prompt-to-app tools are great for demos, but enterprise apps need security, governance, and real integrations from day one. OutSystems just launched Conversational Mentor โ€” a conversational development experience that lets you build and evolve full applications through natural language, while keeping enterprise-grade guardrails built in.

Modify your database, adjust the UI, add access controls โ€” all through a prompt, all production-ready. Developers stay in full control of the architecture while AI handles the heavy lifting. Watch the free launch event recording to see it in action.

๐Ÿ‘‰ Try OutSystems for free

A while ago, I published an article about ASP.NET Core Integration Testing Best Practices. WebApplicationFactory, TestContainers, and Respawn simplifies integration testing in .NET Core applications.

But .NET Aspire simplifies the process further for distributed applications.

.NET Aspire replaces both WebApplicationFactory and TestContainers with a single tool: DistributedApplicationTestingBuilder. You no longer need to set up Docker containers manually or override environment variables. Aspire handles service discovery, container orchestration, and connection strings for you.

In this post, I will share the best practices I discovered while writing integration tests for a distributed Aspire application with multiple APIs, PostgreSQL, and Redis.

In this post, we will explore:

  • The System We Will Be Testing
  • Best Practice 1: Use DistributedApplicationTestingBuilder Instead of WebApplicationFactory
  • Best Practice 2: Share the App Instance Across Tests with ICollectionFixture
  • Best Practice 3: Wait for Resources to Be Healthy
  • Best Practice 4: Wait for Services to Start before Running Tests
  • Best Practice 5: Cleanup Database Between Tests
  • Best Practice 6: Test your API Contracts
  • Best Practice 7: Test Error Responses and Cross-Service Communication

Let's dive in.

Copied

The System We Will Be Testing

I have built a distributed system with two APIs orchestrated by .NET Aspire:

  • Products API: manages products, supports CRUD operations and purchasing. Uses PostgreSQL and Redis for caching. Calls the Stocks API to check and update stock levels during purchases.
  • Stocks API: manages stock inventory for products. Uses PostgreSQL.

Both APIs share the same PostgreSQL server but use separate database schemas.

Here is the Aspire AppHost that defines the architecture:

csharp
var builder = DistributedApplication.CreateBuilder(args); var postgres = builder.AddPostgres("postgres") .WithDataVolume(isReadOnly: false); var redis = builder.AddRedis("cache"); var stocksApi = builder.AddProject<Projects.Stocks_Api>("stocks-api") .WithReference(postgres) .WaitFor(postgres) .WithExternalHttpEndpoints(); builder.AddProject<Projects.Products_Api>("products-api") .WithReference(stocksApi) .WaitFor(stocksApi) .WithReference(postgres) .WaitFor(postgres) .WithReference(redis) .WaitFor(redis) .WithExternalHttpEndpoints(); builder.Build().Run();

The Products API depends on the Stocks API for inter-service communication. When a user purchases a product, the Products API calls the Stocks API via HTTP to verify stock availability and update the count.

Both APIs use EF Core with PostgreSQL and run database migrations on startup.

Now let's explore how to write integration tests for this system.

Copied

Best Practice 1: Use DistributedApplicationTestingBuilder Instead of WebApplicationFactory

In my previous article, I used WebApplicationFactory together with TestContainers to spin up Docker containers for PostgreSQL and RabbitMQ.

With .NET Aspire, you don't need either of these. Aspire provides DistributedApplicationTestingBuilder, which replaces both tools with a single, unified approach.

With the traditional approach, your test project references the API project directly:

xml
<ProjectReference Include="..\Products.Api\Products.Api.csproj" />

With Aspire, your test project references the Aspire AppHost project instead:

xml
<ProjectReference Include="..\AspireNetConf.AppHost\AspireNetConf.AppHost.csproj" />

This means you test the entire distributed application, not just a single API in isolation.

To create a test project, you can use the ready Aspire Test Project template:

Screenshot_1

You can install the Aspire project templates by running the following command:

bash
dotnet new install Aspire.ProjectTemplates

You will also need to install the Aspire CLI:

bash
dotnet tool install --global aspire.cli

The testing project installs the Aspire.Hosting.Testing NuGet package.

This one package replaces multiple packages from the traditional approach:

  • Microsoft.AspNetCore.Mvc.Testing (WebApplicationFactory)
  • Testcontainers.PostgreSql (TestContainers for PostgreSQL)
  • Testcontainers.RabbitMq (or other container packages)

To create the distributed application in your tests, use DistributedApplicationTestingBuilder:

csharp
var appHost = await DistributedApplicationTestingBuilder .CreateAsync<Projects.AspireTests_AppHost>(); var app = await appHost.BuildAsync(); await app.StartAsync();

This starts the entire Aspire application, including all containers and services.

To create HTTP clients for your APIs, use CreateHttpClient with the resource name from your AppHost:

csharp
var productsClient = app.CreateHttpClient("products-api"); var stocksClient = app.CreateHttpClient("stocks-api");

No need to configure base URLs or connection strings. Aspire handles service discovery automatically.

Copied

Best Practice 2: Share the App Instance Across Tests with ICollectionFixture

Starting a distributed application with Docker containers is expensive. It takes several seconds to pull images, start containers, and wait for services to become healthy.

You should not rebuild the distributed application for every test (like in the Aspire Test project template). Instead, build it once and share it across all test classes.

In xUnit, you can achieve this with ICollectionFixture and IAsyncLifetime.

First, create a fixture that manages the application lifecycle:

csharp
public class DistributedAppFixture : IAsyncLifetime { private static readonly TimeSpan StartupTimeout = TimeSpan.FromSeconds(120); private DistributedApplication _app = null!; private DbConnection _dbConnection = null!; public HttpClient ProductsApiClient { get; private set; } = null!; public HttpClient StocksApiClient { get; private set; } = null!; public async Task InitializeAsync() { var appHost = await DistributedApplicationTestingBuilder .CreateAsync<Projects.AspireTests_AppHost>(); appHost.Services.ConfigureHttpClientDefaults(clientBuilder => { clientBuilder.AddStandardResilienceHandler(); }); _app = await appHost.BuildAsync(); await _app.StartAsync(); // Wait for all services to be healthy await _app.ResourceNotifications .WaitForResourceHealthyAsync("products-api") .WaitAsync(StartupTimeout); await _app.ResourceNotifications .WaitForResourceHealthyAsync("stocks-api") .WaitAsync(StartupTimeout); // Create HTTP clients for both APIs ProductsApiClient = _app.CreateHttpClient("products-api"); StocksApiClient = _app.CreateHttpClient("stocks-api"); await InitializeDatabaseConnectionAsync(); } public async Task DisposeAsync() { ProductsApiClient.Dispose(); StocksApiClient.Dispose(); await _dbConnection.DisposeAsync(); await _app.DisposeAsync(); } // ... database methods shown later }

I also added AddStandardResilienceHandler to handle transient HTTP errors during test execution.

Next, define a shared test collection:

csharp
[CollectionDefinition("AspireTests")] public class SharedTestCollection : ICollectionFixture<DistributedAppFixture>;

Now every test class can share the same fixture instance:

csharp
[Collection("AspireTests")] public class GetAllProductsTests(DistributedAppFixture fixture) : IAsyncLifetime { public Task InitializeAsync() => Task.CompletedTask; public async Task DisposeAsync() => await fixture.ResetDatabaseAsync(); [Fact] public async Task GetAllProducts_ShouldReturnEmptyList_WhenNoProductsExist() { // Act var response = await fixture.ProductsApiClient.GetAsync("/products"); var products = await response.Content.ReadFromJsonAsync<List<ProductResponse>>(); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); products.Should().BeEmpty(); } }

The [Collection("AspireTests")] attribute tells xUnit to share the DistributedAppFixture across all test classes that use this collection. The fixture is created once, and all test classes receive the same instance.

Each test class implements IAsyncLifetime and calls ResetDatabaseAsync() in DisposeAsync to ensure a clean database state after each test.

Copied

Best Practice 3: Wait for Resources to Be Healthy

When testing with Aspire, your application starts Docker containers for PostgreSQL, Redis, and your API services. These containers need time to become ready.

Aspire provides a built-in mechanism for waiting: WaitForResourceHealthyAsync.

We need wait for services to become healthy in DistributedAppFixture.InitializeAsync before executing tests:

csharp
await _app.ResourceNotifications .WaitForResourceHealthyAsync("products-api") .WaitAsync(StartupTimeout); await _app.ResourceNotifications .WaitForResourceHealthyAsync("stocks-api") .WaitAsync(StartupTimeout);

This method subscribes to resource health notifications from the Aspire orchestrator. It waits until the specified resource reports a healthy status.

I always wrap this call with .WaitAsync(StartupTimeout) to avoid hanging forever if something goes wrong. I use a 120-second timeout, which is more than enough for containers to start and APIs to become healthy.

For health checks to work, your APIs must have health-check endpoints configured. If you use the Aspire ServiceDefaults project, health checks are configured automatically.

Copied

Best Practice 4: Wait for Services to Start before Running Tests

By default, Aspire starts all resources in parallel. This means your API can start before PostgreSQL is ready to accept connections.

When this happens, EF Core migrations fail silently (when executed from code at startup), and the API crashes. The tricky part is that WaitForResourceHealthyAsync might still succeed briefly before all the initialization has completed.

The fix is to use .WaitFor() in your Aspire AppHost to enforce startup ordering:

csharp
// Bad: APIs may start before PostgreSQL is ready var stocksApi = builder.AddProject<Projects.Stocks_Api>("stocks-api") .WithReference(postgres) .WithExternalHttpEndpoints(); // Good: APIs wait for their dependencies var stocksApi = builder.AddProject<Projects.Stocks_Api>("stocks-api") .WithReference(postgres) .WaitFor(postgres) .WithExternalHttpEndpoints(); builder.AddProject<Projects.Products_Api>("products-api") .WithReference(stocksApi) .WaitFor(stocksApi) .WithReference(postgres) .WaitFor(postgres) .WithReference(redis) .WaitFor(redis) .WithExternalHttpEndpoints();

Always add .WaitFor() for every resource dependency. Without it, your tests may fail.

Also, when it takes longer to spin up an external resource (a database Docker container), even our applications can crash during startup. So it's safer to run our services after all external dependencies are ready.

Copied

Best Practice 5: Cleanup Database Between Tests

After each test, the database contains leftover data that can affect other tests. That's why you need to clean up the database after each test.

I recommend using the Respawn library for database cleanup.

As stated on the library's GitHub:

Respawn examines the SQL metadata intelligently to build a deterministic order of tables to delete based on foreign key relationships between tables. It navigates these relationships to build a DELETE script starting with the tables with no relationships and moving inwards until all tables are accounted for.

First, install the following NuGet package:

csharp
dotnet add package Respawn

Next, update your DistributedAppFixture to use a Respawner:

csharp
public class DistributedAppFixture : IAsyncLifetime { private DistributedApplication _app = null!; private DbConnection _dbConnection = null!; private Respawner _respawner = null!; // ... public async Task InitializeAsync() { // ... app startup and health checks ... await InitializeDatabaseConnectionAsync(); } public async Task ResetDatabaseAsync() { await _respawner.ResetAsync(_dbConnection); } public async Task DisposeAsync() { await _dbConnection.DisposeAsync(); await _app.DisposeAsync(); } private async Task InitializeDatabaseConnectionAsync() { var connectionString = await _app.GetConnectionStringAsync("postgres"); var csBuilder = new NpgsqlConnectionStringBuilder(connectionString); if (string.IsNullOrEmpty(csBuilder.Database)) { csBuilder.Database = "postgres"; } _dbConnection = new NpgsqlConnection(csBuilder.ConnectionString); await _dbConnection.OpenAsync(); await InitializeRespawnerAsync(); } private async Task InitializeRespawnerAsync() { _respawner = await Respawner.CreateAsync(_dbConnection, new RespawnerOptions { SchemasToInclude = [ "products", "stocks" ], DbAdapter = DbAdapter.Postgres }); } }

Notice how _app.GetConnectionStringAsync("postgres") retrieves the connection string directly from Aspire โ€” no environment variables, no hardcoded strings.

The Respawner is initialized with RespawnerOptions specifying the schemas to include (products and stocks) and the database adapter (DbAdapter.Postgres). Respawn will intelligently determine the correct order to delete rows based on foreign key relationships within those schemas.

InitializeDatabaseConnectionAsync is called at the end of InitializeAsync, after the application has fully started and all resources are healthy. This is important because the database schemas are created by EF Core migrations that run on startup โ€” Respawn needs them to already exist when it scans the metadata.

Finally, call ResetDatabaseAsync in each test class's DisposeAsync to ensure every test ends with a clean database:

csharp
[Collection("AspireTests")] public class CreateProductTests(DistributedAppFixture fixture) : IAsyncLifetime { public Task InitializeAsync() => Task.CompletedTask; public async Task DisposeAsync() => await fixture.ResetDatabaseAsync(); // ... test methods }
Copied

Best Practice 6: Test your API Contracts

This best practice comes from my personal experience.

Instead of referencing request and response models from the API project, duplicate them in the test project:

csharp
namespace AspireTests.Models; public record ProductRequest(string Name, decimal Price); public record ProductResponse(int Id, string Name, decimal Price); public record PurchaseProductRequest(int Quantity); public record PurchaseProductResponse(int ProductId, int Quantity, decimal TotalPrice);
csharp
namespace AspireTests.Models; public record StockResponse(int Id, int ProductId, int Count); public record UpdateStockCountRequest(int ProductId, int CountChange);

Why duplicate them?

If someone renames a property in the API project (for example, renames Name to ProductName), tests will fail. You catch the breaking API contract change before it reaches production.

Here is the test for the PurchaseProduct endpoint that uses the models from the Aspire Testing project:

csharp
[Fact] public async Task PurchaseProduct_ShouldReturnOk_WhenProductExistsAndHasStock() { // Arrange var productRequest = new ProductRequest("Laptop", 999.99m); var createResponse = await fixture.ProductsApiClient.PostAsJsonAsync("/products", productRequest); var createdProduct = await createResponse.Content.ReadFromJsonAsync<ProductResponse>(); var stockRequest = new UpdateStockCountRequest(createdProduct!.Id, 10); await fixture.StocksApiClient.PutAsJsonAsync("/stocks/update-count", stockRequest); var purchaseRequest = new PurchaseProductRequest(2); // Act var response = await fixture.ProductsApiClient.PostAsJsonAsync( $"/products/{createdProduct.Id}/purchase", purchaseRequest); var purchaseResponse = await response.Content.ReadFromJsonAsync<PurchaseProductResponse>(); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); purchaseResponse.Should().BeEquivalentTo(new PurchaseProductResponse( createdProduct.Id, 2, 999.99m * 2)); }

If you reference the shared models, the rename compiles successfully everywhere (even in tests), and the broken API contract goes undetected until a consumer reports the issue.

Copied

Best Practice 7: Test Error Responses and Cross-Service Communication

Many developers only write happy-path tests. But testing error responses is just as important.

For every endpoint, test all HTTP status codes that it can return. This includes 200 OK, 201 Created, 204 No Content, 400 Bad Request, 404 Not Found, and 409 Conflict.

Here is an example of a simple CRUD test:

csharp
[Fact] public async Task CreateProduct_ShouldReturnCreated_WhenRequestIsValid() { // Arrange var request = new ProductRequest("Monitor", 349.99m); // Act var response = await fixture.ProductsApiClient.PostAsJsonAsync("/products", request); // Assert response.StatusCode.Should().Be(HttpStatusCode.Created); response.Headers.Location.Should().NotBeNull(); }

Now, the most interesting part of Aspire testing: cross-service integration tests.

With Aspire, you can test how multiple services work together in a single test. This is something that WebApplicationFactory cannot do out of the box.

Here are the tests for the PurchaseProduct endpoint, which calls the Stocks API internally:

csharp
[Collection("AspireTests")] public class PurchaseProductTests(DistributedAppFixture fixture) : IAsyncLifetime { public async Task InitializeAsync() => await fixture.ResetDatabaseAsync(); public Task DisposeAsync() => Task.CompletedTask; [Fact] public async Task PurchaseProduct_ShouldReturnOk_WhenProductExistsAndHasStock() { // Arrange: create a product via the Products API var productRequest = new ProductRequest("Laptop", 999.99m); var createResponse = await fixture.ProductsApiClient .PostAsJsonAsync("/products", productRequest); var createdProduct = await createResponse.Content .ReadFromJsonAsync<ProductResponse>(); // Arrange: add stock via Stocks API var stockRequest = new UpdateStockCountRequest(createdProduct!.Id, 10); await fixture.StocksApiClient .PutAsJsonAsync("/stocks/update-count", stockRequest); var purchaseRequest = new PurchaseProductRequest(2); // Act: purchase via Products API (which internally calls Stocks API) var response = await fixture.ProductsApiClient.PostAsJsonAsync( $"/products/{createdProduct.Id}/purchase", purchaseRequest); var purchaseResponse = await response.Content .ReadFromJsonAsync<PurchaseProductResponse>(); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); purchaseResponse.Should().BeEquivalentTo( new PurchaseProductResponse(createdProduct.Id, 2, 999.99m * 2)); } [Fact] public async Task PurchaseProduct_ShouldReturnNotFound_WhenProductDoesNotExist() { // Arrange var purchaseRequest = new PurchaseProductRequest(1); // Act var response = await fixture.ProductsApiClient.PostAsJsonAsync( "/products/999999/purchase", purchaseRequest); // Assert response.StatusCode.Should().Be(HttpStatusCode.NotFound); } [Fact] public async Task PurchaseProduct_ShouldReturnConflict_WhenNotEnoughStock() { // Arrange var productRequest = new ProductRequest("Tablet", 449.99m); var createResponse = await fixture.ProductsApiClient .PostAsJsonAsync("/products", productRequest); var createdProduct = await createResponse.Content .ReadFromJsonAsync<ProductResponse>(); var stockRequest = new UpdateStockCountRequest(createdProduct!.Id, 3); await fixture.StocksApiClient .PutAsJsonAsync("/stocks/update-count", stockRequest); var purchaseRequest = new PurchaseProductRequest(5); // Act var response = await fixture.ProductsApiClient.PostAsJsonAsync( $"/products/{createdProduct.Id}/purchase", purchaseRequest); // Assert response.StatusCode.Should().Be(HttpStatusCode.Conflict); } }

In the Arrange phase, I use two different API clients:

  • fixture.ProductsApiClient to create a product
  • fixture.StocksApiClient to add stock for that product

In the Act phase, the Products API internally calls the Stocks API to check stock availability and update the count.

This test validates the entire chain: Products API -> Stocks API -> PostgreSQL and back. With WebApplicationFactory, you would need to mock the Stocks API or set up a separate test server. With Aspire, it just works.

Copied

Summary

Let's recap the key takeaways:

  • Use DistributedApplicationTestingBuilder instead of WebApplicationFactory and TestContainers.
  • Share the app instance across all tests with ICollectionFixture. Starting Docker containers is expensive - do it once.
  • Wait for resources using WaitForResourceHealthyAsync with a timeout.
  • Add .WaitFor() in your AppHost to enforce startup ordering. Without it, APIs crash because they start before their dependencies are ready.
  • Use Respawn for database cleanup.
  • Duplicate models in the test project to catch breaking API contract changes.
  • Test cross-service communication end-to-end. This is the biggest advantage of Aspire testing over WebApplicationFactory.

Hope you find this newsletter useful. See you next time.

You can download source code for this newsletter for free
Download source code

Whenever you're ready, here's how I can help you:

The .NET Senior Playbook โ€” 800+ real-world interview questions with expert answers across 50 chapters. You try to answer each question first, then reveal the full solution โ€” and a test after every chapter proves it actually stuck. Finish, and you earn a verifiable certificate for your LinkedIn.

Chapter-test results with a per-answer explanation
View the Playbook

Enjoyed this article? Share it with your network

Improve Your .NET and Architecture Skills

Join my community of 25,000+ developers and architects.

Each week you will get 1 practical tip with best practices and real-world examples.

Learn how to craft better software with source code available for my newsletter.

Join 25,000+ developers already reading
No spam. Unsubscribe any time.