Using Roslyn and unit tests to enforce coding guidelines and more

Β· 1146 words Β· 6 minutes to read

Last year, during a few of my Roslyn talks, I was presenting a cool idea of leveraging Roslyn in unit tests to enforce a certain style in code, and in general inspect the consistency of the code in various ways.

It’s a really powerful concept, and something I wanted to blog about, but of course forgot - until I was reminded of that yesterday on Twitter.

Let’s have a look.

The idea πŸ”—

The idea is quite simple - since Roslyn let’s you analyze the entire solution, in a very meta way you can analyze your current solution from the unit tests in that solution.

With that, you can do pretty much anything - verify naming styles, check whitespace or spacing requirements, and even dig into the semantic model of the code (more on that later. All I use in the following samples is the *Microsoft.CodeAnalysis.Workspaces.Common* Roslyn NuGet package, as well as *xUnit* and *Should* packages for tests and assertions.

Here’s a most basic example. Let’s check if all the interfaces in the solution start with I.

public class SolutionSanity  
{  
private readonly List<Document> _documents;

public SolutionSanity()  
{  
var slnPath = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "Roslyn.Samples.SanityCheck.sln"));

var workspace = MSBuildWorkspace.Create();  
var solution = workspace.OpenSolutionAsync(slnPath).Result;

_documents = new List<Document>();

foreach (var projectId in solution.ProjectIds)  
{  
var project = solution.GetProject(projectId);  
foreach (var documentId in project.DocumentIds)  
{  
var document = solution.GetDocument(documentId);  
if (document.SupportsSyntaxTree)  
{  
_documents.Add(document);  
}  
}  
}  
}

[Fact]  
public void Interfaces_ShouldBePrefixedWithI()  
{  
var interfaces = _documents.SelectMany(x => x.GetSyntaxRootAsync().Result.DescendantNodes().OfType<InterfaceDeclarationSyntax>()).ToList();  
interfaces.All(x => x.Identifier.ToString().StartsWith("I")).ShouldBeTrue();  
}  
}  

So in the constructor (test set up) we navigate up all the way to the solution file. We load the solution file using Roslyn and then iterate through all of the projects, picking up all of the source files - exposing them as a list of Document objects for our tests to process.

Then in the test we simply grab all InterfaceDeclarationSyntax instances, which will represent syntax nodes that declare interfaces, and check if all of their identifies start with capital letter I. Easy and brilliant!

Real life usage πŸ”—

If you are interested in a real life scenario, a few months ago we had a discussion with Jason, the mastermind behind OmniSharp about coding guidelines when contributing to OmniSharp. Jason is really obsessed about two things in the PRs - having spaces instead of tabs and having using statements sorted properly. Of course if you use Visual Studio, it will convert tabs to spaces automatically (unless you explicitly change that behavior), but especially in the OmniSharp world, where developers can use a wide array of editors, tabs can easily creep into the code base.

So instead of manually checking for those two things on every PR, we came up with an idea to use Roslyn in unit tests to verify those two things. You can find the code here, but let’s also paste it here for reference.

[Fact]  
public void Source\_code\_does\_not\_contain_tabs()  
{  
//GetSourcePaths is just a helper method that grabs all files like in the earlier example  
foreach (var sourcePath in GetSourcePaths())  
{  
var source = File.ReadAllText(sourcePath);  
var syntaxTree = CSharpSyntaxTree.ParseText(source);

var hasTabs =  
syntaxTree.GetRoot()  
.DescendantTrivia(descendIntoTrivia: true)  
.Any(node => node.IsKind(SyntaxKind.WhitespaceTrivia)  
&& node.ToString().IndexOf('\t') >= 0);

Assert.False(hasTabs, sourcePath + " should be formatted with spaces");  
}  
}  

This is actually extremely simple - just inspect syntax tree of every source file and find out if there are any SyntaxKind.WhitespaceTrivia that happen to have tabs in them. If so the assertion will fail.

The other one is:

[Fact]  
public void Usings\_are\_ordered\_system\_first\_then\_alphabetically()  
{  
foreach (var sourcePath in GetSourcePaths())  
{  
var source = File.ReadAllText(sourcePath);  
var syntaxTree = CSharpSyntaxTree.ParseText(source);  
var usings = ((CompilationUnitSyntax)syntaxTree.GetRoot()).Usings  
.Select(u => u.Name.ToString());

var sorted = usings.OrderByDescending(u => u.StartsWith("System"))  
.ThenBy(u => u);

if (!usings.SequenceEqual(sorted))  
{  
Console.WriteLine("Usings ordered incorrectly in " + sourcePath);  
}

Assert.Equal(sorted, usings);  
}  
}  

In this case it’s not just blindly checking for the using statements to be sorted alphabetically, but also verifying if the System ones are placed before anything else. And finding all using statements, in all files, with Roslyn, is as simple as grabbing the CompilationUnitSyntax of each syntax tree representing each file (there are some edge cases but for simplicity let’s not get in there).

Enhancing with semantic analysis πŸ”—

So let’s go further, and for example do something that can have some semantic benefit too - instead of purely inspecting the code from the syntactic perspective.

Imagine a scenario where you work on a financial project (and in C#, no F# πŸ™‚ ) so immutability is a bit painful to support and enforce in your team. So say you have a marker interface ICalculationResult and you want to ensure that any type that is any sort of calculation result (and implements ICalculationResult), is immutable.

So this would be acceptable:

public class ImmutableCalculationResult : ICalculationResult  
{  
public int Total { get; private set; }

public ImmutableCalculationResult(int total)  
{  
Total = total;  
}  
}  

But not this:

public class ImmutableCalculationResult : ICalculationResult  
{  
public int Total { get; set; }  
}  

Here’s how you can write a test covering this scenario. The setup remains the same as in the first part of this post - we must grab a list of all documents to iterate through.

[Fact]  
public async Task ICalculationResult_ShouldNotHavePublicSetters()  
{  
foreach (var doc in _documents)  
{  
var root = await doc.GetSyntaxRootAsync();  
var classes = root.DescendantNodes().OfType<ClassDeclarationSyntax>().ToList();  
if (classes.Any())  
{  
var semanticModel = await doc.GetSemanticModelAsync();  
foreach (var c in classes)  
{  
var classSymbol = semanticModel.GetDeclaredSymbol(c) as ITypeSymbol;  
if (classSymbol != null)  
{  
if (classSymbol.AllInterfaces.Any(x => x.Name == "ICalculationResult"))  
{  
var properties = classSymbol.GetMembers().Where(m => m.Kind == SymbolKind.Property).Cast<IPropertySymbol>();  
if (properties.Any())  
{  
properties.All(x => x.IsReadOnly || x.SetMethod.DeclaredAccessibility < Accessibility.Public).ShouldBeTrue(); } } } } } } }

So we iterate the documents, and find all class declarations syntax nodes - by filtering on ClassDeclarationSyntax. For each of the documents, we need to grab a semantic model, using GetSemanticModelAsync method. Now we can iterate through the class syntax nodes of a given document, and get the ITypeSymbol representing each class. This will have invaluable semantic information such as the hierarchy of implemented interfaces - so we will not just know about ICalculationResult in case our class does Foo : ICalculationResult but also if any of its parent types implements ICalculationResult as well.

At that point it becomes very easy - just get all of the property symbols for each of the filtered class symbols and find out whether the properties on the class are all readonly (no setter at all) or at least not public. Now if an irresponsible developer waltzes in and breaks your immutability, the tests will fail immediately.

Bonus πŸ”—

and as a bonus, I’m just gonna leave this here, it’s rather self explanatory! This test should be mandatory in every code base. #endregions

[Fact]  
public void Regions_AreNotAllowed()  
{  
var regions = _documents.SelectMany(x =>  
x.GetSyntaxRootAsync().Result.DescendantNodesAndTokens().  
Where(n => n.HasLeadingTrivia).SelectMany(n => n.GetLeadingTrivia().  
Where(t => t.Kind() == SyntaxKind.RegionDirectiveTrivia))).ToList();

regions.ShouldBeEmpty();  
}  

Source code for this article is on Github.

About


Hi! I'm Filip W., a cloud architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP