.net tests

Automatisiertes Testen einer .NET- und Angular-Anwendung: von Unit-Tests bis E2E

Moderne Softwareentwicklung ist ohne zuverlässige automatisierte Tests kaum vorstellbar.

Es gibt zahlreiche Arten automatisierter Tests. Welche davon in einem konkreten Projekt eingesetzt werden sollten, hängt von dessen Zielen, Architektur und Umfang ab. Je besser eine Anwendung getestet ist, desto geringer ist die Wahrscheinlichkeit, dass Änderungen zu unerwarteten Fehlern führen. Allerdings benötigen auch Tests Ressourcen: Sie müssen entworfen, geschrieben, ausgeführt und gepflegt werden.

Daher bleibt die Frage nach dem richtigen Verhältnis zwischen der Entwicklung neuer Funktionalität und dem Ausbau der Testabdeckung für nahezu jedes Projekt relevant.

In diesem Artikel betrachten wir einen möglichen Testansatz für kleine und mittelgroße Anwendungen. Die vorgestellte Auswahl an Tests und deren Implementierung basieren auf meinen praktischen Erfahrungen und erheben keinen Anspruch darauf, die einzig richtige Lösung zu sein.

Für kleinere Projekte verwende ich üblicherweise drei Ebenen automatisierter Tests:

  • Unit-Tests;
  • API-Integrationstests;
  • E2E-Tests.

Als zu testende Anwendung verwenden wir die aktuelle Version von serdg.net.

Technologie-Stack

Technologie-Stack der Anwendung

Angular 21 → .NET 10 Web API → Entity Framework Core → Microsoft SQL Server

Technologie-Stack für die Tests

NUnit + WebApplicationFactory + Playwright + Moq + SQLite

Im Idealfall bilden diese drei Ebenen eine Art Testpyramide: Es sollte viele Unit-Tests, weniger Integrationstests und nur eine begrenzte Anzahl von E2E-Tests für die wichtigsten Benutzerszenarien geben.

Unit-Tests

Auf Unit-Tests werden wir in diesem Artikel nicht ausführlich eingehen. Über ihren Zweck, ihre Struktur und mögliche Implementierungsansätze wurde bereits ausreichend geschrieben.

Wir beschränken uns daher auf ihren wichtigsten Verantwortungsbereich. Unit-Tests prüfen einzelne Bestandteile einer Anwendung isoliert von Datenbanken, Dateisystemen, Netzwerken und anderen externen Abhängigkeiten.

Besonders nützlich sind sie für Code, der:

  • komplexe Geschäftslogik enthält;
  • kritische Berechnungen ausführt;
  • viele Grenzfälle besitzt;
  • nach jeder Änderung schnell überprüft werden muss.

Unit-Tests allein reichen jedoch nicht aus. Selbst wenn jede Komponente einer Anwendung isoliert korrekt funktioniert, können beim Zusammenspiel mehrerer Komponenten weiterhin Fehler auftreten.

An dieser Stelle kommen API-Integrationstests ins Spiel.

API-Integrationstests

Bei API-Integrationstests wird der serverseitige Teil der Anwendung als zusammenhängendes System betrachtet.

Ein Test sendet eine HTTP-Anfrage und analysiert die HTTP-Antwort, ohne Controller, Services oder Repositories direkt aufzurufen. Aus dieser Perspektive kann die Anwendung als Blackbox betrachtet werden: Wir steuern die Eingabedaten und überprüfen das Ergebnis.

Mit diesem Ansatz können mehrere Ebenen der Anwendung gleichzeitig getestet werden:

  • Routing;
  • Middleware;
  • Autorisierung;
  • Validierung der Eingabedaten;
  • Controller;
  • Geschäftslogik;
  • Serialisierung;
  • Datenbankzugriff.

Gleichzeitig müssen die Tests nicht nur lokal, sondern auch innerhalb einer CI/CD-Umgebung ausführbar, vorhersehbar und reproduzierbar bleiben.

ASP.NET Core stellt dafür die WebApplicationFactory bereit.

Konfiguration der WebApplicationFactory

Mit WebApplicationFactory<TEntryPoint> kann ein Test-Host auf Grundlage der Konfiguration der eigentlichen Anwendung erstellt werden.

Standardmäßig verwendet die Factory einen TestServer. Dadurch können HTTP-Anfragen ausgeführt werden, ohne einen separaten Netzwerkprozess zu starten. Gleichzeitig durchläuft die Testanwendung nahezu denselben Konfigurationsprozess wie die produktive Anwendung.

Von besonderem Interesse ist dabei die folgende Methode:

protected override void ConfigureWebHost(IWebHostBuilder builder)

Mit ihr können die Umgebung, die Konfiguration, das Logging und die registrierten Abhängigkeiten angepasst werden.

Ein Beispiel für die Konfiguration der Factory:


protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    // Set the test environment.
    builder.UseEnvironment("Testing");

    // Load environment-specific settings from the application settings file.
    builder.ConfigureAppConfiguration((context, configBuilder) =>
    {
        configBuilder.AddJsonFile(
            "appsettings.Test.json",
            optional: false,
            reloadOnChange: false);
    });

    // Configure logging.
    builder.ConfigureLogging(logging =>
    {
        logging.ClearProviders();
        logging.AddConsole();
        logging.SetMinimumLevel(LogLevel.Warning);
    });

    // Load JWT settings.
    builder.ConfigureServices((context, services) =>
    {
        JwtSettings = context.Configuration
            .GetSection("JWTSettings")
            .Get<JwtSettings>()!;
    });

    builder.ConfigureTestServices(services =>
    {
        _configureTestServices?.Invoke(services);

        // Replace the production database dependencies.
        services.RemoveAll<SdContext>();
        services.RemoveAll<DbContextOptions<SdContext>>();
        services.RemoveAll<DbConnection>();
        services.RemoveAll<IDbContextFactory<SdContext>>();

        // Create and open the database connection.
        _connection = new SqliteConnection("DataSource=:memory:");
        _connection.Open();

        services.AddSingleton<DbConnection>(_connection);

        services.AddDbContextFactory<SdContext>((serviceProvider, options) =>
        {
            var connection =
                serviceProvider.GetRequiredService<DbConnection>();

            options.UseSqlite(connection);
        });

        // Additional test-specific service configuration.
});


}

Dabei sollten mehrere wichtige Punkte beachtet werden.

Eine separate Testumgebung

Der folgende Aufruf:

builder.UseEnvironment("Testing");

ermöglicht es, die Testausführung der Anwendung von den Umgebungen Development und Production zu unterscheiden.

Das ist besonders hilfreich, wenn bestimmte Teile der Konfiguration ausschließlich während der Tests aktiviert werden sollen.

Eine separate Konfigurationsdatei

Die Datei appsettings.Test.json enthält Parameter, die ausschließlich für Tests vorgesehen sind.

Darin können beispielsweise folgende Einstellungen definiert werden:

  • JWT-Einstellungen für Tests;
  • Logging-Einstellungen;
  • Werte für Feature Flags;
  • weitere Parameter der Testumgebung.

Geheimnisse und Zugangsdaten der produktiven Anwendung sollten nicht in Tests verwendet werden.

Ersetzen der Datenbank

Die produktive Konfiguration für Microsoft SQL Server wird durch eine im Arbeitsspeicher ausgeführte SQLite-Datenbank ersetzt.

Das ist nicht dasselbe wie die Verwendung des EF Core InMemory Providers. SQLite bleibt eine relationale Datenbank und unterstützt viele für SQL typische Einschränkungen und Funktionen:

  • Fremdschlüssel;
  • eindeutige Indizes;
  • Transaktionen;
  • SQL-Abfragen;
  • ein relationales Datenmodell.

Dadurch liegt das Verhalten der Tests näher am Verhalten einer realen Datenbank.

Eine wichtige Besonderheit einer SQLite-In-Memory-Datenbank besteht darin, dass sie nur so lange existiert, wie ihre Verbindung geöffnet bleibt. Aus diesem Grund wird die Verbindung einmal erstellt, als Singleton registriert und bis zum Freigeben der Factory geöffnet gehalten.

Basisklasse für API-Tests

Im nächsten Schritt erstellen wir eine Basisklasse, von der die Controller-Tests erben.

Sie enthält:

  • eine Instanz der WebApplicationFactory;
  • einen HttpClient;
  • die gemeinsame Vorbereitung der Testdaten;
  • die Konfiguration der Autorisierung;
  • das Ersetzen externer Abhängigkeiten;
  • das Freigeben der Ressourcen nach jedem Test.

public abstract class BaseController
{
    protected HttpClient Client { get; private set; } = null!;


    protected CustomWebApplicationFactory<Program> Factory
        { get; private set; } = null!;

    protected Mock<ISmtpClientWrapper> SmtpClientWrapper
        { get; private set; } = null!;

    [SetUp]
    public async Task SetUp()
    {
        SmtpClientWrapper = new Mock<ISmtpClientWrapper>(MockBehavior.Strict);

        Factory = new CustomWebApplicationFactory<Program>(services =>
        {
            services.RemoveAll<ISmtpClientWrapper>();
            services.AddSingleton<ISmtpClientWrapper>(SmtpClientWrapper.Object);
        });

        Client = Factory.CreateClient( new WebApplicationFactoryClientOptions());

        // Reset the database to its initial state before every test.
        await Factory.ResetDatabaseAsync();

        AuthorizeClient();
    }

    [TearDown]
    public void TearDown()
    {
        Client.Dispose();
        Factory.Dispose();
    }
    // ...
}

Die Methoden [SetUp] und [TearDown] sind Standardmechanismen von NUnit. Die Methode SetUp() wird vor jedem Test ausgeführt, während TearDown() nach jedem Test aufgerufen wird.

Während der Testvorbereitung ersetzen wir die reale Implementierung von ISmtpClientWrapper durch ein mit Moq erstelltes Objekt. Dadurch wird verhindert, dass die Tests tatsächlich E-Mails versenden.

Nach demselben Prinzip können auch andere externe Abhängigkeiten ersetzt werden:

  • SMTP-Clients;
  • CAPTCHA-Dienste;
  • Zahlungssysteme;
  • Cloud-Speicher;
  • externe HTTP-APIs;
  • weitere Integrationen außerhalb der Grenzen der getesteten Anwendung.

Zurücksetzen der Datenbank

Die SQLite-Verbindung bleibt während der gesamten Lebensdauer der Factory geöffnet. Vor jedem Test wird die Datenbank jedoch in ihren Ausgangszustand zurückversetzt:

await Factory.ResetDatabaseAsync();

Die Methode ResetDatabaseAsync() kann beispielsweise:

  1. das vorhandene Datenbankschema löschen;
  2. das Schema neu erstellen;
  3. Migrationen anwenden;
  4. einen initialen Satz von Testdaten einfügen.

Die konkrete Implementierung hängt von der Architektur des Projekts ab.

Die wichtigste Anforderung besteht darin, dass jeder Test mit einem bekannten und vorhersehbaren Zustand der Datenbank beginnt.

Tests sollten weder von ihrer Ausführungsreihenfolge noch von Daten abhängen, die nach vorherigen Tests zurückgeblieben sind.

Beispiel eines API-Tests

Nach dieser Konfiguration sieht ein Integrationstest vergleichsweise einfach aus:


[TestFixture]
[NonParallelizable]
public class TopicControllerTests : BaseController
{
private const string TopicBasePath = "/api/Topic";


[Test]
public async Task GetAll_WithValidData_ReturnsValidResponse()
{
    var expectedCount = TestData.GetTopics().Count();
    using var response = await Client.GetAsync(TopicBasePath);

    response.Should().NotBeNull();
    response.StatusCode.Should().Be(HttpStatusCode.OK);

    var responseContent = await response.Content.ReadAsStringAsync();
    var topics = JsonConvert.DeserializeObject<TopicResponse>(responseContent);

    topics.Should().NotBeNull();
    topics!.Items.Should().NotBeNull();
    topics.Items.Count.Should().Be(expectedCount);
}

Der Test führt eine echte HTTP-Anfrage gegen die Testanwendung aus und überprüft:

  • den Status der Antwort;
  • ob die Antwort deserialisiert werden kann;
  • ob die erwarteten Daten vorhanden sind;
  • die Anzahl der zurückgegebenen Elemente.

Im Unterschied zu einem Unit-Test wird hier weder manuell eine Instanz des Controllers erstellt noch dessen Methode direkt aufgerufen. Die Anfrage durchläuft die HTTP-Pipeline der Anwendung.

Parallele Testausführung

Im obigen Beispiel ist die Testklasse mit folgendem Attribut versehen:

[NonParallelizable]

Dadurch führt NUnit die Tests dieses Fixtures nicht parallel zu anderen Tests aus.

Dieser Ansatz ist hilfreich, wenn Tests gemeinsame Ressourcen verwenden:

  • eine gemeinsame Datenbankverbindung;
  • einen gemeinsamen initialen Datensatz;
  • gemeinsam genutzte statische Einstellungen;
  • eine gemeinsame Instanz des Testservers;
  • feste Netzwerkports.

Bei einer großen Anzahl von Tests kann die sequenzielle Ausführung die gesamte Laufzeit deutlich erhöhen. Daher sollte NonParallelizable eher als pragmatischer Ausgangspunkt und nicht als zwingende Regel betrachtet werden.

Um eine parallele Testausführung zu ermöglichen, muss jedem Test oder Fixture eine isolierte Umgebung bereitgestellt werden: eine separate Datenbank, eine eigene Factory und gegebenenfalls ein eigener Netzwerkport.

Auch verschachtelte Testklassen sollten nur dann verwendet werden, wenn sie die Struktur der Tests tatsächlich verbessern. NUnit unterstützt solche Klassen, allerdings können sie das Verständnis des Fixture-Lebenszyklus sowie der Ausführungsreihenfolge von SetUp und TearDown erschweren.

Nach Abschluss dieses Schritts verfügen wir über eine API, deren Verhalten über HTTP geprüft wird und sowohl lokal als auch innerhalb von CI/CD reproduzierbare Ergebnisse liefert.

Übergang zu E2E-Tests

Nun können wir mit den E2E-Tests fortfahren.

Ein wesentlicher Teil der erforderlichen Infrastruktur ist bereits vorhanden:

  • die serverseitige Testanwendung kann gestartet werden;
  • die Datenbank wird erstellt und mit Daten gefüllt;
  • externe Abhängigkeiten können durch Mock-Objekte ersetzt werden;
  • der Server liefert vorhersehbare Ergebnisse.

Es bleibt lediglich, die Benutzeroberfläche hinzuzufügen und die Prüfungen über einen Browser auszuführen, anstatt direkt auf die API zuzugreifen.

Da die Angular-Anwendung Server-Side Rendering unterstützt, muss für die E2E-Tests zusätzlich ein Angular-SSR-Server gestartet werden.

Starten des Angular-SSR-Servers

Die Klasse AngularSsrServer wird verwendet, um den Node.js-Prozess zu verwalten.


public sealed class AngularSsrServer : IAsyncDisposable
{
    private const string AngularSsrServerPathKey = "AngularSsr:ServerPath";
    private const string E2eSettingsFileName = "appsettings.E2E.json";
    private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(30);

    private readonly HttpClient _httpClient;
    private readonly Process _process;
    private readonly StringBuilder _stderr = new();
    private readonly StringBuilder _stdout = new();

    private AngularSsrServer(Process process, Uri baseAddress)
    {
        _process = process;
        BaseAddress = baseAddress;

        _httpClient = new HttpClient
        {
            BaseAddress = baseAddress,
            Timeout = TimeSpan.FromSeconds(2)
        };
    }

    public Uri BaseAddress { get; }
    public string ProcessOutput => GetProcessOutput();
    public static async Task<AngularSsrServer> StartAsync(Uri apiBaseAddress)
    {
        var serverPath = GetAngularSsrServerPath();
        var port = GetFreeTcpPort();
        var baseAddress = new Uri($"http://127.0.0.1:{port}");

        var startInfo = new ProcessStartInfo
        {
            FileName = "node",
            WorkingDirectory = Path.GetDirectoryName(serverPath)!,
            UseShellExecute = false,
            RedirectStandardError = true,
            RedirectStandardOutput = true
        };

        startInfo.ArgumentList.Add(serverPath);

        startInfo.Environment["PORT"] = port.ToString();
        startInfo.Environment["SERDG_API_BASE_URL"] = apiBaseAddress.ToString().TrimEnd('/');
        startInfo.Environment["NG_ALLOWED_HOSTS"] = "127.0.0.1,localhost";
        startInfo.Environment["NODE_ENV"] = "production";
        startInfo.Environment["NO_COLOR"] = "1";

        var process = Process.Start(startInfo)
            ?? throw new InvalidOperationException(
                "Failed to start Angular SSR server process.");

        var server = new AngularSsrServer(process, baseAddress);

        process.OutputDataReceived += (_, args) => server.AppendOutput(args.Data);
        process.ErrorDataReceived += (_, args) => server.AppendError(args.Data);
        process.BeginOutputReadLine();
        process.BeginErrorReadLine();

        try
        {
            await server.WaitUntilReadyAsync();
            return server;
        }
        catch
        {
            await server.DisposeAsync();
            throw;
        }
    }

    // Other implementation details are omitted.
}

Die Klasse übernimmt mehrere Aufgaben:

  1. Sie sucht die kompilierte Datei des Angular-SSR-Servers.
  2. Sie wählt einen freien TCP-Port aus.
  3. Sie startet den Node.js-Prozess.
  4. Sie übergibt die URL der Test-API über eine Umgebungsvariable.
  5. Sie leitet die Standardausgabe und den Fehlerausgabestrom um.
  6. Sie wartet, bis der Server bereit ist, Anfragen zu verarbeiten.
  7. Sie beendet den Prozess nach Abschluss der Tests.

Durch die Verwendung eines dynamisch ausgewählten freien Ports lassen sich Konflikte mit lokal laufenden Anwendungen und anderen Testprozessen vermeiden.

Eine wichtige Einschränkung des TestServer

Der von CreateClient() zurückgegebene HttpClient kommuniziert mit ihm innerhalb desselben Prozesses.

Ein separater Node.js-Prozess kann auf einen solchen Server nicht über eine Adresse wie diese zugreifen:

http://127.0.0.1:5000

Daher muss CustomWebApplicationFactory für SSR- und E2E-Tests die Anwendung über Kestrel starten oder einen anderen Mechanismus verwenden, der eine echte HTTP-Adresse bereitstellt.

In diesem Projekt ist diese Adresse über folgende Eigenschaft verfügbar:

Factory.ServerAddress

Genau diese Adresse wird an den Angular-SSR-Server übergeben.

Wenn die Factory ausschließlich den standardmäßigen TestServer verwendet, funktioniert die Übergabe von ServerAddress an einen externen Node.js-Prozess nicht.

Starten des SSR-Servers in SetUp

Der SSR-Server wird am Ende der Methode SetUp() gestartet:


[SetUp]
public async Task SetUp()
{
    // Configure the test API and reset the database.
    ServerAddress = Factory.ServerAddress;
    SsrServer =    await AngularSsrServer.StartAsync(ServerAddress);
}

Dadurch erhält der Angular-SSR-Server die Adresse der Test-API und kann während des serverseitigen Renderings auf sie zugreifen.

Integration mit Playwright

Da die Prüfungen nun über einen Browser ausgeführt werden, erbt die Basisklasse der E2E-Tests von PageTest. Diese Klasse wird durch das Paket Microsoft.Playwright.NUnit bereitgestellt.


public abstract class BaseTests : PageTest
{
    protected AngularSsrServer SsrServer { get; private set; } = null!;
    protected Uri ServerAddress { get; private set; } = null!;
    // Test initialization and cleanup.
}

PageTest stellt den Zugriff auf die wichtigsten Playwright-Objekte bereit:

  • Browser;
  • Context;
  • Page;
  • Playwright.

Dabei muss auch der Lebenszyklus von NUnit berücksichtigt werden. Wenn sowohl Basisklassen als auch abgeleitete Klassen eigene Methoden mit [SetUp] und [TearDown] definieren, sollte sichergestellt werden, dass diese sich nicht gegenseitig verdecken und in der erwarteten Reihenfolge ausgeführt werden.

Die Cleanup-Methode wird um das Beenden des SSR-Prozesses erweitert:


[TearDown]
public async Task TearDown()
{
    if (SsrServer is not null)
    {
        await SsrServer.DisposeAsync();
    }
    // Dispose of other test resources.
}

Der Node.js-Prozess muss auch dann beendet werden, wenn ein Test fehlschlägt. Andernfalls können nach mehreren Testläufen Hintergrundprozesse aktiv bleiben und Netzwerkports blockieren.

Testen des Server-Side Renderings

Mit der aktuellen Konfiguration kann ein Test folgendermaßen aussehen:


[TestFixture]
[NonParallelizable]
public class TopicTests : BaseTests
{
    [Test]
    public async Task Home_WhenServerSideRendered_ContainsArticlesInInitialHtml()
    {
    await using var context =
    await Browser.NewContextAsync(
    new BrowserNewContextOptions
    {
    // Disable JavaScript to verify the SSR output.
    JavaScriptEnabled = false
    });


        var page = await context.NewPageAsync();

        var response = await page.GotoAsync(
            SsrServer.Url(TopicTestUrls.Home),
            new PageGotoOptions
            {
                WaitUntil = WaitUntilState.DOMContentLoaded
            });

        response.Should().NotBeNull();
        response!.Status.Should().Be((int)HttpStatusCode.OK);

        await RunWithSsrDiagnosticsAsync(async () =>
        {
            await ExpectHomeArticlesAsync(page);
            var html = await page.ContentAsync();
            html.Should().Contain(TopicTestLocators.Topic10Title);
        });
    }
}

JavaScript wird in diesem Test absichtlich deaktiviert:

JavaScriptEnabled = false

Dadurch kann überprüft werden, ob die Titel der Artikel bereits im vom SSR-Server erzeugten HTML enthalten sind und nicht erst nach dem Laden der Seite durch Angular hinzugefügt werden.

Dieses Szenario ist besonders wichtig für die Überprüfung folgender Aspekte:

  • Indexierung durch Suchmaschinen;
  • Open-Graph-Metadaten;
  • Linkvorschauen;
  • Verfügbarkeit der wichtigsten Inhalte ohne Ausführung von JavaScript;
  • korrektes serverseitiges Rendering.

Zu diesen Szenarien gehören:

  • Navigation zwischen Seiten;
  • Ausfüllen von Formularen;
  • Anmeldung;
  • Absenden von Daten;
  • Fehlerbehandlung;
  • Hochladen von Bildern;
  • Anzeige der von der API empfangenen Daten.

Nach dem Hinzufügen der erforderlichen Benutzerszenarien wird die gesamte Kette getestet:

Browser → Angular → SSR → .NET API → Entity Framework Core → SQLite

Ausführen der E2E-Tests in GitHub Actions

Als letzter Schritt werden die Tests zur CI/CD-Pipeline hinzugefügt.

Nach dem Build des Projekts müssen der Playwright-Browser und die benötigten Systemabhängigkeiten installiert werden:


- name: Install Playwright browsers
working-directory: ${{ env.API_DIR }}
run: >
pwsh
./E2ETests/bin/${{ env.CONFIGURATION }}/net10.0/playwright.ps1
install
--with-deps
chromium

Anschließend können die E2E-Tests ausgeführt werden:


- name: Run E2E tests
working-directory: ${{ env.API_DIR }}
run: >
dotnet test
./E2ETests/E2ETests.csproj
--configuration "${{ env.CONFIGURATION }}"
--no-build
--settings ./E2ETests/playwright.runsettings
--logger "trx;LogFileName=e2e-test-results.trx"
--results-directory ./TestResults/e2e

Der folgende Parameter:

--no-build

bedeutet, dass das Projekt bereits in einem der vorherigen Schritte des Workflows gebaut worden sein muss.

Der Build ist außerdem erforderlich, damit die folgende Datei erzeugt wird:

playwright.ps1

In der beschriebenen Konfiguration wird ausschließlich Chromium installiert. Dadurch lässt sich die Ausführungszeit des Workflows reduzieren.

Wenn zusätzlich die browserübergreifende Kompatibilität überprüft werden soll, können auch Firefox und WebKit installiert werden. Für ein kleines Projekt ist die Ausführung der Tests ausschließlich in Chromium jedoch häufig ein sinnvoller Kompromiss zwischen Testabdeckung und Ausführungszeit.

Die E2E-Tests werden nun bei jeder Ausführung des entsprechenden GitHub-Actions-Workflows gestartet. Wenn eines der kritischen Szenarien fehlschlägt, wird der Build nicht mit den folgenden Schritten fortgesetzt, beispielsweise mit dem Erstellen und Veröffentlichen eines Docker-Images.

Fazit

Als Ergebnis haben wir drei Ebenen automatisierter Tests implementiert.

Unit-Tests prüfen einzelne Teile der Geschäftslogik schnell und isoliert.

API-Integrationstests starten die serverseitige Anwendung und überprüfen die vollständige Verarbeitung einer HTTP-Anfrage, einschließlich Routing, Autorisierung, Controller, Services und Datenbankzugriff.

E2E-Tests ergänzen Angular, SSR und einen echten Browser. Dadurch kann die Anwendung aus Sicht des Benutzers überprüft werden.

Für ein kleines oder mittelgroßes Projekt bietet diese Kombination ein gutes Gleichgewicht zwischen Qualität, Ausführungsgeschwindigkeit und Wartungsaufwand.

Dabei ist es nicht erforderlich, jedes mögliche Szenario auf allen drei Ebenen zu überprüfen. Entscheidend ist vielmehr, für jeden Fall die passende Testebene auszuwählen:

  • einzelne Geschäftslogik sollte mit Unit-Tests geprüft werden;
  • das Verhalten der HTTP-API und das Zusammenspiel serverseitiger Komponenten sollten mit Integrationstests überprüft werden;
  • kritische Benutzerszenarien sollten durch E2E-Tests abgedeckt werden.

Auf diese Weise werden Tests nicht zum Selbstzweck. Sie bleiben ein Werkzeug, das dabei hilft, die Anwendung sicher weiterzuentwickeln und Fehler schneller zu erkennen.