Testing Your Analyzers
Learn how to create comprehensive unit tests for your Roslyn-based analyzers and source generators using Albatross.CodeAnalysis.Testing.
Overview
Testing is crucial for building reliable code analyzers and source generators. The Albatross.CodeAnalysis.Testing library provides utilities to simplify the creation of unit tests by making it easy to create compilations with proper framework references.
Getting Started
Installation
dotnet add package Albatross.CodeAnalysis.Testing
Basic Setup
using Albatross.CodeAnalysis.Testing;
using FluentAssertions;
using Microsoft.CodeAnalysis;
using Xunit;
Creating Test Compilations
Basic Compilation Creation
The core feature is the CreateNet8CompilationAsync() extension method:
[Fact]
public async Task TestBasicCompilation() {
const string code = @"
public class TestClass {
public string Name { get; set; } = string.Empty;
public int Age { get; set; }
}";
var compilation = await code.CreateNet8CompilationAsync();
// Now you can analyze symbols
var testClass = compilation.GetRequiredSymbol("TestClass");
testClass.Should().NotBeNull();
testClass.Name.Should().Be("TestClass");
}
Advanced Compilation Options
[Fact]
public async Task TestCompilationWithOptions() {
const string code = @"
public class TestClass {
public string? NullableProperty { get; set; }
}";
var compilation = await code.CreateNet8CompilationAsync(
assemblyName: "MyTestAssembly",
cancellationToken: CancellationToken.None
);
// The compilation includes proper .NET 8.0 framework references
// and supports C# 12 language features by default
}
Testing Symbol Analysis
Nullability Testing
public class NullabilityTests {
const string TestCode = @"
public class TestClass {
public int Value { get; set; }
public int? NullableValue { get; set; }
public string Text { get; set; } = string.Empty;
public string? NullableText { get; set; }
public int[] Array { get; set; } = Array.Empty<int>();
public int[]? NullableArray { get; set; }
}";
[Theory]
[InlineData("Value", false)]
[InlineData("NullableValue", true)]
[InlineData("Text", false)]
[InlineData("NullableText", true)]
[InlineData("Array", false)]
[InlineData("NullableArray", true)]
public async Task VerifyIsNullable(string propertyName, bool expectedNullable) {
var compilation = await TestCode.CreateNet8CompilationAsync();
var symbol = compilation.GetRequiredSymbol("TestClass");
var property = symbol.GetMembers()
.OfType<IPropertySymbol>()
.First(x => x.Name == propertyName);
var actualNullable = property.Type.IsNullable(compilation);
actualNullable.Should().Be(expectedNullable);
}
}
Collection Type Testing
public class CollectionTests {
[Theory]
[InlineData("List<string>", true, "String")]
[InlineData("string[]", true, "String")]
[InlineData("IEnumerable<int>", true, "Int32")]
[InlineData("Dictionary<string, int>", true, "Int32")] // Value type for dictionaries
[InlineData("string", false, null)] // String is not considered a collection
[InlineData("int", false, null)]
public async Task TestCollectionDetection(string typeExpression, bool expectedIsCollection, string? expectedElementType) {
var code = $@"
using System;
using System.Collections.Generic;
public class TestClass {{
public {typeExpression} Property {{ get; set; }}
}}";
var compilation = await code.CreateNet8CompilationAsync();
var testClass = compilation.GetRequiredSymbol("TestClass");
var property = testClass.GetMembers("Property").OfType<IPropertySymbol>().First();
var type = property.Type;
var isCollection = type.IsCollection(compilation);
isCollection.Should().Be(expectedIsCollection);
if (expectedIsCollection && expectedElementType != null) {
var hasElement = type.TryGetCollectionElementType(compilation, out var elementType);
hasElement.Should().BeTrue();
elementType!.Name.Should().Be(expectedElementType);
}
}
}
Attribute Testing
public class AttributeTests {
[Fact]
public async Task TestAttributeDetection() {
const string code = @"
using System.ComponentModel.DataAnnotations;
public class User {
[Required]
[StringLength(50, MinimumLength = 2)]
public string Name { get; set; } = string.Empty;
[Range(18, 120)]
public int Age { get; set; }
}";
var compilation = await code.CreateNet8CompilationAsync();
var userClass = compilation.GetRequiredSymbol("User");
var nameProperty = userClass.GetMembers("Name").OfType<IPropertySymbol>().First();
// Test Required attribute
var requiredAttr = compilation.GetRequiredSymbol("System.ComponentModel.DataAnnotations.RequiredAttribute");
nameProperty.HasAttribute(requiredAttr).Should().BeTrue();
// Test StringLength attribute details
var stringLengthAttr = compilation.GetRequiredSymbol("System.ComponentModel.DataAnnotations.StringLengthAttribute");
nameProperty.TryGetAttribute(stringLengthAttr, out var attrData).Should().BeTrue();
// Verify constructor argument
attrData!.ConstructorArguments[0].Value.Should().Be(50);
// Verify named argument
attrData.TryGetNamedArgument("MinimumLength", out var minLength).Should().BeTrue();
minLength.Value.Should().Be(2);
}
}
Testing Source Generators
Generator Testing Setup
public class SourceGeneratorTests {
[Fact]
public async Task TestSourceGenerator() {
const string inputCode = @"
using System;
[GenerateToString]
public partial class TestClass {
public string Name { get; set; } = string.Empty;
public int Age { get; set; }
}";
var compilation = await inputCode.CreateNet8CompilationAsync();
// Create and run your generator
var generator = new MySourceGenerator();
var driver = CSharpGeneratorDriver.Create(generator);
var runResult = driver.RunGenerators(compilation).GetRunResult();
// Assert no diagnostics
runResult.Diagnostics.Should().BeEmpty();
// Assert generated sources
runResult.Results.Should().HaveCount(1);
var generatorResult = runResult.Results[0];
generatorResult.GeneratedSources.Should().HaveCount(1);
var generatedSource = generatorResult.GeneratedSources[0];
generatedSource.SourceText.ToString().Should().Contain("public override string ToString()");
}
}
Testing Generator with Multiple Files
[Fact]
public async Task TestGeneratorWithMultipleFiles() {
var sources = new Dictionary<string, string> {
["Class1.cs"] = @"
[GenerateSerializer]
public partial class Class1 {
public string Name { get; set; } = string.Empty;
}",
["Class2.cs"] = @"
[GenerateSerializer]
public partial class Class2 {
public int Value { get; set; }
}"
};
var compilation = await sources.CreateNet8CompilationAsync();
var generator = new SerializationGenerator();
var driver = CSharpGeneratorDriver.Create(generator);
var runResult = driver.RunGenerators(compilation).GetRunResult();
// Should generate one file per input class
runResult.Results[0].GeneratedSources.Should().HaveCount(2);
}
Advanced Testing Scenarios
Testing with Custom References
[Fact]
public async Task TestWithCustomReferences() {
const string code = @"
using Newtonsoft.Json;
public class TestClass {
[JsonProperty(""custom_name"")]
public string Name { get; set; } = string.Empty;
}";
// Add custom references beyond the default .NET 8.0 references
var references = new[] {
MetadataReference.CreateFromFile(typeof(Newtonsoft.Json.JsonPropertyAttribute).Assembly.Location)
};
var compilation = await code.CreateNet8CompilationAsync();
compilation = compilation.AddReferences(references);
var testClass = compilation.GetRequiredSymbol("TestClass");
var nameProperty = testClass.GetMembers("Name").OfType<IPropertySymbol>().First();
var jsonPropertyAttr = compilation.GetTypeByMetadataName("Newtonsoft.Json.JsonPropertyAttribute");
nameProperty.HasAttribute(jsonPropertyAttr).Should().BeTrue();
}
Testing Compilation Errors
[Fact]
public async Task TestCompilationWithErrors() {
const string invalidCode = @"
public class TestClass {
// This will cause a compilation error
public UndefinedType Property { get; set; }
}";
var compilation = await invalidCode.CreateNet8CompilationAsync();
var diagnostics = compilation.GetDiagnostics();
diagnostics.Should().NotBeEmpty();
diagnostics.Should().Contain(d => d.Severity == DiagnosticSeverity.Error);
}
Testing with Different Target Frameworks
While the testing library defaults to .NET 8.0, you can test compatibility:
[Fact]
public async Task TestNetStandard20Compatibility() {
const string code = @"
using System;
using System.Collections.Generic;
public class TestClass {
public List<string> Items { get; set; } = new List<string>();
}";
var compilation = await code.CreateNet8CompilationAsync();
// Test that your analyzer works with the compilation
var analyzer = new MyAnalyzer();
// ... run analyzer logic
// Verify compatibility with .NET Standard 2.0 concepts
var testClass = compilation.GetRequiredSymbol("TestClass");
testClass.Should().NotBeNull();
}
Test Organization
Base Test Class Pattern
public abstract class AnalyzerTestBase {
protected async Task<Compilation> CreateTestCompilationAsync(string code) {
return await code.CreateNet8CompilationAsync();
}
protected INamedTypeSymbol GetRequiredType(Compilation compilation, string typeName) {
return compilation.GetRequiredSymbol(typeName);
}
protected IPropertySymbol GetRequiredProperty(INamedTypeSymbol type, string propertyName) {
return type.GetMembers(propertyName).OfType<IPropertySymbol>().First();
}
}
public class MyAnalyzerTests : AnalyzerTestBase {
[Fact]
public async Task TestMyAnalyzer() {
const string code = "public class Test { public string Name { get; set; } }";
var compilation = await CreateTestCompilationAsync(code);
var testClass = GetRequiredType(compilation, "Test");
var nameProperty = GetRequiredProperty(testClass, "Name");
// Test your analyzer logic...
}
}
Parameterized Test Patterns
public static class TestCases {
public static IEnumerable<object[]> NullabilityTestCases() {
yield return new object[] { "string", false };
yield return new object[] { "string?", true };
yield return new object[] { "int", false };
yield return new object[] { "int?", true };
yield return new object[] { "List<string>", false };
yield return new object[] { "List<string>?", true };
}
}
public class ParameterizedTests {
[Theory]
[MemberData(nameof(TestCases.NullabilityTestCases), MemberType = typeof(TestCases))]
public async Task TestNullabilityDetection(string typeExpression, bool expectedNullable) {
var code = $@"
using System.Collections.Generic;
public class TestClass {{ public {typeExpression} Property {{ get; set; }} }}";
var compilation = await code.CreateNet8CompilationAsync();
var testClass = compilation.GetRequiredSymbol("TestClass");
var property = testClass.GetMembers("Property").OfType<IPropertySymbol>().First();
property.Type.IsNullable(compilation).Should().Be(expectedNullable);
}
}
Best Practices
1. Test Organization
// ✅ Good: Organize tests by feature
public class NullabilityAnalysisTests { }
public class CollectionAnalysisTests { }
public class AttributeInspectionTests { }
// ✅ Good: Use descriptive test names
[Fact]
public async Task IsNullable_WithNullableReferenceType_ReturnsTrue() { }
[Fact]
public async Task TryGetCollectionElementType_WithListOfString_ReturnsStringType() { }
2. Test Data Management
// ✅ Good: Use constants for reusable test code
public static class TestCode {
public const string SimpleClass = @"
public class TestClass {
public string Name { get; set; } = string.Empty;
public int Age { get; set; }
}";
public const string GenericClass = @"
public class GenericClass<T> {
public T Value { get; set; }
public List<T> Items { get; set; } = new();
}";
}
3. Assertion Patterns
// ✅ Good: Use FluentAssertions for readable tests
compilation.GetDiagnostics()
.Where(d => d.Severity == DiagnosticSeverity.Error)
.Should()
.BeEmpty("compilation should not have errors");
property.Type.IsNullable(compilation)
.Should()
.BeTrue($"{property.Name} should be nullable");
4. Error Handling in Tests
// ✅ Good: Test error conditions explicitly
[Fact]
public async Task GetRequiredSymbol_WithMissingType_ThrowsException() {
var compilation = await "public class Test { }".CreateNet8CompilationAsync();
var act = () => compilation.GetRequiredSymbol("NonExistentType");
act.Should().Throw<InvalidOperationException>();
}
Integration with CI/CD
Test Configuration
<!-- In your test project -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Albatross.CodeAnalysis.Testing" Version="8.0.1" />
</ItemGroup>
</Project>
Running Tests
# Run all tests
dotnet test
# Run with detailed output
dotnet test --logger "console;verbosity=detailed"
# Run specific test class
dotnet test --filter "ClassName=NullabilityTests"
# Generate code coverage
dotnet test --collect:"XPlat Code Coverage"
Prerequisites
- .NET 8.0 SDK or later
- Understanding of Roslyn APIs and symbol analysis
- Familiarity with unit testing frameworks (xUnit, MSTest, NUnit)
Related Topics
- Symbol Analysis Guide - What to test in symbol analysis
- Nullability Detection - Testing nullability scenarios
- Collection Types - Testing collection analysis
- Attribute Inspection - Testing attribute analysis
- API Reference - Testing library API documentation