Test Your Roslyn Code Analyzers

Introduction

Testing is one of the most important aspect in (modern) software development, it is like having insurance in our lives, we are not getting insurances because everything will be bad in our life, we get them in case if something goes south, then there is something to helps us survive from the potential damages. The same is with tests, we write them to ensure our application(Code Analyzer), First works as expected Second) later on by refactoring and enhancement of it, we are not breaking the expected behavior, and finally they are specifications of our system, describing the expected behavior.

With all that in mind, let's jump into the bits and bytes of how to test our previously created code analyzer.

Setup the test project

The first thing to do is to setup the project, create a test project in a way you like and with your preferred testing framework, I am using xUnit for as many years as I could remember and in this post will use it as well; however, most of the concepts and classes that will be discussed here have similar equivalents in either of testing frameworks of your choice. I am using Rider.

In order to start testing, it is required to add certain NuGet packages to our project, since I am using xUnit, the NuGet packages are for that testing framework, if you are using NUnit or MSTest then consider the equivalents there! For example, Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.NUnit or Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.MSTest.

<ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit" Version="1.1.2" />
</ItemGroup>

PS: I am using version 1.1.2, since some of the classes the we will see later are obsolete from the earlier versions.

It is also required to add a project reference to the project containing the code analyzers we want to test:

<ItemGroup>
    <ProjectReference Include="..\Sample.Analyzers\Sample.Analyzers.csproj"/>
</ItemGroup>

Writing the first Test

Now create a class called MaybeSemanticAnalyzerSpecs.cs. The fist test is about checking whenever a throw statement exists in a method that returns some sort of Maybe<T> then we expect the test to check if the Diagnostic API has reported an issue or not, and at the expected location.

Before writing that it is good to learn about a couple of helper classes, one of which is CSharpAnalyzerVerifier, it provides us with factory methods to create DiagnosticResults representing an expected diagnostic for the single DiagnosticDescriptor supported by the provided analyzer:

var expectedDiagnostic = CSharpAnalyzerVerifier<MaybeSemanticAnalyzer, DefaultVerifier>
            .Diagnostic(MaybeSemanticAnalyzer.DiagnosticId) // "SHG001"
            .WithLocation(8, 9);

The first type parameter is the analyzer under test, in our case the MaybeSemanticAnalyzer from the previous post, and as the second type parameter it accepts a verifier; you might have seen test framework dependent verifiers, like XUnitVerifier which were available from Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit version 1.1.1 but with version 1.1.2 they are now deprecated and it is better off to use the DefaultVerifier which is obviously the default implementation of the IVerifier interface. The Diagnostic method indicates the ID of the diagnostic to be reported and the WithLocation points to the location in the source file that this diagnostic should have been reported.

The next class is CSharpAnalyzerTest in which we define the test state and all the dependencies that are required, for instance, in our example the Maybe type is in a separate project, so we have to add a project reference to that in the TestState. Speaking of TestState which is of type SolutionState and requires properties that more or less we are familiar with from the first post in this series( or the video ) when creating a compilation using CSharpCompilation.Create. AdditionalReferences property is exactly for the purpose of adding reference to other assemblies; Source property accepts the sources that we want to run our analyzer against them,and finally, we also need to add a reference to the .NET SDK, in our case net9.0, which is possible via ReferenceAssemblies using RefereneAssemblies.Net static class.

var testAnalyzer = new CSharpAnalyzerTest<MaybeSemanticAnalyzer, DefaultVerifier>()
        {
            TestState =
            {
                Sources = { code },
                ReferenceAssemblies = ReferenceAssemblies.Net.Net90,
                AdditionalReferences = { MetadataReference.CreateFromFile(typeof(None).Assembly.Location) }
            },
            ExpectedDiagnostics = { expectedDiagnostic }
        };

In the code above, the last property, ExpectedDiagnostics, as the name suggests, accepts the expected diagnostics for this test analyzer to verify against; at the end, calling RunAsync from testAnalyzer instance ensures that all the expected diagnostics are met for the test to pass and be green.

await testAnalyzer.RunAsync();

The whole Test

[Fact]
public async Task When_MethodWithReturnTypeMaybe_ContainsThrow_Then_ReportDiagnostic()
{
    //lang=c#
    const string code = """
                        using System;
                        using Sample.Fx;
                                                    
                        public class Program
                        {
                            public Maybe<int> GetValue(string number)
                            {
                                throw new InvalidOperationException("Could not parse the number");
                            }
                        }
                        """;
    
    var expectedDiagnostic = CSharpAnalyzerVerifier<MaybeSemanticAnalyzer, DefaultVerifier>
        .Diagnostic(MaybeSemanticAnalyzer.DiagnosticId)
        .WithLocation(8, 9);
    
    var testAnalyzer = new CSharpAnalyzerTest<MaybeSemanticAnalyzer, DefaultVerifier>()
    {
        TestState =
        {
            Sources = { code },
            ReferenceAssemblies = ReferenceAssemblies.Net.Net90,
            AdditionalReferences = { MetadataReference.CreateFromFile(typeof(None).Assembly.Location) },
        },
        ExpectedDiagnostics = { expectedDiagnostic }
    };

    await testAnalyzer.RunAsync();
}

PS: ReferenceAssemblies static class provides other runtime references as well, depending on the scenarios, you might want to use another one.

Another test case would be if the return type of the method is not Maybe<T> and there is a throw statement, then no diagnostics should be reported, I suggest you try it yourself and then check the rest of the blog for the answer 😉

[Fact]
public async Task When_MethodWithReturnTypeNotMaybe_ContainsThrow_Then_ReportNoDiagnostic()
{
    //lang=c#
    const string code = 
    """
		    using System;
		    using Sample.Fx;

        public class Program
        {
            public int GetValue(string number)
            {
                throw new InvalidOperationException("Could not parse the number");
            }
        }
    """;

var testAnalyzer = new CSharpAnalyzerTest<MaybeSemanticAnalyzer, DefaultVerifier>()
{
    TestState =
	  {
        Sources = { code },
        ReferenceAssemblies = ReferenceAssemblies.Net.Net90,
        AdditionalReferences = { MetadataReference.CreateFromFile(typeof(None).Assembly.Location) },
    },
};

await testAnalyzer.RunAsync();
}

Conclusion

To write tests for code analysis we need to use certain classes provided by the Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.* which * depends on which testing framework you are using (xUnit, NUnit, or MSTest); Two of those classes are 1) CSharpAnalyzerVerifier<TAnalyzer, TVerifier> providing us with factories for creating the expected DiagnosticResult, if you are using versions before 1.1.2 you could use verifiers like XUnitVerifier but from this version they are obsolete and one could use DefaultVerifier. 2) is CSharpAnalyzerTest which acts as the primary underlying API for testing the analyzers, it requires certain properties to be set, such as Sources, ReferenceAssemblies, AdditionalAssemblies, and ExpectedDiagnostics.

At the end, thanks for reading, enjoy coding and Dametoon Garm [2]

Resources

Buy Me a Coffee at ko-fi.com

Create Syntax Trees using Roslyn APIs

Sometimes when we want to generate code whether it is source generators or code fixes for a code analyzer, it is required to know how a syntax tree could be created using the Roslyn Compiler API. There are two ways, that we will discuss them in this post.

Write your own Code Analyzer with Roslyn

We are all familiar with diagnostics that are provided from the compiler when we develop an application, they could be in form of warnings, errors or code suggestions. A diagnostic or code analyzer, inspects our open files for various metrics, like style, maintainability, design, etc. However, sometimes we need to write a tailor-made analysis for our specific situation, tool, or project.

Rolsyn Code Fix Providers to the Rescue

In the previous post in the Roslyn series we have explored how we could create code analyzers and how to test them, to reduce the cognitive load on our development team! However, what if we take an extra mile and create a Code Fix Provider to provide some suggestions for the developers! Shall we?

An error has occurred. This application may no longer respond until reloaded. Reload x