Roslyn

edit

Meziantou.Framework.SnapshotTesting.Roslyn serializes Roslyn objects, so testing a source generator, a syntax rewriter or an analyzer is a single Snapshot.Validate call. Generated code is stored as .cs files that you can read and diff.

dotnet package add Meziantou.Framework.SnapshotTesting.Roslyn

Set up

Call AddRoslyn() on SnapshotSettings.Default in a module initializer (AddRoslyn is in the Meziantou.Framework.SnapshotTesting.Roslyn namespace):

internal static class SnapshotConfiguration
{
    [ModuleInitializer]
    internal static void Initialize()
    {
        SnapshotSettings.Default.AddRoslyn();
    }
}
ValueSnapshot
GeneratorDriverRunResultOne source file per generated source, plus a text file with the diagnostics
SyntaxTree, SyntaxNode, SyntaxToken, SyntaxNodeOrToken, SyntaxTrivia, SyntaxTokenList, SyntaxTriviaListA single source file with its full text
SourceTextA single file with its text
Diagnostic, and any collection of themA text file with one diagnostic per line

Line endings are normalized to \n in every file, so the snapshots depend neither on the platform the tests run on nor on the line endings of the source they were produced from.

Test a source generator

Run the generator in a test and validate the result of the run:

var source = "partial class Customer; partial class Order;";
MetadataReference[] references = [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)];

var compilation = CSharpCompilation.Create("compilation", [CSharpSyntaxTree.ParseText(source)], references);
GeneratorDriver driver = CSharpGeneratorDriver.Create(new MyGenerator());
driver = driver.RunGenerators(compilation);

Snapshot.Validate(driver.GetRunResult());

The first snapshot file contains the first generated source:

// HintName: Customer.g.cs
partial class Customer
{
    public override string ToString() => "Customer";
}
  • There is one .cs file per generated source, ordered by hint name. The order in which the driver runs the generators is not a contract, so it is not used to name the files.
  • Each file starts with a // HintName: <name> comment, so renaming a generated file shows up as a diff.
  • A final .txt file lists the diagnostics reported by the generators and, if a generator threw, the exception it threw. The file is omitted when there is nothing to report.

A run usually produces several files, so the snapshots are numbered: MyTests_GenerateCode_0.verified.cs, MyTests_GenerateCode_1.verified.cs, MyTests_GenerateCode_2.verified.txt, ... Adding a generated source shifts the files that come after it in hint-name order. A run that generated no source and reported no diagnostic has nothing to compare, and fails with a SnapshotException.

The snapshot files are .cs files in your test project: the package keeps them out of the compilation (see Snapshots and the build).

Test a syntax rewriter

Validate a syntax tree, or any node of it, for example the output of a syntax rewriter:

var tree = CSharpSyntaxTree.ParseText("""
    public class Sample
    {
        public class Nested;
    }
    """);

Snapshot.Validate(new MyRewriter().Visit(tree.GetRoot()));

The value is stored as a single .cs file (.vb for Visual Basic) that contains its full text, trivia included. The text is not reformatted, so a change in the generated whitespace shows up as a diff.

A SourceText, and an empty token or trivia list, have no language: they use the extension of the requested snapshot type, .txt by default.

var sourceText = SourceText.From("class Sample;\n");
Snapshot.Validate(sourceText, SnapshotType.Create("cs"));

Test an analyzer

Validate the diagnostics reported by an analyzer:

var source = """
    class Sample
    {
        void DoNotUse() { }
    }
    """;
var compilation = CSharpCompilation.Create("compilation", [CSharpSyntaxTree.ParseText(source, path: "Sample.cs")]);

var diagnostics = await compilation.WithAnalyzers([new MyAnalyzer()]).GetAnalyzerDiagnosticsAsync();

Snapshot.Validate(diagnostics);

Each diagnostic is written on its own line, the way the compiler reports it:

Sample.cs(3,10): warning MY0001: Do not use this method
  • The message is formatted with the invariant culture, so the snapshot does not depend on the UI culture of the machine.
  • Positions are one-based, like the compiler reports them. A diagnostic without a location is written without a path.
  • The diagnostics are sorted by path, position, ID, then message, so the snapshot does not depend on the order in which the analyzers reported them, which is not deterministic when they run concurrently.
  • An empty collection produces an empty file: that is what a test that asserts no diagnostic is reported expects.
  • The path comes from the syntax tree. A tree parsed from a string has no path; a tree read from disk carries the path it was read with, which depends on the machine unless it is relative. Use a scrubber if that path ends up in the snapshot.

Roslyn values inside other snapshots

Roslyn values nested inside another object are written by the human-readable serializer, which would otherwise dump their object graph. AddRoslyn() registers converters for them:

TypeWritten as
DiagnosticSample.cs(4,10): warning MY0001: message
LocationSample.cs(3,9)-(3,20), or its LocationKind when it is not in source
LinePosition3,9
LinePositionSpan(3,9)-(3,20)
TextSpan[42..53)

LinePosition and LinePositionSpan keep the zero-based values Roslyn exposes. Only a diagnostic is written one-based, because that is how the compiler reports it.