Testing war bereits des Öfteren Thema auf diesem Blog. Neben Property-based testing habe ich Integrationstest in .NET mit Testcontainern und Architektur-Test mit ArchUnitNET vorgestellt.

Ein weiteres nützliches Tool im Testing-Werkzeugkasten sind Snapshot Tests. Snapshot Tests machen genau das, was der Name vermuten lässt: Sie erstellen einen Snapshot von einem Testergebnis. Damit lässt sich das Verhalten eines Systems einfrieren und gegen unerwünschte Änderungen und Regression absichern.

Der Beispielcode zu diesem Blog-Post ist auf Github verfügbar:

https://github.com/davull/demo-snapshot-testing-in-csharp

Die hier gezeigten Beispiele verwenden das Nuget-Package Snapshooter. Das Vorgehen ist aber problemlos auf andere Libraries übertragbar.

Warum Snapshot Testing?

Snapshot Tests frieren das Verhalten eines Systems zum Zeitpunkt der Testausführung ein. Beim ersten Ausführen eines Snapshot Tests wird das Ergebnis einer zu testenden Funktionalität in eine Datei geschrieben und dient nun als Referenz für spätere Testausführungen. Weit das Ergebnis von dem Inhalt der Referenzdatei ab, gilt der Test als fehlgeschlagen.

Die Unterschiede kann man sich optisch sehr einfach in einem Diff-Tool anschauen und schnell entscheiden, ob es sich um erwartete oder unerwartete Änderungen handelt. Hat man die Änderungen geprüft und sieht diese als valide an, übernimmt man die aktualisierte Datei als neue Referenz.

{
  "Date": "2025-12-01",
  "TemperatureC": 18,
--  "TemperatureF": 59,
++  "TemperatureF": 64,
--  "Summary": ""
++  "Summary": "Mild"
}

Ein weiterer Vorteil ist, dass man den Inhalt großer Datenobjekte einfach visuell überprüfen kann. Möchte man mehrere Properties eines Rückgabeobjektes überprüfen, schreibt man klassischerweise mehrere Assert-Statements:

[Test]
public void GetForecast_Should_ReturnCorrectValues()
{
    var tomorrow = new DateOnly(2025, 12, 02);
    const int temperatureC = 18;
    
    var forecast = WeatherForecastProvider.GetForecast(tomorrow , temperatureC);
    
    Assert.That(forecast.Date, Is.EqualTo(tomorrow));
    Assert.That(forecast.Summary, Is.EqualTo("Mild"));
    Assert.That(forecast.TemperatureC, Is.EqualTo(18));
    Assert.That(forecast.TemperatureF, Is.EqualTo(64));
}

Nutzt man hingegen eine Snapshot-Test, kann man mit einem Aufruf von MatchSnapshot() sämtliche Properties des Objektes festhalten.

[Test]
public void GetForecast_Should_MatchSnapshot()
{
    var tomorrow = new DateOnly(2025, 12, 02);
    const int temperatureC = 18;
    
    var forecast = WeatherForecastProvider.GetForecast(tomorrow , temperatureC);
    forecast.MatchSnapshot();
}

In der entsprechenden Snapshot-Datei __snapshots__/WeatherForecastProviderTests.GetForecast_Should_MatchSnapshot.snap findet man die JSON-Repräsentation des Objektes und kann mit einem Blick sehen, ob die Properties die erwarteten Werte enthalten.

{
  "Date": "2025-12-02",
  "TemperatureC": 18,
  "TemperatureF": 64,
  "Summary": "Mild"
}

Snapshot Testing im Backend

Im Bereich der JavaScript Frontend-Frameworks ist Snapshot Testing bereits seit geraumer Zeit verbreitet, etwas um den HTML-Output von Render-Funktionen zu prüfen.

Aber auch Webseiten mit SSR oder API-Endpunkte lassen sich mittels Snapshot Testing effektiv überprüfen.

private readonly CustomWebApplicationFactory _factory = new();

[Test]
public async Task GetWeatherForecast_Should_MatchSnapshot()
{
    var client = _factory.CreateClient();
    var response = await client.GetAsync("/WeatherForecast");
    var content = await response.Content.ReadAsStringAsync();

    content.MatchSnapshot();
}

Stellt ein API-Aufruf keine Pure Function dar, ändern sich manche Return-Werte, unabhängig vom gegebenen Input. So zum Beispiel das heutige Datum. Kann man diese in seinem Testsetup, etwas über FakeTimeProvider, nicht steuern, so kann man die Werte aus dem Snapshot-Resultat herausfiltern.

content.MatchSnapshot(o => o.ExcludeField("$[*].date"));

HTML Snapshots

HTML-Seiten lassen sich auf die gleiche Weise testen. Hier bietet es sich an, den HTML Output vor dem Snapshot-Vergleich etwas aufzuräumen, um Änderungen an zufällige Werten nicht zu berücksichtigen. Dazu zählen etwa dynamische IDs, Anti Forgery Tokens oder automatisch generierte CSS-Klassen.

public static string PrepareMarkup(string markup)
{
    var replacements = new Dictionary<string, string>
    {
        {
          """
          <input name="__RequestVerificationToken" type="hidden" value="[\d\w-]+" \/>
          """,
          """
          <input name="__RequestVerificationToken" type="hidden" value="<replaced>" />
          """
        },
        {
          @"\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b",
          "00000000-0000-0000-0000-000000000000"
        },
        // ...
    };

    foreach (var (pattern, replacement) in replacements)
    {
        markup = Regex.Replace(markup, pattern, replacement);
    }

    return markup;
}

Erstellt man auf einer derartigen Höhe Snapshots von ganzen Webseiten, bekommt man unerwünschte Änderungen am generierten Output zuverlässig mit und erhält effektive Regressionstests.

Arbeiten mit Legacy Code

Code ohne Tests ist bekanntlich Legacy Code. Um nun aber schnell und effektiv mit einer Legacy Codebasis arbeiten zu können, bieten sich Snapshot Tests förmlich an. Sie ermöglichen es, das bestehende Verhalten der Anwendung festzuhalten. Somit ist ein Sicherheitsnetz aufgespannt, dass uns das Ändern der Codebasis erlaubt und unerwartet Änderungen aufdeckt. Für Webanwendungen ist das Testen von HTTP- und API-Endpunkten einfach umzusetzen. Bei Desktop-Anwendungen oder native Mobile Apps muss man eine Ebene unter der UI ansetzen. So kann man etwa vor dem Umsetzen von Codeänderungen einen Snapshot eines ViewModels erstellen.

Hat man die Möglichkeit, wiederholbare Integrationstests mit externen Ressourcen wie einer Datenbank zu erstellen, kann auch deren Inhalt das Ziel eines Snapshot Tests sein.

[Test]
public async Task Table_User_Should_MatchSnapshot()
{
    // Prepare database

    // Do testing operation

    var user = Repository.GetUsers();
    users.MatchSnapshot();
}