Inline Snapshot testing in .NET

 
 
  • Gérald Barré

Snapshot testing is a technique that allows you to write tests that assert the state of an object. The object is serialized and stored in a file. The next time the test is run, the serialized object is compared to the existing snapshot file. If the content is different, the test fails. If the content is the same, the test passes. In .NET, the most common library to do snapshot testing is Verify.

Inline Snapshot testing is the same as Snapshot testing, except that the snapshot is stored in the test itself as a string. There is no external file. So, it's easier to read the test as you can see the expected value next to the assertion instead of having to find the snapshot file in the project. You can use inline snapshots for small text snapshots. But, you should not use it to inline a binary file such as an image.

#Benefits of snapshot testing

Snapshot testing has several benefits:

  • Assertions are easy to write and maintain as they are generated automatically.
  • Most of the time, people assert more things as assertions are easy to write and maintain.
  • Assertions are more expressive and easier to read. It's hard to read a test containing multiple assertions to test multiple properties. A snapshot can validate a complete object graph and make it easy to read.
  • In case of failure, the diff tool shows the difference between the expected value and the actual value. It's easier to understand the failure.

#Inline Snapshot testing in .NET

Let's look at an example of Inline Snapshot testing using Meziantou.Framework.InlineSnapshotTesting:

Shell
dotnet new xunit
dotnet add package Meziantou.Framework.InlineSnapshotTesting
UnitTest1.cs (C#)
using Meziantou.Framework.InlineSnapshotTesting;

public class UnitTest1
{
    [Fact]
    public void Test1()
    {
        var subject = new
        {
            FirstName = "Gérald",
            LastName = "Barré",
            Nickname = "Meziantou",
        };

        InlineSnapshot.Validate(subject); // The snapshot will be generated the first time you run the test
    }
}

Demo of Inline Snapshot testing in .NET

In the previous video, you can see that the test fails because the snapshot is empty. The library generates the new file with the new snapshot and opens a diff tool, so the user can accept or reject it. If you accept the new snapshot, the source file is edited, and the test will pass the next time you run it.

#Testing a REST API using snapshot testing

Snapshot testing is useful to test the output of a REST API. The default serializer can serialize an instance of HttpResponseMessage, so testing an API is very easy. By default, JSON and XML are pretty printed to make it easier to read the snapshot.

C#
[Fact]
public async Task ApiTest()
{
    // Arrange: Prepare the test server
    var builder = WebApplication.CreateBuilder();
    builder.WebHost.UseTestServer();
    await using var application = builder.Build();
    application.MapGet("samples/{id}", (int id) => Results.Ok(new { id = id, name = "Sample" }));
    _ = application.RunAsync();
    using var httpClient = application.GetTestClient();

    // Act: Call the API
    using var response = await httpClient.GetAsync("samples/1");

    // Assert: Validate the response
    // The content of the snapshot is generated automatically when runing the test
    InlineSnapshot.Validate(response, """
        StatusCode: 200 (OK)
        Content:
          Headers:
            Content-Type: application/json; charset=utf-8
          Value:
            {
                "id": 1,
                "name": "Sample"
            }
        """);
}

#Testing a Blazor component using snapshot testing

You can use bUnit to test a Blazor component:

C#
[Fact]
public void Test()
{
    var cut = RenderComponent<Sample.MyComponent>();

    // The content of the snapshot is generated automatically when runing the test
    InlineSnapshot.Validate(cut.Markup, """
        <div class="my-component" b-9hms91upey>
            This component is defined in the Sample library.
        </div>
        """);
}

#Configuration

C#
// Set the defaut configuration
static class AssemblyInitializer
{
    [System.Runtime.CompilerServices.ModuleInitializer]
    public static void Initialize()
    {
        InlineSnapshotSettings.Default = InlineSnapshotSettings.Default with
        {
            // Set the update strategy when a snapshot is different from the expected value.
            // - Default: MergeTool on a dev machine, Disallow on a CI machine
            // - Disallow: the test will fail
            // - Overwrite: the snapshot will be overwritten with the new value, and the test will fail
            // - MergeTool: the configured merge tool will be opened to edit the snapshot
            // - MergeToolSync: the configured merge tool will be opened to edit the snapshot, and wait for the diff to be closed before continuing the test
            SnapshotUpdateStrategy = SnapshotUpdateStrategy.Default,

            // Set the default merge tool.
            // If not set, it uses the diff tool from the current IDE (Visual Studio, Rider, VS Code)
            MergeTools = [MergeTool.VisualStudioCode],

            // Configure the serializer used to create the snapshot.
            // The default serializer is the HumanReadableSerializer. You can use JsonSnapshotSerializer or ArgonSnapshotSerializer if you already use Verify.
            SnapshotSerializer = new HumanReadableSnapshotSerializer(settings =>
            {
                settings.IncludeFields = true;
                settings.ShowInvisibleCharactersInValues = true;
                settings.DefaultIgnoreCondition = HumanReadableIgnoreCondition.WhenWritingDefault,
            }),
        };
    }
}

You can also provide a configuration for a specific test:

C#
[Fact]
public void Test1()
{
    // Customize settings
    var settings = InlineSnapshotSettings.Default with
    {
        MergeTools = [MergeTool.VisualStudioCode],
    };

    InlineSnapshot.WithSettings(settings).Validate(subject, "snapshot");
}

#Dev machine vs CI

By default, the library relies on DiffEngine to detect CI environments. This detection is based on environment variables set by the build systems.

When a CI environment is detected, the SnapshotUpdateStrategy is not used and the test will fail if the snapshot is different from the expected value. This is to avoid accidentally passing a test on the CI machine if you use SnapshotUpdateStrategy.Overwrite.

If you use a different build system, you can set the BuildServerDetector.Detected property to true to force the CI mode. The library also detects continuous testing tools such as NCrunch or Live Unit Testing in Visual Studio. You can set the ContinuousTestingDetector.Detected property to true to disable the update strategy for these tools.

#How does it know which file to edit?

There are two ways to know which file to edit. First, the Validate uses [CallerFilePath] and [CallerLineNumber] attributes to get compiler info. Second, it uses the call stack and information from the PDB file to confirm the file to edit. Also, the PDB provides the column to edit. BTW, the PDB provides other information such as the C# version, so the library can use valid string syntax to generate the snapshot. For instance, if the C# version is 10, the library won't use raw string literals.

Then, the library parses the file using Roslyn, the C# compiler, and finds the argument to edit. It ensures the value in the file is the same as the one passed to the method. If the value differs, the snapshot is not updated, and the test fails. So, the tool only updates a file when it's sure it's the right file.

Note that you can disable PDB validation if needed:

C#
InlineSnapshotSettings.Default.ValidateSourceFilePathUsingPdbInfoWhenAvailable = false;
InlineSnapshotSettings.Default.ValidateLineNumberUsingPdbInfoWhenAvailable = false;

#How is the data serialized

By default, it uses HumanReadableSerializer. Why not use an existing serializer? Most serializers are not designed to be used by humans. For instance, the JSON serializer needs to escape some characters in string values. Plus there are lots of useless characters such as the quotes and brackets. This makes the snapshot harder to read. YAML has the same kind of issues. The main issue with these serializers is that they need to be able to deserialize the data. This is not the case for snapshot testing as the snapshot is only used to compare the values. So, the serializer can be optimized for readability.

While there is a need for a new serializer, the output format doesn't need to be revolutionary. The HumanReadableSerializer uses a format similar to YAML. The main difference is that the serializer doesn't escape characters in strings. It also doesn't add quotes around strings. This makes the snapshot easier to read. Another benefit is that it can serialize more types as there is no need for these types to be deserialized. For instance, you can serialize an HttpResponseMessage.

#Windows-only tools

On Windows, when using the default configuration, a tool starts in the notification tray. This allows you to quickly change the update strategy.

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?Buy Me A Coffee💖 Sponsor on GitHub