A lightweight framework for writing unit tests for Roslyn diagnostic analyzers, code fixes, refactorings, and completion providers targeting the AL Language of Microsoft Dynamics 365 Business Central. This project is a fork of RoslynTestKit, created to support the specific CodeAnalysis assemblies required by the AL language.
| CSharp | AL |
|---|---|
| Microsoft.CodeAnalysis | Microsoft.Dynamics.Nav.CodeAnalysis |
| Microsoft.CodeAnalysis.CodeActions | Microsoft.Dynamics.Nav.CodeAnalysis.CodeActions |
| Microsoft.CodeAnalysis.CodeFixes | Microsoft.Dynamics.Nav.CodeAnalysis.CodeFixes |
| Microsoft.CodeAnalysis.CodeRefactorings | Microsoft.Dynamics.Nav.CodeAnalysis.CodeRefactoring |
| Microsoft.CodeAnalysis.Completion | Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces.Completion |
| Microsoft.CodeAnalysis.Diagnostics | Microsoft.Dynamics.Nav.CodeAnalysis.Diagnostics |
| Microsoft.CodeAnalysis.Text | Microsoft.Dynamics.Nav.CodeAnalysis.Text |
| Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces.dll |
- Install the ALCops.RoslynTestKit package from NuGet into your project.
- Create appropriate test fixture using
RoslynFixtureFactory. - Configure the fixture to perform assertions.
This is a minimal test project setup example with NUnit and the RoslynTestKit. We need minimal target net8.0 and depedencies to Microsoft.Dynamics.Nav.CodeAnalysis.dll and Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces.dll.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ALCops.RoslynTestKit" Version="1.0.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="NUnit" Version="4.1.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.2.0" />
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
<Using Include="NUnit.Framework" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="src/MyCodeAnalyzer.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.Dynamics.Nav.CodeAnalysis">
<SpecificVersion>False</SpecificVersion>
<HintPath>myFolder/Microsoft.Dynamics.Nav.CodeAnalysis.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces">
<SpecificVersion>False</SpecificVersion>
<HintPath>myFolder/Microsoft.Dynamics.Nav.CodeAnalysis.Workspaces.dll</HintPath>
<Private>True</Private>
</Reference>
</ItemGroup>
</Project>RoslynTestKit accepts strings that are marked up with [| and |] to identify a particular span. This could represent the span of an expected diagnostic or the text selection before a refactoring is applied.
Instead of the markers you can also provide line number to locate the place of expected diagnostic.
Create a FlowFieldEditable.al file which the AL compiler can parse and apply the code location markers.
table 50100 MyTable
{
fields
{
field(1; MyField; Integer) { }
[|field(2; MyCalcField; Boolean)|]
{
FieldClass = FlowField;
CalcFormula = exist(MyTable where (MyField = field(MyField)));
}
}
}Create a C# class to execute the tests. It's best practice to always use the HasDiagnosticAtAllMarkers method, especially when one of the .al files contain multiple markers.
[Test]
[TestCase("FlowFieldEditable")]
[TestCase("FlowFieldEditableWithoutComment")]
...
[TestCase("n")]
public async Task HasDiagnostic(string testCase)
{
var code = await File.ReadAllTextAsync($"{testCase}.al").ConfigureAwait(false);
var fixture = RoslynFixtureFactory.Create<MyCodeAnalyzer.Rules.FlowFieldsShouldNotBeEditable>();
fixture.HasDiagnosticAtAllMarkers(code, DiagnosticIds.FlowFieldsShouldNotBeEditable);
}Create a FlowFieldObsoleteRemoved.al file which the AL compiler can parse and apply the code location markers.
table 50100 MyTable
{
fields
{
field(1; MyField; Integer) { }
[|field(2; MyCalcField; Boolean)|]
{
FieldClass = FlowField;
CalcFormula = exist(MyTable where (MyField = field(MyField)));
ObsoleteState = Removed;
}
}
}Create a C# class to execute the tests. It's best practice to always use the NoDiagnosticAtAllMarkers method, especially when one of the .al files contain multiple markers.
[Test]
[TestCase("FlowFieldObsoleteRemoved")]
[TestCase("FlowFieldTableObsoleteRemoved")]
...
[TestCase("n")]
public async Task NoDiagnostic(string testCase)
{
var code = await File.ReadAllTextAsync($"{testCase}.al").ConfigureAwait(false);
var fixture = RoslynFixtureFactory.Create<Analyzer.FlowFieldsShouldNotBeEditable>();
fixture.NoDiagnosticAtAllMarkers(code, DiagnosticIds.FlowFieldsShouldNotBeEditable);
}Create two files: current.al and expected.al.
Place the diagnostic marker in the current.al and in expected.al define the expected result without the markers.
table 50100 MyTable
{
fields
{
field(1; MyField; Integer) { }
[|field(2; MyCalcField; Boolean)|]
{
FieldClass = FlowField;
CalcFormula = exist(MyTable where (MyField = field(MyField)));
}
}
}
table 50100 MyTable
{
fields
{
field(1; MyField; Integer) { }
field(2; MyCalcField; Boolean)
{
FieldClass = FlowField;
CalcFormula = exist(MyTable where (MyField = field(MyField)));
Editable = false;
}
}
}
Create a C# class to execute the tests.
[Test]
[TestCase("SingleFlowFieldIsEditable")]
public async Task HasFix(string testCase)
{
var currentCode = await File.ReadAllTextAsync("current.al").ConfigureAwait(false);
var currentCode = await File.ReadAllTextAsync("expected.al").ConfigureAwait(false);
var fixture = RoslynFixtureFactory.Create<FlowFieldsShouldNotBeEditableCodeFixProvider>(
new CodeFixTestFixtureConfig
{
AdditionalAnalyzers = [new Analyzer.FlowFieldsShouldNotBeEditable()]
});
fixture.TestCodeFix(currentCode, expectedCode, DiagnosticDescriptors.FlowFieldsShouldNotBeEditable);
}FixAll tests verify that all diagnostics from a given rule can be fixed simultaneously using the CodeFix's FixAllProvider. This catches a class of bugs where individual fixes work fine, but applying all fixes at once fails due to overlapping text changes.
Create two files: current.al with [| |] markers at every expected diagnostic location, and expected.al with the fully-fixed output.
table 50100 MyTable
{
fields
{
field(1; MyField; Integer) { }
[|field(2; FirstCalcField; Boolean)|]
{
FieldClass = FlowField;
CalcFormula = exist(MyTable where (MyField = field(MyField)));
}
[|field(3; SecondCalcField; Boolean)|]
{
FieldClass = FlowField;
CalcFormula = exist(MyTable where (MyField = field(MyField)));
}
}
}table 50100 MyTable
{
fields
{
field(1; MyField; Integer) { }
field(2; FirstCalcField; Boolean)
{
FieldClass = FlowField;
CalcFormula = exist(MyTable where (MyField = field(MyField)));
Editable = false;
}
field(3; SecondCalcField; Boolean)
{
FieldClass = FlowField;
CalcFormula = exist(MyTable where (MyField = field(MyField)));
Editable = false;
}
}
}Create a C# class to execute the tests.
[Test]
[TestCase("MultipleFlowFieldsAreEditable")]
public async Task HasFixAll(string testCase)
{
var currentCode = await File.ReadAllTextAsync("current.al").ConfigureAwait(false);
var expectedCode = await File.ReadAllTextAsync("expected.al").ConfigureAwait(false);
var fixture = RoslynFixtureFactory.Create<FlowFieldsShouldNotBeEditableCodeFixProvider>(
new CodeFixTestFixtureConfig
{
AdditionalAnalyzers = [new Analyzer.FlowFieldsShouldNotBeEditable()]
});
fixture.TestFixAll(currentCode, expectedCode, DiagnosticDescriptors.FlowFieldsShouldNotBeEditable);
}Note: The number of
[| |]markers incurrent.almust exactly match the number of diagnostics the analyzer reports. If there is a mismatch, the test throws aRoslynTestKitExceptionwith details about the diagnostics found. TheEquivalenceKeyfor the FixAll operation is auto-detected from the first diagnostic's code fix. If you need to select a specific fix (when a CodeFix registers multiple actions), pass theequivalenceKeyparameter explicitly.
When a CodeFix registers multiple actions for the same diagnostic (e.g., "Remove unused permission" vs. "Mark as used"), pass the equivalenceKey to select which fix to apply across all diagnostics.
[Test]
public async Task HasFixAll_SpecificAction(string testCase)
{
var currentCode = await File.ReadAllTextAsync("current.al").ConfigureAwait(false);
var expectedCode = await File.ReadAllTextAsync("expected.al").ConfigureAwait(false);
var fixture = RoslynFixtureFactory.Create<MyCodeFixProvider>(
new CodeFixTestFixtureConfig
{
AdditionalAnalyzers = [new Analyzer.MyDiagnosticAnalyzer()]
});
fixture.TestFixAll(
currentCode,
expectedCode,
DiagnosticDescriptors.MyRule,
equivalenceKey: "RemoveUnusedPermission");
}Tip: The
equivalenceKeyvalue must match theCodeAction.EquivalenceKeyset by the CodeFix when registering the action viacontext.RegisterCodeFix(). If unsure, run a singleTestCodeFixfirst and inspect the available code actions.
Every RoslynFixtureFactory.Create<T>() overload accepts an optional config object. The config classes share a common base (BaseTestFixtureConfig) that exposes project-level settings. Fixture-specific config classes inherit from this base and can add extra options on top.
| Property | Type | Default | Description |
|---|---|---|---|
Language |
string |
LanguageNames.AL |
Language used for the test project. |
ThrowsWhenInputDocumentContainsError |
bool |
true |
Throw when the code under test has compiler errors. |
References |
IReadOnlyList<MetadataReference> |
empty | Extra metadata references added to the project. |
AdditionalFiles |
IReadOnlyList<AdditionalText> |
empty | Additional files exposed to analyzers (e.g. .editorconfig). |
RuleSetPath |
string? |
null |
Path to a .ruleset file that controls diagnostic severity. |
PackageCachePaths |
IReadOnlyList<string> |
empty | Directories containing .app packages the compiler resolves symbols from. |
CompilationOptions |
CompilationOptions? |
null |
Override the default CompilationOptions. |
ParseOptions |
ParseOptions? |
null |
Override the default ParseOptions. |
ProjectInfoCustomizer |
Func<ProjectInfo, ProjectInfo>? |
null |
Escape hatch to set any ProjectInfo property not exposed above. |
FileSystem |
IFileSystem? |
null |
Virtual file system injected into the compilation via Compilation.WithFileSystem(). Use for analyzers that read files from the workspace (e.g. XLIFF translations, config files). |
| Property | Type | Default | Description |
|---|---|---|---|
AdditionalAnalyzers |
IReadOnlyCollection<DiagnosticAnalyzer> |
empty | Analyzers that produce the diagnostics the code fix needs to act on. |
var fixture = RoslynFixtureFactory.Create<FlowFieldsShouldNotBeEditable>(
new AnalyzerTestFixtureConfig
{
RuleSetPath = @"rules\my.ruleset"
});
fixture.HasDiagnosticAtAllMarkers(code, DiagnosticIds.FlowFieldsShouldNotBeEditable);var fixture = RoslynFixtureFactory.Create<FlowFieldsShouldNotBeEditable>(
new AnalyzerTestFixtureConfig
{
PackageCachePaths = [@"C:\packages\base-app"]
});
fixture.HasDiagnosticAtAllMarkers(code, DiagnosticIds.FlowFieldsShouldNotBeEditable);var fixture = RoslynFixtureFactory.Create<FlowFieldsShouldNotBeEditable>(
new AnalyzerTestFixtureConfig
{
CompilationOptions = new CompilationOptions(target: CompilationTarget.OnPrem)
});
fixture.HasDiagnosticAtAllMarkers(code, DiagnosticIds.FlowFieldsShouldNotBeEditable);var fixture = RoslynFixtureFactory.Create<FlowFieldsShouldNotBeEditableCodeFixProvider>(
new CodeFixTestFixtureConfig
{
AdditionalAnalyzers = [new Analyzer.FlowFieldsShouldNotBeEditable()],
RuleSetPath = @"rules\my.ruleset"
});
fixture.TestCodeFix(currentCode, expectedCode, DiagnosticDescriptors.FlowFieldsShouldNotBeEditable);Analyzers that depend on Compilation.FileSystem (e.g. to read XLIFF translation files) require a virtual file system during tests. The SDK ships a MemoryFileSystem class that accepts a dictionary of path-to-content mappings.
using System.Text;
using Microsoft.Dynamics.Nav.CodeAnalysis;
var xliff = """
<?xml version="1.0" encoding="utf-8"?>
<xliff version="1.2">
<file datatype="xml" source-language="en-US" target-language="da-DK">
<body />
</file>
</xliff>
""";
var files = new Dictionary<string, byte[]>
{
["Translations/MyApp.da-DK.xlf"] = Encoding.UTF8.GetBytes(xliff)
};
var fixture = RoslynFixtureFactory.Create<TranslatableTextAnalyzer>(
new AnalyzerTestFixtureConfig
{
FileSystem = new MemoryFileSystem(files)
});
fixture.HasDiagnosticAtAllMarkers(code, DiagnosticIds.MissingTranslation);Note:
MemoryFileSystem.GetDirectoryPath()always returns"". Keys should use forward slashes and match the glob patterns your analyzer passes toIFileSystem.GetFiles()(e.g.Translations/*.xlf).
When you need to configure a ProjectInfo property that has no dedicated config option, use ProjectInfoCustomizer.
var fixture = RoslynFixtureFactory.Create<FlowFieldsShouldNotBeEditable>(
new AnalyzerTestFixtureConfig
{
ProjectInfoCustomizer = info => info.WithAssemblyProbingPaths([@"C:\probing"])
});
fixture.HasDiagnosticAtAllMarkers(code, DiagnosticIds.FlowFieldsShouldNotBeEditable);The NavCodeAnalysisBase class is a base class for analyzer tests that need to behave differently depending on the version of the AL Language (Microsoft.Dynamics.Nav.CodeAnalysis). By inheriting from this class, your tests automatically gain utilities for:
- Detecting the currently loaded Microsoft.Dynamics.Nav.CodeAnalysis assembly version.
- Comparing that version against minimum/maximum requirements.
- Skipping or adjusting tests when features are introduced, changed, or removed between versions.
// Example 1: Skip specific test cases based on version
[TestCase("FeatureOne")]
[TestCase("FeatureTwo")]
public void TestFeatures(string testCase)
{
SkipTestIfVersionIsTooLow(
["FeatureOne"],
testCase,
"15.0.0"
);
SkipTestIfVersionIsTooHigh(
["FeatureTwo"],
testCase,
"14.9.99"
);
// Test code
}
// Example 2: Require minimum version for entire test
[Test]
public void TestModernFeature()
{
RequireMinimumVersion("15.0.0", "Requires new API");
// Test code that uses version 15+ features
}
// Example 3: Test feature that only exists in specific range
[Test]
public void TestTransitionalFeature()
{
RequireVersionRange("15.0.0", "16.5.0", "Feature deprecated after 16.5");
// Test code
}
// Example 4: Ensure version detection worked
[Test]
public void TestVersionDependentBehavior()
{
RequireVersionDetection();
// Now safe to use version comparison methods
}Working with .al files instead of declaring the code inline the test method itself, requires a structure. A example for this could be something like this.
├───MyCodeAnalyzer.Test
│ └──Rules
│ ├───FlowFieldsShouldNotBeEditable
│ │ ├───HasDiagnostic
│ │ │ ├───FlowFieldEditable.al
│ │ │ ├───FlowFieldEditableWithoutComment.al
│ │ │ └───X.al
│ │ └───NoDiagnostic
│ │ │ ├───FlowFieldObsoleteRemoved.al
│ │ │ ├───FlowFieldTableObsoleteRemoved.al
│ │ │ └───X.al
│ │ └───HasFix
│ │ │ └───SingleFlowFieldIsEditable
│ │ │ │ ├───current.al
│ │ │ │ └───expected.al
│ │ │ └───SingleFlowFieldIsEditableWithComment
│ │ │ │ ├───current.al
│ │ │ │ └───expected.al
│ │ └───HasFixAll
│ │ │ └───MultipleFlowFieldsAreEditable
│ │ │ │ ├───current.al
│ │ │ │ └───expected.al
│ ├───MyOtherDiagnostic
│ │ ├───HasDiagnostic
│ │ └───NoDiagnostic
│ │ └───HasFix
│ │ └───HasFixAllIn case of discrepancy between the expected code and the generated one, when testing CodeFixes and CodeRefactorings, the TransformedCodeDifferentThanExpectedException is thrown. The Text difference is presented in the console using inline diff format, which looks as follows:
RoslynTestKit.TransformedCodeDifferentThanExpectedException : Transformed code is different than expected:
===========================
From line 25:
- ················ZipCode·=·src.MainAddress.ZipCode,␍␊
===========================
From line 29:
- ············dst.Addresses·=·src.Addresses.ConvertAll(srcAddress·=>·new·AddressDTO()␍␊
- ············{␍␊
- ················City·=·srcAddress.City,␍␊
- ················ZipCode·=·srcAddress.ZipCode,␍␊
- ················Street·=·srcAddress.Street,␍␊
- ················FlatNo·=·srcAddress.FlatNo,␍␊
- ················BuildingNo·=·srcAddress.BuildingNo␍␊
- ············}).AsReadOnly();␍␊
- ············dst.UnitId·=·src.Unit.Id;␍␊
===========================
From line 71:
- ········public·string·ZipCode·{·get;·set;·}␍␊
+ ········public·string·ZipCode·{·get;·}␍␊
===========================
From line 94:
- ········public·List<AddressEntity>·Addresses·{·get;·set;·}␍␊
- ········public·UnitEntity·Unit·{·get;·set;·}␍␊
===========================
From line 124:
- ········public·string·BankName·{·get;·set;·}␍␊
+ ············public·string·BankName·{·get;·set;·}␍␊
However, when the test is run with the attached debugger, an external diff tool is launched to present the differences. RoslynTestKit is using under the hood the ApprovalTests.Net so a wide range of diff tools on Windows, Linux and Mac are supported.
Appreciation to all the amazing contributors who made this happen.
A special thank you to @christophstuber, who originally developed this for AL Language and made it available with the LinterCop and to @ans-bar-bm for implementing multiple test markers.
