Deterministic snapshots

edit

A snapshot must be the same on every run and every machine. Values that change from one run to the next (GUIDs, the current time, the user name) make a test fail for no reason: replace them before the comparison.

There are two places to do it:

  • During serialization, with the options of the human-readable serializer: you get the actual values, and can replace a value by type wherever it appears.
  • After serialization, with scrubbers: they rewrite the text, line by line.

Scrub values by type

ScrubValue<T> replaces each distinct value of a type with <Type>_<index>. Equal values get the same replacement, so the snapshot still shows which values are the same:

var data = new string[] { "a", "a", "b" };
InlineSnapshot
    .WithSerializer(options => options.ScrubValue<string>())
    .Validate(data, """
        - String_0
        - String_0
        - String_1
        """);

Pass an equality comparer to decide which values are the same:

var data = new string[] { "a", "A", "b" };
InlineSnapshot
    .WithSerializer(options => options.ScrubValue<string>(StringComparer.OrdinalIgnoreCase))
    .Validate(data, """
        - String_0
        - String_0
        - String_1
        """);

Pass a function to write the replacement from the value and its index:

var data = new string[] { "a", "A", "b" };
InlineSnapshot
    .WithSerializer(options => options.ScrubValue<string>((value, index) => $"{value}_{index}", StringComparer.OrdinalIgnoreCase))
    .Validate(data, """
        - a_0
        - a_0
        - b_1
        """);

Scrub GUIDs

ScrubGuid replaces each distinct GUID with a sequential one, and keeps Guid.Empty:

var data = new Guid[] { Guid.NewGuid(), Guid.NewGuid(), Guid.Empty };
InlineSnapshot
    .WithSerializer(options => options.ScrubGuid())
    .Validate(data, """
        - 00000000-0000-0000-0000-000000000001
        - 00000000-0000-0000-0000-000000000002
        - 00000000-0000-0000-0000-000000000000
        """);

Relative dates

UseRelativeDateTime writes DateTime values as their difference with a reference time. UseRelativeDateTimeOffset and UseRelativeTimeSpan do the same for DateTimeOffset and TimeSpan values:

var now = DateTime.UtcNow;
var data = now.AddSeconds(10);
InlineSnapshot
    .WithSerializer(options => options.UseRelativeDateTime(now))
    .Validate(data, "00:00:10"); // TimeSpan relative to the now variable

Scrub JSON and XML values

ScrubJsonValue replaces the nodes a JSONPath selects in JSON values:

var data = JsonNode.Parse("""{ "prop": "value" }""");
InlineSnapshot
    .WithSerializer(options => options.ScrubJsonValue("$.prop", node => "[redacted]"))
    .Validate(data, """
        {
          "prop": "[redacted]"
        }
        """);

ScrubXmlAttribute and ScrubXmlNode replace the attributes and nodes an XPath selects in XML values. Overloads take an IXmlNamespaceResolver for namespaced documents:

var data = XDocument.Parse("""
                <root>
                  <item attr="1">test1</item>
                </root>
                """);
InlineSnapshot
    .WithSerializer(options => options.ScrubXmlAttribute("//item/@attr", attribute => "[redacted]"))
    .Validate(data, """
        <root>
          <item attr="[redacted]">test1</item>
        </root>
        """);

Scrub lines

Scrubbers run on the serialized text. Remove the lines that match a predicate:

InlineSnapshot
    .WithSettings(settings => settings.ScrubLines(line => line.Contains("dummy", StringComparison.Ordinal)))
    .Validate("abc\ndummy", "abc");

Remove the lines that contain a text. Pass several texts to remove the lines that contain any of them, in a single pass over the snapshot:

InlineSnapshot
    .WithSettings(settings => settings.ScrubLinesContaining(StringComparison.Ordinal, "dummy"))
    .Validate("abc\ndummy", "abc");

Remove the lines that match a regular expression:

InlineSnapshot
    .WithSettings(settings => settings.ScrubLinesMatching("d.*y"))
    .Validate("abc\ndummy", "abc");

Rewrite each line:

InlineSnapshot
    .WithSettings(settings => settings.ScrubLinesWithReplace(line => line.Replace("abc", "123", StringComparison.Ordinal)))
    .Validate("abcdef", "123def");

ScrubUserName and ScrubMachineName replace the name of the current user and of the machine where they form a whole word, ignoring case. For anything else, derive from Scrubber, implement Scrub(string text), and add an instance to InlineSnapshotSettings.Scrubbers.

Invisible characters

When spaces, tabs or line endings matter, write them as visible characters. Line endings are shown as control pictures (␍, ␊) followed by a normalized line break, a tab as ␉, a space as ␠ only at the start or the end of a line, and other space characters as their code point (<U+00A0>):

InlineSnapshot
    .WithSerializer(options => options.ShowInvisibleCharactersInValues = true)
    .Validate("line 1 \r\nline\t2", """
        line 1␠␍␊
        line␉2
        """);