為 IIncrementalGenerator 增量 Source Generator 原始碼生成專案新增單元測試

lindexi發表於2024-04-26

本文屬於 IIncrementalGenerator 增量 Source Generator 原始碼生成入門系列部落格,本文將和大家介紹如何為原始碼生成專案新增單元測試

新增單元測試的作用不僅可以用來實現通用的單元測試提高質量的功能,還能用來輔助除錯 IIncrementalGenerator 增量 Source Generator 原始碼生成專案,從而提高開發效率

傳統的類似原始碼生成專案的開發除錯方式都是需要依賴於另一個專案,透過對另一個專案的構建進行除錯測試。透過 Debugger.Break 或 Launch 實現另一個專案構建過程中回到當前 VS 進行除錯。詳細請參閱之前 walterlv 大佬編寫的部落格 使用 Source Generator 在編譯你的 .NET 專案時自動生成程式碼 - walterlv

這樣的過程顯然對開發效率造成了一定的影響,本文接下來介紹的新增單元測試的方法,將可以實現比較友好的除錯。且定製給的除錯的內容還可以存放起來作為單元測試的內容,同時單元測試本身的單元功能可以讓單元測試專案裡面存放不同的多個方向的測試內容,方便除錯多個不同的模組

為了方便部落格描述,接下來我將建立一個簡單的 IIncrementalGenerator 增量 Source Generator 原始碼生成專案。我是直接建立名為 YawrofajuGekeyaljilay 控制檯專案,然後編輯控制檯的 csproj 專案檔案,替換為如下程式碼,進行快速建立的

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
  </ItemGroup>

</Project>

接下來按照官方的例子編寫一個特別簡單的原始碼生成程式碼,如下面程式碼

using Microsoft.CodeAnalysis;

using System;
using System.Collections.Generic;
using System.Text;

namespace YawrofajuGekeyaljilay
{
    [Generator(LanguageNames.CSharp)]
    public class CodeCollectionIncrementalGenerator : IIncrementalGenerator
    {
        public void Initialize(IncrementalGeneratorInitializationContext context)
        {
            string source = @"
using System;

namespace YawrofajuGekeyaljilay
{
    public static partial class Program
    {
        public static void HelloFrom(string name)
        {
            Console.WriteLine($""Says: Hi from '{name}'"");
        }
    }
}
";

            context.RegisterPostInitializationOutput(initializationContext =>
            {
                initializationContext.AddSource("GeneratedSourceTest", source);
            });
        }
    }
}

基礎邏輯準備完成之後,接下來即可為此原始碼生成專案建立單元測試專案

為了方便和效率起見,我依然是透過建立控制檯專案編輯 csproj 專案檔案替換為如下程式碼的方式快速建立單元測試專案

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
    <PackageReference Include="MSTest.TestAdapter" Version="3.2.0" />
    <PackageReference Include="MSTest.TestFramework" Version="3.2.0" />

    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
    <PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.8.0" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.8.0" />

    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.MSTest" Version="1.1.1" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing.MSTest" Version="1.1.1" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeRefactoring.Testing.MSTest" Version="1.1.1" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing.MSTest" Version="1.1.1" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\YawrofajuGekeyaljilay\YawrofajuGekeyaljilay.csproj" />
  </ItemGroup>

</Project>

以上的單元測試專案和傳統的單元測試專案不同的在於新增了以下這些額外的引用庫

    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
    <PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.8.0" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.8.0" />

    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.MSTest" Version="1.1.1" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing.MSTest" Version="1.1.1" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeRefactoring.Testing.MSTest" Version="1.1.1" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing.MSTest" Version="1.1.1" />

完成基礎的專案構建之後,接下來可以對原始碼生成編寫單元測試。以下例子將建立名為 GeneratorTests 的單元測試用來演示如何對原始碼生成進行測試或除錯

新建 GeneratorTests 型別,先新增輔助的方法,程式碼如下

    private static CSharpCompilation CreateCompilation(string source)
        => CSharpCompilation.Create("compilation",
            new[] { CSharpSyntaxTree.ParseText(source) },
            new[] { MetadataReference.CreateFromFile(typeof(Binder).GetTypeInfo().Assembly.Location) },
            new CSharpCompilationOptions(OutputKind.ConsoleApplication));

以上的輔助方法的作用就是可以讓單元測試在傳入一段程式碼時,轉換為 CSharpCompilation 型別。同時新增上預設的 System.Runtime 的引用,防止一些基礎型別找不到

完成以上輔助方法之後,可以編寫 SimpleGeneratorTest 單元測試方法,開始的程式碼如下,先傳入一段程式碼用來作為測試的輸入

[TestClass]
public class GeneratorTests
{
    [TestMethod]
    public void SimpleGeneratorTest()
    {
        Compilation inputCompilation = CreateCompilation(@"
namespace YawrofajuGekeyaljilay
{
    public static class Program
    {
        public static void Main(string[] args)
        {
        }
    }
}
");
        // 忽略其他程式碼
    }
}

透過以上程式碼就可以在單元測試裡面定義多個不同的輸入程式碼源,從而使用不同的程式碼輸入源進行測試或除錯原始碼生成專案

接下來建立用來測試的 CodeCollectionIncrementalGenerator 型別

        var codeCollectionIncrementalGenerator = new CodeCollectionIncrementalGenerator();

再建立用來輔助測試的 CSharpGeneratorDriver 型別

        var driver = CSharpGeneratorDriver.Create(codeCollectionIncrementalGenerator);

在 CSharpGeneratorDriver 的 Create 方法裡面,是允許傳入多個 IIncrementalGenerator 的,這就意味著你可以同時對多個 IIncrementalGenerator 例項進行測試

完成建立之後,接下來就是開始執行,程式碼如下

        driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);

此 RunGeneratorsAndUpdateCompilation 方法將會透過方法返回執行完成之後,現在所有的 Compilation 和過程產生的 Diagnostic 集合。以上程式碼的 outputCompilation 的 SyntaxTrees 不僅包含原本輸入的 Compilation 裡的程式碼也包含原始碼生成器新增的原始碼

拿到執行結果之後,即可繼續編寫程式碼測試結果,如下面程式碼

        Assert.AreEqual(true, outputCompilation.ContainsSymbolsWithName("HelloFrom"));

也可以使用下面程式碼展開所有的程式碼,透過字串比對之類的,判斷生成是否正確,或者進行除錯,瞭解生成的內容

        foreach (var outputCompilationSyntaxTree in outputCompilation.SyntaxTrees)
        {
            var text = outputCompilationSyntaxTree.GetText();
        }

如果只是想要獲取生成的程式碼,可以取 RunGeneratorsAndUpdateCompilation 方法的返回值,此方法的返回值也是一個 GeneratorDriver 物件。返回自身型別在這裡不是為了方便做鏈呼叫,而是使用不可變思想,即任何的更改都會建立出新的物件,不會對原有的物件進行更改。不可變思想在 Roslyn 裡貫穿實現,從而造就了 Roslyn 如此複雜卻又方便進行除錯。取到返回的 GeneratorDriver 的 GetRunResult 即可獲取到 GeneratorDriverRunResult 型別物件,透過 GeneratorDriverRunResult 的 GeneratedTrees 即可獲取到只有原始碼生成專案生成的程式碼

        GeneratorDriver driver = CSharpGeneratorDriver.Create(codeCollectionIncrementalGenerator);
        driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);

        var generatorDriverRunResult = driver.GetRunResult();
        Assert.AreEqual(1, generatorDriverRunResult.GeneratedTrees.Length);

在一些比較複雜的專案上,可能需要參與測試的程式碼會需要使用到各種各樣的 dotnet 引用,此時適合將整個 dotnet 執行時都新增進入引用,防止找不到引用導致失敗。以下是我新增的輔助型別,用來將整個 dotnet 的基礎庫新增到引用

internal static class MetadataReferenceProvider
{
    public static IReadOnlyList<MetadataReference> GetDotNetMetadataReferenceList()
    {
        if (_cacheList is not null)
        {
            return _cacheList;
        }

        var metadataReferenceList = new List<MetadataReference>();
        var assembly = Assembly.Load("System.Runtime");
        foreach (var file in Directory.GetFiles(Path.GetDirectoryName(assembly.Location)!, "*.dll"))
        {
            try
            {
                metadataReferenceList.Add(MetadataReference.CreateFromFile(file));
            }
            catch
            {
                // 忽略
            }
        }

        _cacheList = metadataReferenceList;
        return _cacheList;
    }

    private static IReadOnlyList<MetadataReference>? _cacheList;
}

使用例子如下

    private static CSharpCompilation CreateCompilation(string source)
    {
        return CSharpCompilation.Create("compilation",
            new[] { CSharpSyntaxTree.ParseText(source) },
            new[]
            {
            	// 新增業務方的程式集
                MetadataReference.CreateFromFile(typeof(Foo).Assembly.Location), 
            }
            // 加上整個 dotnet 的基礎庫
            .Concat(MetadataReferenceProvider.GetDotNetMetadataReferenceList()),
            new CSharpCompilationOptions(OutputKind.ConsoleApplication));
    }

額外的,大家也看到本身的例子裡面的輸入是靠程式碼裡面編寫字串進行實現的。這樣的方法會導致編寫程式碼字串的難度,且寫錯了可能自己還不知道,從而導致了單元測試反而影響除錯效率。每次都在外面寫完複製字串進來,看起來實現也不友好。解決方法就是新增正常的程式碼給到自己的專案裡面,然後直接將程式碼檔案的內容讀取出來。比如說將程式碼檔案輸出到輸出資料夾,或者是將程式碼檔案嵌入到程式集,走程式集讀取資源的方式。下面的例子是我建立一個名為 TestCode.cs 的檔案,我在 csproj 裡面額外將此檔案設定作為嵌入的資源,如下面程式碼

  <ItemGroup>
    <EmbeddedResource Include="TestCode.cs" />
  </ItemGroup>

於是程式碼裡面就可以讀取程式集嵌入資源,從而讀取到程式碼檔案裡面的內容作為字串進行輸入

internal static class TestCodeProvider
{
    public static string GetTestCode()
    {
        var manifestResourceStream = typeof(TestCodeProvider).Assembly.GetManifestResourceStream("程式集名.TestCode.cs")!;
        var streamReader = new StreamReader(manifestResourceStream);
        return streamReader.ReadToEnd();
    }
}

另外的常見問題就是預設開啟了 ImplicitUsings 導致 System 之類的名稱空間沒有引用,進而在單元測試裡面,導致原始碼生成專案解析失敗。在正式使用的時候,需要先確保所有的引用載入上,且作為輸入源的程式碼都能正常構建透過

本文以上程式碼放在githubgitee 歡迎訪問

可以透過如下方式獲取本文的原始碼,先建立一個空資料夾,接著使用命令列 cd 命令進入此空資料夾,在命令列裡面輸入以下程式碼,即可獲取到本文的程式碼

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 3b7623ad46e80e8cc88a51e8084339ac29937b64

以上使用的是 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源。請在命令列繼續輸入以下程式碼

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin 3b7623ad46e80e8cc88a51e8084339ac29937b64

獲取程式碼之後,進入 YawrofajuGekeyaljilay 資料夾

更多關於原始碼生成部落格請參閱我的 部落格導航

相關文章