Unit testing your analyzer

Blazor Analyzer Feature Image3

Analyzers are wonderfully testable. You give the framework a string of C#, it compiles it in memory, runs your analyzer, and checks that the diagnostics you expected — and only those — were produced. No files, no projects, no I/O.

In this part we test the PSC001 rule from Writing your first diagnostic. The full source code of the analyzer is available on GitHub.

The test project

Create a test project that targets your app’s framework (here net10.0) — the in-memory compilation needs a modern runtime and the Blazor assemblies:

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

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
    <!-- Pin Roslyn to the analyzer's version; the Workspaces reference silences
         the ancient transitive package the test framework pulls in (NU1701). -->
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.11.0" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.2" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\PSC.Analyzers.Blazor\PSC.Analyzers.Blazor.csproj" />
  </ItemGroup>

</Project>

Note the ProjectReference is a normal reference (no OutputItemType="Analyzer") — we want the analyzer’s types so we can instantiate them in tests.

Reference assemblies: the one tricky bit

The framework builds its own compilation, so we must tell it which assemblies that compilation references. Our test source uses ComponentBase and [Parameter], so the reference set needs the Blazor assemblies. We build a custom ReferenceAssemblies once and reuse it:

using System.Collections.Immutable;
using System.IO;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Testing;

namespace PSC.Analyzers.Blazor.Tests;

internal static class BlazorReferences
{
    public static readonly ReferenceAssemblies Net10 = new ReferenceAssemblies(
            "net10.0",
            new PackageIdentity("Microsoft.NETCore.App.Ref", "10.0.5"),
            Path.Combine("ref", "net10.0"))
        .AddPackages(ImmutableArray.Create(
            new PackageIdentity("Microsoft.AspNetCore.Components", "10.0.8")));
}

internal static class Verify<TAnalyzer>
    where TAnalyzer : DiagnosticAnalyzer, new()
{
    public static Task ForSource(string source)
    {
        var test = new CSharpAnalyzerTest<TAnalyzer, DefaultVerifier>
        {
            TestCode = source,
            ReferenceAssemblies = BlazorReferences.Net10,
        };
        return test.RunAsync();
    }
}

AddPackages pulls Microsoft.AspNetCore.Components (and its dependencies) from NuGet into the reference set, so ComponentBase and ParameterAttribute resolve. The Verify<TAnalyzer> helper trims the boilerplate down to a single call.

Writing tests with markup

Expected diagnostics are declared inline in the source using {|ID:span|} markup. {|PSC001:policyId|} says: “I expect a PSC001 diagnostic whose squiggle covers exactly policyId.” The markup is stripped before compilation.

using System.Threading.Tasks;
using Xunit;

namespace PSC.Analyzers.Blazor.Tests;

public class ParameterNamingAnalyzerTests
{
    [Fact]
    public async Task Flags_camelCase_parameter()
    {
        const string source = """
            using Microsoft.AspNetCore.Components;

            public class Sample : ComponentBase
            {
                [Parameter] public int {|PSC001:policyId|} { get; set; }
            }
            """;

        await Verify<ParameterNamingAnalyzer>.ForSource(source);
    }

    [Fact]
    public async Task Ignores_PascalCase_parameter()
    {
        const string source = """
            using Microsoft.AspNetCore.Components;

            public class Sample : ComponentBase
            {
                [Parameter] public int PolicyId { get; set; }
            }
            """;

        await Verify<ParameterNamingAnalyzer>.ForSource(source);
    }

    [Fact]
    public async Task Ignores_property_without_Parameter_attribute()
    {
        const string source = """
            using Microsoft.AspNetCore.Components;

            public class Sample : ComponentBase
            {
                public int policyId { get; set; }
            }
            """;

        await Verify<ParameterNamingAnalyzer>.ForSource(source);
    }
}

Positive and negative cases

The two kinds of test matter equally:

  • The positive test (Flags_camelCase_parameter) proves the rule fires — with markup declaring the expected diagnostic.
  • The negative tests have no markup, which asserts the analyzer produces nothing. These guard against false positives — the fastest way to make a rule hated is to flag correct code. Here we prove a PascalCase parameter and a plain (non-[Parameter]) property are both left alone.

RunAsync() fails the test if there’s any mismatch: a missing expected diagnostic, an unexpected one, or one on the wrong span. It also fails on unexpected compiler errors, which is a handy signal that your reference assemblies are wrong — if ComponentBase didn’t resolve, you’d see a CS0246 and know immediately.

Run them

dotnet test
Passed!  -  Failed: 0, Passed: 3, Skipped: 0, Total: 3

Three tests, three behaviours pinned down. As you add rules — a “use EventCallback, not Action” rule, say — you add a test class per analyzer and follow the same positive/negative pattern.

Tips

  • Assert the message text too when it carries information. Markup checks the id and location; to also check the message, build a DiagnosticResult explicitly with .WithSpan(...).WithArguments(...) instead of using markup.
  • Keep sources minimal. The smallest snippet that triggers the rule is the clearest test and the fastest to compile.
  • One analyzer per Verify<T>. Only the analyzer under test runs, so rules never interfere with each other’s tests.

We now have a rule that’s proven to behave. In Part 4 we turn the project into a proper NuGet package — placed correctly so the compiler loads it — and consume it from another project.

Related posts

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.