Engineering
Aspire - Aceing Integration Tests
I am a big fan of integration tests. They allow me to quickly add new features to a system and ensure that I don’t break existing features.
Last year, I started an experiment in a project. The goal was to exclusively use automated tests and only resort to manual testing when somebody else issues a bug report. Today, I can say that this approach not only made me significantly faster in development but also led to a very low rate of bugs found by testers in the backend.
And Aspire was one cornerstone that helped me to write relevant tests.
Testing with Aspire Testing
And this, despite the first impression of
Aspire.Hosting.Testing not being entirely positive.
The reason: Aspire sees its own projects in testing as black boxes. The projects are started as would other binaries
and you get access to a HttpClient that allows access your own APIs.
This is interesting for black-box tests, but that’s not how I want my integration tests. I want to have full control over my own service while perhaps mocking other services. I also want to be able to change feature toggles, adjust the time for the service, or whatever I need to write relevant test cases — so I want full access, as provided by the WebApplicationFactory.
Why Integration Tests with Aspire and WebApplicationFactory
Aspire provides all the services and configuration I need to run my own service.
In my case, these are a database server, an OIDC server, and a few integrated services.
My tests use the database as-is. Since I want to be able to switch users easily in tests, authentication is mocked. One service provides special calculations that are relevant to my process, so I use it directly. Other services are mocked.
I want Aspire to start and configure all services except my service when starting the tests and provide the configuration I need to configure my service for the dependencies.
Aspire provides the orchestration and configuration, the WebApplicationFactory provides the power to intervene in your own service.
Setting up Integration Tests with Aspire and WebApplicationFactory
Let’s look at the implementation. First, the setup of the tests, ideally as OneTimeSetUp (NUnit) or ClassFixture (XUnit). There are three important steps:
- Create
DistributedApplicationTestingBuilder. - Identify your own service project and remove it from the appHost so it is not started by Aspire.
- After starting the application, get the configuration of your own service from the Aspire project and pass it to a WebApplicationFactory.
1private static async Task<IServiceProvider> ConfigureWebApplication()
2{
3 IDistributedApplicationTestingBuilder appHost = await DistributedApplicationTestingBuilder.CreateAsync<Some_AppHost>([],
4 (dap, hao) =>
5 {
6 hao.EnvironmentName = "Integrationtests";
7 });
8
9 appHost.Services.AddLogging(logging =>
10 {
11 logging.AddFakeLogging();
12 logging.SetMinimumLevel(LogLevel.Information);
13 logging.AddFilter("Aspire", LogLevel.Warning);
14 });
15
16 IResource apiProject = appHost.Resources.First(r => r.Name == "some-api");
17 appHost.Resources.Remove(apiProject);
18
19 var resourcesNotRequiredForIntegrationTest = appHost.Resources.Where(r => r.Annotations.OfType<NotRequiredForIntegrationTests>().Any()).ToList();
20
21 foreach (IResource resource in resourcesNotRequiredForIntegrationTest) appHost.Resources.Remove(resource);
22 _app = await appHost.BuildAsync();
23 await _app.StartAsync();
24
25 Dictionary<string, string?>? configuration = await apiProject.LoadConfigurationAsync(_app.Services);
26
27 var webApplicationFactory = new SomeWebApplicationFactory(configuration);
28
29 ServiceCollection svc = new();
30
31 svc.AddSingleton(webApplicationFactory);
32 // Also register everything else you need for tests eg LightBdd Contexts
33
34 return svc.BuildServiceProvider();
35}
The WebApplicationFactory can apply the configuration directly as an InMemoryCollection.
Thus, a WebApplicationFactory created in this way has the same configuration as the service that was removed at startup. Afterwards, the mechanisms of the WebApplicationFactory can be used to make further changes to the configuration or services in the dependency injection.
The WebApplicationFactory created in this way then becomes the basis for all my tests.
The implementation of LoadConfigurationAsync is a slight variation of the code that David Fowler provided in the Aspire forum.
1public static class AspireWebApplicationFactoryExtensions
2{
3 public static async Task<Dictionary<string, string?>?> LoadConfigurationAsync(this IResource resource, IServiceProvider appHostServiceProvider, CancellationToken cancellationToken = default)
4 {
5 // Use David Fowlers title to bind WebApplication factory to Aspire until it is supported by Aspire: https://github.com/dotnet/aspire/discussions/878#discussioncomment-9631749
6 if (resource is IResourceWithEnvironment resourceWithEnvironment && resourceWithEnvironment.TryGetEnvironmentVariables(out IEnumerable<EnvironmentCallbackAnnotation>? annotations))
7 {
8 var environmentCallbackContext =
9 new EnvironmentCallbackContext(
10 new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { ServiceProvider = appHostServiceProvider }),
11 cancellationToken: cancellationToken);
12
13 foreach (EnvironmentCallbackAnnotation annotation in annotations) await annotation.Callback(environmentCallbackContext);
14
15 // Translate environment variable __ syntax to :
16 var config = new Dictionary<string, string?>();
17 foreach ((string key, var value) in environmentCallbackContext.EnvironmentVariables)
18 {
19 if (resource is ProjectResource && key == "ASPNETCORE_URLS") continue;
20 if (resource is ProjectResource && key == "ASPNETCORE_HTTPS_PORT") continue;
21
22
23 string? configValue = value switch
24 {
25 string val => val,
26 IValueProvider v => await v.GetValueAsync(cancellationToken),
27 null => null,
28 _ => throw new InvalidOperationException($"Unsupported value, {value.GetType()}")
29 };
30
31 if (configValue is not null) config[key.Replace("__", ":")] = configValue;
32 }
33
34 return config;
35 }
36
37 return null;
38 }
39}
From here on, all the power of Aspire and WebApplicationFactory is at your hands.
Conclusion
Aspire makes it very easy to set up integration tests without using other solutions like Docker Compose or Testcontainers.NET.
At the same time, it ensures that other users, such as teammates or CI/CD pipelines, use the same basis to run integration tests. One less “works on my machine” issue.
Additionally, reproducing errors becomes easier since manual tests are conducted with the same configuration as automated integration tests, as long as they don’t explicitly change it.
Overall, such a setup reduces the complexity of the development process and thus the effort required.
References
Aspire Discussion on integration tests with David Fowler’s code: https://github.com/dotnet/aspire/discussions/878#discussioncomment-9631749