Inline Snapshots

August 17, 2026 ยท View on GitHub

Currently in 32.0.0-beta

Only C# and F# are supported

If using DiffEngineTray ensure to update to the current beta.

Inline snapshots store the expected text inside the test file (.cs, .fs or .fsx) as a raw string literal, next to the code that produces it, instead of in a .verified. file on disk.

The Rider/R# Verify plugin handles them from 2026.3.0: accepting from the test runner splices the snapshot into the source, and comparing shows the received text against the snapshot the run was measured against.

Usage

Add .Snapshot(...) to any verification:

[Fact]
public Task MultiLine()
{
    var input = "line1\nline2";
    return Verify(input)
        .Snapshot(
            """
            line1
            line2
            """);
}

snippet source | anchor

Omitting the expected argument (or passing null) marks the snapshot as new; accepting it writes the literal into the source file.

Because Snapshot is a modifier rather than a separate entry point, it composes with every overload: VerifyXml(...).Snapshot(...), VerifyJson(...).Snapshot(...), VerifyFile(...).Snapshot(...), and so on.

Combinations are included, since Combination().Verify(...) also returns a SettingsTask:

static string Concat(string a, int b) =>
    $"{a}{b}";

[Fact]
public Task Combinations() =>
    Combination()
        .Verify(
            Concat,
            ["a", "b"],
            [1, 2])
        .Snapshot(
            """
            {
              a, 1: a1,
              a, 2: a2,
              b, 1: b1,
              b, 2: b2
            }
            """);

snippet source | anchor

The verification pipeline is unchanged: the target is serialized and scrubbed exactly as for file snapshots, then compared against the literal. Line endings in the literal are normalized to \n before comparison, whether the source file uses \r\n, \n or a lone \r, so the comparison is not affected by the line endings of the source file.

Multiple inline verifications in a single test method are supported.

Enabling inline snapshots globally

Most codebases want inline snapshots everywhere rather than one call at a time. Turn them on in a module initializer:

public static class ModuleInitializer
{
    [ModuleInitializer]
    public static void Init() =>
        VerifierSettings.Inline();
}

snippet source | anchor

Every Verify* then uses an inline snapshot, unless it is declined by one of the rules below, and accepting one appends the .Snapshot(...) call to the verify invocation.

To decide per verification, pass a delegate:

public static class ModuleInitializer
{
    [ModuleInitializer]
    public static void Init() =>
        VerifierSettings.Inline(
            (typeName, methodName, sourceFile, extension) => extension == "txt");
}

snippet source | anchor

extension is that of the target that would be inlined, which is the first one.

Opt a single test out with .NotInline(), on either the instance or the fluent settings:

await Verify(target)
    .NotInline();

NotInline wins over both the global switch and an explicit .Snapshot(...), with one exception: a .Snapshot(...) call combined with UseUniqueDirectory throws whether or not NotInline() is alongside it.

Limiting the size of an inline snapshot

A long literal drowns the test method it sits in, which is the opposite of what an inline snapshot is for. maxLines keeps those on files:

public static class ModuleInitializer
{
    [ModuleInitializer]
    public static void Init() =>
        VerifierSettings.Inline(maxLines: 30);
}

snippet source | anchor

A result of more than 30 lines then uses a .verified. file, and everything shorter is inlined.

The limit and the delegate combine as an and: the delegate picks the candidate tests, and the limit applies to what those produce.

maxLines counts the lines of the snapshot content, not the lines the literal occupies in source. A raw string literal adds two delimiter lines plus indentation on top of that. A single trailing newline starts no line, so it is not counted. Nothing is measured for width, so a one line snapshot is inlined however long that line is.

By default the limit routes new snapshots only, and a .Snapshot(...) call that already exists is left alone. Removing one rewrites source, so that is opted in to separately:

public static class ModuleInitializer
{
    [ModuleInitializer]
    public static void Init() =>
        VerifierSettings.Inline(
            maxLines: 30,
            applyMaxLinesToExisting: true);
}

snippet source | anchor

Every existing literal over the limit then migrates, not only the ones whose content changed, so a test that was passing keeps passing: the literal seeds the verified file, as described below. Nothing is rewritten on a build server, where such a test keeps using its literal.

Parameterised tests

An inline snapshot is one literal at one call site, so it cannot hold a different expected value for each case of a parameterised test. What decides compatibility is whether the parameters reach the verified name:

  • Parameters in the verified name: not inlineable. The global switch declines such a test, which keeps using .verified. files, so turning inline on across a codebase leaves data driven tests alone. An explicit .Snapshot(...) throws instead, since it is a stated intent that cannot be honoured.
  • No parameters in the verified name: inlineable. Every case already shares the one snapshot, which is exactly what a literal can represent.

Constructor arguments are treated the same as method parameters: they form part of the verified name unless ignored.

Dropping the parameters from the verified name therefore opts a parameterised test back in:

[Theory]
[InlineData("a")]
[InlineData("b")]
public Task IgnoredParameters(string value) =>
    Verify(value.Length)
        .IgnoreParameters()
        .Snapshot("1");

snippet source | anchor

The APIs that do this are IgnoreParameters(), IgnoreParametersForVerified() and IgnoreConstructorParameters() on the instance settings, and VerifierSettings.IgnoreParameters() and VerifierSettings.IgnoreConstructorParameters() globally. Ignoring only some of the parameters is not enough, since the remaining ones still vary the verified name per case. UseTextForParameters counts as a parameter here for the same reason, while UseFileName pins the verified name so no parameter ever reaches it.

NotInline() is the other way out: it keeps a test on files without any of this applying.

Which target is inlined

The first target is the inline snapshot. Any others are written to .verified. files as usual, keeping the names they would have had, so turning inline on never renames a snapshot file. That leaves a deliberate gap where the first target's file would have been: a verification that produced #00, #01 and #02 keeps #01 and #02 on disk.

If the first target is not text, the verification throws. .NotInline() keeps that test on files. Target.DontInline opts an extension out as well, but only where the global switch is what turned inline on: an explicit .Snapshot(...) is honoured whatever the target says. A maxLines limit does not change this: a binary target has no lines to count, so it still reaches the same error rather than quietly falling back to files.

Extensions that should never inline

A converter that splits one input into several text targets has no sensible first target: inlining the first page of a document and writing the rest to files helps nobody. Such a converter sets DontInline on the target that would otherwise be inlined:

new Target("md", page1)
{
    DontInline = true
}

The whole verification then falls back to files.

How a verification is routed

Every rule above feeds one decision, made per verification once the targets have been serialized and scrubbed:

graph TD
start["Verification"]
notInline{"NotInline() ?"}
start-->notInline

literal{"Snapshot(...)<br/>already in the source ?"}
notInline-- No -->literal

existing{"applyMaxLinesToExisting,<br/>over maxLines, and<br/>not a build server ?"}
literal-- Yes -->existing

migrate["Strip the Snapshot call.<br/>The literal seeds<br/>the verified file"]
existing-- Yes -->migrate

globalSwitch{"VerifierSettings.Inline()<br/>in a module initializer ?"}
literal-- No -->globalSwitch

compatible{"C# or F# source, a recognised test,<br/>no parameters in the verified name,<br/>and no UseUniqueDirectory ?"}
globalSwitch-- Yes -->compatible

accepted{"Delegate accepts, and the first<br/>target is not DontInline ?"}
compatible-- Yes -->accepted

within{"Within maxLines ?"}
accepted-- Yes -->within

inline["Inline snapshot"]
within-- Yes -->inline
existing-- No -->inline

isText{"First target<br/>is text ?"}
inline-->isText

compare["Compare against the literal.<br/>Accepting rewrites it in source"]
isText-- Yes -->compare

throws["Throws: inline snapshots<br/>only support text"]
isText-- No -->throws

file["Verified file"]
notInline-- Yes -->file
migrate-->file
globalSwitch-- No -->file
compatible-- No -->file
accepted-- No -->file
within-- No -->file

The checks that route a verification to files are silent rather than errors, so turning the switch on across a codebase leaves the tests it cannot represent alone. An explicit .Snapshot(...) is stricter: it throws for parameterised tests and for UseUniqueDirectory, since those are a stated intent that cannot be honoured.

Accepting a snapshot

On a mismatch (or a new snapshot), Verify records the call site (file, line, and the literal's source text via CallerArgumentExpression) and produces a patch. Accepting the patch splices a new raw string literal into the source file, preserving the file's encoding, BOM, and line endings. The literal's location is found by content search, so line shifts from earlier edits do not break later ones.

Accept mechanisms:

  • AutoVerify: with AutoVerify enabled, the source file is rewritten immediately during the test run.
  • DiffEngineViewer: opens showing the received text against the expected text, with Accept and Discard. It ships inside the DiffEngine package, so it needs no install, and it runs on Windows, macOS and Linux. Several snapshots failing in one run queue into a single window.
  • DiffEngineTray: pending snapshots appear under "Pending Snapshots" and can be accepted, discarded, or opened in the viewer.

The queue belongs to whichever process bound the port first, which is normally the tray since it starts at login. Whichever holds it, the other drives it over the same socket, so the two always agree about what is pending.

Nothing is written to disk for a pending inline snapshot: the patch is handed to the queue owner. Only when nothing owns a queue does Verify fall back to staging the received text, the expected text and the patch itself under obj/VerifyInline/, and launching whatever diff tool is configured.

On a build server, no source rewriting, review or staging occurs; the failure exception carries the full content.

F#

F# test files (.fs, .fsx) work the same way, with one difference worth knowing because it decides what a literal means.

C# has raw strings: the compiler drops the line break after the opening delimiter and the indentation the closing delimiter sits at, and hands over the snapshot. F# has no such form. A triple-quoted string is verbatim, so what F# hands over still carries that line break and the indentation of every line. Writing the snapshot at the left margin instead is not an option either, since F#'s offside rule then rejects anything ending in a newline.

So the layout is taken off by agreement rather than by the compiler. Verify writes the shape C# would, and reads it back the same way:

[Fact]
public async Task AcceptWritesTheIndentedForm()
{
    var template = WriteTemplate(
        """
        module Tests

        let MyTest () =
            Verifier.Verify(value).Snapshot("old").ToTask()
        """);
    try
    {
        var settings = new VerifySettings();
        settings.IgnoreParameters();
        settings.Snapshot("old", template, 4, null, "MyTest");
        settings.AutoVerify();
        settings.DisableDiff();

        await Verify("line one\nline two", settings);

        Assert.Equal(
            """"
            module Tests

            let MyTest () =
                Verifier.Verify(value).Snapshot(
                    """
                    line one
                    line two
                    """).ToTask()
            """",
            await File.ReadAllTextAsync(template));
    }
    finally
    {
        Directory.Delete(Path.GetDirectoryName(template)!, true);
    }
}

snippet source | anchor

Which means a literal like that compares as the snapshot it looks like, rather than as the indented text F# produced:

[Fact]
public Task LayoutIsNotContent()
{
    var settings = new VerifySettings();
    settings.IgnoreParameters();
    settings.Snapshot(asFSharpHandsItOver, FakeSource(), 1, null, "LayoutIsNotContent");
    return Verify("line one\nline two", settings);
}

snippet source | anchor

Two consequences. Content ending in a newline is written as a blank line before the closing delimiter, exactly as in C#. And an F# expected argument is only the snapshot once Verify has read it: look at it any other way, in a debugger or by passing it somewhere else, and it still has its indentation.

Two further differences need nothing from the reader. F# does not implement CallerArgumentExpression (it warns FS0202), so a patch is anchored by the previous snapshot's value and by CallerMemberName rather than by the literal's source text. And Snapshot returns the SettingsTask, so an accepted snapshot is written in front of the ToTask() an F# test ends its chain with.

Moving between file and inline snapshots

Both directions are handled without any manual file editing.

File to inline. The existing .verified. file for the inlined target is detected as stale and flows through the standard Delete handling: deleted automatically under AutoVerify, otherwise listed in the Delete: section and pended for review. A pending delete goes to the tray when one is running, and to the queue owner otherwise, so it is reviewable with no tray installed. Files belonging to the other targets keep their names and are left alone.

There is no size opt out on this direction, so a snapshot that shrinks back under a maxLines limit returns to being inline and leaves its file behind as a stale delete. One sitting on the boundary therefore moves each time it crosses it.

Inline to file. When a .Snapshot(...) call exists but inline is off for that verification, the call is removed from the source and the snapshot runs as a normal file snapshot. The literal was the approved snapshot, so it seeds the verified file: an unchanged snapshot migrates without failing, and a changed one is an ordinary mismatch with the old and new text, accepted the usual way. Accepting a migration means committing both the source edit and the new .verified. file.

The two triggers for this direction are .NotInline() and an existing literal over a maxLines limit.

Exception message

Inline failures use the InlineNew: and InlineNotEqual: sections of the exception message format, and can be parsed with the Verify.ExceptionParsing package. Because only the first target is inlined, one message can carry both an inline section and the file sections for the remaining targets.