Engineering

Dotnet Aspire - Acing Integration Tests

Gute Integrationstests sind oft der entscheidende Unterschied zwischen einem Softwareprodukt und einem guten Softwareprodukt. Aspire macht es endlich einfach die Konfiguration der externen Abhängigkeiten in die Tests zu integrieren. Aus dem AppHost wird die Basis für die Konfiguration der Integrationstests.

Ich bin ein großer Fan von Integrationstests. Sie ermöglichen mir, schnell neue Funktionen in ein System einzufügen und dabei sicherzustellen, dass ich bestehende Funktionen nicht breche.

Letztes Jahr habe ich in einem Projekt einen Versuch gestartet. Ich wollte meine APIs in der Entwicklung ausschließlich mittels automatisierter Tests testen und nur wenn ich Fehlermeldungen bekomme, die ich automatisiert nicht nachstellen kann, dazu übergehen, Tests manuell durchzuführen.

Heute kann ich sagen, dass ich dadurch in der Entwicklung nicht nur deutlich schneller geworden bin, sondern dass das auch zu einer sehr geringen Rate an Bugs geführt hat, die im Backend von Testern gefunden werden.

Und Aspire war ein Puzzlestein, der mir dabei geholfen hat, relevante Tests zu schreiben.

Testing mit Aspire Testing

Und das, obwohl der erste Eindruck von Aspire.Hosting.Testing nicht ganz so gut war.

Der Grund: Aspire sieht die eigenen Projekte im Testing als Blackbox an. Die Projekte werden normal gestartet und man kommt sehr einfach zu einem Client, der den Zugriff auf eigene APIs ermöglicht.

Das ist interessant für reine Blackbox-Tests - so sehe ich meine Integrationstests aber nicht. Ich möchte mein eigenes Service vollständig unter Kontrolle haben, während ich andere Services vielleicht mocke. Ich möchte auch die Möglichkeit haben, Feature-Toggles zu ändern, die Zeit für das Service anzupassen, oder was auch immer ich brauche, relevante Testcases zu schreiben - also vollen Zugriff, so wie es die WebApplicationFactory bietet.

Warum Integrationstests mit Aspire und WebApplicationFactory

Aspire liefert alle Services, die ich benötige, um mein Service zu testen.

In meinem Fall sind das ein Datenbankserver, ein OIDC-Server und ein paar Services die integriert sind.

Meine Tests verwenden die Datenbank as-is. User möchte ich in Tests einfach wechseln können, die Authentication wird daher gemockt. Ein Service liefert spezielle Berechnungen, die in meinem Prozess relevant sind. Ich verwende es daher direkt. Andere Services werden gemockt.

Ich möchte, dass Aspire beim Starten der Tests alle Services außer meinem Service startet und konfiguriert. Zusätzlich soll es eine Konfiguration für mein Service bereitstellen, die automatisch alle Abhängigkeiten berücksichtigt.

Aspire liefert die Orchestrierung und Konfiguration, die WebApplicationFactory die Macht, in das eigene Service einzugreifen.

Setup der Integrationstests mit Aspire und WebApplicationFactory

Sehen wir uns die Implementierung an.

Zuerst das Setup der Tests, idealerweise als OneTimeSetUp (NUnit) oder ClassFixture(XUnit).

Dabei gibt es drei wichtige Schritte:

  1. DistributedApplicationTestingBuilder erstellen.
  2. Das Projekt des eigenen Services identifizieren und aus dem appHost entfernen, damit es nicht von Aspire gestartet wird.
  3. Nach dem Start der Applikation die Konfiguration des eigenen Services aus dem Aspire Projekt holen und in eine WebApplicationFactory übergeben
 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}

Die WebApplicationFactory kann die Konfiguration direkt als InMemoryCollection anwenden. Eine so erstellte WebApplicationFactory hat dann also die gleiche Konfiguration wie das Service, der beim Start entfernt wurde. Danach können die Mechanismen der WebApplicationFactory verwendet werden, um weitere Änderungen an der Konfiguration oder den Services in der Dependency Injection zu machen.

Die so erstellte WebApplicationFactory wird dann die Basis für all meine Tests.

Die Implementierung von LoadConfigurationAsync ist dabei eine leichte Abwandlung von dem Code, den David Fowler im Aspire Forum zur Verfügung gestellt hat.

 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}

Von hier ausgehend steht all die Macht von Aspire und WebApplicationFactory in deinen Tests zur Verfügung.

Fazit

Aspire macht es somit sehr einfach, Integrationstests voll aufzusetzen, ohne noch andere Lösungen wie zB Docker Compose oder Testcontainers.NET zu verwenden.

Gleichzeitig ist damit sichergestellt, dass andere User, wie Teamkollegen oder CI/CD Pipelines, die gleiche Basis verwenden, um Integrationstests auszuführen. Noch ein Works-on-my-machine weniger.

Zusätzlich wird auch das Reproduzieren von Fehlern einfacher, da manuelle Tests mit der gleichen Konfiguration durchgeführt werden wie automatisierte Integrationstests, solange diese sie nicht explizit verändern.

Insgesamt reduziert ein solches Setup die Komplexität des Entwicklungsprozesses und damit den Aufwand, der notwendig ist.

Referenzen

Aspire Discussion zu Integrationstests mit David Fowlers Code: https://github.com/dotnet/aspire/discussions/878#discussioncomment-9631749