Arch tests - Check project references

Arch tests - Check project references

V našej firme máme veľké solution, ktoré obsahuje vyše 100 projektov (bez testovacích). V rámci toho solution sú WebAPI projekty, knižnice a AZURE funkcie.
Párkrát sa nám stalo, že sme medzi sebou práve referencovali WebAPI projekty. Čo principiálne nie je správne. Mali by byť navzájom nezávislé a referencovať by mali len iné knižnice.
(počas code review nám to ušlo)

Je to zlé nie len principiálne, ale aj z dôvodu že MSBuild má ešte stále problém a pokiaľ takto referencujete WebAPI projekty, tak vám nedeterministicky môže dať do outputu jedného z projektov json súbory, ktoré sú z iného projektu. Nám sa práve toto dosť často stávalo.

Rozhodli sme sa, že si na to spravíme test. Neviem či je to naozaj typ testu, ktorý sa radí medzi architektonické testy, ale povedzme že áno 😊.

Pôvodne som sa pokúšal využiť knižnicu Microsoft.Build a Microsoft.Build.Locator. Tieto knižnice obsahujú triedy Project a ProjectCollection, ktoré dokážu načítať vlastnosti projektu a jeho referencie. (Microsoft to využíva pri MSBuild) Problém ale bol s tým, že toto bolo veľmi pomalé a malo to svoje muchy.

Našťastie my sme nepotrebovali z csproj nič zložité, vedieť zistiť či sa jedná o host projekt a jeho referencie.
A tak sme si načítali potrebné informácie z csproj súborov priamo cez XDocument.

internal class ProjectFile
{
    private HashSet<string> _projectReferences = [];

    public string Name { get; private set; } = string.Empty;

    public string DirectoryPath { get; private set; } = string.Empty;

    public string FullPath { get; private set; } = string.Empty;

    public string Sdk { get; private set; } = string.Empty;

    public string OutputType { get; private set; } = string.Empty;

    public string AzureFunctionsVersion { get; private set; } = string.Empty;

    public bool IsWebProject
        => OutputType.Equals("Exe", StringComparison.OrdinalIgnoreCase)
        || Sdk.Equals("Microsoft.NET.Sdk.Web", StringComparison.OrdinalIgnoreCase)
        || AzureFunctionsVersion.StartsWith("v", StringComparison.OrdinalIgnoreCase);

    public IEnumerable<string> ProjectsReferences => _projectReferences;

    public static async Task<ProjectFile> LoadAsync(string projectFilePath)
    {
        var projectFile = new ProjectFile();

        projectFile.Name = Path.GetFileNameWithoutExtension(projectFilePath);
        projectFile.FullPath = projectFilePath;
        projectFile.DirectoryPath = Path.GetDirectoryName(projectFilePath) ?? string.Empty;

        using var fileStream = new FileStream(projectFilePath, FileMode.Open, FileAccess.Read);

        var doc = await XDocument.LoadAsync(fileStream, LoadOptions.None, default);

        var projectElement = doc.Element("Project");
        if (projectElement != null)
        {
            projectFile.Sdk = projectElement.Attribute("Sdk")?.Value ?? string.Empty;

            var propertyGroup = projectElement.Element("PropertyGroup");
            if (propertyGroup != null)
            {
                projectFile.OutputType = propertyGroup.Element("OutputType")?.Value ?? string.Empty;
                projectFile.AzureFunctionsVersion = propertyGroup.Element("AzureFunctionsVersion")?.Value ?? string.Empty;
            }

            projectFile._projectReferences = projectElement
                .Elements("ItemGroup")
                .Elements("ProjectReference")
                .Attributes("Include")
                .Select(attr => attr.Value)
                .ToHashSet();
        }

        return projectFile;
    }
}

pozor pri používaní Directory.Build.props, vtedy nie všetky vlastnosti sú priamo v csproj súbore.
Nám to však nevadilo, lebo tie potrebné sme tam mali.

Zistenie či sa jedná o host projekt sme robili na základe SDK vlastnosti v prípade ASP.NET Core WebAPI projektov a Azure Functions sme zase zisťovali podľa AzureFunctionsVersion vlastnosti.

public bool IsWebProject
    => OutputType.Equals("Exe", StringComparison.OrdinalIgnoreCase)
    || Sdk.Equals("Microsoft.NET.Sdk.Web", StringComparison.OrdinalIgnoreCase)
    || AzureFunctionsVersion.StartsWith("v", StringComparison.OrdinalIgnoreCase);

Ešte ProjectFileCollection trieda, ktorá načíta všetky projekty v podadresároch a ich referencie.

internal class ProjectFileCollection : IEnumerable<ProjectFile>
{
    private readonly Dictionary<string, ProjectFile> _projects = new();

    public static async Task<ProjectFileCollection> LoadSolution(string solutionDirectory)
    {
        var projectFileCollection = new ProjectFileCollection();
        foreach (var projectFile in Directory.GetFiles(solutionDirectory, "*.csproj", SearchOption.AllDirectories))
        {
            var project = await ProjectFile.LoadAsync(projectFile);
            projectFileCollection._projects.Add(project.FullPath, project);
        }

        return projectFileCollection;
    }

    public ProjectFile GetProject(string projectPath)
        => _projects[projectPath];

    public IEnumerable<ProjectFile> GetProjectReferences(ProjectFile project)
        => project.ProjectsReferences
            .Select(p => GetProject(GetReferenceFullPath(project.DirectoryPath, p)));

    private static string GetReferenceFullPath(string projectDir, string referencePath)
    {
        if (Path.IsPathRooted(referencePath))
        {
            return referencePath;
        }

        return Path.GetFullPath(Path.Combine(projectDir, referencePath));
    }

    public IEnumerator<ProjectFile> GetEnumerator() => _projects.Values.GetEnumerator();

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

Samotný test potom môže vyzerať napríklad takto:

[Fact]
public async Task WebProjects_ShouldNotReferenceOtherWebProjects()
{
    string solutionDirectory = FindSolutionDirectory();
    var projects = await ProjectFileCollection.LoadSolution(Path.Combine(solutionDirectory, "src"));

    var webProjects = projects.Where(p => p.IsWebProject);
    var errorMessage = new StringBuilder();
    var wrongRecerencesCount = 0;

    foreach (var webProject in webProjects)
    {
        var projectReferences = projects.GetProjectReferences(webProject);
        var webProjectReferences = projectReferences.Where(p => p.IsWebProject);

        if (webProjectReferences.Any())
        {
            errorMessage.AppendFormat("> {0}:", webProject.Name).AppendLine();
            foreach (var reference in webProjectReferences)
            {
                errorMessage.AppendFormat("\t- {0}", reference.Name).AppendLine();
            }
            wrongRecerencesCount++;
            errorMessage.AppendLine("----------------------------------------");
        }
    }

    wrongRecerencesCount.Should()
        .Be(0, "Web project should not reference other web project:\n" + errorMessage.ToString());
}

Výsledok môže vyzrať napríklad takto:

Expected wrongRecerencesCount to be 0 because Web project should not reference other web project:
> Kros.Esw.ApiProjectA:
	- Kros.Esw.ApiProjectB
	- Kros.Esw.ApiProjectC
	- Kros.Esw.ApiProjectD
----------------------------------------
, but found 1 (difference of 1).