Softwaretest ist ein Thema, was jeden Softwareentwickler stets begleitet. Wenn es um Testautomatisierung geht, erhalten wir dazu ein breites Set an Werkzeugen in die Hand gedrückt. Das geht bei Testframeworks wie xUnit los und geht über die Integration solcher Frameworks in moderne IDEs wie Visual Studio und Rider weiter. In diesem Beitrag möchte ich mir eines dieser Werkzeuge herauspicken und ins Rampenlicht rücken: Es geht um Integrationstests mit ASP.NET Core. Warum? Genau diese Art von Tests hat einen sicheren Platz in meinem Werkzeugkoffer eingenommen. Es passt sehr gut zu der Art, wie ich Testautomatisierung und das Thema Testpyramide / Testdiamant schon seit sehr langer Zeit sehe (mehr dazu unter [1]). Ich setze bei Testautomatisierung i. d. R. mehr auf einen sog. „Testdiamanten“, als auf eine „Testpyramide“. Dafür gibt es viele Gründe. Ein wichtiger ist, dass damit das Verhalten einer Softwarekomponente auf einer höheren Ebene getestet und damit festgezogen wird. So viel möchte ich jetzt aber nicht über die Gründe schreiben, sondern jetzt zum Thema des Artikels kommen.
Inhaltsverzeichnis
Beispiel-Service mit ASP.NET Core
Als Beispiel verwende ich den Service aus meinem Artikel zur hexagonalen Architektur (Siehe [2]). Der Service ist sehr einfach aufgebaut. Er stellt eine REST-API nach außen bereit, über die Workshop-Protokolle erstellt, geändert und gelöscht werden können. Als Konsument dieser API ist ebenso ein Blazor-Projekt in der Projektmappe. Auf der Persistenzschicht wird Entity Framework Core mit SQLite benutzt.
Integrationstests mit ASP.NET Core
Was bedeutet „Integrationstest“ genau? Google leitet bei einer Suche danach relativ schnell auf die Dokumentation von Microsoft unter [3]. In kurzen Worten steckt hinter dem Ansatz folgendes: Man startet den ASP.NET Core Service lokal im Memory und führt Tests gegen die öffentliche Schnittstelle des Service aus. Man spricht also direkt die API Endpunkte per Http-Schnittstelle an. Das gute daran ist, dass der Entwickler dennoch die Möglichkeit bekommt, einzelne Komponenten des ASP.NET Core Services zu mocken. Schließlich entsteht bei diesem Vorgehen schnell das Problem, dass der Service auch andere Services anfunken möchte. Das kann man in einzelnen Fällen durchaus so wollen, normalerweise möchte man in diesem Kontext aber nur diesen einen Service testen und andere daher mocken.
Integrationstests am Beispiel-Service
Beim Beispiel-Service verwende ich xUnit als Testframework. Das erste, was man für einen Integrationstest braucht, ist den zu testenden ASP.NET Core Service. Dieser wird innerhalb des Tests mit der Klasse WebApplicationFactory<T> hochgezogen. Das Paket Microsoft.AspNetCore.Mvc.Testing stellt diese Klasse bereit. Sie startet den ASP.NET Core Service im Memory und ermöglicht uns, an verschiedenen Stellen auf den Startprozess einzuwirken. Nachfolgender Codeausschnitt zeigt die Implementierung aus meinem Beispiel. Da ich mit der SQLite Datenbank teste, setze ich ganz am Anfang die Datenbank zurück. Ebenso sorge ich in Zeile 22 dafür, dass der ASP.NET Core Service „IntegrationTests“ als Environment verwendet. So ist es möglich, dass dafür explizit ein appsettings.IntegrationTests.json bereitgestellt werden kann. In Zeile 37 könnten jetzt noch Services innerhalb der Applikation durch Mocks ausgetasucht werden. Hier im Beispiel ist das nicht notwendig, daher habe ich es hier lediglich als Platzhalter hinterlegt.
public class WebApplicationFixture : WebApplicationFactory<Startup> { public static readonly string HostEnvironment = "IntegrationTests"; public WebApplicationFixture() { this.ResetDatabase(); } private void ResetDatabase() { var assemblyPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; var dbPath = Path.Combine(assemblyPath, "workshopDatabase-integrationtest.db"); if (File.Exists(dbPath)) { File.Delete(dbPath); } } protected override IHostBuilder CreateHostBuilder() { return Program.CreateHostBuilder($"--Environment:{HostEnvironment}"); } protected override void ConfigureWebHost(IWebHostBuilder builder) { var assemblyPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); base.ConfigureWebHost(builder .ConfigureAppConfiguration((_, configBuilder) => { configBuilder.SetBasePath(assemblyPath); }) .UseEnvironment(HostEnvironment) .ConfigureServices(services => { // Place for registering mocks })); } }
Tests gegen den ASP.NET Core Service
Nachfolgender Codeausschnitt zeigt einen Test gegen den ASP.NET Core Service. Im Konstruktor geben wir dabei die WebApplicationFixture (siehe oben) rein. In Zeile 15 wird daraus ein Client erzeugt, über den der ASP.NET Core Service angesprochen werden kann. Tatsächlich ladet das Framework den ASP.NET Core Service genau an dieser Stelle. Anschießend feuere ich hier im Beispiel einen POST-Request gegen den Service und möchte damit ein neues Objekt in der Datenbank anlegen. Im Assert prüfe ich schließlich, ob die Response wie erwartet war und ob das Objekt korrekt in der Datenbank liegt.
[Collection(nameof(WebApplicationTestCollection))] public class WorkshopControllerTests : IClassFixture<WebApplicationFixture> { private readonly WebApplicationFixture _application; public WorkshopControllerTests(WebApplicationFixture application) { _application = application; } [Fact] public async Task CreateWorkshopTest() { // Arrange using var client = _application.CreateClient(); // Act var httpResponse = await client.PostAsJsonAsync("Workshops", new WorkshopWithoutIDDto() { Project = "Dummy Project", Title = "Dummy Workshop #1", Protocol = new List<ProtocolEntryDto>() { new() { EntryType = ProtocolEntryTypeDto.Information, Priority = 2, Text = "Workshop started" } }, StartTimestamp = DateTimeOffset.UtcNow }); var responseObject = await httpResponse.Content.ReadFromJsonAsync<WorkshopDto>(); // Assert Assert.NotNull(responseObject); Assert.True(responseObject!.ID != Guid.Empty); using var assertScope = _application.Services.CreateScope(); var assertUnitOfWork = assertScope.ServiceProvider.GetRequiredService<IUnitOfWork>(); var objInDB = await assertUnitOfWork.Workshops.GetWorkshopAsync(responseObject.ID, CancellationToken.None); Assert.Equal("Dummy Project", objInDB.Project); Assert.Equal("Dummy Workshop #1", objInDB.Title); Assert.Single(objInDB.Protocol); } // ... }
Fazit
Hier im Artikel handelt es sich um ein sehr einfaches Beispiel. Es steht stellvertretend für viele weitere Tests, welche ich in den letzten Jahren schreiben und lesen durfte. Tests auf dieser Ebene haben viele Vorteile. So testen wir den Service an seiner äußeren Schnittstelle. Bei Änderungen innerhalb des Service kann auf dieser Basis sichergestellt werden, dass sich das Verhalten nach außen nicht ändert.
An dieser Stelle müssen wir aber auch über Nachteile sprechen. Hier im Beispiel etwa wird die Datenbank beim Test angesprochen. Das führt zu längeren Laufzeiten der Tests und liefert verzögertes Feedback an den Entwickler. Weiterhin muss man dafür sorgen, dass solche externen Services auch in der Testumgebung (z. B. am BuildServer) verfügbar sind. Weiterhin sprechen wir bei diesem Vorgehen erfahrungsgemäß von größeren Testmethoden – also allgemein mehr Code bei den Tests.
Downloads
- Quellcode des Beispiels aus diesem Artikel
https://www.rolandk.de/files/2023/HappyCoding.HexagonalArchitecture.zip
Verweise
- Um was geht es beim Testen?
https://www.rolandk.de/wp-posts/2015/02/um-was-geht-es-beim-testen/ - Hexagonale Architektur mit C#, .NET 6 und Blazor
https://www.rolandk.de/wp-posts/2022/09/hexagonale-architektur-mit-c-net-6-und-blazor/ - Integration tests in ASP.NET Core
https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests?view=aspnetcore-7.0
Ebenfalls Interessant
- MSTest, oder wie ich es verwende
https://www.rolandk.de/wp-posts/2014/08/mstest-oder-wie-ich-es-verwende/