From d45d9f5ce20a2f599f518d58a7dc1f1c885251c6 Mon Sep 17 00:00:00 2001 From: Timothy Makkison Date: Wed, 28 Jan 2026 22:35:41 +0000 Subject: [PATCH] feat: add incremental generator tests --- .../Fixture.cs | 72 +++++++++ ...ethodAssertionGeneratorIncrementalTests.cs | 126 ++++++++++++++++ ...it.SourceGenerator.IncrementalTests.csproj | 28 ++++ .../TestHelper.cs | 137 ++++++++++++++++++ TUnit.sln | 15 ++ 5 files changed, 378 insertions(+) create mode 100644 TUnit.SourceGenerator.IncrementalTests/Fixture.cs create mode 100644 TUnit.SourceGenerator.IncrementalTests/MethodAssertionGeneratorIncrementalTests.cs create mode 100644 TUnit.SourceGenerator.IncrementalTests/TUnit.SourceGenerator.IncrementalTests.csproj create mode 100644 TUnit.SourceGenerator.IncrementalTests/TestHelper.cs diff --git a/TUnit.SourceGenerator.IncrementalTests/Fixture.cs b/TUnit.SourceGenerator.IncrementalTests/Fixture.cs new file mode 100644 index 0000000000..8dad9a415e --- /dev/null +++ b/TUnit.SourceGenerator.IncrementalTests/Fixture.cs @@ -0,0 +1,72 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using TUnit.Assertions.Attributes; + +namespace TUnit.Assertions.SourceGenerator.IncrementalTests; + +public static class Fixture +{ + public static readonly Assembly[] ImportantAssemblies = new[] + { + typeof(object).Assembly, + typeof(Console).Assembly, + typeof(GenerateAssertionAttribute).Assembly, + typeof(MulticastDelegate).Assembly, + typeof(IServiceProvider).Assembly, + }; + + public static Assembly[] AssemblyReferencesForCodegen => + AppDomain + .CurrentDomain.GetAssemblies() + .Concat(ImportantAssemblies) + .Distinct() + .Where(a => !a.IsDynamic) + .ToArray(); + + public static DirectoryInfo GetSolutionDirectoryInfo() + { + var slnDir = SolutionDir(); + var directory = new DirectoryInfo(slnDir); + // Assert.True(directory.Exists); + return directory; + } + + private static string SolutionDir([CallerFilePath] string thisFilePath = "") => + Path.GetFullPath(Path.Join(thisFilePath, "../../../")); + + public static CSharpCompilation CreateLibrary(params string[] source) => + CreateLibrary(source.Select(s => CSharpSyntaxTree.ParseText(s)).ToArray()); + + public static CSharpCompilation CreateLibrary(params SyntaxTree[] source) + { + var references = new List(); + var assemblies = AssemblyReferencesForCodegen; + foreach (Assembly assembly in assemblies) + { + if (!assembly.IsDynamic) + { + references.Add(MetadataReference.CreateFromFile(assembly.Location)); + } + } + + var compilation = CSharpCompilation.Create( + "Library", + source, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + return compilation; + } + + public static async Task SourceFromResourceFile(string file) + { + var currentDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + Assert.NotNull(currentDir); + var resourcesDir = Path.Combine(currentDir, "resources"); + + return await File.ReadAllTextAsync(Path.Combine(resourcesDir, file)); + } +} diff --git a/TUnit.SourceGenerator.IncrementalTests/MethodAssertionGeneratorIncrementalTests.cs b/TUnit.SourceGenerator.IncrementalTests/MethodAssertionGeneratorIncrementalTests.cs new file mode 100644 index 0000000000..4dc97a8c8a --- /dev/null +++ b/TUnit.SourceGenerator.IncrementalTests/MethodAssertionGeneratorIncrementalTests.cs @@ -0,0 +1,126 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using TUnit.Assertions.SourceGenerator.Generators; + +namespace TUnit.Assertions.SourceGenerator.IncrementalTests; + +public class MethodAssertionGeneratorIncrementalTests +{ + private const string DefaultAssertion = + """ + #nullable enabled + using System.ComponentModel; + using TUnit.Assertions.Attributes; + + public static partial class IntAssertionExtensions + { + [GenerateAssertion(ExpectationMessage = "to be positive")] + public static bool IsPositive(this int value) + { + return value > 0; + } + + public static bool IsNegative(this int value) + { + return value < 0; + } + } + """; + + [Fact] + public void AddUnrelatedMethodShouldNotRegenerate() + { + var syntaxTree = CSharpSyntaxTree.ParseText(DefaultAssertion, CSharpParseOptions.Default); + var compilation1 = Fixture.CreateLibrary(syntaxTree); + + var driver1 = TestHelper.GenerateTracked(compilation1); + AssertRunReasons(driver1, IncrementalGeneratorRunReasons.New); + + var compilation2 = compilation1.AddSyntaxTrees(CSharpSyntaxTree.ParseText("struct MyValue {}")); + var driver2 = driver1.RunGenerators(compilation2); + AssertRunReasons(driver2, IncrementalGeneratorRunReasons.Cached); + } + + [Fact] + public void AddNewTypeAssertionShouldRegenerate() + { + var syntaxTree = CSharpSyntaxTree.ParseText(DefaultAssertion, CSharpParseOptions.Default); + var compilation1 = Fixture.CreateLibrary(syntaxTree); + + var driver1 = TestHelper.GenerateTracked(compilation1); + AssertRunReasons(driver1, IncrementalGeneratorRunReasons.New); + + var compilation2 = compilation1.AddSyntaxTrees(CSharpSyntaxTree.ParseText( + """ + using TUnit.Assertions.Attributes; + + public static partial class LongAssertionExtensions + { + [GenerateAssertion(ExpectationMessage = "to be positive")] + public static bool IsPositive(this long value) + { + return value > 0; + } + } + """)); + var driver2 = driver1.RunGenerators(compilation2); + AssertRunReasons(driver2, IncrementalGeneratorRunReasons.Cached, 0); + AssertRunReasons(driver2, IncrementalGeneratorRunReasons.New, 1); + } + + [Fact] + public void AddNewSameTypeAssertionShouldRegenerate() + { + var syntaxTree = CSharpSyntaxTree.ParseText(DefaultAssertion, CSharpParseOptions.Default); + var compilation1 = Fixture.CreateLibrary(syntaxTree); + + var driver1 = TestHelper.GenerateTracked(compilation1); + AssertRunReasons(driver1, IncrementalGeneratorRunReasons.New); + + var compilation2 = TestHelper.ReplaceMethodDeclaration(compilation1, "IsNegative", + """ + [GenerateAssertion(ExpectationMessage = "to be less than zero")] + public static bool IsNegative(this int value) + { + return value < 0; + } + """ + ); + var driver2 = driver1.RunGenerators(compilation2); + AssertRunReasons(driver2, IncrementalGeneratorRunReasons.Cached, 0); + AssertRunReasons(driver2, IncrementalGeneratorRunReasons.New, 1); + } + + [Fact] + public void ModifyMessageShouldRegenerate() + { + var syntaxTree = CSharpSyntaxTree.ParseText(DefaultAssertion, CSharpParseOptions.Default); + var compilation1 = Fixture.CreateLibrary(syntaxTree); + + var driver1 = TestHelper.GenerateTracked(compilation1); + AssertRunReasons(driver1, IncrementalGeneratorRunReasons.New); + + var compilation2 = TestHelper.ReplaceMethodDeclaration(compilation1, "IsPositive", + """ + [GenerateAssertion(ExpectationMessage = "to be more than zero")] + public static bool IsPositive(this int value) + { + return value > 0; + } + """ + ); + var driver2 = driver1.RunGenerators(compilation2); + AssertRunReasons(driver2, IncrementalGeneratorRunReasons.Modified); + } + + private static void AssertRunReasons( + GeneratorDriver driver, + IncrementalGeneratorRunReasons reasons, + int outputIndex = 0 + ) + { + var runResult = driver.GetRunResult().Results[0]; + + TestHelper.AssertRunReason(runResult, MethodAssertionGenerator.BuildAssertion, reasons.BuildStep, outputIndex); + } +} diff --git a/TUnit.SourceGenerator.IncrementalTests/TUnit.SourceGenerator.IncrementalTests.csproj b/TUnit.SourceGenerator.IncrementalTests/TUnit.SourceGenerator.IncrementalTests.csproj new file mode 100644 index 0000000000..41b69e16eb --- /dev/null +++ b/TUnit.SourceGenerator.IncrementalTests/TUnit.SourceGenerator.IncrementalTests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + TUnit.Assertions.SourceGenerator.IncrementalTests + + + + + + + + + + + + + + + + + + + + diff --git a/TUnit.SourceGenerator.IncrementalTests/TestHelper.cs b/TUnit.SourceGenerator.IncrementalTests/TestHelper.cs new file mode 100644 index 0000000000..7ea01a1997 --- /dev/null +++ b/TUnit.SourceGenerator.IncrementalTests/TestHelper.cs @@ -0,0 +1,137 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace TUnit.Assertions.SourceGenerator.IncrementalTests; + +internal static class TestHelper +{ + private static readonly GeneratorDriverOptions _enableIncrementalTrackingDriverOptions = new( + IncrementalGeneratorOutputKind.None, + trackIncrementalGeneratorSteps: true + ); + + internal static GeneratorDriver GenerateTracked(Compilation compilation) + where TSourceGenerator : IIncrementalGenerator, new() + { + var generator = new TSourceGenerator(); + + var driver = CSharpGeneratorDriver.Create( + [ generator.AsSourceGenerator() ], + driverOptions: _enableIncrementalTrackingDriverOptions + ); + return driver.RunGenerators(compilation); + } + + internal static CSharpCompilation ReplaceMemberDeclaration( + CSharpCompilation compilation, + string memberName, + string newMember + ) + { + var syntaxTree = compilation.SyntaxTrees.Single(); + var memberDeclaration = syntaxTree + .GetCompilationUnitRoot() + .DescendantNodes() + .OfType() + .Single(x => x.Identifier.Text == memberName); + var updatedMemberDeclaration = SyntaxFactory.ParseMemberDeclaration(newMember)!; + + var newRoot = syntaxTree.GetCompilationUnitRoot().ReplaceNode(memberDeclaration, updatedMemberDeclaration); + var newTree = syntaxTree.WithRootAndOptions(newRoot, syntaxTree.Options); + + return compilation.ReplaceSyntaxTree(compilation.SyntaxTrees.First(), newTree); + } + + internal static CSharpCompilation ReplaceLocalDeclaration( + CSharpCompilation compilation, + string variableName, + string newDeclaration + ) + { + var syntaxTree = compilation.SyntaxTrees.Single(); + + var memberDeclaration = syntaxTree + .GetCompilationUnitRoot() + .DescendantNodes() + .OfType() + .Single(x => x.Declaration.Variables.Any(x => x.Identifier.ToString() == variableName)); + var updatedMemberDeclaration = SyntaxFactory.ParseStatement(newDeclaration); + + var newRoot = syntaxTree.GetCompilationUnitRoot().ReplaceNode(memberDeclaration, updatedMemberDeclaration); + var newTree = syntaxTree.WithRootAndOptions(newRoot, syntaxTree.Options); + + return compilation.ReplaceSyntaxTree(compilation.SyntaxTrees.First(), newTree); + } + + internal static CSharpCompilation ReplaceMethodDeclaration( + CSharpCompilation compilation, + string methodName, + string newDeclaration + ) + { + var syntaxTree = compilation.SyntaxTrees.Single(); + + var memberDeclaration = syntaxTree + .GetCompilationUnitRoot() + .DescendantNodes() + .OfType() + .First(x => x.Identifier.Text == methodName); + var updatedMemberDeclaration = SyntaxFactory.ParseMemberDeclaration(newDeclaration)!; + + var newRoot = syntaxTree.GetCompilationUnitRoot().ReplaceNode(memberDeclaration, updatedMemberDeclaration); + var newTree = syntaxTree.WithRootAndOptions(newRoot, syntaxTree.Options); + + return compilation.ReplaceSyntaxTree(compilation.SyntaxTrees.First(), newTree); + } + + public static void AssertRunReason( + GeneratorRunResult runResult, + string stepName, + IncrementalStepRunReason expectedStepReason, + int outputIndex + ) + { + var actualStepReason = runResult + .TrackedSteps[stepName] + .SelectMany(x => x.Outputs) + .ElementAt(outputIndex) + .Reason; + + if (actualStepReason != expectedStepReason) + { + throw new Exception($"Incremental generator step {stepName} at index {outputIndex} failed " + + $"with the expected reason: {expectedStepReason}, with the actual reason: {actualStepReason}."); + } + } +} + +internal record IncrementalGeneratorRunReasons( + IncrementalStepRunReason BuildStep, + IncrementalStepRunReason ReportDiagnosticsStep +) +{ + public static readonly IncrementalGeneratorRunReasons New = new( + IncrementalStepRunReason.New, + IncrementalStepRunReason.New + ); + + public static readonly IncrementalGeneratorRunReasons Cached = new( + // compilation step should always be modified as each time a new compilation is passed + IncrementalStepRunReason.Cached, + IncrementalStepRunReason.Cached + ); + + public static readonly IncrementalGeneratorRunReasons Modified = Cached with + { + ReportDiagnosticsStep = IncrementalStepRunReason.Modified, + BuildStep = IncrementalStepRunReason.Modified, + }; + + public static readonly IncrementalGeneratorRunReasons ModifiedSource = Cached with + { + ReportDiagnosticsStep = IncrementalStepRunReason.Unchanged, + BuildStep = IncrementalStepRunReason.Modified, + }; +} + diff --git a/TUnit.sln b/TUnit.sln index 7273f2676d..02b43bffdd 100644 --- a/TUnit.sln +++ b/TUnit.sln @@ -155,6 +155,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.FsCheck", "TUnit.FsCh EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.Example.FsCheck.TestProject", "TUnit.Example.FsCheck.TestProject\TUnit.Example.FsCheck.TestProject.csproj", "{3428D7AD-B362-4647-B1B0-72674CF3BC7C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.SourceGenerator.IncrementalTests", "TUnit.SourceGenerator.IncrementalTests\TUnit.SourceGenerator.IncrementalTests.csproj", "{93A728CE-CC78-4F9B-897B-AA6F72E870F2}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.SourceGenerator.Benchmarks", "TUnit.SourceGenerator.Benchmarks\TUnit.SourceGenerator.Benchmarks.csproj", "{F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}" EndProject Global @@ -911,6 +913,18 @@ Global {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Release|x64.Build.0 = Release|Any CPU {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Release|x86.ActiveCfg = Release|Any CPU {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Release|x86.Build.0 = Release|Any CPU + {93A728CE-CC78-4F9B-897B-AA6F72E870F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {93A728CE-CC78-4F9B-897B-AA6F72E870F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {93A728CE-CC78-4F9B-897B-AA6F72E870F2}.Debug|x64.ActiveCfg = Debug|Any CPU + {93A728CE-CC78-4F9B-897B-AA6F72E870F2}.Debug|x64.Build.0 = Debug|Any CPU + {93A728CE-CC78-4F9B-897B-AA6F72E870F2}.Debug|x86.ActiveCfg = Debug|Any CPU + {93A728CE-CC78-4F9B-897B-AA6F72E870F2}.Debug|x86.Build.0 = Debug|Any CPU + {93A728CE-CC78-4F9B-897B-AA6F72E870F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {93A728CE-CC78-4F9B-897B-AA6F72E870F2}.Release|Any CPU.Build.0 = Release|Any CPU + {93A728CE-CC78-4F9B-897B-AA6F72E870F2}.Release|x64.ActiveCfg = Release|Any CPU + {93A728CE-CC78-4F9B-897B-AA6F72E870F2}.Release|x64.Build.0 = Release|Any CPU + {93A728CE-CC78-4F9B-897B-AA6F72E870F2}.Release|x86.ActiveCfg = Release|Any CPU + {93A728CE-CC78-4F9B-897B-AA6F72E870F2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -979,6 +993,7 @@ Global {6134813B-F928-443F-A629-F6726A1112F9} = {503DA9FA-045D-4910-8AF6-905E6048B1F1} {3428D7AD-B362-4647-B1B0-72674CF3BC7C} = {0BA988BF-ADCE-4343-9098-B4EF65C43709} {6846A70E-2232-4BEF-9CE5-03F28A221335} = {1B56B580-4D59-4E83-9F80-467D58DADAC1} + {93A728CE-CC78-4F9B-897B-AA6F72E870F2} = {62AD1EAF-43C4-4AC0-B9FA-CD59739B3850} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {109D285A-36B3-4503-BCDF-8E26FB0E2C5B}