爲 IIncrementalGenerator 增量 Source Generator 源代碼生成項目添加單元測試

本文屬於 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 文件夾

更多關於源代碼生成博客請參閱我的 博客導航

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章