.NET Aspire Integration Testing: Best Practices for Distributed Apps
Learn .NET Aspire integration testing the simple way. Start your whole app, wait for services, and test how they really work together.
Imagine your school is putting on a big play. There is a kid on lights, a kid on music, a kid on curtains, and actors on stage. Each kid practices their own part alone. That practice is like a unit test. It checks one small job.
But on show night, all the kids must work together. The music must start when the curtain opens. The lights must change when the actor speaks. The only way to know it all works is the full dress rehearsal, where everyone plays their part at the same time.
A .NET Aspire integration test is that dress rehearsal for your app. Your app is not one program any more. It is many small services talking to each other, plus a database, plus a cache, plus a message queue. Aspire lets you start the whole show in a test and check that the parts really work together.
Let me show you how to do this well, step by step, in simple words.
What is a distributed application?
A distributed application is an app made of many small pieces that run separately and talk over the network.
A simple online shop might have:
- An API that takes orders.
- A worker that sends emails.
- A Postgres database that stores orders.
- A Redis cache that remembers things for speed.
- A RabbitMQ queue that passes messages between services.
Each piece is its own little program. Testing one piece alone is good, but it does not prove the pieces fit together. That is the gap Aspire fills.
What is .NET Aspire?
.NET Aspire is a toolkit that helps you build and run apps made of many services. The heart of it is the AppHost project. Think of the AppHost as the director of the play. It knows about every service, every database, and how they connect.
When you run the AppHost, it starts everything for you. The cool part for testing is this: you can ask the AppHost to start in a test, not just on your machine. That gives your test a real, running copy of the whole app.
Note on .NET versions: .NET 10 is the current LTS release, and C# 14 shipped with it. The testing ideas here work on .NET 8 and newer, but the newest Aspire packages target the latest runtime.
Why not just use WebApplicationFactory?
Many .NET developers know WebApplicationFactory. It spins up one web app in memory for testing. That is great for one service. But it does not start your database, your queue, or your other services.
With Aspire, you start the whole thing. You no longer need to glue together WebApplicationFactory for the API and Testcontainers for the database by hand. Aspire does both at once.
| Tool | What it starts | Good for |
|---|---|---|
| Unit test | One class or method | Tiny logic checks |
| WebApplicationFactory | One web service in memory | Single API testing |
| Testcontainers | One container, set up by hand | A single real dependency |
| Aspire testing | The whole app: services + containers | Real cross-service tests |
This is the big win. With Aspire you can test how the API, the queue, and the worker behave together, which the older tools cannot do on their own.
The key building block: DistributedApplicationTestingBuilder
To test with Aspire, you add one NuGet package to your test project:
Aspire.Hosting.Testing
You also add a project reference to your AppHost project, so the test can see the director’s plan.
The main class you use is DistributedApplicationTestingBuilder. It creates a test version of your whole app. Here is the simplest possible test.
using Aspire.Hosting;
using Aspire.Hosting.Testing;
public class OrdersApiTests
{
[Fact]
public async Task Get_orders_returns_ok()
{
// 1. Build a test version of the whole app.
var appHost = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.Shop_AppHost>();
// 2. Start everything (services + containers).
await using var app = await appHost.BuildAsync();
await app.StartAsync();
// 3. Get an HttpClient pointed at the "orders-api" service.
var client = app.CreateHttpClient("orders-api");
// 4. Call the API like a real user would.
var response = await client.GetAsync("/orders");
// 5. Check the result.
response.EnsureSuccessStatusCode();
}
}Projects.Shop_AppHost is a generated type. Aspire creates it for you from your AppHost project name, so you do not type the path by hand.
Anatomy of one Aspire test
Steps
Create
Make the test builder from your AppHost
Build
Turn the plan into a runnable app
Start
Boot all services and containers
Call
Hit a service with an HttpClient
Assert
Check the response is correct
Best practice 1: Wait for resources before you test
Here is a trap that catches beginners. When you call StartAsync, your services begin to start. But a database container can take a few seconds to be ready. If your test calls the API too early, the API might fail because the database is not up yet.
The fix is to wait. Aspire gives you a service called ResourceNotificationService. It can pause your test until a named resource is in the state you want, like Running or Healthy.
[Fact]
public async Task Create_order_saves_to_database()
{
var appHost = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.Shop_AppHost>();
await using var app = await appHost.BuildAsync();
// Get the notification service from the running app.
var notifications = app.Services
.GetRequiredService<ResourceNotificationService>();
await app.StartAsync();
// Wait up to 60 seconds for the API to be ready.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
await notifications.WaitForResourceAsync(
"orders-api",
KnownResourceStates.Running,
cts.Token);
var client = app.CreateHttpClient("orders-api");
var response = await client.PostAsJsonAsync("/orders",
new { Item = "Notebook", Quantity = 2 });
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}If your resource has a health check, you can wait for it to be healthy instead of just running. Use WaitForResourceHealthyAsync. Healthy is a stronger promise than running, because it means the service answered a check and said "I am truly ready."
The rule is simple: never call a service before it says it is ready. Waiting turns flaky tests into stable ones.
Best practice 2: Start the AppHost once, not every test
Starting the whole app is slow. It pulls containers, boots databases, and starts services. If every single test does this, your test run will crawl.
The smart move is to start the AppHost once for a whole group of tests and share it. In xUnit, you do this with a collection fixture. The fixture starts the app one time. Every test in the collection reuses the same running app.
public class AspireAppFixture : IAsyncLifetime
{
public DistributedApplication App { get; private set; } = default!;
public async Task InitializeAsync()
{
var builder = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.Shop_AppHost>();
App = await builder.BuildAsync();
var notifications = App.Services
.GetRequiredService<ResourceNotificationService>();
await App.StartAsync();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120));
await notifications.WaitForResourceAsync(
"orders-api", KnownResourceStates.Running, cts.Token);
}
public async Task DisposeAsync() => await App.DisposeAsync();
}
[CollectionDefinition("aspire")]
public class AspireCollection : ICollectionFixture<AspireAppFixture> { }
[Collection("aspire")]
public class OrderFlowTests
{
private readonly AspireAppFixture _fixture;
public OrderFlowTests(AspireAppFixture fixture) => _fixture = fixture;
[Fact]
public async Task Health_check_is_ok()
{
var client = _fixture.App.CreateHttpClient("orders-api");
var res = await client.GetAsync("/health");
res.EnsureSuccessStatusCode();
}
}Shared fixture lifecycle
Steps
Init
Fixture starts the AppHost one time
Run tests
Each test runs against the same app
Reuse app
No restart between tests = fast
Dispose
App is shut down after the last test
Because the app is shared, write tests so they do not step on each other. For example, give each order a different id, or clean up data after a test. Tests that share state but do not clean up become flaky.
Best practice 3: Add resilience to the HttpClient
Networks are not perfect. A service might be a tiny bit slow on the first call. A single hiccup should not fail your test.
You can add the standard resilience handler to the test HttpClient. This makes the client retry a few times and wait politely before giving up. It uses the same Polly-based resilience that real Aspire apps use, so your test behaves like production.
var client = _fixture.App.CreateHttpClient("orders-api");
// Or build a client with resilience baked in:
var services = new ServiceCollection();
services.AddHttpClient("api", c =>
c.BaseAddress = _fixture.App.GetEndpoint("orders-api"))
.AddStandardResilienceHandler();Resilience makes tests less jumpy. But do not hide real bugs with it. If a test only passes after ten retries, something is genuinely wrong and you should look.
Best practice 4: Test the real cross-service flow
The whole point of Aspire testing is to check the flow across services. Let’s test the full order story:
- Post an order to the API.
- The API saves it and drops a message on the queue.
- The worker picks up the message and sends an email.
In a real test you might check that the order appears in the database and that the worker reacted. This is the kind of test the old tools could not do alone.
Because the queue and worker are real (started by the AppHost), this proves the wiring is correct, not just the API in isolation.
Picking the right kind of test
You do not test everything with Aspire. Small logic still belongs in fast unit tests. Use the right tool for the job.
| Question you are asking | Best test type |
|---|---|
| Does this one method give the right answer? | Unit test |
| Does this API endpoint work alone? | WebApplicationFactory |
| Does my API talk to a real database? | Aspire (or Testcontainers) |
| Do my services work together end to end? | Aspire integration test |
A healthy project has many fast unit tests and a smaller number of Aspire tests for the important flows. Aspire tests are powerful but slower, so use them where they add the most value.
A note on third-party libraries
Some popular .NET libraries changed their licensing. MediatR and MassTransit are now under commercial licenses for many uses. If your distributed app uses them for messaging or in-process handlers, check the license terms before you rely on them in a paid product. For tests, this does not change how Aspire works, but it is good to know what your app depends on.
Common mistakes to avoid
- Not waiting for resources. This is the number one cause of flaky Aspire tests. Always wait for Running or Healthy.
- Restarting the app per test. It makes your suite painfully slow. Share one app with a fixture.
- Sharing data without cleanup. Two tests writing the same record will clash. Use unique ids or reset state.
- Too-short timeouts. The first container pull can be slow. Give it room, especially in CI.
- Forgetting the container runtime. Most resources need Docker or Podman running. No runtime means no containers.
Debugging a flaky Aspire test
Steps
Waited?
Did you wait for Running or Healthy?
Shared state?
Are two tests using the same data?
Timeout?
Is the timeout long enough for CI?
Runtime up?
Is Docker or Podman actually running?
Putting it all together
A good Aspire test suite looks like this:
- One shared fixture starts the AppHost a single time.
- The fixture waits until services are Healthy.
- Each test gets a resilient
HttpClientand exercises a real flow. - Tests clean up after themselves so they never clash.
- Fast unit tests handle the small logic; Aspire handles the big flows.
Follow these and your tests will be both realistic and stable, the two things that matter most.
Quick recap
- A distributed app is many small services that talk over the network.
- Aspire integration tests start the whole app at once, like a dress rehearsal.
- Use
DistributedApplicationTestingBuilderfrom theAspire.Hosting.Testingpackage. - Always wait for resources with
WaitForResourceAsyncorWaitForResourceHealthyAsyncbefore testing. - Start the AppHost once with a shared fixture so tests stay fast.
- Add the standard resilience handler to your
HttpClientfor steadier tests. - Use Aspire for real cross-service flows, and keep small logic in fast unit tests.
- Most resources need a container runtime like Docker or Podman running.
References and further reading
- Access resources in Aspire tests – Microsoft Learn
- Getting started with testing and .NET Aspire – .NET Blog
- Testing overview – Aspire docs
- Write your first Aspire integration test with xUnit
- .NET Aspire Integration Testing Best Practices – antondevtips
- .NET Aspire SQL Server integration tests – endjin
Related Posts
.NET Aspire: A Game Changer for Cloud-Native Development
A beginner-friendly guide to .NET Aspire, the cloud-native stack that orchestrates your services, databases, and dashboards with one simple command.
Getting Started With .NET Aspire 13: Building and Deploying an App
A beginner-friendly guide to .NET Aspire 13: build a small app with PostgreSQL and Redis, watch it run on the dashboard, then deploy with Docker Compose.
Solving Distributed Cache Invalidation with Redis and HybridCache
Learn how Redis and HybridCache solve distributed cache invalidation in ASP.NET Core with tags, backplanes, and a simple kitchen-counter analogy.
Testcontainers: Integration Testing Using Docker in .NET
A friendly .NET 10 guide to Testcontainers: spin up real databases in Docker for trustworthy integration tests, with xUnit, WebApplicationFactory, and clean cleanup.
Creating Data-Driven Tests With xUnit (.NET 10)
A friendly .NET 10 guide to data-driven tests in xUnit: Theory, InlineData, MemberData, ClassData, strongly typed TheoryData, and xUnit v3 tips.
Testcontainers Best Practices for .NET Integration Testing (.NET 10)
A friendly .NET 10 guide to Testcontainers: real databases in Docker, shared container fixtures, clean test isolation, faster CI, and the mistakes to avoid.