.NET Testing: Unit-, Integrations- und E2E-Teststrategien

15 Min. Lesezeit9. Februar 2026Aktualisiert: 9. März 2026
.NET testingxUnit tutorialC# unit testingIntegration testing .NETMoq .NETFluentAssertions.NET test best practicesTest pyramid .NET

# .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.

csharp
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:

  • Pro Testmethode genau ein Verhalten pruefen
  • Testnamen beschreiben das Szenario: `Methode_Bedingung_ErwartetesErgebnis`
  • Externe Abhaengigkeiten mocken, nicht das System under Test
  • Private Methoden nicht direkt testen
  • 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.

    csharp
    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:

    csharp
    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.

    csharp
    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.

  • E2E-Tests auf 5-10 kritische Ablaeufe beschraenken (Login, Checkout, Zahlung)
  • Nicht bei jedem Commit ausfuehren, sondern vor Releases oder zeitgesteuert
  • Retry-Logik fuer transiente Fehler einsetzen, aber wiederholte Retries untersuchen
  • 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:

    csharp
    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

    csharp
    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

    csharp
    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

  • **Domain-Logik und Geschaeftsregeln** -- der Kern Ihrer Anwendung
  • **Validierungslogik** -- Grenzwerte, Pflichtfelder, Formatregeln
  • **Randfaelle** -- Null-Eingaben, leere Collections, Ueberlauf-Szenarien
  • **Fehlerbehandlung** -- was passiert, wenn eine Abhaengigkeit ausfaellt
  • **Sicherheitskritische Ablaeufe** -- Authentifizierung, Autorisierung, Input-Bereinigung
  • **Daten-Mapping** -- EF-Core-Konfigurationen, AutoMapper-Profile
  • In der Regel auslassen

  • **Framework-Code** -- ASP.NET-Routing oder EF-Core-Interna nicht testen
  • **Triviale Getter/Setter** -- keine Logik, kein Test noetig
  • **Drittanbieter-Bibliotheken** -- deren eigenen Test-Suiten vertrauen
  • **UI-Layout-Details** -- pixelgenaue Positionierung aendert sich staendig
  • **Einzeilige Durchreichmethoden** -- wenn eine Methode nur delegiert, deckt der Integrationstest sie ab
  • Grauzone (testen wenn komplex)

  • **Controller** -- schlanke Controller brauchen keinen Unit-Test, aber testen Sie sie ueber Integrationstests
  • **Konfigurations-Binding** -- testen, wenn bedingte Logik auf Config-Werten basiert
  • **Logging** -- nur pruefen, wenn es eine Compliance-Anforderung ist
  • 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.

    yaml
    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-trx

    Tests fuer CI kategorisieren

    Verwenden Sie xUnit-Traits, um Tests zu taggen, damit CI sie in getrennten Stufen ausfuehren kann:

    csharp
    [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

    csharp
    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

    csharp
    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

    csharp
    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:

    csharp
    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

    Haben Sie ein Flutter-Projekt?

    Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.

    Kontakt aufnehmen