Advertisement
Blazor Analyzer Feature Image3
Advertisement

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 list of articles about the Roslyn analyzers is:

Advertisement

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.

Advertisement

By Enrico

My greatest passion is technology. I am interested in multiple fields and I have a lot of experience in software design and development. I started professional development when I was 6 years. Today I am a strong full-stack .NET developer (C#, Xamarin, Azure)

Leave a Reply

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

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.