BrandGhost
Testing Roslyn Analyzers with Microsoft.CodeAnalysis.Testing in C#

Testing Roslyn Analyzers with Microsoft.CodeAnalysis.Testing in C#

You have built a Roslyn analyzer -- great. Now comes the part that separates a hobbyist side project from something you can confidently ship: testing your Roslyn analyzer with a suite that actually proves it works. Without reliable tests, every deployment is a gamble. Did your diagnostic fire on the right code? Did it fire on clean code it should have ignored? Does the code fix produce valid, formatted output? Without tests, you cannot answer those questions with confidence.

The Microsoft.CodeAnalysis.Testing library is the purpose-built answer to all of this. It provides a harness specifically designed for testing Roslyn analyzers and code fixes -- with inline diagnostic markers, reference assembly management, multi-file test scenarios, and a clean async API. This guide walks through every layer of that harness: NuGet setup, writing your first analyzer unit test in C#, asserting both positive and negative cases, testing code fixes, handling edge cases, and integrating everything into CI.

For background on how the analyzer and code fix are implemented before you start testing them, see the analyzer and code fix implementation. The Roslyn Analyzers guide covers the overall ecosystem and architecture if you need a broader orientation first.


Why Analyzer Testing Is Different

Testing a Roslyn analyzer is not the same as testing a service class, a repository method, or even a complicated algorithm. There is no runtime execution, no live HTTP request, no database connection. What you are exercising is a compilation process -- whether your diagnostic fires at the correct syntax location, carries the right message and severity, and whether your code fix transforms the input into exactly the output you intended.

The typical unit test mental model -- inject a mock, call a method, assert the return value -- does not apply directly here. You need to express the source code being analyzed as a string, mark exactly which span within that string should trigger a diagnostic, run the analyzer over a real in-memory compilation, and compare the result against your declared expectations. For code fix tests, you also need to compare the transformed output character-by-character with what you expect.

Microsoft.CodeAnalysis.Testing solves all three concerns through a single abstraction: the test class. CSharpAnalyzerTest<TAnalyzer, TVerifier> and CSharpCodeFixTest<TAnalyzer, TCodeFix, TVerifier> each orchestrate compilation, analysis, and assertion in a single RunAsync() call. The framework compiles your test string in memory against a known set of reference assemblies, runs your analyzer over the compiled syntax tree and semantic model, and checks that the reported diagnostics match your declared expectations exactly -- right ID, right span, right message.

The most immediately useful feature is the [| |] marker syntax. You embed these markers directly inside your test source string to declare where you expect a diagnostic to appear. The framework strips those markers out before compilation, compiles the clean source, runs the analyzer, and then checks that a diagnostic was reported at exactly the span covered by each marker pair. This approach is far more resilient than hardcoding line and column numbers, which become stale every time you reformat the test input.

Understanding this abstraction matters when thinking about testing extensible systems in general -- analyzers are essentially plugins to the compiler, and the same isolation principles that apply to plugin testing apply here. The test harness stands in for the compiler host; your analyzer runs in isolation from any real project.

It is also worth understanding what the test framework does NOT do. It does not test whether your analyzer package is correctly referenced by a project. It does not test NuGet packaging, <PackageReference> metadata, or the AnalyzerLanguage attribute. Those concerns belong to integration-level tests or manual verification. What the framework does extremely well is verify that the core diagnostic and code fix logic behaves correctly under a wide range of source inputs -- and that is the most valuable thing to test because it is where most analyzer bugs live.


NuGet Package Setup

The testing framework is distributed as a set of NuGet packages. The exact packages you add depend on your test framework.

For xUnit (the most common choice for .NET open-source projects), add the following to your test project's .csproj:

<ItemGroup>
  <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit" Version="1.1.*" />
  <PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing.XUnit" Version="1.1.*" />
  <PackageReference Include="xunit" Version="2.*" />
  <PackageReference Include="xunit.runner.visualstudio" Version="2.*">
    <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
    <PrivateAssets>all</PrivateAssets>
  </PackageReference>
</ItemGroup>

For MSTest, swap the .XUnit suffix for .MSTest. For NUnit, use .NUnit. The core API is identical across all three -- only the verifier type changes.

Your test project should target net8.0 or net10.0. Using a modern target framework prevents subtle assembly version mismatches between the Roslyn runtime and the Microsoft.CodeAnalysis packages. net8.0 is a safe minimum; net10.0 is preferred if the rest of your toolchain supports it.

Choosing reference assemblies. The ReferenceAssemblies type controls which BCL types are available when the framework compiles your test input. Always set this explicitly rather than relying on defaults:

  • ReferenceAssemblies.Net.Net80 -- the .NET 8 BCL. Stable, widely supported.
  • ReferenceAssemblies.Net.Net90 -- the .NET 9 BCL.
  • ReferenceAssemblies.Net.Net100 -- the .NET 10 BCL, if available in your package version. If Net100 is not yet exposed in the version you are using, fall back to Net90 and upgrade the package when the support ships.

For analyzer projects that target .NET 10 explicitly, use Net100 where available. For projects that need to support multiple framework targets, write separate test cases for each or parameterize with [Theory].


CSharpAnalyzerTest: Writing Your First Analyzer Unit Test in C#

The fastest path to your first working Roslyn analyzer test is the static VerifyAnalyzerAsync helper exposed by the xUnit package. The convention in every Microsoft sample and established open-source analyzer project is to declare a using alias at the top of the test file:

using VerifyCS = Microsoft.CodeAnalysis.CSharp.Testing.XUnit.AnalyzerVerifier<
    MyNamespace.MyAnalyzer>;

With that alias, your first test is concise and readable:

[Fact]
public async Task Analyzer_FlagsViolation_WhenConditionMet()
{
    // [| |] markers define the span where the diagnostic is expected.
    // The framework strips markers before compiling, then checks the analyzer
    // reported exactly one diagnostic covering that span.
    var code = """
        using System;

        class Example
        {
            void Method()
            {
                [|Console.WriteLine("hello");|]
            }
        }
        """;

    await VerifyCS.VerifyAnalyzerAsync(code);
}

The framework expects exactly one diagnostic whose span matches the marked text. If your analyzer reports no diagnostic, the test fails with a clear message about the missing diagnostic. If it reports a diagnostic at the wrong location, the test fails with a span mismatch. If it reports an unexpected second diagnostic, the test fails listing the unexpected entry. All failures are actionable.

When you need to assert a specific diagnostic ID, severity, or message, use the Diagnostic builder rather than relying solely on the markers:

[Fact]
public async Task Analyzer_ReportsDiagnosticWithCorrectIdAndSeverity()
{
    var code = """
        class Example
        {
            void Method()
            {
                var x = 1;
            }
        }
        """;

    // Build an explicit expected diagnostic: ID, line/column span, severity.
    var expected = VerifyCS.Diagnostic("MY001")
        .WithSpan(5, 13, 5, 22)
        .WithSeverity(Microsoft.CodeAnalysis.DiagnosticSeverity.Warning);

    await VerifyCS.VerifyAnalyzerAsync(code, expected);
}

Use WithSpan carefully. Hardcoded line and column numbers are brittle -- any change to indentation or test input ordering requires updating them. The [| |] marker approach is the better default; reserve explicit spans for tests where the exact location is part of what you are verifying.

For cases where you need properties not exposed by VerifyAnalyzerAsync -- such as custom reference assemblies or multi-file input -- construct the test object directly:

[Fact]
public async Task Analyzer_WorksAgainstNet10Assemblies()
{
    var code = """
        class Example
        {
            void Method()
            {
                [|var x = 1;|]
            }
        }
        """;

    var test = new CSharpAnalyzerTest<MyAnalyzer, DefaultVerifier>
    {
        TestCode = code,
        // fall back to Net90 if your package version does not yet expose Net100
        ReferenceAssemblies = ReferenceAssemblies.Net.Net100,
    };

    await test.RunAsync();
}

This pattern -- constructing the test object explicitly and calling RunAsync() -- is the escape hatch for anything the static helper does not expose.


Testing Diagnostics: Positive and Negative Cases

A test suite that only covers the happy path is incomplete. Testing a Roslyn analyzer properly requires both positive cases (the diagnostic fires) and negative cases (the analyzer stays silent on valid code). Negative cases are arguably more important in practice -- a false positive is more disruptive than a missed detection, because it trains developers to suppress or ignore the rule.

Negative tests use no markers and declare no expected diagnostics. The framework's default is to treat zero declarations as "expect zero diagnostics":

[Fact]
public async Task Analyzer_ProducesNoDiagnostic_ForCleanCode()
{
    // No [| |] markers. The framework expects silence from the analyzer.
    var cleanCode = """
        using System;

        class Example
        {
            void Method()
            {
                int x = 1;
                Console.WriteLine(x);
            }
        }
        """;

    await VerifyCS.VerifyAnalyzerAsync(cleanCode);
}

Structure your test suite as a matrix: for every condition your analyzer checks, write at least one positive case and at least one negative case. Common negative cases to include are:

  • Code that looks syntactically similar to the violation but is semantically different
  • Generic types and methods where the analyzer might over-match
  • Code using nullable reference types (? annotations)
  • Expression-bodied members (=> syntax)
  • Switch expressions and pattern matching
  • partial classes where the relevant declaration is in a different file

For each negative case, the cost of the test is minimal. The benefit is catching regression when a refactor or fix inadvertently broadens the analyzer's pattern matching.

One practical approach is to build your negative test cases from the analyzer's own internal conditions. If your analyzer checks for a method whose name starts with "Get" and whose return type is a collection, then your negative tests should cover: a method starting with "Get" that returns a scalar, a method returning a collection whose name does not start with "Get", and a non-method symbol. Each of those paths through your if chain is a potential false positive. Cover each one explicitly.

Another class of negative tests that is easy to miss: test with null values in the semantic model. In certain contexts -- for example, analyzing code that does not compile cleanly due to an unresolved reference -- semantic information can come back null. A properly defensive analyzer calls GetTypeInfo() and checks for null before accessing properties. A test that passes partially-broken code can surface these null reference issues before they reach a user.

When you are asserting which rule IDs fire, cross-reference your DiagnosticDescriptor rule IDs in tests to make sure the ID in Diagnostic("MY001") matches what the descriptor declares. It is easy to copy-paste a test and forget to update the ID, leading to a test that passes for the wrong reason.

The same principle -- testing both the positive and boundary behavior -- applies broadly to any testing approaches in .NET. The analyzer testing framework just provides a specialized surface for expressing those cases.


Testing Code Fixes: CSharpCodeFixTest

Once your analyzer tests are solid, the next layer is testing the code fix. CSharpCodeFixTest takes a TestCode (the broken input with diagnostic markers) and a FixedCode (the expected post-fix output), applies your fix provider, and compares the result character-by-character.

Set up a code fix verifier alias:

using VerifyCS = Microsoft.CodeAnalysis.CSharp.Testing.XUnit.CodeFixVerifier<
    MyNamespace.MyAnalyzer,
    MyNamespace.MyCodeFix>;

A basic code fix test:

[Fact]
public async Task CodeFix_ReplacesImplicitVar_WithExplicitType()
{
    var inputCode = """
        class Example
        {
            void Method()
            {
                [|var result = GetValue();|]
            }

            string GetValue() => "hello";
        }
        """;

    var fixedCode = """
        class Example
        {
            void Method()
            {
                string result = GetValue();
            }

            string GetValue() => "hello";
        }
        """;

    await VerifyCS.VerifyCodeFixAsync(inputCode, fixedCode);
}

The exact-match comparison is intentional -- it catches whitespace and formatting issues introduced by your fix. If your CodeFixProvider accidentally adds an extra blank line or changes indentation, this test will tell you precisely where the output diverged.

For scenarios where your fix needs to run more than once to stabilize (because the first fix application triggers another diagnostic that the second application resolves), use NumberOfFixAllIterations:

[Fact]
public async Task CodeFix_ConvergesInTwoIterations()
{
    var test = new CSharpCodeFixTest<MyAnalyzer, MyCodeFix, DefaultVerifier>
    {
        TestCode = inputCode,
        FixedCode = finalStableCode,
        NumberOfFixAllIterations = 2,
        ReferenceAssemblies = ReferenceAssemblies.Net.Net100,
    };

    await test.RunAsync();
}

You can also test partial fix scenarios -- where your fix provider registers multiple code actions and you want to verify that applying only the first one produces an intermediate state. Set CodeFixTestBehaviors = CodeFixTestBehaviors.FixOne and provide the FixedCode that corresponds to the first action only. This is particularly useful when your provider offers both a "rename this occurrence" and a "rename all occurrences" action.

One more thing worth testing explicitly: that the fix title shown in the lightbulb menu is clear and readable. The title is what the developer sees when they hover over the squiggle -- "Replace 'var' with explicit type" is far more useful than "Fix violation." While the testing framework does not have a dedicated assertion for fix titles, you can verify it by inspecting the Title property of the registered code action, or by simply reviewing it during development and adding a comment in the test that documents the expected string.

Another failure mode to watch for: the FixedCode must be a valid, compilable C# file. If your fix produces output that does not compile -- even if it is syntactically close -- the test fails with a compilation error in the fixed state rather than just a string mismatch. This is actually useful behavior. It prevents you from shipping a code fix that introduces a build error in the user's project. If you see unexpected compilation errors in the fixed state, check your fix for missing using directives, unresolved type names, or incorrect expression syntax.


Testing Multiple Diagnostics and Edge Cases

Real-world code rarely triggers exactly one diagnostic in exactly one isolated method. A thorough Roslyn analyzer test suite handles multiple markers, pragma suppression, and cross-file scenarios.

Multiple Markers in One File

Place multiple [| |] markers in a single test string to express multiple expected diagnostics. The framework matches them in document order:

[Fact]
public async Task Analyzer_FlagsAllViolations_InSingleFile()
{
    var code = """
        class Example
        {
            void MethodA()
            {
                [|var a = GetA();|]
            }

            void MethodB()
            {
                [|var b = GetB();|]
            }

            string GetA() => "a";
            string GetB() => "b";
        }
        """;

    // Exactly two diagnostics are expected, at the two marked spans.
    await VerifyCS.VerifyAnalyzerAsync(code);
}

Respecting Pragma Suppression

Roslyn's compiler infrastructure automatically suppresses diagnostics covered by #pragma warning disable -- the framework marks them IsSuppressed and excludes them from the expected count. This test verifies that your analyzer does not accidentally bypass that infrastructure.

[Fact]
public async Task Analyzer_RespectsPragmaSuppression_NoFalsePositive()
{
    // No markers -- the violation is suppressed, so expect no diagnostic.
    var code = """
        class Example
        {
            void Method()
            {
        #pragma warning disable MY001
                var x = GetValue();
        #pragma warning restore MY001
            }

            string GetValue() => "hello";
        }
        """;

    await VerifyCS.VerifyAnalyzerAsync(code);
}

If this test fails, your analyzer is reporting diagnostics that the compiler has suppressed -- a serious bug that will prevent adoption in projects that use GlobalSuppressions.cs or assembly-level suppressions.

Multi-File Scenarios

Some analyzers examine relationships across files -- checking that a type implementing an interface follows a naming convention, or that extension methods for a class live in the expected namespace. Use TestState.Sources to add additional files to the compilation:

[Fact]
public async Task Analyzer_DetectsViolation_AcrossMultipleFiles()
{
    var primaryFile = """
        // File 1: declares the interface
        public interface IMyService
        {
            void Execute();
        }
        """;

    var secondaryFile = """
        // File 2: implements the interface without following the naming rule
        public class [|BadName|] : IMyService
        {
            public void Execute() { }
        }
        """;

    var test = new CSharpAnalyzerTest<MyAnalyzer, DefaultVerifier>
    {
        TestCode = primaryFile,
        ReferenceAssemblies = ReferenceAssemblies.Net.Net100,
    };

    // Add the second file to the same compilation.
    test.TestState.Sources.Add(("ServiceImpl.cs", secondaryFile));

    await test.RunAsync();
}

The multi-file setup also lets you test partial class scenarios, where the violation is spread across two files that must be considered together to trigger the rule.


CI Integration

An analyzer test suite that only runs locally is not a safety net -- it is a good intention. Roslyn analyzer tests belong in CI, running on every push and pull request. This section covers a practical GitHub Actions setup and two complementary CI strategies.

GitHub Actions Workflow

A minimal but complete workflow for an analyzer project:

name: Analyzer CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'

      - name: Restore
        run: dotnet restore

      - name: Build
        run: dotnet build --no-restore --configuration Release

      - name: Test
        run: dotnet test --no-build --configuration Release --verbosity normal

Build in Release mode -- this is important. Debug builds can include optimization settings that mask subtle differences between the binary you test and the binary you ship. Testing in Release mode means the analyzer package that runs in CI is the same one that runs in the user's project.

Build-Time Analyzer Testing

Beyond unit tests, you can validate your analyzer against a real project during CI. Add your analyzer project reference to a sample project in the same repository, then configure that sample project to treat your diagnostic as a build error:

<!-- In your sample/test-project.csproj -->
<PropertyGroup>
  <!-- Only promote YOUR rules to errors, not everything -->
  <WarningsAsErrors>MY001;MY002</WarningsAsErrors>
</PropertyGroup>

If the sample project contains intentional violations (to confirm the diagnostic fires), use #pragma warning disable MY001 with a comment. If it contains clean code (to confirm no false positives), the build will fail with a compiler error if your analyzer fires unexpectedly. This two-layer approach -- unit tests plus a build-time smoke test -- catches classes of bugs that unit tests alone sometimes miss.

Catching Regressions

When a user reports a false positive or a missed detection, always start the fix by writing a failing test that reproduces the regression. The new test should fail against the current code, then pass after your fix. This is the same discipline as TDD for any other kind of bug fix -- the test proves you fixed the right thing and that the fix is permanent.

Pin the version of Microsoft.CodeAnalysis.CSharp in your test project and update it deliberately. Major Roslyn versions can change how certain constructs are parsed or how semantic models are built, causing previously-passing tests to fail through no fault of your own. Intentional upgrades during a dedicated version-bump pass are far less disruptive than unexpected failures on a routine PR.


Frequently Asked Questions

What is the difference between VerifyAnalyzerAsync and constructing CSharpAnalyzerTest directly?

VerifyAnalyzerAsync is a static convenience wrapper around CSharpAnalyzerTest<TAnalyzer, TVerifier>. It covers the most common scenario -- a single source file with inline markers and default reference assemblies. When you need to set ReferenceAssemblies, add files to TestState.Sources, configure NumberOfFixAllIterations, or adjust CodeFixTestBehaviors, construct the test object directly and call RunAsync().

Why do my tests fail with "reference assembly not found" or type resolution errors?

The framework uses a minimal default reference set that may not include all BCL types your test code uses. Set ReferenceAssemblies = ReferenceAssemblies.Net.Net100 (or the appropriate version) on the test object explicitly. If your test code imports external NuGet packages -- for example, to test an analyzer that validates usage of a specific library -- add those assemblies via test.TestState.AdditionalReferences.

Can I test an IOperation-based analyzer the same way as a syntax-based one?

Yes. The framework compiles your test input to a full semantic model, making the IOperation tree available just like the syntax tree. The test code, marker syntax, and assertion patterns are identical regardless of whether your analyzer walks syntax nodes or operation nodes. The framework does not distinguish between them.

How do I test a code fix that registers multiple code actions?

Set CodeActionIndex on the CSharpCodeFixTest object to select which registered action to apply during the test. Index 0 applies the first action, 1 the second, and so on. Write separate test cases for each action to verify that they all produce correct output independently.

Should I use a separate test project or test within the analyzer project itself?

Use a dedicated test project. This avoids circular references (the test project references the analyzer project, not the reverse), keeps build artifacts separate, and lets CI treat analyzer tests as a standalone job. Reference the analyzer project directly rather than via a NuGet package during development -- this eliminates the publish cycle that would otherwise be required before each test run.

How do I test diagnostics that only fire in specific language versions?

Set LanguageVersion on the test object:

var test = new CSharpAnalyzerTest<MyAnalyzer, DefaultVerifier>
{
    TestCode = code,
    ReferenceAssemblies = ReferenceAssemblies.Net.Net100,
};

// Restrict to C# 11 to verify language-version-specific behavior.
test.LanguageVersion = Microsoft.CodeAnalysis.CSharp.LanguageVersion.CSharp11;

await test.RunAsync();

This is useful when your analyzer detects patterns that only exist in newer language versions, or when you want to verify that it does not fire on code targeting an older version.


Wrapping Up

Testing a Roslyn analyzer is a discipline in itself. The Microsoft.CodeAnalysis.Testing framework provides the scaffolding -- in-memory compilation, reference assembly management, inline diagnostic markers, code fix comparison -- but the rigor comes from your test coverage. Positive cases, negative cases, edge cases around suppression, multi-file scenarios, and CI integration are all required before an analyzer is production-ready.

The payoff is real. A well-tested Roslyn analyzer test suite means you ship with confidence, reproduce user-reported bugs with a failing test, and upgrade Roslyn safely knowing the test suite will catch any behavioral regressions. That feedback loop is what separates a polished, reliable tool from a fragile internal script.

To continue: review the DiagnosticDescriptor rule IDs in tests to make sure your rule identifiers are structured for easy targeting in test assertions. If you still need to implement the analyzer being tested, the analyzer and code fix implementation walks through the full setup.


This article was written by Nick Cosentino (Dev Leader). Nick is a Principal Engineering Manager at Microsoft who writes about C#, .NET, software architecture, and engineering leadership. All code examples are written for .NET 10.

Testing Plugin Architectures in C#: Strategies for Extensible Systems

Strategies for testing plugin architectures in C# with xUnit -- unit testing, contract tests, and integration testing for extensible .NET plugin systems.

Roslyn Analyzers in C#: The Complete Guide

Learn what roslyn analyzers are, how they work in the .NET 10 compiler pipeline, why teams build custom diagnostics, and how to write your first rule in C#.

Testing C# Source Generators: A Practical Guide

Learn how to test C# source generators with .NET 10. Complete guide covering Microsoft.CodeAnalysis.Testing, unit tests, snapshot testing with Verify, and CI integration.

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