Integrationstest First mit Gherkin und Docker
Viele Softwareprojekte starten mit Unit-Tests. Das ist gut, aber oft nicht genug. Spätestens wenn mehrere Komponenten zusammenspielen, echte Datenbanken im Spiel sind oder Schnittstellen geprüft werden müssen, zeigt sich: Die Realität ist komplexer als ein einzelner Funktionsaufruf.
Im folgenden Beitrag zeige ich, wie sich Integrationstests von Beginn an in den Entwicklungsprozess einbinden lassen. Mit Gherkin und Docker entsteht eine Testumgebung, die fachlich verständlich, technisch robust und automatisiert lauffähig ist, und so für mehr Sicherheit und Qualität im gesamten System sorgt.
Unit-Tests zwischen Idealbild und Alltag
Automatisierte Unit-Tests sind ein bewährtes Werkzeug in der Softwareentwicklung, um zur Qualität von Anwendungen beizutragen. Sie prüfen die kleinsten Bausteine einer Software, also einzelne Komponenten, Module oder Klassen mit ihren Methoden und Funktionen, auf ihr korrektes Verhalten. So lässt sich sicherstellen, dass auch nach Änderungen oder Refactorings das Verhalten dieser Bausteine erhalten bleibt.
Unit-Tests sind ein fester Bestandteil automatisierter Builds. Sie bilden ein wichtiges Qualitätskriterium, bevor Änderungen in die zentrale Codebasis übernommen werden dürfen. Deshalb liegt in vielen Projekten ein starker Fokus auf diesen Tests. In Kombination mit einer hohen Testabdeckung gilt das oft als Zeichen für gute Softwarequalität.
In der Praxis zeigt sich jedoch, dass Unit-Tests nicht immer die erhoffte Zeitersparnis bringen. Sie haben unbestritten einen hohen Wert, stoßen aber auch an Grenzen, die Entwicklungsteams oft ausbremsen.
Hoher Anpassungsaufwand bei Änderungen
Da Unit-Tests sehr kleine Einheiten prüfen, müssen sie häufig angepasst werden, sobald sich die Architektur oder eine Methode ändert. Gerade bei Refactorings kann der Aufwand beträchtlich sein, bis alle Tests wieder lauffähig sind. Das führt dazu, dass manche Umstrukturierungen aus Zeitgründen verschoben oder ganz vermieden werden, insbesondere wenn ein Release ansteht.
Tests mit viel Mocking, aber wenig Aussagekraft
Je stärker man sich an Test Driven Development hält und die Einheiten immer kleiner schneidet, desto mehr Mockcode entsteht. Klassen delegieren dann häufig nur noch Aufrufe, die getestet werden, während echte Funktionalität kaum noch überprüft wird. Das wirft die Frage auf, wie aussagekräftig solche Tests am Ende wirklich sind.
Triviale Fälle überrepräsentiert, Grenzfälle vernachlässigt
Oft werden vor allem einfache Szenarien getestet, weil sie schnell umzusetzen sind und die Testabdeckung erhöhen. Komplexe oder seltene Grenzfälle, die erst im Zusammenspiel mehrerer Komponenten sichtbar werden, bleiben hingegen ungetestet.
Nicht getestete lose Enden
Bestimmte Bereiche wie Benutzeroberflächen, Dateisysteme, Datenbanken oder Netzwerke werden in Unit-Tests meist ausgespart. Gründe sind der Aufwand, technische Grenzen oder lange Laufzeiten. Damit bleibt immer ein Teil der Anwendung ungetestet, der erst in manuellen Tests auffällt.
Unit-Tests vorhanden und trotzdem funktioniert die Software nicht
Trotz aller Bemühungen kann es vorkommen, dass eine Anwendung nach erfolgreichen Unit-Tests nicht richtig läuft. Denn Unit-Tests betrachten Komponenten isoliert und nicht ihr Zusammenspiel. Sie sind deshalb wichtig, aber niemals ausreichend, um die korrekte Funktion einer Software sicherzustellen.
Integrationstests – das Zusammenspiel im Blick
Selbst eine Software mit hundert Prozent Testabdeckung kann scheitern, wenn ihre Teile nicht richtig miteinander arbeiten. Unit-Tests liefern wertvolle Sicherheit im Kleinen, aber das große Ganze bleibt oft ungetestet. Integrationstests schließen genau diese Lücke. Sie prüfen, ob das Zusammenspiel aller Komponenten funktioniert und ob das System als Ganzes das leistet, was es soll.
Während Unit-Tests isolierte Funktionen betrachten, testen Integrationstests reale Abläufe über mehrere Schichten hinweg, etwa vom Service über die Datenbank bis zur Schnittstelle. Damit sind sie aufwendiger, aber auch deutlich aussagekräftiger.
In der Praxis bedeutet das, dass eine Anwendung in einer möglichst realistischen Umgebung getestet wird. Statt gemockter Datenbankzugriffe kommt eine echte Datenbank zum Einsatz, statt simulierten Schnittstellen werden echte API-Endpunkte angesprochen. So lassen sich technische und fachliche Fehler frühzeitig erkennen.
Natürlich hat dieser Ansatz seinen Preis. Integrationstests sind komplexer, benötigen mehr Infrastruktur und Laufzeit, und ihre Fehlersuche kann anspruchsvoller sein. Doch der Gewinn an Vertrauen in die Funktionsfähigkeit des Gesamtsystems ist erheblich.
Damit Integrationstests ihren Mehrwert voll entfalten, sollten sie einige grundlegende Anforderungen erfüllen:
Fachliche Verständlichkeit
Integrationstests sollten so formuliert sein, dass sie auch für Personen außerhalb der Entwicklung nachvollziehbar sind. Ideal ist, wenn sich die Testfälle direkt aus den Systemanforderungen ableiten lassen und fachliche Szenarien beschreiben.
Abdeckung mehrerer Schichten
Ein Integrationstest sollte möglichst viele Schichten des Systems einbeziehen. Nur so lässt sich prüfen, ob Datenflüsse, Schnittstellen und Logik im Zusammenspiel funktionieren.
Automatisierte Ausführung
Von Anfang an sollten die Tests automatisiert laufen können. Das sichert Wiederholbarkeit und ermöglicht eine einfache Integration in eine CI/CD-Pipeline.
Wartbarkeit und Stabilität
Da Integrationstests meist umfangreicher sind, ist eine klare Struktur entscheidend. Stabil laufende Tests, die sich leicht anpassen lassen, schaffen Vertrauen und Akzeptanz im Team.
Erfüllt ein Test diese Anforderungen, kann er schon früh im Entwicklungsprozess wertvolle Hinweise liefern. Er zeigt, ob zentrale Funktionen wie geplant zusammenspielen und ob Architekturentscheidungen in der Praxis tragfähig sind.
Genau diesen Ansatz verfolge ich im nächsten Schritt. Am Beispiel einer kleinen Anwendung möchte ich zeigen, wie sich Integrationstests mit Gherkin und Docker als technische Basisvon Anfang an in ein Projekt einbauen lassen.
Beispielprojekt To-do-Listenanwendung
Um den Ansatz „Integrationstest First“ greifbar zu machen, werfen wir einen Blick auf ein einfaches Beispiel: eine Anwendung zum Verwalten und Teilen von To-do-Listen. Sie eignet sich gut, um die Struktur eines echten Systems abzubilden und gleichzeitig den Umfang überschaubar zu halten.
Anforderungen an das Beispielsystem
Die Anwendung soll im Webbrowser verfügbar sein und grundlegende Funktionen für Benutzer und To-do-Listen bereitstellen:
- Benutzer können eigene To-do-Listen anlegen.
- Eine To-do-Liste kann mit anderen Benutzern geteilt werden.
- Jeder Benutzer kann sowohl eigene als auch geteilte To-do-Listen sehen.
- Eine geteilte To-do-Liste zeigt an, von welchem Benutzer sie ursprünglich geteilt wurde.
Diese Anforderungen bilden die Grundlage für die späteren Tests. Sie sind bewusst einfach gehalten, aber repräsentieren typische fachliche Szenarien, bei denen mehrere Systemschichten zusammenspielen – von der Datenhaltung über die Logik bis zur Schnittstelle.
Technologiestack
Da es sich um eine Webanwendung handelt, kommen bewährte Technologien aus dem .NET-Umfeld zum Einsatz. Sie ermöglichen eine saubere Trennung zwischen Backend, Frontend und Datenbank sowie eine einfache Integration in automatisierte Testumgebungen.
- Frontend: Angular
- Backend: ASP.NET Core Web API
- Datenbank: PostgreSQL
- Testframeworks: XUnit, Reqnroll (als Gherkin-Implementierung)
- Infrastruktur: Testcontainers für Docker
Im weiteren Verlauf konzentrieren wir uns auf das Backend und die Integration der Tests mit der Datenbank. Das Frontend wird im Ausblick kurz erwähnt, spielt für das Verständnis des Testaufbaus jedoch eine untergeordnete Rolle.
Damit ist die Grundlage gelegt, um im nächsten Schritt in die konkrete Umsetzung einzusteigen und zu zeigen, wie sich Integrationstests mit Gherkin und Docker praktisch realisieren lassen.
Die Umsetzung des Beispielprojekts
Für die Umsetzung beginnen wir mit einer sauberen Projektstruktur und den grundlegenden Abhängigkeiten. Ziel ist es, eine Umgebung zu schaffen, in der die Tests von Anfang an ausführbar sind und sich später problemlos in eine CI/CD-Pipeline integrieren lassen.
Projektstruktur und Vorbereitung
Wir legen zwei Projekte an:
- ein ASP.NET Core Web API-Projekt für den eigentlichen Service
- ein XUnit-Testprojekt für die Integrationstests
Das Testprojekt referenziert das Web-API-Projekt, um direkt mit dessen Code und Schnittstellen zu arbeiten.
Abbildung 1: Anlegen einer Projektstruktur
Für den Start werden einige NuGet-Pakete benötigt, um Datenbankzugriffe, Testausführung und Infrastruktur zu ermöglichen:
Web API-Projekt:
- Npgsql.EntityFrameworkCore.PostgreSQL
- Microsoft.EntityFrameworkCore.Design
Testprojekt:
- FluentAssertions – für aussagekräftige und lesbare Testausdrücke
- Microsoft.AspNetCore.Mvc.Testing – zum Starten der Web-API in der Testumgebung
- Npgsql – für den Datenbankzugriff
- Reqnroll.Tools.MsBuild.Generation
- Reqnroll.xUnit – zur Ausführung der Gherkin-Tests
- Testcontainers.PostgreSql – um automatisch einen Docker-Container für die Tests bereitzustellen
Zusätzlich empfiehlt sich das Reqnroll-Plugin für Visual Studio oder JetBrains Rider. Es bietet Syntax-Highlighting für Feature-Dateien und ermöglicht das direkte Springen zwischen Szenarien und C#-Testschritten.
Erster Gherkin-Test
Nachdem alle Pakete installiert sind, wird im Testprojekt eine neue Datei mit der Endung .feature angelegt. Sie beschreibt in Gherkin-Syntax das gewünschte Verhalten der Anwendung.
Abbildung 2: Datei mit Endung .feature anlegen
Ein Beispiel für ein solches Feature lautet:
Feature: Todo Lists
This feature is about handling todo lists for different users, including sharing.
Background:
Given The system has the following users with the following owned todo lists
| UserName | Owned Todo Lists |
| Theo | "Theos Todos 1", "Theos Todos 2" |
| Tina | |
Rule: Owned Todos are only for the owners
Scenario: Create a new todo
When "Tina" has created the todo list "Tinas Todos"
Then "Theo" has the todo lists "Theos Todos 1" and "Theos Todos 2"
And "Tina" has the todo list "Tinas Todos"
Rule: Shared todos are visible for shared users.
Scenario: Share todo with a user
When "Theo" shares "Theos Todos 1" with "Tina"
Then "Theo" has the overall todo lists
| Name | Shared From |
| Theos Todos 1 | |
| Theos Todos 2 | |
And "Tina" has the overall todo lists
| Name | Shared From |
| Theos Todos 1 | Theo |
Reqnroll ist die C# Implementierung der Sprache Gherkin [1], um BDD zu betreiben. Sie bietet Anbindungen an übliche Testframeworks wie NUnit, MSTest oder auch wie in unserem Fall XUnit. Letzendlich werden aus jeder Feature-Datei eine oder mehrere XUnit Tests generiert, die sich mit dem Testrunner von Visual Studio, dotnet test oder auch Rider einfach ausführen lassen. Eine SourceGenerator aus Reqnroll kümmert sich darum, aus den Feature-Dateien die XUnit-Tests zu generieren.
Und wie ist die Feature-Datei aufgebaut? Am leichtesten lässt sich dies mit einzelnen Schlüsselwörtern erklären:
Feature: Der Titel oder Name des Feature-Files. Hier sollte man einen Titel wählen, der einem fachlichen Feature entspricht.
Scenario: Entspricht einem Testfall mit einer Menge von Schritten, die sich aus Given/When/Then Klauseln zusammensetzen. Aus einem Scenario wird ein Test generiert. Der Name des Scenarios bildet den technischen Namen des Unit-Tests.
Given/When/Then: Jede Given/When/Then Zeile ist ein sogenannte Testschritt und dient dazu, dass man das Scenario mit sprechendem Text formulieren kann. Innerhalb eines solchen Schrittes können Platzhalter als Parameter verwendet werden. Diese können zum Beispiel Text (gekennzeichnet durch Anführungszeichen) oder auch Tabellen sein.
Rule: Beschreibung einer Business-Regel, der man einer oder mehreren Szenarios zuordnet. Dadurch lassen sich verschiedene Szenarien in Gruppen einteilen und dem Leser mitteilen, was der Fokus ist.
Background: Die im Background beschriebenen Given/When/Then Schritte beschreiben Verhalten, welches für alle Szenarien in diesem Feature vorher ausgeführt werden sollen. Man kann den Background zum Beispiel verwenden, um Stammdaten im System zu beschreiben.
Und woher weiß Gherkin, was in den einzelnen Schritten passieren soll? Die Antwort findet sich in den StepDefinitions.
StepDefinitions: Die Feature-Files selbst sind unabhängig von der verwendeten Programmiersprache und dienen dem Formulieren von Testfällen. Reqnroll generiert pro Scenario einen XUnit Fact, die sich aus Given/When/Then Schritten zusammensetzt.
Die Given/When/Then Schritte müssen von einem Entwickler implementiert werden. Dazu werden in Reqnroll Klassen definiert, die Mittels Code-Attribute an die Testschritte gebunden sind. Man kann diese Klassen mit Attributen per Hand oder mithilfe des Reqnroll Plugin definieren. Beim ersten Schritt muss noch eine neue Datei angelegt werden. Wir generieren alle Schritte in eine Steps.cs:
using Reqnroll;
namespace T2Informatik.SampleService.Tests;
[Binding]
public class Steps
{
[Given("The system has the following users with the following owned todo lists")]
public void GivenTheSystemHasTheFollowingUsersWithTheFollowingOwnedTodoLists(Reqnroll.Table table)
{
ScenarioContext.StepIsPending();
}
}
Die generierte Methode, die man auch umbenennen kann, wird einfach an an das vorher ausgewählte Given angebunden. Die Tabelle wird erkannt und Reqnroll generiert Code, sodass wir die Daten als Tabellenobjekt erhalten. Und ScenarioContext.StepIsPending() sorgt dafür, dass die Tests fehlschlagen.
Umgang mit StepDefinitions
Als Faustregel gilt, dass Feature-Files zu StepDefinitions orthogonal zueinander stehen:
Abbildung 3: Feature Files und StepDefinitionen
Die StepDefinitions bilden die Brücke zwischen den Feature-Files und der technischen Umsetzung. Mehrere unterschiedlich formulierte Schritte können auf dieselbe Methode verweisen, indem mehrere Given-, When- oder Then-Attribute an eine Methode gebunden werden. So entsteht mit der Zeit eine wachsende Wiederverwendung, und interne Refactorings sind möglich, ohne die Tests selbst anpassen zu müssen.
Nachdem die Tests definiert und die StepDefinitions als leere Methoden erzeugt wurden, folgt im nächsten Schritt der Aufbau der Testinfrastruktur. Dazu ist es wichtig, den Service genauer zu verstehen, um die Umgebung korrekt und vollständig aufsetzen zu können.
Aufbau des Service-Projekts
Das Service-Projekt verwendet PostgreSQL in Kombination mit Entity Framework Core im Code-First-Ansatz. Die Datenbank und das Schema werden also aus dem Service heraus generiert.
Zur Vereinfachung greift der Beispielcode in den Controllern direkt auf das Entity-Modell zu. An den Client werden jedoch eigene ViewModel-Objekte zurückgegeben bzw. von dort entgegengenommen.
Ein vereinfachtes Beispiel, wie alle Benutzer per GET aus dem System gelesen werden:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using T2Informatik.SampleService.PersistenceModel;
namespace T2Informatik.SampleService.Controllers;
public record UserReadViewModel(int Id, string UserName);
[ApiController]
[Route("api/[controller]")]
public sealed class UsersController(SampleServiceDbContext dbContext) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll()
{
var viewModels = (await dbContext.User.ToListAsync())
.Select(user => new UserReadViewModel(user.Id, user.UserName));
return Ok(viewModels);
}
}
Die anderen Endpunkte sind im gleichen Stil umgesetzt. In einem echten Projekt würde man in der Regel nicht direkt in den Controllern auf die Persistenzschicht zugreifen. Für diesen Beitrag ist der Aufbau bewusst vereinfacht, damit der Fokus auf den Tests bleibt.
Erstellung der Testinfrastruktur
Da wir uns auf das Backend konzentrieren, legen wir zunächst fest, unter welchen Rahmenbedingungen die Tests laufen sollen:
- Es wird eine PostgreSQL-Datenbank als Docker-Container gestartet.
- Es wird ein Testservice über das .NET-Testframework erzeugt, der per HttpClient angesprochen wird.
- Für jeden Test wird eine Datenbank mit dem benötigten Schema initialisiert.
- Alle Testdaten werden explizit im Test angelegt. Es gibt keine „magischen“ Stammdaten im Hintergrund und keine extern angenommenen Bestände.
- Die StepDefinitions kommunizieren ausschließlich über den HttpClient mit dem Backend.
- Alle Aufrufe über den HttpClient verwenden offizielle Schnittstellen des Backends, also dieselben Endpunkte, die auch das Frontend nutzen würde.
- Ein direkter Zugriff auf die Datenbank ist während des Tests nicht erlaubt.
Testdurchlauf
Auf Basis dieser Regeln ergibt sich folgender Ablauf für die Tests:
1. Vor allen Tests wird der PostgreSQL-Container gestartet.
2. Für jeden einzelnen Test wird eine neue Datenbank mit eindeutigem Namen erzeugt, ein Testservice erstellt und mit der Datenbankverbindung gestartet, der Test ausgeführt und die erzeugte Datenbank wieder gelöscht.
3. Nach allen Tests wird der Docker-Container heruntergefahren.
Auf diese Weise minimieren wir Seiteneffekte zwischen den Tests. Tests können nacheinander oder sogar parallel laufen, ohne sich gegenseitig zu beeinflussen.
Docker-Container erstellen und löschen
Für das Handling des Datenbank-Containers gibt es eine eigene Klasse TestBed. Sie ist über alle Testläufe hinweg gültig, startet und stoppt den Docker-Container und stellt mit CreateTestCaseAsync() für jeden Test ein vorbereitetes TestCase-Objekt bereit.
using Testcontainers.PostgreSql;
namespace T2Informatik.SampleService.Tests.Context;
public class TestBed : IAsyncLifetime
{
private PostgreSqlContainer? _postgreSqlContainer;
public async Task<TestCase> CreateTestCaseAsync()
{
if (_postgreSqlContainer == null)
throw new NullReferenceException("Postgresql Container not build");
await _postgreSqlContainer.StartAsync();
var testContext = new TestCase(_postgreSqlContainer.GetConnectionString());
return testContext;
}
public Task InitializeAsync()
{
_postgreSqlContainer = new PostgreSqlBuilder()
.WithImage("postgres:17.5")
.WithName("SampleServiceDatabaseContainer")
.WithUsername("test")
.WithPassword("test")
.Build();
return Task.CompletedTask;
}
public Task DisposeAsync() =>
_postgreSqlContainer != null
? _postgreSqlContainer.DisposeAsync().AsTask()
: Task.CompletedTask;
}
Datenbank und Service erstellen
Die eigentliche Datenbank für einen Test wird in TestCase angelegt. Diese Klasse ist dafür verantwortlich, pro Testlauf eine eigene Datenbank zu erstellen, den Service zu starten und einen HttpClient bereitzustellen.
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using FluentAssertions.Execution;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
namespace T2Informatik.SampleService.Tests.Context;
public class TestCase : IAsyncDisposable
{
private readonly WebApplicationFactory<Program> _factory;
public TestCase(string connectionString)
{
var connectionStringBuilder = new Npgsql.NpgsqlConnectionStringBuilder(connectionString)
{
Database = Guid.NewGuid().ToString("N"),
};
_factory = new SampleServiceWebApplicationFactory(connectionStringBuilder.ToString());
HttpClient = _factory.CreateClient(
new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }
);
}
public async Task InitializeAsync()
{
using var scope = _factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<SampleServiceDbContext>();
await dbContext.Database.EnsureCreatedAsync();
}
public HttpClient HttpClient { get; }
public ValueTask DisposeAsync() => _factory.DisposeAsync();
}
Im Wesentlichen erhält TestCase den Connection String von TestBed, erzeugt damit eine neue Datenbank mit zufälligem Namen, startet eine Webanwendung und erstellt einen HttpClient, den wir in den Tests verwenden.
WebApplicationFactory konfigurieren
Die Klasse SampleServiceWebApplicationFactory ist ein typisches Muster aus dem ASP.NET-Core-Framework [2], um einen Webservice in Tests zu starten.
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
namespace T2Informatik.SampleService.Tests.Context;
internal sealed class SampleServiceWebApplicationFactory(string connectionString) : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
var configurationValues = new[]
{
new KeyValuePair<string, string?>("ConnectionStrings:Database", connectionString),
};
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
.AddInMemoryCollection(configurationValues)
.Build();
builder
.UseConfiguration(configuration)
.ConfigureAppConfiguration(configurationBuilder =>
configurationBuilder.AddInMemoryCollection(configurationValues)
);
}
}
Praktisch ist hier, dass die Konfiguration des Services über eine In-Memory-Collection gesetzt wird. So behalten wir die volle Kontrolle über die verwendeten Einstellungen und vermeiden Seiteneffekte durch andere Konfigurationsdateien.
Hilfsfunktionen für den HttpClient
Damit die Tests gut lesbar bleiben, gibt es eine Extension-Klasse für HttpClient. Sie kapselt die wichtigsten Aufrufe und sorgt für aussagekräftige Fehlermeldungen, wenn ein Request unerwartet fehlschlägt.
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using FluentAssertions.Execution;
namespace T2Informatik.SampleService.Tests.Context;
public static class HttpClientExtensions
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() },
};
public static async Task PostAsync<T>(
this HttpClient client,
string route,
T body
)
{
var content = GetContent(route, body, "post");
var response = await client.PostAsync(route, content);
await EvaluateStatusCodeAsync(response, route, "POST");
}
public static async Task<T> GetAsync<T>(this HttpClient client, string route)
{
var response = await client.GetAsync(route);
if (!response.IsSuccessStatusCode)
{
throw new AssertionFailedException(
$"GET Request to {route} failed with status code {response.StatusCode}"
);
}
return await DeserializeResponseAsync<T>(response, "GET", route);
}
private static async Task EvaluateStatusCodeAsync(
HttpResponseMessage response,
string route,
string method
)
{
if (
!response.IsSuccessStatusCode
)
{
var stream = await response.Content.ReadAsStringAsync();
throw new AssertionFailedException(
$"{method} Request to {route} failed with status code {response.StatusCode}. Message: {stream}"
);
}
}
private static StringContent? GetContent<T>(
string route,
T body,
string method
)
{
if (body == null)
throw new InvalidOperationException($"{method} '{route}' with null body not allowed");
var json = JsonSerializer.Serialize<object>(body, SerializerOptions);
return new StringContent(json, Encoding.UTF8, "application/json");
}
private static async Task<T> DeserializeResponseAsync<T>(
HttpResponseMessage response,
string method,
string route
)
{
var stringContent = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<T>(stringContent, SerializerOptions);
if (result == null)
{
throw new AssertionFailedException(
$"Cannot deserialize {method} {route} to type {typeof(T).FullName}"
);
}
return result;
}
}
Damit lässt sich zum Beispiel ein GET-Request sehr einfach formulieren:
var todoLists = await httpClient.GetAsync<TodoListReadViewModel[]>("api/TodoLists");
Wir erhalten direkt die ViewModel-Typen aus dem Service, die dort ebenfalls für die Serialisierung und Deserialisierung verwendet werden.
Anmerkung: Ich habe mich bewusst dafür entschieden, dass die Tests hart an die ViewModel-Typen des Services gekoppelt sind. Alternativ könnte man die Typen im Testprojekt duplizieren, um eine lose Kopplung zu erreichen. Der Vorteil der gewählten Variante: Inkompatibilitäten fallen sofort beim Kompilieren auf und nicht erst zur Laufzeit.
Integration in den Reqnroll-Ablauf
Mit TestBed, TestCase und HttpClient ist die technische Basis für die Tests gelegt. Im nächsten Schritt geht es darum, den Ablauf vor und nach den Tests zu steuern. Dadurch wird die Implementierung der Testschritte deutlich einfacher und folgt einem klaren Muster. Reqnroll unterstützt dies mit seinem Tooling.
Hooks: Hooks werden in Reqnroll verwendet, um eigenen Code vor oder nach bestimmten Phasen der Testausführung auszuführen. Für unser Szenario sind vor allem BeforeTestRun, AfterTestRun, BeforeScenario und AfterScenario interessant.
Wie bei TestSteps legen wir die Hooks in eine eigene Klasse Hooks und verwenden die ensprechenden Code-Attribute und implementieren Methoden. Zuerst der Rahmen vor und nach allen Tests:
using Reqnroll;
using Reqnroll.BoDi;
namespace T2Informatik.SampleService.Tests.Context;
[Binding]
public sealed class Hooks
{
private static TestBed? _testBed;
[BeforeTestRun]
public static async Task BeforeTestRun()
{
_testBed = new TestBed();
await _testBed.InitializeAsync();
}
[AfterTestRun]
public static async Task AfterTestRunAsync()
{
if (_testBed != null)
{
await _testBed.DisposeAsync();
}
_testBed = null;
}
}
Danach ergänzen wir die Hooks um das Setup und Cleanup pro Szenario. Hier wird pro Szenario ein eigener TestCase erzeugt, der HttpClient registriert und am Ende wieder aufgeräumt:
using Reqnroll;
using Reqnroll.BoDi;
namespace T2Informatik.SampleService.Tests.Context;
[Binding]
public sealed class Hooks(ScenarioContext scenarioContext, IObjectContainer container)
{
// ... Code aus dem vorherigen Listing.
[BeforeScenario]
public async Task BeforeScenarioAsync()
{
if (_testBed == null)
throw new ArgumentNullException(nameof(_testBed));
var testCase = await _testBed.CreateTestCaseAsync();
scenarioContext.Set(testCase);
container.RegisterInstanceAs(testCase.HttpClient);
await testCase.InitializeAsync();
}
[AfterScenario]
public async Task AfterScenarioAsync()
{
var testCase = scenarioContext.Get<TestCase>();
if (testCase != null)
{
await testCase.DisposeAsync();
}
}
}
Reqnroll unterstützt Dependency Injection in gebundenen Klassen. Standardmäßig können ScenarioContext und der DI-Container IObjectContainer per Konstruktor angefordert werden.
IObjectContainer dient dazu, zusätzliche Typen zu registrieren, die später in Testklassen injiziert werden sollen. In unserem Fall ist das der HttpClient aus dem TestCase.
Den ScenarioContext nutzen wir, um während eines Szenarios Informationen abzulegen und wieder abzurufen, zum Beispiel den aktuellen TestCase. Da er nur für die Dauer eines Szenarios gilt, eignet er sich gut, um solche Daten zu kapseln und nach dem Testlauf sicher aufzuräumen.
Implementierung der Testschritte
Nachdem die Infrastruktur steht, können wir die Testschritte umsetzen. Dazu benötigen wir nur die Parameter der Gherkin-Schritte und den HttpClient, den wir per Konstruktor übergeben bekommen.
Wir beginnen mit dem Background-Schritt, der die Benutzer und ihre To-do-Listen anlegt:
using FluentAssertions;
using Reqnroll;
using T2Informatik.SampleService.Controllers;
using T2Informatik.SampleService.Tests.Context;
namespace T2Informatik.SampleService.Tests;
[Binding]
public class Steps(HttpClient httpClient)
{
[Given("The system has the following users with the following owned todo lists")]
public async Task GivenTheSystemHasTheFollowingUsersWithTheFollowingOwnedTodoLists(Table table)
{
foreach (var row in table.Rows)
{
await httpClient.PostAsync("api/Users", new UserWriteViewModel(row["UserName"]));
var userId = await GetUserIdFromNameAsync(row["UserName"]);
var todoLists = row["Owned Todo Lists"]
.Split(',')
.Select(t => t.Trim(' ', '"'))
.Where(t => !string.IsNullOrWhiteSpace(t));
foreach (var todoListName in todoLists)
{
await httpClient.PostAsync($"api/Users/{userId}/TodoLists",
new TodoListWriteViewModel(todoListName));
}
}
}
private async Task<int> GetUserIdFromNameAsync(string userName) =>
(await httpClient.GetAsync<UserReadViewModel[]>("api/Users"))
.Single(u => u.UserName == userName).Id;
}
Der Primärkonstruktor fordert den HttpClient an, der im Szenario über die Hooks bereitgestellt wurde. Im Schritt selbst rufen wir die REST-API des Backends auf, legen für jede Tabellenzeile einen Benutzer an und erzeugen die zugehörigen To-do-Listen.
Die Informationen aus der Tabelle werden hier manuell verarbeitet. Alternativ könnten die Zeilen auch in eigene Objekttypen serialisiert werden. Für das Beispiel ist die einfache Variante gut lesbar, für größere Tabellen kann die automatische Zuordnung jedoch sinnvoller sein.
Im Wesentlichen iteriert der Code über alle Zeilen, legt je Zeile einen Benutzer an und erzeugt anschließend alle To-do-Listen aus der zweiten Spalte.
Als nächstes ein Schritt, der eine Überprüfung vornimmt. Hier das Beispiel:
Then "Theo" has the todo lists "Theos Todos 1" and "Theos Todos 2"`:
[Then("{string} has the todo lists {string} and {string}")]
public async Task ThenHasTheTodoListsAnd(string userName, string todoListName1, string todoListName2)
{
var userId = await GetUserIdFromNameAsync(userName);
var todoListNames = (await httpClient.GetAsync<TodoListReadViewModel[]>($"api/Users/{userId}/TodoLists"))
.Select(t => t.Name)
.OrderBy(t => t)
.ToArray();
todoListNames
.Should()
.BeEquivalentTo(
new[] { todoListName1, todoListName2 }
.OrderBy(t => t));
}
Hier wird zunächst die ID des Benutzers über dessen Namen ermittelt. Anschließend werden alle To-do-Listen dieses Benutzers geladen und die Namen mit den im Testschritt übergebenen Werten verglichen. Für die Assertion kommt FluentAssertions zum Einsatz.
Die übrigen Schritte folgen demselben Schema:
- Verwende den HttpClient, um lesend oder schreibend auf das Backend zuzugreifen.
- Führe die notwendigen Assertions durch.
- Nutze bei Bedarf den ScenarioContext, um Informationen zwischen den Schritten auszutauschen.
Alle Testschritte im Detail zu zeigen, wäre an dieser Stelle repetitiv. Für das Verständnis des Konzepts reicht die gezeigte Struktur aus.
Abbildung 4: Ergebnis nach Testdurchlauf
Sind alle Tests implementiert, kann die gesamte Solution gebaut und die Tests ausgeführt werden. Der Durchlauf dauert in der Regel nur wenige Sekunden. Am längsten dauert meist das einmalige Herunterladen des Docker-Images. Besonders angenehm ist, wenn für den Service in der Entwicklung dieselbe Docker-Version verwendet wird wie in den Tests.
Ausblick: Integrationstests weitergedacht
Der gezeigte Ansatz bildet die Grundlage, um Integrationstests von Beginn an fest in den Entwicklungsprozess einzubinden. Mit Gherkin, Reqnroll und Docker entsteht eine Testumgebung, die fachlich verständlich, technisch belastbar und automatisiert ausführbar ist.
Natürlich lässt sich dieser Aufbau beliebig erweitern. Eine naheliegende Weiterentwicklung wäre, dass der Product Owner oder das Fachteam die Feature-Dateien selbst formuliert. Auf diese Weise könnten Akzeptanzkriterien direkt als automatisierte Tests umgesetzt werden – ein echter Schritt in Richtung „Living Documentation“.
Ein weiterer sinnvoller Schritt ist die Integration der Tests in die CI/CD-Pipeline. Moderne Build-Systeme wie GitHub Actions, GitLab CI oder Azure DevOps unterstützen Docker-Umgebungen problemlos. Dadurch lassen sich die Tests automatisch bei jedem Build ausführen, was die Rückmeldung zu Änderungen erheblich beschleunigt.
Auch die Authentifizierung kann problemlos ergänzt werden. Wird zum Beispiel ein JWT-Token für Anfragen verwendet, lässt sich dieses im Testkontext generieren und über den HttpClient mitsenden. Damit können auch Szenarien mit Benutzerrollen oder Berechtigungen realistisch geprüft werden.
Schließlich bietet Gherkin die Möglichkeit, die bestehenden Feature-Dateien als Grundlage für End-to-End-Tests zu verwenden. So können dieselben fachlichen Szenarien auf Systemebene erneut validiert werden – nur diesmal mit einem eingebundenen Frontend. Der Aufwand bleibt überschaubar, da viele Testbausteine und Definitionen wiederverwendet werden können.
Damit zeigt sich: Integrationstests sind nicht nur ein zusätzlicher Qualitätssicherungsmechanismus, sondern ein wirksames Werkzeug, um Architektur, Logik und Fachlichkeit kontinuierlich in Einklang zu halten.
Hinweise:
Gerne können Sie sich das Beispielprojekt im Detail anschauen, klonen und für Ihre eigenen Zwecke anpassen.
[1] Cucumber: Gherkin Reference
[2] ASP.NET Core Framework
Her finden Sie einen Beitrag über Unit-Tests mit KI.
Suchen Sie nach einem Team für Ihre Softwareentwicklung oder -modernisierung? Dann laden Sie sich den t2informatik Steckbrief herunter.
Interessieren Sie sich für weitere Tipps aus der Welt der Softwareentwicklung? Testen Sie unseren wöchentlichen Newsletter mit interessanten Beiträgen, Downloads, Empfehlungen und aktuellem Wissen.
Peter Friedland hat hier im t2informatik Blog einige weitere Beiträge veröffentlicht, u. a.:

Peter Friedland
t2informatik GmbH
Peter Friedland ist bei der t2informatik GmbH als Software Consultant tätig. In verschiedenen Kundenprojekten entwickelt er innovative Lösungen in enger Zusammenarbeit mit Partnern und den Ansprechpartnern vor Ort. Und privat spielt der junge Familienvater Cello im Orchester der Musikschule Wildau.
Im t2informatik Blog veröffentlichen wir Beträge für Menschen in Organisationen. Für diese Menschen entwickeln und modernisieren wir Software. Pragmatisch. ✔️ Persönlich. ✔️ Professionell. ✔️ Ein Klick hier und Sie erfahren mehr.






