Testing Roslyn Incremental Source Generators
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.
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
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
.
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!