.NET Testing: Unit-, Integrations- und E2E-Teststrategien
# .NET Testing: Unit-, Integrations- und E2E-Teststrategien
Tests sollen geschaeftskritisches Verhalten absichern, nicht nur Coverage-Werte erhoehen. Eine gute Teststrategie balanciert Geschwindigkeit, Sicherheit und Wartungsaufwand. In diesem Leitfaden gehe ich das gesamte Test-Spektrum mit echten Code-Beispielen, Tool-Empfehlungen und Erkenntnissen aus Produktivsystemen durch.
Praxistaugliche Testpyramide
Die Testpyramide ist nicht nur Theorie. Sie bestimmt direkt, wie schnell Ihre CI-Pipeline laeuft und wie viel Vertrauen jedes Deployment mit sich bringt.
Unit-Tests
Unit-Tests pruefen Domain-Regeln und reine Anwendungslogik isoliert. Sie muessen deterministisch, schnell und frei von Infrastrukturabhaengigkeiten sein. Ein gut geschriebener Unit-Test liest sich wie eine Spezifikation.
public class BestellServiceTests
{
private readonly Mock<IBestellRepository> _bestellRepoMock;
private readonly Mock<ILagerService> _lagerMock;
private readonly BestellService _sut;
public BestellServiceTests()
{
_bestellRepoMock = new Mock<IBestellRepository>();
_lagerMock = new Mock<ILagerService>();
_sut = new BestellService(_bestellRepoMock.Object, _lagerMock.Object);
}
[Fact]
public async Task BestellungAufgeben_BeiAusreichendemBestand_SollteBestellungAnlegen()
{
class=class="code-string">"code-comment">// Arrange
var anfrage = new BestellAnfrage(class="code-string">"SKU-class="code-number">001", menge: class="code-number">3);
_lagerMock
.Setup(x => x.BestandPruefen(class="code-string">"SKU-class="code-number">001"))
.ReturnsAsync(new BestandErgebnis(Verfuegbar: class="code-number">10));
class=class="code-string">"code-comment">// Act
var ergebnis = await _sut.BestellungAufgebenAsync(anfrage);
class=class="code-string">"code-comment">// Assert
ergebnis.Should().NotBeNull();
ergebnis.Status.Should().Be(BestellStatus.Bestaetigt);
_bestellRepoMock.Verify(x => x.SpeichernAsync(It.IsAny<Bestellung>()), Times.Once);
}
[Fact]
public async Task BestellungAufgeben_BeiUnzureichendemBestand_SollteNichtVorratigZurueckgeben()
{
class=class="code-string">"code-comment">// Arrange
var anfrage = new BestellAnfrage(class="code-string">"SKU-class="code-number">001", menge: class="code-number">50);
_lagerMock
.Setup(x => x.BestandPruefen(class="code-string">"SKU-class="code-number">001"))
.ReturnsAsync(new BestandErgebnis(Verfuegbar: class="code-number">2));
class=class="code-string">"code-comment">// Act
var ergebnis = await _sut.BestellungAufgebenAsync(anfrage);
class=class="code-string">"code-comment">// Assert
ergebnis.Status.Should().Be(BestellStatus.NichtVorraetig);
_bestellRepoMock.Verify(x => x.SpeichernAsync(It.IsAny<Bestellung>()), Times.Never);
}
}Wichtige Prinzipien fuer Unit-Tests:
Integrationstests
Integrationstests stellen sicher, dass Komponenten korrekt zusammenarbeiten: Datenbank-Mappings, API-Vertraege, Messaging-Grenzen und Drittanbieter-Integrationen. Sie sind langsamer als Unit-Tests, fangen aber Fehler ab, die Unit-Tests nicht erkennen koennen.
public class BestellApiTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public BestellApiTests(CustomWebApplicationFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task BestellungAnlegen_GibtCreatedStatusZurueck()
{
class=class="code-string">"code-comment">// Arrange
var anfrage = new { Sku = class="code-string">"SKU-class="code-number">001", Menge = class="code-number">2 };
var content = new StringContent(
JsonSerializer.Serialize(anfrage),
Encoding.UTF8,
class="code-string">"application/json");
class=class="code-string">"code-comment">// Act
var response = await _client.PostAsync(class="code-string">"/api/bestellungen", content);
class=class="code-string">"code-comment">// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await response.Content.ReadFromJsonAsync<BestellResponse>();
body!.Sku.Should().Be(class="code-string">"SKU-class="code-number">001");
body.Status.Should().Be(class="code-string">"Bestaetigt");
}
[Fact]
public async Task BestellungAnlegen_MitUngueltigemPayload_GibtBadRequestZurueck()
{
class=class="code-string">"code-comment">// Arrange
var anfrage = new { Sku = class="code-string">"", Menge = -class="code-number">1 };
var content = new StringContent(
JsonSerializer.Serialize(anfrage),
Encoding.UTF8,
class="code-string">"application/json");
class=class="code-string">"code-comment">// Act
var response = await _client.PostAsync(class="code-string">"/api/bestellungen", content);
class=class="code-string">"code-comment">// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
}Konfiguration der `WebApplicationFactory` mit ueberschriebenen Diensten:
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
class=class="code-string">"code-comment">// Echten Datenbank-Context entfernen
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null) services.Remove(descriptor);
class=class="code-string">"code-comment">// In-Memory-Datenbank fuer schnelle Tests
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase(class="code-string">"TestDb"));
class=class="code-string">"code-comment">// Externe Dienste durch Fakes ersetzen
services.AddScoped<IZahlungsGateway, FakeZahlungsGateway>();
});
}
}Integrationstests mit Testcontainers
Fuer Tests, die eine echte Datenbank-Engine benoetigen, startet Testcontainers Docker-Container automatisch. Das liefert produktionsnahe Genauigkeit ohne manuelles Setup.
public class BestellRepositoryTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
.WithImage(class="code-string">"postgres:class="code-number">16-alpine")
.WithDatabase(class="code-string">"testdb")
.WithUsername(class="code-string">"test")
.WithPassword(class="code-string">"test")
.Build();
private AppDbContext _dbContext = null!;
public async Task InitializeAsync()
{
await _postgres.StartAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(_postgres.GetConnectionString())
.Options;
_dbContext = new AppDbContext(options);
await _dbContext.Database.MigrateAsync();
}
public async Task DisposeAsync()
{
await _dbContext.DisposeAsync();
await _postgres.DisposeAsync();
}
[Fact]
public async Task BestellungSpeichern_PersistiertInDatenbank()
{
class=class="code-string">"code-comment">// Arrange
var repo = new BestellRepository(_dbContext);
var bestellung = new Bestellung(class="code-string">"SKU-class="code-number">001", class="code-number">3, BestellStatus.Bestaetigt);
class=class="code-string">"code-comment">// Act
await repo.SpeichernAsync(bestellung);
var abgerufen = await repo.NachIdLadenAsync(bestellung.Id);
class=class="code-string">"code-comment">// Assert
abgerufen.Should().NotBeNull();
abgerufen!.Sku.Should().Be(class="code-string">"SKU-class="code-number">001");
abgerufen.Menge.Should().Be(class="code-number">3);
}
}End-to-End-Tests
E2E-Tests schuetzen einige wenige kritische Nutzerfluss. Sie laufen gegen eine vollstaendig deployte (oder lokal zusammengestellte) Umgebung. Halten Sie den Umfang begrenzt, um instabile Test-Suiten zu vermeiden.
Empfohlene .NET-Tools
| Tool | Einsatzzweck |
|------|-------------|
| xUnit | Test-Runner mit hervorragender Parallelausfuehrung |
| FluentAssertions | Lesbare, verkettbare Assertions |
| Moq | Mock-Framework fuer Interface-basierte Abhaengigkeiten |
| NSubstitute | Alternatives Mock-Framework mit sauberer Syntax |
| Testcontainers | Docker-basierte Testinfrastruktur |
| Bogus | Realistische Fake-Datengenerierung |
| Respawn | Schnelle Datenbank-Bereinigung zwischen Tests |
| Verify | Snapshot-Testing fuer komplexe Ausgaben |
FluentAssertions in der Praxis
FluentAssertions verwandelt kryptische Testfehler in klare Diagnosen:
class=class="code-string">"code-comment">// Statt:
Assert.Equal(class="code-string">"Bestaetigt", bestellung.Status);
class=class="code-string">"code-comment">// Expected: "Bestaetigt", Actual: "Ausstehend"
class=class="code-string">"code-comment">// Besser:
bestellung.Status.Should().Be(class="code-string">"Bestaetigt");
class=class="code-string">"code-comment">// Expected bestellung.Status to be "Bestaetigt", but found "Ausstehend".
class=class="code-string">"code-comment">// Collection-Assertions
bestellungen.Should().HaveCount(class="code-number">3)
.And.OnlyContain(b => b.Gesamtbetrag > class="code-number">0)
.And.BeInAscendingOrder(b => b.ErstelltAm);
class=class="code-string">"code-comment">// Exception-Assertions
var act = () => service.BestellungAufgebenAsync(ungueltigeAnfrage);
await act.Should().ThrowAsync<ValidationException>()
.WithMessage(class="code-string">"*SKU*erforderlich*");Testdaten-Management
Hartcodierte Testdaten werden schnell zur Wartungslast. Verwenden Sie Builder und Fixtures, um Tests lesbar und robust gegenueber Schema-Aenderungen zu halten.
Test Data Builder
public class BestellungBuilder
{
private string _sku = class="code-string">"SKU-class="code-number">001";
private int _menge = class="code-number">1;
private BestellStatus _status = BestellStatus.Ausstehend;
private DateTime _erstelltAm = DateTime.UtcNow;
public BestellungBuilder MitSku(string sku) { _sku = sku; return this; }
public BestellungBuilder MitMenge(int menge) { _menge = menge; return this; }
public BestellungBuilder MitStatus(BestellStatus s) { _status = s; return this; }
public BestellungBuilder ErstelltAm(DateTime dt) { _erstelltAm = dt; return this; }
public Bestellung Erstellen() => new Bestellung(_sku, _menge, _status)
{
ErstelltAm = _erstelltAm
};
}
class=class="code-string">"code-comment">// Verwendung in Tests:
var bestellung = new BestellungBuilder()
.MitSku(class="code-string">"SKU-PREMIUM")
.MitMenge(class="code-number">5)
.MitStatus(BestellStatus.Bestaetigt)
.Erstellen();Gemeinsame Fixtures mit xUnit
public class DatenbankFixture : IAsyncLifetime
{
public AppDbContext DbContext { get; private set; } = null!;
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage(class="code-string">"postgres:class="code-number">16-alpine")
.Build();
public async Task InitializeAsync()
{
await _container.StartAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(_container.GetConnectionString())
.Options;
DbContext = new AppDbContext(options);
await DbContext.Database.MigrateAsync();
}
public async Task DisposeAsync()
{
await DbContext.DisposeAsync();
await _container.DisposeAsync();
}
}
[CollectionDefinition(class="code-string">"Datenbank")]
public class DatenbankCollection : ICollectionFixture<DatenbankFixture> { }
[Collection(class="code-string">"Datenbank")]
public class ProduktRepositoryTests
{
private readonly DatenbankFixture _fixture;
public ProduktRepositoryTests(DatenbankFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task AlleAbrufen_GibtGeseedeteProduktZurueck()
{
var repo = new ProduktRepository(_fixture.DbContext);
var produkte = await repo.AlleAbrufenAsync();
produkte.Should().NotBeEmpty();
}
}Was testen, was auslassen
Nicht alles verdient einen Test. Die falschen Dinge zu testen kostet Zeit und erzeugt Reibung, ohne Vertrauen zu schaffen.
Immer testen
In der Regel auslassen
Grauzone (testen wenn komplex)
In meinen .NET-Services verfolge ich folgende Test-Coverage-Strategie: 80% meiner Tests sind Unit-Tests fuer Domain- und Anwendungslogik, 15% sind Integrationstests gegen Datenbank und API-Schicht mit Testcontainers, und 5% sind E2E-Smoke-Tests fuer kritische Nutzerablaeufe. Ich jage keiner Coverage-Zahl hinterher; ich konzentriere mich darauf, Verhalten zu testen, das bei einem Fehler echten Schaden verursachen wuerde.
CI-Integration
Tests sind nur dann wertvoll, wenn sie automatisch laufen. Hier ist ein GitHub-Actions-Workflow, der die komplette Test-Suite bei jedem Pull Request ausfuehrt.
name: .NET Test Suite
on:
pull_request:
branches: [main, develop]
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: .NET einrichten
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Abhaengigkeiten wiederherstellen
run: dotnet restore
- name: Kompilieren
run: dotnet build --no-restore --configuration Release
- name: Unit-Tests ausfuehren
run: dotnet test --no-build --configuration Release --filter "Category=Unit" --logger "trx;LogFileName=unit-results.trx"
- name: Integrationstests ausfuehren
run: dotnet test --no-build --configuration Release --filter "Category=Integration" --logger "trx;LogFileName=integration-results.trx"
env:
ConnectionStrings__Default: "Host=localhost;Port=5432;Database=testdb;Username=test;Password=test"
- name: Testergebnisse veroeffentlichen
uses: dorny/test-reporter@v1
if: always()
with:
name: Testergebnisse
path: '**/*.trx'
reporter: dotnet-trxTests fuer CI kategorisieren
Verwenden Sie xUnit-Traits, um Tests zu taggen, damit CI sie in getrennten Stufen ausfuehren kann:
[Trait(class="code-string">"Category", class="code-string">"Unit")]
public class PreisberechnungServiceTests
{
[Fact]
public void RabattBerechnen_GoldMitglied_Gibt20ProzentZurueck()
{
var service = new PreisberechnungService();
var rabatt = service.RabattBerechnen(MitgliedStufe.Gold, 100m);
rabatt.Should().Be(20m);
}
}
[Trait(class="code-string">"Category", class="code-string">"Integration")]
public class ZahlungsIntegrationTests : IClassFixture<CustomWebApplicationFactory>
{
class=class="code-string">"code-comment">// Integrationstests, die Infrastruktur benoetigen
}Haeufige Testing-Fehler
Dies sind Muster, die ich wiederholt in .NET-Projekten gesehen habe und die zu fragilen, langsamen oder irrefuehrenden Test-Suiten fuehren.
1. Implementierung statt Verhalten testen
class=class="code-string">"code-comment">// Schlecht: eng an interne Methodenaufrufe gekoppelt
_repoMock.Verify(x => x.TransactionStarten(), Times.Once);
_repoMock.Verify(x => x.TransactionCommit(), Times.Once);
_loggerMock.Verify(x => x.Log(It.IsAny<string>()), Times.Exactly(class="code-number">3));
class=class="code-string">"code-comment">// Gut: das beobachtbare Ergebnis pruefen
var ergebnis = await _sut.ZahlungVerarbeitenAsync(anfrage);
ergebnis.IstErfolgreich.Should().BeTrue();
ergebnis.TransaktionsId.Should().NotBeEmpty();2. Geteilter veraenderlicher Zustand zwischen Tests
class=class="code-string">"code-comment">// Schlecht: Tests haengen von der Ausfuehrungsreihenfolge ab
private static List<Bestellung> _bestellungen = new();
[Fact]
public void Test1_BestellungHinzufuegen() { _bestellungen.Add(new Bestellung()); }
[Fact]
public void Test2_AnzahlPruefen() { _bestellungen.Should().HaveCount(class="code-number">1); } class=class="code-string">"code-comment">// Instabil!
class=class="code-string">"code-comment">// Gut: jeder Test erstellt seinen eigenen Zustand
[Fact]
public void BestellungHinzufuegen_ErhoehtAnzahl()
{
var bestellungen = new List<Bestellung>();
bestellungen.Add(new Bestellung());
bestellungen.Should().HaveCount(class="code-number">1);
}3. Uebertriebenes Mocking
class=class="code-string">"code-comment">// Schlecht: das zu testende Objekt selbst mocken
var mockService = new Mock<IBestellService>();
mockService.Setup(x => x.BestellungAufgeben(It.IsAny<Bestellung>())).Returns(true);
var ergebnis = mockService.Object.BestellungAufgeben(new Bestellung());
class=class="code-string">"code-comment">// Glueckwunsch, Sie haben Moq getestet, nicht Ihren Code.
class=class="code-string">"code-comment">// Gut: Abhaengigkeiten mocken, den echten Service testen
var repoMock = new Mock<IBestellRepository>();
var service = new BestellService(repoMock.Object);
var ergebnis = service.BestellungAufgeben(new Bestellung());4. Fehlerpfade ignorieren
Wenn Ihr Service Exceptions faengt und Fehler-Ergebnisse zurueckgibt, testen Sie diese Pfade. Der Happy Path ist oft offensichtlich; der Fehlerpfad ist, wo sich die Bugs verstecken.
5. Riesige Arrange-Bloecke
Wenn Ihr Test-Setup 40 Zeilen lang ist, extrahieren Sie es in einen Builder oder eine Fixture. Tests sollten auf einen Blick lesbar sein.
6. Testdaten nicht aufraeumen
Integrationstests, die Daten hinterlassen, verursachen Kaskadenfehler. Verwenden Sie `Respawn` oder Transaction-Rollback, um den Zustand zwischen Tests zurueckzusetzen:
public class IntegrationTestBasis : IAsyncLifetime
{
private readonly Respawner _respawner;
protected readonly AppDbContext DbContext;
public async Task InitializeAsync()
{
_respawner = await Respawner.CreateAsync(connectionString, new RespawnerOptions
{
DbAdapter = DbAdapter.Postgres,
SchemasToInclude = new[] { class="code-string">"public" }
});
}
public async Task DisposeAsync()
{
await _respawner.ResetAsync(connectionString);
}
}Fazit
Eine belastbare .NET-Teststrategie kombiniert schnelle Unit-Tests fuer Geschaeftslogik, aussagekraeftige Integrationstests mit realer Infrastruktur und wenige, aber kritische End-to-End-Absicherungen. Das Ziel ist nicht 100% Coverage, sondern 100% Vertrauen in die Verhaltensweisen, die wirklich zaehlen.
In meinen .NET-Services habe ich festgestellt, dass die Investition in Test-Infrastruktur (Builder, Fixtures, geteilte Factories) sich enorm auszahlt. Eine Test-Suite, die leicht zu schreiben und zu warten ist, wird auch geschrieben. Eine, die muehsam ist, wird uebersprungen.
Ich unterstuetze gern beim Aufbau einer effektiven Teststrategie.
Verwandte Artikel
Clean Architecture in .NET: Skalierbare Projektstruktur
Wenden Sie Clean Architecture in .NET-Projekten an. Schichten, Abhängigkeiten und testbarer Code.
Dependency Injection in .NET: Grundkonzepte und Umsetzung
Verstehen und implementieren Sie Dependency Injection in .NET richtig. Service Lifetimes und Best Practices.
.NET CI/CD: Automatisiertes Deployment mit GitHub Actions und Azure DevOps
Entwerfen Sie CI/CD-Pipelines für .NET-Projekte. GitHub Actions und Azure DevOps.
Haben Sie ein Flutter-Projekt?
Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.
Kontakt aufnehmen