.NET Aspire SQL Server integration tests

This is the second part in a series showing how to use .NET Aspire's dev-time orchestration to write integration tests for code that uses SQL Server.
Last time, I described the basic operation of Aspire's dev-time orchestration. I'll now show how this fits in with integration testing.
Integration tests
Unlike unit tests, which aim to test a fairly small region of code in isolation, integration tests check the behaviour we get as we connect components together. Aspire's dev-time orchestration is all about connecting components, so it seems like something we might want to use in an integration test.
For example, applications that use relational databases often implement significant application logic in their SQL queries. Unit testing has a serious shortcoming in these cases: a unit test will verify only that the code produces the query the developer intended, so if the developer has slightly misunderstood how the database will handle that query, a unit test can't detect that flaw. But an integration test that connects the code under test to a real database can verify that the query behaves as expected, increasing the chances of tests detecting problems.
Aspire's dev-time orchestration provides our application with the services it needs (such as a relational database) when running locally. A newly-created Aspire project will do this when you debug, but we can also ask it to provide similar orchestration when we run integration tests.
Kicking off Aspire orchestration in an integration test
Normally, Aspire's dev-time orchestration happens when we run the app host. Typically, you'll set the app host as the startup project in your development environment, and when that runs, Aspire launches local emulators for your resources, and it will start any projects you told it about. For example, because the example in my previous post included this code:
builder
.AddProject<Projects.MyApp_WebApi>("webapi")
.WithReference(sqlDb)
.WaitFor(sqlDb);
the app host will run the MyApp.WebApi
project, passing it connection details for the SQL Server database it requires and attaching the debugger to that process.
But what about tests? We don't typically want to run our entire test suite as part of normal application startup1, so tests generally live in dedicated projects, and when we run a test, it's the test project that runs. Our app host project won't run when we execute a test, so what's going to ensure that the resources we require are running?
Aspire provides an API for this scenario. A test can include code like this:
IDistributedApplicationTestingBuilder appHost = await
DistributedApplicationTestingBuilder.CreateAsync<Projects.MyApp_AppHost>();
For that to work, we'll need to do two things in our test project:
- Add a NuGet reference to the
Aspire.Hosting.Testing
package - Add a Project reference to our app host project
If you run the code above inside a test, it will execute your app host's entry point inside your test project's process. That means your test runs exactly the same orchestration configuration code as when you run the application normally. In effect, the process running your tests becomes the app host.
You can see that this CreateAsync
method returns an IDistributedApplicationTestingBuilder
. This derives from the IDistributedApplicationBuilder
that app hosts use, so you could configure additional services if your test needs that. In my example, I don't need anything else, so I can tell the host to build my app and then launch it:
await using DistributedApplication app = await appHost.BuildAsync();
await app.StartAsync();
Note that I've used a using
declaration here. This causes Aspire to shut down the application when the app
variable goes out of scope. If you put this code inside a test method, that means the application will run only for the duration of that one test. This ensures that this test will be isolated from other tests, but it can also make for fairly slow tests. You can trade off isolation for speed by putting this app launch code in something that runs at the start of a test suite, disposing it at the end, and having all the tests share the same hosted instance.
The StartAsync
method doesn't wait for services to finish their initialization. Any integration tests will need to wait for the code under test to be ready, along with whatever services that code depends on, so the next thing you'll want to do is this:
var resourceNotificationService =
app.Services.GetRequiredService<ResourceNotificationService>();
The ResourceNotificationService
lets us wait for named services to start up. For example, if our integration test works by making requests to our web API's HTTP endpoints, we'll need to wait until the web API project is ready. Here's how that looks:
await resourceNotificationService
.WaitForResourceAsync("webapi", KnownResourceStates.Running)
.WaitAsync(TimeSpan.FromSeconds(30));
That "webapi"
argument is the same name our app host gave in its call to AddProject
. This await
completes only once the project is up and running (and in this example, it will throw an exception if that takes longer than 30 seconds). Because our app host told Aspire that this project needs the SQL database, and because our test code here is running the orchestration configuration code in our app host, Aspire will automatically start SQL Server first, and will only start the web API project after the database is ready.
Aspire can supply us with an HttpClient
configured with a suitable base address for using the web API:
HttpClient httpClient = app.CreateHttpClient("webapi");
Now our test can make whatever API calls it needs into the API.
But wait! How do we ensure that the database contains suitable data for our tests? In the next post in this series I'll talk about how to initialize the database.
-
We might also want to be able to run some of our tests continuously in production. The 'monitoring and QA are the same thing' note in Steve Yegge's platforms rant talks more about this, but that's really a kind of end to end testing, so it's out of scope for this blog series.↩