Header image for Testing .NET Applications with Aspire

Testing .NET Applications with Aspire

Monday, 04 May 2026

.NET
Aspire
Testing
C#

Introduction

One of the things I like most about Aspire is that it gives us a proper way to model a distributed application locally. We can define our API, web app, database, cache, and supporting services in one place, wire them together, and run the whole thing consistently. That is already great for development, but it becomes even more interesting when you bring testing into the picture.

Historically, integration testing a distributed .NET application has been a bit messy. You either test one project in isolation, mock half the world, or write a brittle setup script that tries to spin everything up before the tests run. That can work, but it often drifts away from how the application really behaves.

Aspire testing takes a different approach. Instead of pretending your app is a single in-memory process, it embraces the fact that modern applications are made up of multiple moving parts. Your tests can start the AppHost, let Aspire orchestrate the application and its dependencies, and then exercise the app in a way that is much closer to production reality.

That makes it useful for more than a simple smoke test. You can use it for end-to-end API testing, for UI automation with Playwright, and for scenarios where you want a real database running underneath the test rather than a fake or an in-memory substitute. You can even connect directly to that running database from your test code to verify state after a request completes.

In this post I want to walk through how Aspire testing works, when it makes sense, and how I think you can get a lot more value from it by combining it with tools like Playwright and Verify.

The concrete examples here come from my sample repo, aspire-chat-demo, which includes an Aspire test project, shared AppHost fixture, Verify snapshots for the API flows, and a Playwright browser test for registration.

Understanding Aspire Testing ๐Ÿงช

Aspire testing is built around the Aspire.Hosting.Testing package. The key type you will work with is DistributedApplicationTestingBuilder. This gives you a test harness for your AppHost so your tests can build and run the application topology in the background.

The important thing to understand is that these are not unit tests, and they are not the same as using WebApplicationFactory<T> to boot a single ASP.NET Core application in memory. Aspire testing is aimed at closed-box integration testing of your distributed application. The AppHost starts, your resources come up as separate processes or containers, and your test code then interacts with the running system from the outside.

That distinction matters.

It means Aspire testing is excellent when you want to:

  • verify your app works end-to-end across multiple services
  • test against a real database or other real dependencies
  • check service-to-service wiring and configuration
  • prove that your web app, API, and data layer all work together

It is less suitable when you want to:

  • mock dependencies through DI
  • swap internal services inside a single app project
  • test one project in isolation with lots of in-memory control

If you want the latter, WebApplicationFactory<T> is still a really good tool. If you want to test the actual distributed application you have modelled in Aspire, then Aspire testing is where things get exciting.

Writing Your First Aspire Test

Aspire provides test project templates, including xUnit, MSTest, and NUnit. I will stick with xUnit here because that is what I use most often.

You can create a new test project with:

dotnet new aspire-xunit -o MyApp.Tests

Then add a project reference to your AppHost project:

dotnet add MyApp.Tests.csproj reference ../MyApp.AppHost/MyApp.AppHost.csproj

Once that is in place, the basic pattern is very straightforward. In my aspire-chat-demo repo, I wrap that startup work in a shared fixture. A trimmed version looks like this:

public sealed class DistributedApplicationFixture : IAsyncLifetime
{
    private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMinutes(5);

    public DistributedApplication App { get; private set; } = null!;

    public async ValueTask InitializeAsync()
    {
        using var cts = new CancellationTokenSource(DefaultTimeout);
        var cancellationToken = cts.Token;

        var appHost = await DistributedApplicationTestingBuilder.CreateAsync<Projects.AspireChat_AppHost>(
            cancellationToken);

        App = await appHost.BuildAsync(cancellationToken)
            .WaitAsync(DefaultTimeout, cancellationToken);
        await App.StartAsync(cancellationToken)
            .WaitAsync(DefaultTimeout, cancellationToken);
        await App.ResourceNotifications.WaitForResourceHealthyAsync("sql", cancellationToken)
            .WaitAsync(DefaultTimeout, cancellationToken);
        await App.ResourceNotifications.WaitForResourceHealthyAsync("db", cancellationToken)
            .WaitAsync(DefaultTimeout, cancellationToken);
        await App.ResourceNotifications.WaitForResourceHealthyAsync("api", cancellationToken)
            .WaitAsync(DefaultTimeout, cancellationToken);
        await App.ResourceNotifications.WaitForResourceHealthyAsync("web", cancellationToken)
            .WaitAsync(DefaultTimeout, cancellationToken);
    }

    public async ValueTask DisposeAsync()
    {
        if (App is not null)
        {
            await App.DisposeAsync();
        }
    }

    public HttpClient CreateApiClient() => App.CreateHttpClient("api");

    public HttpClient CreateWebClient() => App.CreateHttpClient("web");
}

There are a few things I like about this model:

  • the test fixture starts the real AppHost rather than a cut-down version of your app
  • Aspire manages the lifecycle of the application and its resources
  • you can create HttpClient instances for named resources directly from the AppHost model
  • you can wait for a resource to become healthy before firing requests at it

That last point is especially important. A resource being "running" is not always the same thing as being ready. In my sample I wait for the database resources, api, and web to be healthy before any test runs, and I also use a small API readiness probe. That makes the suite much more reliable.

Managing the AppHost Efficiently

One thing the Aspire docs call out clearly is that building and starting the AppHost can be expensive. That is especially true once you have a few containers involved. If every test spins everything up from scratch, your suite will get slow quickly.

In a real test suite, I think the goal should be to start the AppHost once for the test run, or as close to that as your framework setup allows, and then share it across your tests. That keeps the suite much faster and avoids paying the full startup cost over and over again.

For xUnit, the basic building block is IAsyncLifetime. In aspire-chat-demo, I pair that with a collection fixture so the AppHost starts once and is shared across the suite:

[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class DistributedApplicationCollection
    : ICollectionFixture<DistributedApplicationFixture>
{
    public const string Name = "distributed-application";
}

[Collection(DistributedApplicationCollection.Name)]
public sealed class WebTests(DistributedApplicationFixture fixture)
{
    [Fact]
    public async Task GetWebResourceRootReturnsOkStatusCode()
    {
        using var httpClient = fixture.CreateWebClient();
        using var response = await httpClient.GetAsync("/", TestContext.Current.CancellationToken);

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

That is much closer to how I would run Aspire tests in a real codebase. The shared fixture owns startup and disposal, the collection keeps test classes on the same running application, and DisableParallelization = true avoids multiple tests trampling shared distributed state.

You can pass arguments into the AppHost, and because those flow into the .NET configuration system, you can use them to alter test behaviour. For example:

  • force the environment to Testing
  • disable data volumes for ephemeral runs
  • turn off a feature flag
  • change configuration used by the AppHost at startup

You can also use this setup hook for a couple of useful testing options from the Aspire docs, like enabling the dashboard while debugging or disabling port randomisation if you have a very specific reason to keep ports stable:

var builder = await DistributedApplicationTestingBuilder
    .CreateAsync<Projects.MyApp_AppHost>(
        ["DcpPublisher:RandomizePorts=false"],
        (appOptions, _) =>
        {
            appOptions.DisableDashboard = false;
        });

I would generally leave randomised ports on unless you have a genuine need not to. The default is safer, especially in CI or when you have multiple runs happening locally.

If you need even more control, Aspire also exposes DistributedApplicationFactory, which lets you hook into lifecycle methods such as OnBuilderCreating, OnBuilderCreated, OnBuilding, and OnBuilt. That is useful when you want to inject configuration before the AppHost is created, or tweak services used by the AppHost itself.

Testing Your APIs End-to-End

This is where I think Aspire testing becomes a really strong option. ๐Ÿš€

Instead of testing your API with a fake repository or an in-memory database, you can bring up the real API, the real backing services, and then make an actual HTTP request against the running app.

One of the flows from AspireChat.Tests/ApiTests.cs registers a user and then logs in through the real /users/login endpoint:

[Fact]
public async Task LoginEndpointReturnsSuccessSnapshot()
{
    var registeredUser = await RegisterUserAsync("login", "Login User", "login-password");
    using var client = fixture.CreateApiClient();
    using var response = await client.PostAsJsonAsync("/users/login", new Login.Request
    {
        Email = registeredUser.Email,
        Password = registeredUser.Password
    }, TestContext.Current.CancellationToken);

    var body = await ReadJsonAsync<Login.Response>(response);

    await Verifier.Verify(new
    {
        StatusCode = (int)response.StatusCode,
        body.Success,
        TokenPresent = !string.IsNullOrWhiteSpace(body.Token)
    });
}

That gives you much more confidence than checking a controller in isolation, because you are verifying the actual request pipeline, the actual service registration, the actual infrastructure wiring, and the actual authentication configuration. In my sample AppHost, that means the test is exercising the real Aspire topology rather than a cut-down in-memory substitute.

This is also a great place to test things like:

  • authentication and authorization flows
  • validation behaviour
  • database migrations being applied correctly
  • resilience when dependent services are unavailable
  • resource startup ordering and health checks

Aspire even gives you resource commands, so you can manually start or stop resources in tests. That can be really useful if you want to prove your application handles a dependency outage gracefully.

Accessing the Database Directly from Tests

One of the most useful features in the Aspire testing docs is access to resource connection information. This is the bit that makes proper end-to-end verification much easier.

If your test creates data through the API, you do not have to stop at the HTTP response. You can connect to the running database and verify what was actually written.

My current aspire-chat-demo suite does not yet include a direct database assertion, but that is the next place I would take it. The same shared DistributedApplication fixture can use GetConnectionString to fetch connection details from Aspire and let the test inspect persisted state after the API call completes.

I really like this because it gives you the best of both worlds:

  • you exercise the system through its public interface
  • you can still verify data state very precisely afterwards

That is especially useful for workflows where the HTTP response only tells part of the story. Maybe the API returns 202 Accepted and work continues in the background. Maybe a record is created and several child rows should exist. Maybe you want to prove a migration or seed routine ran correctly. Direct database access makes those checks much easier. ๐Ÿ”Ž

Just be disciplined with it. I would use direct database access to verify outcomes, not as a shortcut to set up brittle test state all over the place. Keep the important part of the test flowing through the real application where possible.

I would also strongly recommend that each test creates the data it needs rather than depending on shared records already being present. That makes concurrent execution much safer, avoids tests accidentally stepping on each other, and makes failures easier to reason about. If you need uniqueness, generate it inside the test with something like a GUID, timestamp, or other deterministic test identifier.

Using Aspire for UI End-to-End Tests with Playwright

Once you realise Aspire testing can start your full application topology for you, Playwright becomes a very natural next step.

In the demo repo, WebTests.cs now has both a lightweight smoke test and a Playwright registration test. The smoke test still proves the web resource is reachable:

[Collection(DistributedApplicationCollection.Name)]
public sealed class WebTests(DistributedApplicationFixture fixture)
{
    [Fact]
    public async Task GetWebResourceRootReturnsOkStatusCode()
    {
        using var httpClient = fixture.CreateWebClient();
        using var response = await httpClient.GetAsync("/", TestContext.Current.CancellationToken);

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

The Playwright test uses that same running web resource as the browser entry point. It opens the login page, switches to register mode, fills the form with a unique email address, submits it, and verifies that successful registration navigates away from /Login:

[Fact]
public async Task RegistrationForm_SubmitsSuccessfully()
{
    using var webClient = fixture.CreateWebClient();
    var webUrl = webClient.BaseAddress!.ToString().TrimEnd('/');
    var uniqueEmail = $"{fixture.RunPrefix}-pwreg@example.com";

    using var playwright = await Playwright.CreateAsync();
    await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
    {
        Headless = true
    });

    var context = await browser.NewContextAsync(new BrowserNewContextOptions
    {
        IgnoreHttpErrors = true
    });
    var page = await context.NewPageAsync();

    await page.GotoAsync($"{webUrl}/Login");
    await page.GetByRole(AriaRole.Button, new() { Name = "Register" }).ClickAsync();

    await page.GetByLabel("Email").FillAsync(uniqueEmail);
    await page.GetByLabel("Name").FillAsync("Playwright Test");
    await page.GetByLabel("Password").FillAsync("P@ssw0rd123!");
    await page.GetByLabel("Confirm Password").FillAsync("P@ssw0rd123!");

    await page.GetByRole(AriaRole.Button, new() { Name = "Register" }).ClickAsync();

    await page.WaitForURLAsync(
        url => !url.Contains("/Login", StringComparison.OrdinalIgnoreCase),
        new() { Timeout = 15000 });

    Assert.False(await page.GetByRole(AriaRole.Alert).IsVisibleAsync());
}

That is the point where Aspire and Playwright fit together nicely: Aspire starts the application and its dependencies, waits for web to be healthy, and gives the test a real frontend URL. Playwright then drives the app the way a user would.

This kind of setup is also a strong argument for using a real database in UI tests. If the UI creates data, loads it back, filters it, edits it, and deletes it, you are validating the actual end-to-end workflow rather than a mocked version of it.

Snapshot Testing Responses with Verify ๐Ÿ“ธ

This is the other tool I really wanted to mention, because it pairs nicely with Aspire-based API and UI testing.

Verify is a snapshot testing library for .NET. Instead of asserting one or two fields manually, you can capture a richer response or object graph and compare it against a committed snapshot file.

That is incredibly useful when you are testing larger API payloads.

In aspire-chat-demo, I use it to shape the important parts of the response and snapshot that result rather than dumping the raw HTTP body blindly:

[Fact]
public async Task GetProfileEndpointReturnsSnapshotForAuthenticatedUser()
{
    var registeredUser = await RegisterUserAsync("profile", "Profile User", "profile-password");
    using var client = CreateAuthenticatedApiClient(registeredUser.Response.Token!);
    using var response = await client.GetAsync("/users/profile", TestContext.Current.CancellationToken);

    var profile = await ReadJsonAsync<GetProfile.Response>(response);

    await Verifier.Verify(new
    {
        StatusCode = (int)response.StatusCode,
        Profile = new
        {
            profile.Name,
            Email = "<registered-user-email>",
            profile.ProfileImageUrl
        }
    });
}

I like this pattern a lot because it keeps the snapshot focused. The test still drives the real API end to end, but the verified output is shaped into something stable and readable. In the same test project I also snapshot chat messages, groups, auth flows, and image upload responses.

I think Verify is especially useful for:

  • larger JSON responses
  • error payloads and validation messages
  • problem details responses
  • HTML output from pages
  • DTOs that are awkward to assert property by property

There are a couple of practical tips worth keeping in mind.

First, snapshot testing works best when you scrub unstable values like IDs, timestamps, generated URLs, and correlation IDs. Otherwise your snapshots will be noisy. My sample repo has a dedicated VerifySettings.cs for exactly that:

public static class VerifySettings
{
    [ModuleInitializer]
    public static void Initialize()
    {
        VerifierSettings.ScrubInlineGuids();
        VerifierSettings.AddScrubber(builder =>
        {
            var text = builder.ToString();
            text = Regex.Replace(text, @"itest-[a-f0-9]{32}", "itest-<run-prefix>");
            text = Regex.Replace(text,
                @"https?://127\.0\.0\.1:\d+/devstoreaccount1/images/[^\s,}\]]+",
                "https://blob-host/devstoreaccount1/images/<blob-name>");
            text = Regex.Replace(text,
                @"https?://localhost:\d+/devstoreaccount1/images/[^\s,}\]]+",
                "https://blob-host/devstoreaccount1/images/<blob-name>");
            builder.Clear();
            builder.Append(text);
        });
    }
}

Second, commit your *.verified.* files to source control, and make sure *.received.* files are ignored. In aspire-chat-demo, the committed snapshots sit right alongside the tests in AspireChat/AspireChat.Tests.

Third, use snapshots to complement assertions, not replace all thinking. I still like to keep a few explicit assertions for status codes, key headers, or specific business rules, then use Verify for the heavier payload validation.

If you want to go further, Verify also has extensions in its ecosystem for things like HTTP and browser verification. That makes it a really interesting companion for Aspire-powered end-to-end suites.

A Sensible Testing Strategy

Just because Aspire lets you run the whole world in a test does not mean every test should do that.

My preference would be something like this:

  • unit tests for pure business logic
  • focused integration tests for a single application when WebApplicationFactory<T> is enough
  • Aspire tests for distributed workflows and service wiring
  • Playwright tests for a smaller number of critical user journeys
  • Verify snapshots where payloads are large enough to make classic assertions clumsy

If you want this kind of suite to stay healthy, I think two habits matter a lot:

  • start the AppHost once for the suite so the expensive infrastructure boot only happens once
  • make each test responsible for creating the data it needs so the suite stays repeatable and can be parallelised later if the shared resources allow it

That gives you a decent balance of speed and confidence. โš–๏ธ

If you lean too heavily on Aspire and browser tests for everything, your suite will get slower and harder to maintain. But if you use them for the right scenarios, they can cover the exact failures that lighter-weight tests often miss.

A Few Practical Notes

There are a few details from the Aspire docs that are worth remembering:

  • Aspire testing randomises proxied ports by default so multiple test runs can coexist more safely
  • the Aspire dashboard is disabled by default in tests, though you can enable it when debugging
  • resource health is usually a better wait target than just checking for Running
  • you can pass arguments into the AppHost to alter configuration for test runs
  • disposing the app cleans up the resources started for the test

That default port randomisation is especially helpful in CI and on busy developer machines. It means you are less likely to have tests colliding with each other or with something already running locally.

Final Thoughts

I think Aspire testing fills a really valuable gap in the .NET ecosystem. It gives us a much cleaner way to test distributed applications as they actually run, rather than pretending they are just a single project with a couple of mocks attached.

For simple apps, you might not need it. But once you have an API, a frontend, a database, maybe a cache, maybe a queue, and you care about proving that the whole thing works together, Aspire starts to look very compelling.

The part I especially like is that it scales beyond just "can I hit the homepage?" testing. You can use it for real API end-to-end tests, for browser automation with Playwright, for verifying persisted state in a live database, and for stronger response validation with Verify snapshots.

That is a pretty powerful combination.

Working Example

The full working example for this post lives in my demo project: aspire-chat-demo.

The most useful files to look at are:

  • AspireChat/AspireChat.Tests/DistributedApplicationFixture.cs for the shared Aspire AppHost setup
  • AspireChat/AspireChat.Tests/DistributedApplicationCollection.cs for the xUnit collection fixture
  • AspireChat/AspireChat.Tests/ApiTests.cs for the end-to-end API flows and Verify usage
  • AspireChat/AspireChat.Tests/VerifySettings.cs for scrubbing unstable values
  • AspireChat/AspireChat.Tests/WebTests.cs for the basic web resource smoke test and Playwright registration flow

That repo is the concrete reference behind the examples in this post, and it is where I can show the setup in a more realistic form than a tiny blog-sized snippet allows.

Try It in Your App

If you are already using Aspire, I really recommend creating a dedicated test project and trying a couple of proper end-to-end scenarios. Start with one API test, then add a database verification, and then maybe try one Playwright happy-path test for your UI.

If you have not tried Verify before, it is also well worth a look for response snapshot testing, especially when you are dealing with larger JSON payloads.

And if there is interest, I will probably follow this up with a more hands-on post that builds out a full Aspire testing setup in a sample app with xUnit, Postgres, Playwright, and Verify all wired together.

Comments (0)

No comments yet. Be the first to comment.


Subscribe so you don't miss out ๐Ÿš€

Be the first to know about new blog posts, news, and more. Delivered straight to your inbox ๐Ÿ“จ


Related Posts

Why You Should Try .Net Aspire

05/03/2025

.NET Aspire is a powerful, flexible tool for modelling, developing, and observing cloud-native applications—making it worth exploring whether you’re a .NET developer or not.

.NET
Architecture
Aspire
Read More
Getting Started with .NET Aspire

05/17/2025

This guide walks you through getting started with .NET Aspire by building a real-time chat application, managing infrastructure with the App Host, and deploying seamlessly to Azure with modern developer tooling

C#
Aspire
Azure
Read More
Using Aspire with Docker Hosts

05/24/2025

This guide shows how to deploy a multi-language app to any Docker host using .NET Aspire, making orchestration easy across different technologies.

Architecture
Aspire
Read More