Unit Testing in .NET: Saubere Tests für sauberen Code

Viele Entwickler schreiben ungern Unit Tests. Die Gründe dafür sind unterschiedlich. Manche empfinden Tests als lästig, andere sehen sie als Zeitverschwendung oder haben schlechte Erfahrungen gemacht. Oft werden Tests als etwas betrachtet, das man zusätzlich erledigen muss, wenn die eigentliche Arbeit abgeschlossen ist. Dabei sind gut geschriebene Tests ein zentraler Bestandteil von wartbarem, robustem und verständlichem Code.
In diesem Artikel geht es darum, wie Unit Tests in .NET sinnvoll strukturiert werden können. Der Fokus liegt auf der Qualität und dem Aufbau einzelner Tests. Themen wie Testdaten, Coverage, Integrationstests oder UI-Tests werden in einem separaten Artikel behandelt. Ziel ist es, zu zeigen, wie Tests geschrieben werden, die schnell, lesbar, zuverlässig und hilfreich sind.
Was gute Unit Tests ausmacht
Gute Unit Tests sind mehr als nur kleine Prüfmethoden. Sie sind ein Werkzeug, um Vertrauen in den Code zu schaffen. Unit Tests zeigen ihre Vorteile besonders dann, wenn sie ohne Abhängigkeiten zu externen Systemen auskommen. Werden beispielsweise Datenbanken, Dateisysteme oder Netzwerke eingebunden, gehen viele Stärken verloren: Die Tests werden langsamer, weniger zuverlässig und schwerer zu warten. Es ist daher sinnvoll, Unit Tests so zu gestalten, dass sie unabhängig von externen Systemen funktionieren. So bleiben sie schnell, stabil und aussagekräftig.
Ein Test sollte isoliert sein. Er darf nicht von anderen Tests oder globalem Zustand abhängen. Jeder Test muss für sich allein funktionieren, unabhängig davon, in welcher Reihenfolge er ausgeführt wird. Nur so lässt sich zuverlässig erkennen, ob ein Fehler wirklich durch die getestete Methode verursacht wurde.
Ein Test muss deterministisch sein. Das bedeutet, dass er bei gleichem Input immer dasselbe Ergebnis liefert. Zufallswerte, Zeitstempel oder andere dynamische Elemente sollten vermieden oder kontrolliert werden. Ein Test, der manchmal besteht und manchmal fehlschlägt, ist nicht hilfreich.
Lesbarkeit ist ebenfalls entscheidend. Ein Test sollte klar zeigen, was geprüft wird. Die Struktur muss nachvollziehbar sein, damit andere Entwickler den Test verstehen und bei Bedarf erweitern können. Ein guter Test ist klein und fokussiert. Er prüft genau einen Aspekt des Verhaltens. Wenn mehrere Dinge gleichzeitig getestet werden, wird es schwierig, Fehler zu analysieren.
Vertrauenswürdigkeit ist das Ziel. Ein Test, der flaky ist oder sich auf unsichere Bedingungen verlässt, wird schnell ignoriert. Gute Tests sind stabil, klar und liefern verlässliche Ergebnisse. Sie helfen dabei, Änderungen am Code sicher durchzuführen und Fehler früh zu erkennen.
Clean Code gilt auch für Tests
Viele Entwickler achten bei ihrer Produktivsoftware auf sauberen Code, lassen diese Prinzipien aber bei ihren Tests oft außer Acht. Dabei gelten dieselben Regeln auch für Testcode. Tests sind Teil der Codebasis und sollten genauso lesbar, wartbar und strukturiert sein wie jede andere Methode im Projekt.
Ein Test sollte klar benannt sein, keine unnötige Logik enthalten und sich auf das Wesentliche konzentrieren. Schlechte Tests enthalten oft komplexe Bedingungen, verschachtelte Schleifen oder unübersichtliche Initialisierungen. Das erschwert nicht nur das Verständnis, sondern auch die Fehlersuche, wenn ein Test fehlschlägt.
Guter Testcode ist einfach aufgebaut. Er enthält keine Duplikate, keine versteckten Abhängigkeiten und keine Logik, die nichts mit dem Testziel zu tun hat. Wenn ein Test nicht sofort verständlich ist, sollte er überarbeitet werden. Lesbarkeit ist hier wichtiger als Kürze. Ein klarer Test hilft dem Team, das Verhalten der Anwendung zu verstehen und Änderungen sicher umzusetzen.
Tests sind kein Code zweiter Klasse. Sie verdienen dieselbe Aufmerksamkeit wie der produktive Code. Wer saubere Tests schreibt, schafft eine stabile Grundlage für Weiterentwicklung und Qualitätssicherung.
[Fact]
public void CalculateDiscount_VariousCases()
{
var calculator = new OrderCalculator();
// Testdaten in einer Liste, Schleife statt klarer Einzeltests
// Unterschiedliche Fälle werden nicht klar voneinander getrennt
// CreatedAt ist nicht deterministisch, da es auf DateTime.UtcNow basiert
var orders = new[]
{
new Order { Amount = 100m, IsPremiumCustomer = true, CreatedAt = DateTime.UtcNow.AddDays(-2) },
new Order { Amount = 200m, IsPremiumCustomer = false, CreatedAt = DateTime.UtcNow.AddDays(-2) },
new Order { Amount = 150m, IsPremiumCustomer = true, CreatedAt = DateTime.UtcNow },
new Order { Amount = 0m, IsPremiumCustomer = true, CreatedAt = DateTime.UtcNow.AddDays(-3) }
};
foreach (var order in orders)
{
var discount = calculator.CalculateDiscount(order);
// Unklare Assertion, abhängig von Logik im Test
var expected = (order.IsPremiumCustomer && order.CreatedAt < DateTime.UtcNow.Date.AddDays(-1))
? order.Amount * 0.1m
: 0m;
discount.Should().Be(expected);
}
}
Test-Naming: Klarheit durch Konsistenz
Ein guter Testname beschreibt, was getestet wird und unter welchen Bedingungen. Dabei gibt es verschiedene Ansätze, die sich in der Praxis bewährt haben. Häufig genutzt werden Muster wie Method_State_ExpectedResult
oder Given_When_Then
. Beide Varianten haben ihre Stärken. Die erste ist eher technisch orientiert, die zweite legt den Fokus auf das fachliche Verhalten.
Ein Beispiel für das erste Muster wäre:
CalculateTotal_WithEmptyCart_ReturnsZero
Ein Beispiel für das zweite Muster wäre:
GivenEmptyCart_WhenCalculatingTotal_ThenResultIsZero
Beide Varianten sind gültig. Entscheidend ist nicht das gewählte Schema, sondern die konsequente Anwendung im gesamten Projekt oder Team. Uneinheitliche Namensgebung erschwert das Verständnis und macht es schwerer, Tests schnell zu überblicken.
Die Given_When_Then
-Struktur hat den Vorteil, dass sie sich stärker an der fachlichen Sicht orientiert. Sie beschreibt, in welchem Zustand sich das System befindet, was ausgelöst wird und welches Verhalten erwartet wird. Dadurch wird der Test zur Dokumentation des gewünschten Verhaltens und nicht nur zur Prüfung einer Methode. Die Namensgebung orientiert sich auch an Behavior-Driven Development (BDD) und fördert eine klare Kommunikation zwischen Entwicklern und Fachabteilungen.
Unabhängig vom gewählten Stil sollte ein Testname klar, präzise und verständlich sein. Er sollte nicht die Implementierung beschreiben, sondern das Verhalten, das überprüft wird. So wird der Test auch dann noch verständlich bleiben, wenn sich die interne Struktur der Anwendung ändert.
[Fact]
public void GivenPremiumOrderBeforeYesterday_WhenCalculatingDiscount_ThenReturnsTenPercent() { }
[Fact]
public void GivenRegularOrderBeforeYesterday_WhenCalculatingDiscount_ThenReturnsZero() { }
[Fact]
public void GivenPremiumOrderToday_WhenCalculatingDiscount_ThenReturnsZero() { }
[Fact]
public void GivenPremiumOrderWithZeroAmountBeforeYesterday_WhenCalculatingDiscount_ThenReturnsZero()
Test-Dateistruktur: Klarheit durch Organisation
Die Organisation von Testdateien ist ein wichtiger Aspekt, der oft unterschätzt wird. Eine klare Struktur hilft dabei, Tests schnell zu finden, zu pflegen und zu erweitern. Es gibt zwei gängige Modelle, wie Testprojekte im Repository organisiert werden können.
Das erste Modell sieht vor, alle Testprojekte in einem separaten tests-Ordner auf Projektebene zu halten. Zum Beispiel:
📁 src/
└── MyApp/
📁 tests/
└── MyApp.Tests/
Das zweite Modell platziert das Testprojekt direkt neben dem zu testenden Projekt im src-Ordner:
📁 src/
├── MyApp/
└── MyApp.Tests/
Beide Varianten haben ihre Vor- und Nachteile. Die Variante mit dem separaten tests-Ordner sorgt für eine klare Trennung zwischen produktivem Code und Testcode. Sie ist besonders hilfreich in größeren Repositories mit vielen Projekten. Die Variante mit Tests direkt neben dem Projekt kann bei kleineren Lösungen übersichtlicher wirken, führt aber schneller zu vermischten Strukturen.
Die Empfehlung lautet, Testprojekte in einem eigenen tests-Ordner zu organisieren. Das schafft Klarheit und erleichtert die Skalierung, wenn weitere Projekte oder Testarten hinzukommen.
Auch innerhalb eines Testprojekts stellt sich die Frage, wie die Testklassen organisiert werden sollten. Üblich ist eine Testklasse pro zu testender Klasse. Bei komplexem Verhalten kann es sinnvoll sein, die Tests nach Verhalten aufzuteilen. Das lässt sich mit partial class-Dateien umsetzen, zum Beispiel:
OrderService_GivenInvalidInput.cs
OrderService_GivenValidInput.cs
OrderService_WhenCalculatingTotal.cs
Diese Aufteilung hilft dabei, einzelne fachliche Aspekte gezielt zu testen und die Übersicht zu behalten. So wird klar, welcher Teil des Verhaltens gerade geprüft wird, ohne dass die Testklasse zu groß oder unübersichtlich wird.
Teststruktur: Setup, Teardown und Wiederverwendbarkeit
Eine klare Struktur innerhalb von Tests hilft dabei, Wiederverwendbarkeit und Lesbarkeit zu verbessern. Jeder Test sollte für sich verständlich sein, ohne dass man andere Tests oder Hilfsmethoden lesen muss. Gleichzeitig sollte man vermeiden, dieselbe Initialisierung mehrfach zu schreiben.
Das Anlegen von Mocks oder das Konfigurieren von Methoden, die in allen Tests gleich funktionieren sollen, kann in den Setup-Bereich verschoben werden. Wenn ein Test eine andere Konfiguration benötigt, kann der entsprechende Mock im Test selbst überschrieben werden. So bleibt die gemeinsame Basis erhalten, ohne die Flexibilität zu verlieren.
Wenn die zu testende Klasse viele Parameter oder Abhängigkeiten hat, lohnt sich eine Factory-Methode. Diese Methode erstellt die Instanz mit allen Standardwerten und reduziert den Aufwand im einzelnen Test. Dadurch entsteht weniger visuelles Rauschen, und der Fokus bleibt auf dem Verhalten, das geprüft wird.
Auch das Erzeugen von Testdaten kann in Hilfsmethoden ausgelagert werden. Wenn viele Parameter gleich bleiben und nur einzelne Werte variieren, hilft das dabei, die Tests kompakt und verständlich zu halten. So wird vermieden, dass jeder Test mit langen Initialisierungsblöcken beginnt, die vom eigentlichen Zweck ablenken.
Die Trennung zwischen Setup und Testlogik sollte klar sein. Alles, was für mehrere Tests gleich ist, gehört in die gemeinsame Vorbereitung. Alles, was spezifisch für einen Testfall ist, bleibt im Test selbst. So entsteht eine saubere Struktur, die leicht zu pflegen und gut nachvollziehbar ist.
public class PremiumOrderTests
{
public PremiumOrderTests()
=> _discountHandlerMock = new FakeDiscountHandler();
[Fact]
public void GivenPremiumOrderToday_WhenCalculatingDiscount_ThenReturnsZero()
{
var order = CreatePremiumOrder(createdAt: DateTime.UtcNow);
var calculator = CreateCalculator();
var discount = calculator.CalculateDiscount(order);
discount.Should().Be(0m);
}
private static OrderCalculator CreateCalculator()
=> new OrderCalculator(_discountHandlerMock);
private static Order CreatePremiumOrder(DateTime createdAt, decimal? amount = null)
=> new Order(amount ?? 100m, true, createdAt);
}
Test-Frameworks und Tools im Überblick
Für Unit Testing in .NET gibt es eine Vielzahl an Werkzeugen, die unterschiedliche Aufgaben abdecken. Als Test-Frameworks kommen beispielsweise xUnit, NUnit, MSTest, oder auch TUnit zum Einsatz. Diese Frameworks bieten die grundlegende Struktur für das Schreiben und Ausführen von Tests und unterscheiden sich in ihrer Syntax, Erweiterbarkeit und Integration in Entwicklungsumgebungen.
Für die Formulierung von Assertions, also die Überprüfung von erwarteten Ergebnissen, greifen viele Entwickler zu Bibliotheken wie Shouldly oder AwesomeAssertions. Diese bieten eine lesbare und ausdrucksstarke Syntax, die über die klassischen Assert-Methoden hinausgeht und die Fehlersuche bei Testfehlschlägen erleichtert.
Beim Erstellen von Mocks, also simulierten Objekten für Abhängigkeiten, sind Werkzeuge wie NSubstitute, Moq oder FakeItEasy weit verbreitet. Sie ermöglichen es, Verhalten gezielt zu definieren und Interaktionen zu überprüfen, ohne echte Implementierungen verwenden zu müssen.
Für die Generierung von Testdaten bieten sich Tools wie AutoFixture oder Bogus an. Diese helfen dabei, Objekte mit sinnvollen Werten zu erstellen, ohne dass man jeden Parameter manuell setzen muss. Besonders bei komplexen Objekten oder bei Tests mit vielen Variationen spart das Zeit und reduziert den Aufwand.
Die Auswahl der Tools hängt vom Projektkontext und den Anforderungen des Teams ab. Wichtig ist, dass die eingesetzten Werkzeuge gut zusammenarbeiten und sich in den Entwicklungsprozess integrieren lassen. Viele davon sind über NuGet verfügbar und lassen sich problemlos mit dotnet test oder anderen Test-Runnern ausführen.
Was man vermeiden sollte
Nicht jeder Test, der kompiliert und grün ist, ist auch sinnvoll. Es gibt einige Muster, die in der Praxis häufig vorkommen und die Qualität von Tests deutlich beeinträchtigen. Ein häufiger Fehler ist übermäßiges Mocking. Nur weil ein Unit Test klein und fokussiert sein soll, heißt das nicht, dass jede Abhängigkeit simuliert werden muss. Mocks sollten gezielt eingesetzt werden, um Verhalten zu isolieren, nicht um jede Interaktion künstlich nachzubilden.
Ein weiteres Problem entsteht, wenn Tests die Implementierung statt das Verhalten prüfen. Wenn ein Test sich auf interne Details verlässt, etwa auf die Reihenfolge von Methodenaufrufen oder konkrete Ausdrücke, wird er bei jeder kleinen Änderung am Code instabil. Besser ist es, das Ergebnis oder die Wirkung zu prüfen, nicht den Weg dorthin.
Auch Tests, die mehrere Dinge gleichzeitig prüfen, sind problematisch. Wenn ein solcher Test fehlschlägt, ist oft unklar, welcher Teil die Ursache war. Jeder Test sollte sich auf genau einen Aspekt konzentrieren. Das erleichtert die Analyse und macht die Tests robuster.
Ein besonders schwerwiegender Fehler ist die Abhängigkeit von gemeinsamem, veränderbarem Zustand. Wenn Tests sich gegenseitig beeinflussen, etwa durch statische Felder oder gemeinsam genutzte Objekte, entstehen flakey Tests, die manchmal bestehen und manchmal nicht. Solche Tests verlieren schnell das Vertrauen des Teams und werden ignoriert, obwohl sie eigentlich wichtige Hinweise liefern könnten.
Gute Tests sind unabhängig, klar und verlässlich. Sie helfen dabei, Fehler früh zu erkennen und Änderungen sicher umzusetzen. Schlechte Tests hingegen erzeugen Unsicherheit und führen dazu, dass Tests als lästig empfunden werden. Wer diese typischen Fehler vermeidet, schafft eine stabile Grundlage für Qualität und Weiterentwicklung.
Fazit
Gute Unit Tests sind mehr als nur ein Werkzeug zur Fehlervermeidung. Sie dokumentieren das Verhalten einer Anwendung, schaffen Vertrauen in den Code und ermöglichen sicheres Refactoring. Wer Tests konsequent strukturiert, verständlich benennt und auf Qualität achtet, legt die Grundlage für wartbare Software.
Für Tests gelten dieselben Prinzipien wie für produktiven Code. Sie sollten lesbar, klar aufgebaut und frei von unnötiger Komplexität sein. Eine saubere Struktur, konsistente Namensgebung und gezielter Einsatz von Setup und Hilfsmethoden helfen dabei, den Fokus auf das Wesentliche zu richten. Schlechte Tests führen zu Unsicherheit, Frustration und im schlimmsten Fall zu ignorierten Fehlern.
Unit Testing ist kein Selbstzweck, sondern ein zentraler Bestandteil professioneller Softwareentwicklung. Wer Tests als Teil der Architektur versteht und nicht als lästige Pflicht, profitiert langfristig durch bessere Qualität, schnellere Entwicklung und mehr Vertrauen im Team.
Wie sieht eure aktuelle Teststruktur aus und welche Erfahrungen habt ihr mit Unit Tests gemacht? Schau gerne bei uns auf LinkedIn vorbei und diskutiere mit.