Testing Roslyn Incremental Source Generators

 
 
  • Gérald Barré

Roslyn Source Generators allow generating code based on the current project code and additional files. Each keystroke in the editor may trigger source generators. So, it is important to ensure that the source generators are fast enough to not impact the user experience. One important feature is incremental generation. This means that the source generator only calls the source generator when some significant changes are made. Each generator can configure what a significant change means. This post describes how to write a test to ensure that the incremental generation is working as expected.

Let's create a project containing a class library with the Source Generator and a test project. The Source Generator will generate a file for each struct in the project. The test project will ensure that the Source Generator is only called when a struct is added or removed.

Shell
dotnet new classlib --output SampleGenerator
dotnet add SampleGenerator package Microsoft.CodeAnalysis

dotnet new xunit --output SampleGenerator.Tests
dotnet add SampleGenerator.Tests reference SampleGenerator
dotnet add SampleGenerator.Tests package Basic.Reference.Assemblies.Net70

dotnet new sln --name SampleGenerator
dotnet sln add SampleGenerator
dotnet sln add SampleGenerator.Tests
SampleSourceGenerator.cs (C#)
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;

[Generator]
public sealed partial class SampleSourceGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var structPovider = context.SyntaxProvider
            .CreateSyntaxProvider(
                predicate: static (syntax, cancellationToken) => syntax.IsKind(SyntaxKind.StructDeclaration),
                transform: static (ctx, cancellationToken) => (TypeDeclarationSyntax)ctx.Node)
            .WithTrackingName("Syntax"); // WithTrackingName allow to record data about the step and access them from the tests

        var assemblyNameProvider = context.CompilationProvider
            .Select((compilation, cancellationToken) => compilation.AssemblyName)
            .WithTrackingName("AssemblyName");

        var valueProvider = structPovider.Combine(assemblyNameProvider);

        context.RegisterSourceOutput(valueProvider, (spc, valueProvider) =>
        {
            (var node, var assemblyName) = (valueProvider.Left, valueProvider.Right);
            spc.AddSource(node.Identifier.ValueText + ".cs", SourceText.From($"// {node.Identifier.Text} - {assemblyName}", Encoding.UTF8));
        });
    }
}

The call to WithTrackingName is important to ensure that the incremental generation is working as expected. Indeed, you can configure Roslyn to track diagnostic information about the pipeline. This information is available in the IncrementalGeneratorRunStep property of the GeneratorExecutionContext. The following code shows how to retrieve the diagnostic information.

The test creates a compilation with a single struct. Then, it creates a GeneratorDriver and runs the generator. Then, it updates the compilation with a new file. The GeneratorDriver is then run again. The test ensures that the GeneratorDriver doesn't recompute the output. It also ensures that the GeneratorDriver uses the cached result from AssemblyName and Syntax.

Test.cs (C#)
public sealed class SampleSourceGeneratorTests
{
    [Fact]
    public void Test()
    {
        var compilation = CSharpCompilation.Create("TestProject",
            new[] { CSharpSyntaxTree.ParseText("struct Test { }") },
            Basic.Reference.Assemblies.Net70.References.All,
            new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));

        var generator = new SampleSourceGenerator();
        var sourceGenerator = generator.AsSourceGenerator();

        // trackIncrementalGeneratorSteps allows to report info about each step of the generator
        GeneratorDriver driver = CSharpGeneratorDriver.Create(
            generators: new ISourceGenerator[] { sourceGenerator },
            driverOptions: new GeneratorDriverOptions(default, trackIncrementalGeneratorSteps: true));

        // Run the generator
        driver = driver.RunGenerators(compilation);

        // Update the compilation and rerun the generator
        compilation = compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText("// dummy"));
        driver = driver.RunGenerators(compilation);

        // Assert the driver doesn't recompute the output
        var result = driver.GetRunResult().Results.Single();
        var allOutputs = result.TrackedOutputSteps.SelectMany(outputStep => outputStep.Value).SelectMany(output => output.Outputs);
        Assert.Collection(allOutputs, output => Assert.Equal(IncrementalStepRunReason.Cached, output.Reason));

        // Assert the driver use the cached result from AssemblyName and Syntax
        var assemblyNameOutputs = result.TrackedSteps["AssemblyName"].Single().Outputs;
        Assert.Collection(assemblyNameOutputs, output => Assert.Equal(IncrementalStepRunReason.Unchanged, output.Reason));

        var syntaxOutputs = result.TrackedSteps["Syntax"].Single().Outputs;
        Assert.Collection(syntaxOutputs, output => Assert.Equal(IncrementalStepRunReason.Unchanged, output.Reason));
    }
}

#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