Intro

In my previous post, I explained how to write a C# Source Generator from scratch. It’s advised to read that article to get a heads-up for this blogpost.

Recap from previous blog post…

Source generation is possible using T4 templating, however using Roslyn provides more possibilities and you can easily generate multiple source files, which is not possible using T4. Another thing to keep in mind is that analyzing existing source code files is done using Roslyn, which is released some time ago and had proven itself, so if you are familiar with Roslyn, then writing a Source Generator should be doable.

Steps to create a source generator

1️⃣ Create a library

:Create a .NET Standard 2.0 library, and add the following NuGet references:

1
2
3
4
5
6
7
  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.x.x">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.CodeAnalysis.Common" Version="3.x.x" PrivateAssets="all" />
  </ItemGroup>
2️⃣ Add a new Source Generator class

Add a new C# class which defines the Source Generator.

An example source generator class looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[Generator]
public class ExampleClassGenerator : ISourceGenerator
{
    public void Initialize(GeneratorInitializationContext context) { }

    // A source generator needs to implement the Execute method that is called by the framework.
    // This Execute method expects a GeneratorExecutionContext.
    public void Execute(GeneratorExecutionContext context)
    {
        var src = new StringBuilder();
        src.AppendLine("namespace ExampleClassCodeGenerator");
        src.AppendLine("{");
        src.AppendLine("    public class ExampleClass");
        src.AppendLine("    {");

        // Use the StringBuilder to add more methods or properties here ...

        src.AppendLine("    }");
        src.AppendLine("}");

        // This adds the C# source code as SourceText and generates a C# file with the name `ExampleClass_Generated.cs`.
        context.AddSource("ExampleClass_Generated", SourceText.From(src.ToString(), Encoding.UTF8));
    }
}
3️⃣ Use the Source Generator project

Create a simple ConsoleApp project, and reference the ExampleClassCodeGenerator project.

When referencing the project, make sure to add the OutputItemType="Analyzer" and ReferenceOutputAssembly="false" attributes like this:

1
2
3
4
5
6
<ItemGroup>
    <ProjectReference
      Include="..\..\src\ExampleClassCodeGenerator\ExampleClassCodeGenerator.csproj"
      OutputItemType="Analyzer"
      ReferenceOutputAssembly="false" />
</ItemGroup>

Now you can use the ExampleClass just like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
namespace ExampleClassConsumer;

internal class Program
{
    private static void Main(string[] args)
    {
        var example = new ExampleClassCodeGenerator.ExampleClass();
        // call methods or set/get properties from that ExampleClass...
    }
}

Continuation

In this blog I’ll focus on how to unit-test a source generator and describe the challenges and a few possible scenarios on how to do this.

💡 Challenges

Unit testing a source generator is not straightforward. There is no easy way to just ’new’ the Source Generator because two contexts are needed:


How to Unit-Test a source generator ?

In the next chapter, I’ll go into detail on the possible scenarios, challenges and present a better and easier solution.

1️⃣ Mocking

The first option would be to use a Mocking framework like Moq to setup all required methods and properties from Microsoft CodeAnalysis interfaces to ensure that you can test the internal business logic from your source generator and excluding the Microsoft CodeAnalysis logic.

Challenges & Solutions

The GeneratorExecutionContext is passed to the Execute method from your source generator. This Execute method represents the main generation step.

This GeneratorExecutionContext can also be used to add new classes (which will be generated) and to get a reference to the ISyntaxReceiver which is used to determine which objects (e.g. classes or interfaces) in your project comply to your rules to be analyzed. This is mostly done by annotating a class or interface with an custom defined attribute.

The problem is that the GeneratorExecutionContext is a real class, so not suitable for easy mocking using Moq. A possible solution for this could be to use Microsoft Fakes, however this is only supported in Visual Studio Enterprise.

A workaround for this issue, is to wrap the GeneratorExecutionContext in a custom interface and wrapper code so that this can be mocked by Moq.

Example for the new interface:

1
2
3
4
5
6
7
8
internal interface IGeneratorExecutionContextWrapper
{
    /// <see cref="GeneratorExecutionContext.Compilation.AssemblyName"/>
    public string AssemblyName { get; }

    /// <see cref="GeneratorExecutionContext.AddSource(string, SourceText)"/>
    public void AddSource(string hintName, SourceText sourceText);
}

Example for the wrapper class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
internal class GeneratorExecutionContextWrapper : IGeneratorExecutionContextWrapper
{
    private readonly GeneratorExecutionContext _context;

    public GeneratorExecutionContextWrapper(GeneratorExecutionContext context)
    {
        _context = context;
    }

    public string AssemblyName => _context.Compilation.AssemblyName ?? "FluentBuilder";

    public void AddSource(string hintName, SourceText sourceText) => _context.AddSource(hintName, sourceText);
}

With this workaround, mocking can be setup like this:

1
2
var _contextMock = new Mock<IGeneratorExecutionContextWrapper>();
_contextMock.SetupGet(c => c.AssemblyName).Returns("FluentBuilderGeneratorTests");

But in addition to this GeneratorExecutionContext, more interfaces from the Microsoft CodeAnalysis namespace (e.g. INamespaceSymbol, ITypeSymbol) need to be mocked in order to run a simple unit test.

This quickly gets very extensive, error-prone and complex, so this is not preferred.

In case you are still interested how this could be achieved, see an example here.


2️⃣ Simulate the Execution

The second option would to simulate the main Execute method from your source generator so that you just need to provide this simulated method with the following information:

  1. The source file which needs be be analyzed

  2. The attribute which needs to be added to the source class or source interface which is used by your own implementation from the ISyntaxReceiver to determine if this class or interface needs to be processed by the business logic.

    📓 This is actually a common pattern in most source generators because you only want to analyze a specific set of classes in your project, and not all classes.

The next chapters describe a possible scenario and the explanation how the execution can be simulated and what steps are needed.

Example scenario

Let’s suppose you have a class which needs to analyzed by your Source Generator and depending on the business logic from your Source Generator, some new C# files are generated.

This can look like this:

The class which needs to analyzed:

1
2
3
4
public class SimpleClass
{
    public int Id { get; set; }
}

The unit test:

The business logic from a Source Generator (e.g. FluentBuilder) is tested and a verification is done if the correct output is generated.

This unit-test can look like the code below, and I’ll explain in detail all the steps (1 to 5) in that test.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
[Fact]
public void GenerateFiles_ForSimpleClass_Should_GenerateCorrectFiles()
{
    const string namespace = "FluentBuilderGeneratorTests";

    // 1. The path from that class which need to be analyzed
    string path = "./DTO/SimpleClass.cs";

    // 2. The path, the c# source-code as text and the attribute which needs to be added
    SourceFile sourceFile = new SourceFile
    {
        Path = path,
        Text = File.ReadAllText(path),
        AttributeToAddToClass = "FluentBuilder.AutoGenerateBuilder"
    };

    // 3. Call the main entry method Execute on your `_sut` (System Under Test = Your Source Generator)
    ExecuteResult result = _sut.Execute(namespace, new[] { sourceFile });

    // 4. Do some assertions on the ExecuteResult
    result.Valid.Should().BeTrue();
    result.Files.Should().HaveCount(9);

    // 5. Do some assertions on the generated C# code file
    FileResult file = result.Files[8];
    file.Text.Should().NotBeEmpty();
}

step 1: Define the location from the class

Define the relative path to the C# class you want to process by your source generator.

(e.g. "./DTO/SimpleClass.cs")

And make sure you set Copy to Output Directory to Copy if newer:

1
2
3
4
5
6
7
8
<Project Sdk="Microsoft.NET.Sdk">

  <ItemGroup>
    <Compile Update="DTO\SimpleClass.cs">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </Compile>
  </ItemGroup>
</Project>

step 2: Define inputs for the Execute method

Create a new instance from the SourceFile class and provide the following properties:

  • Path: The path to the C# class you want to process

  • Text: The code from the C# class you want to process as text (string)

  • AttributeToAddToClass1: A string 2 which defines the attribute-name which needs to be dynamically added to your C# class to make sure that you mark your class with the correct attribute so that it’s processed by the source-generator.

    📓 Notes:

    • 1 This AttributeToAddToClass is optional, and in case you want to add an attribute to an interface, set the property AttributeToAddToInterface.
    • 2 It’s also possible to provide a ExtraAttribute instead of a string, when using this option, you can also add arguments to that attribute (in case that attribute allows arguments). For more information on how this is achieved, see the project AnyOf.

    Example:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    var sourceFile = new SourceFile
    {
      Path = path,
      Text = File.ReadAllText(path),
      AttributeToAddToClass = new ExtraAttribute
      {
        Name = "AutoGenerateBuilder",
        ArgumentList = "typeof(AppDomain)" // The AutoGenerateBuilder attribute allows a Type argument, so provide that argument here.
      }
    }   
    

    For more details see the implementation from SourceFile and ExtraAttribute.

step 3: Call the Execute method

Call the extension method Execute and provide the namespace and the SourceFile.

The purpose from this Execute method is to run the source generator and capture the output so that the output (path + c# file content) can be verified in the unit-test.

The internal logic follows these steps:

a. A SyntaxTree is created by parsing the source file as text.

b. In case the AttributeToAddToClass or AttributeToAddToInterface is defined, a new SyntaxTree with that new attribute is generated so that this updated class is found suitable to be processed by your custom ISyntaxReceiver (required later in the process).

This means that the following example class-under-test is changed from this:

1
2
3
4
5
6
namespace Test;

public class SimpleClass
{
    public int Id { get; set; }
}

Into this:

1
2
3
4
5
6
7
namespace Test;

[FluentBuilder.AutoGenerateBuilder()] //⭐ This attribute is added.
public class SimpleClass
{
    public int Id { get; set; }
}

c. Now a new CSharpCompilation is created from scratch based on the updated SyntaxTree(s).

d. Then a new CSharpGeneratorDriver is created and the RunGeneratorsAndUpdateCompilation method is called to run all generator(s) and update the CSharpCompilation object which was created in the previous step.

e. The previous step returns a new Compilation object which is an immutable representation of a single invocation of the compiler. This object contains the newly generated SyntaxTrees, where each SyntaxTree is an unique output which is generated by your generator.

f. The generated SyntaxTrees are converted to an helper result object which can be analyzed and verified in your unit-test.

step 4 & 5: Assert

The ExecuteResult contains a list of FileResult objects. This FileResult contains the SyntaxTree, source code as string and the path which can be asserted by the unit-test.

Conclusion

With the help of this custom helper code (NuGet Project: CSharp.SourceGenerators.Extensions) it’s now very easy to unit-test your Source Generator without having to resort on Mocking a lot of interfaces or writing complex testing code yourself.


📚 References

comments powered by Disqus