如何在单元测试中针对IIncrementalGenerator类型的源代码生成器测试源码变更的响应逻辑?
我之前在测试IIncrementalGenerator的增量缓存逻辑时,也踩过几乎一模一样的坑!你想验证源码变更后生成器内部阶段的状态(Modified/New)是否符合预期的思路完全没问题,问题出在多轮编译时的Driver调用方式和Compilation的传递逻辑上。
首先先明确你遇到的报错根源:
报错信息:
Unexpected value '(Modified, Added)' of type 'System.ValueTuple'2[[Microsoft.CodeAnalysis.EntryState, Microsoft.CodeAnalysis.EntryState]]
这个异常本质是因为你启用了增量生成器的状态追踪(driverOptions的第二个参数trackIncrementalGeneratorSteps设为true)后,直接传入完全独立的全新Compilation实例给后续的RunGenerators调用,生成器的内部状态追踪逻辑无法正确映射前后编译的条目(比如语法树、符号)的关联关系,导致遇到了未预期的状态组合。
你提到关闭状态追踪就不报错但没法验证状态,这是正常的——关闭状态追踪后,生成器不会记录各个阶段的EntryState,自然不会触发状态组合的异常,但也失去了我们要测试的增量状态验证能力,这只是临时规避,不是解决办法。
修正后的测试方案
咱们换个思路:不要传入完全独立的Compilation实例,而是让每一轮的编译基于上一轮的结果修改而来,同时调整Driver的调用方式,让它能正确维护增量状态。
1. 重写RunGeneratorIterations方法
修正后的方法会强制每一轮编译都基于上一轮的上下文修改,并且全程用RunGeneratorsAndUpdateCompilation衔接,确保Driver能正确追踪状态:
public static IReadOnlyList<GeneratorDriverRunResult> RunGeneratorIterations<T>(T generator, params Func<Compilation, Compilation>[] compilationModifiers) where T : IIncrementalGenerator { // 初始化Driver配置,保留状态追踪 var driverOptions = new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, true); var wrappedGenerator = generator.AsSourceGenerator(); var driver = CSharpGeneratorDriver.Create(new[] { wrappedGenerator }, driverOptions: driverOptions); var runResults = new List<GeneratorDriverRunResult>(); Compilation currentCompilation = null; foreach (var modifier in compilationModifiers) { // 基于上一轮编译(或初始创建)得到当前轮的编译 currentCompilation = modifier(currentCompilation); // 用RunGeneratorsAndUpdateCompilation更新编译并运行生成器,维护增量状态 driver = driver.RunGeneratorsAndUpdateCompilation(currentCompilation, out var updatedCompilation, out _); runResults.Add(driver.GetRunResult()); // 更新当前编译为最新版本 currentCompilation = updatedCompilation; } Assert.AreEqual(compilationModifiers.Length, runResults.Count); return runResults; }
2. 编写编译修改逻辑
现在你需要把原来的独立Compilation改成“基于上一轮修改”的逻辑,比如用两个辅助方法创建初始编译和替换语法树:
// 创建初始编译的辅助方法 private static Compilation CreateCompilation(string sourceCode) { var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode); var references = new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location), MetadataReference.CreateFromFile(typeof(Compilation).Assembly.Location) }; return CSharpCompilation.Create( "TestCompilation", new[] { syntaxTree }, references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); } // 替换编译中指定语法树的辅助方法 private static Compilation UpdateCompilationSyntaxTree(Compilation prevCompilation, string oldSource, string newSource) { var oldTree = prevCompilation.SyntaxTrees.First(t => t.GetText().ToString().Trim() == oldSource.Trim()); var newTree = CSharpSyntaxTree.ParseText(newSource); return prevCompilation.ReplaceSyntaxTree(oldTree, newTree); }
3. 实际测试调用
现在你可以这样编写测试用例,验证源码变更后的生成器状态:
// 定义初始和修改后的源码 var initialCode = @"public class TestClass { }"; var modifiedCode = @"public class TestClass { public int Id { get; set; } }"; // 构建编译迭代:先创建初始编译,再基于初始编译修改 var compilationIterations = new Func<Compilation, Compilation>[] { // 第一轮:创建初始编译 _ => CreateCompilation(initialCode), // 第二轮:替换初始编译的语法树 prev => UpdateCompilationSyntaxTree(prev, initialCode, modifiedCode) }; // 运行多轮生成器测试 var runResults = RunGeneratorIterations(yourIncrementalGenerator, compilationIterations); // 验证第二轮的生成器阶段状态 var secondRun = runResults[1]; var generatorResult = secondRun.Results.First(r => r.Generator is YourGeneratorType); // 找到你要验证的追踪阶段(需要你的生成器在定义IncrementalValuesProvider时设置了Name) var trackedStage = generatorResult.TrackedOutputs.First(o => o.Name == "YourTrackedStageName"); // 断言状态为Modified,符合预期 Assert.AreEqual(EntryState.Modified, trackedStage.State);
关键注意事项
- 确保你的增量生成器在定义
IncrementalValuesProvider时,用WithTrackingName方法设置了阶段名称,这样才能在TrackedOutputs中找到对应的状态:// 示例:给你的增量阶段设置追踪名称 provider = provider.WithTrackingName("YourTrackedStageName"); - 不要创建完全独立的Compilation实例,必须基于上一轮的编译修改,这样生成器的增量逻辑才能正确识别哪些内容变更了。
备注:内容来源于stack exchange,提问作者user3797758

