Skip to content

Commit

Permalink
build: Include assembly name and target framework when publishing tes…
Browse files Browse the repository at this point in the history
…t results to Azure Pipelines

When publishing test results to Azure Pipelines, pass in a custom name for each result file that includes both the assembly name and the target framework.
With this, results for different target frameworks should show up separately in the Azure Pipelines Web UI.

Pull-Request: #9
  • Loading branch information
ap0llo authored Nov 24, 2022
1 parent ecc29ef commit 1e01884
Show file tree
Hide file tree
Showing 3 changed files with 152 additions and 12 deletions.
1 change: 1 addition & 0 deletions build/Build.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<PackageReference Include="Cake.BuildSystems.Module" Version="3.0.3" />
<PackageReference Include="Cake.Frosting" Version="3.0.0" />
<PackageReference Include="Cake.GitVersioning" Version="3.5.119" />
<PackageReference Include="Mono.Cecil" Version="0.11.4" />
</ItemGroup>


Expand Down
56 changes: 44 additions & 12 deletions build/Tasks/TestTask.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cake.Common.Build;
using Cake.Common.Build.AzurePipelines.Data;
Expand Down Expand Up @@ -64,7 +65,7 @@ private void RunTests(BuildContext context)
context.DotNetTest(context.SolutionPath.FullPath, testSettings);

//
// Publish Test Resilts
// Publish Test Results
//
PublishTestResults(context, failOnMissingTestResults: true);
}
Expand All @@ -77,24 +78,28 @@ private static void PublishTestResults(BuildContext context, bool failOnMissingT
if (!testResults.Any() && failOnMissingTestResults)
throw new Exception($"No test results found in '{context.Output.TestResultsDirectory}'");


if (context.AzurePipelines.IsActive)
{
context.Log.Information("Publishing Test Results to Azure Pipelines");
var azurePipelines = context.AzurePipelines();

// Publish test results to Azure Pipelines test UI
azurePipelines.Commands.PublishTestResults(new()
{
Configuration = context.BuildSettings.Configuration,
TestResultsFiles = testResults,
TestRunner = AzurePipelinesTestRunnerType.VSTest
});
var testRunNames = GetTestRunNames(context, testResults);

// Publish result files as downloadable artifact
foreach (var testResult in testResults)
{
context.Log.Debug($"Publishing '{testResult}' as build artifact");
azurePipelines.Commands.UploadArtifact(
// Publish test results to Azure Pipelines test UI
context.Log.Debug($"Publishing Test Results from '{testResult}' with title '{testRunNames[testResult]}'");
context.AzurePipelines().Commands.PublishTestResults(new()
{
Configuration = context.BuildSettings.Configuration,
TestResultsFiles = new[] { testResult },
TestRunner = AzurePipelinesTestRunnerType.VSTest,
TestRunTitle = testRunNames[testResult]
});

// Publish result file as downloadable artifact
context.Log.Debug($"Publishing Test Result file '{testResult}' as build artifact");
context.AzurePipelines().Commands.UploadArtifact(
folderName: "",
file: testResult,
context.AzurePipelines.ArtifactNames.TestResults
Expand Down Expand Up @@ -146,5 +151,32 @@ private void GenerateCoverageReport(BuildContext context)
});
}
}

private static IReadOnlyDictionary<FilePath, string> GetTestRunNames(BuildContext context, IEnumerable<FilePath> testResultPaths)
{
var testRunNamer = new TestRunNamer(context.Log, context.Environment, context.FileSystem);

var previousNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var testRunNames = new Dictionary<FilePath, string>();

foreach (var testResultPath in testResultPaths)
{
var baseName = testRunNamer.GetTestRunName(testResultPath);
var name = baseName;

// Test run names should be unique, otherwise Azure Pipeline will overwrite results for a previous test with the same name
// To avoid this, append a number at the end of the name until it is unique.
var counter = 1;
while (previousNames.Contains(name))
{
name = $"{baseName} ({counter++})";
}

previousNames.Add(name);
testRunNames.Add(testResultPath, name);
}

return testRunNames;
}
}
}
107 changes: 107 additions & 0 deletions build/_Utilities/TestRunNamer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using System;
using System.Linq;
using System.Xml.Linq;
using Cake.Core;
using Cake.Core.Diagnostics;
using Cake.Core.IO;
using Mono.Cecil;

namespace Build
{
internal class TestRunNamer
{
private static readonly XNamespace s_TrxNamespace = XNamespace.Get("http://microsoft.com/schemas/VisualStudio/TeamTest/2010");

private readonly ICakeLog m_Log;
private readonly ICakeEnvironment m_Environment;
private readonly IFileSystem m_FileSystem;


public TestRunNamer(ICakeLog log, ICakeEnvironment environment, IFileSystem fileSystem)
{
m_Log = log ?? throw new ArgumentNullException(nameof(log));
m_Environment = environment ?? throw new ArgumentNullException(nameof(environment));
m_FileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
}


public string GetTestRunName(FilePath testResultPath)
{
// Default case: Use the name of the test result file
var name = testResultPath.GetFilenameWithoutExtension().ToString();

try
{
// For supported formats, try to get a better test run name
switch (testResultPath.GetExtension().ToLower())
{
case ".trx":
var trxTestRunName = GetTrxTestRunName(testResultPath);
if (!String.IsNullOrWhiteSpace(trxTestRunName))
{
name = trxTestRunName;
}
break;
}
}
catch (Exception ex)
{
m_Log.Warning($"Failed to determine test run name for file '{testResultPath}': {ex.Message}. Falling back to use the file name.");
name = testResultPath.GetFilenameWithoutExtension().ToString();
}

return name.ToString();
}

private string? GetTrxTestRunName(FilePath testResultPath)
{
var document = XDocument.Load(testResultPath.MakeAbsolute(m_Environment).FullPath);

if (document.Root is null)
return null;

var assemblyFiles = document.Root
// Get the assembly path for all tests in the TRX file
.Elements(s_TrxNamespace.GetName("TestDefinitions"))
.Elements(s_TrxNamespace.GetName("UnitTest"))
.Elements(s_TrxNamespace.GetName("TestMethod"))
.Select(x => x.Attribute("codeBase")?.Value)
.Where(x => !String.IsNullOrEmpty(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
// Filter down to files that exist on disk
.Where(x => m_FileSystem.Exist(new FilePath(x)))
.Select(x => m_FileSystem.GetFile(new FilePath(x)))
.ToHashSet();

// no assemblies found => cannot determine test run name
if (assemblyFiles.Count == 0)
return null;

// For the test run name, use the assembly name + the assembly's target framework
// If there are multiple values, join them together using " | "
return String.Join(" | ", assemblyFiles.Select(assemblyFile =>
{
var name = assemblyFile.Path.GetFilename().ToString();
var targetFramework = GetTargetFrameworkFromAssembly(assemblyFile.Path.FullPath);

return String.IsNullOrEmpty(targetFramework)
? name
: $"{name} ({targetFramework})";
}));
}

private static string? GetTargetFrameworkFromAssembly(string path)
{
using var assemblyDefinition = AssemblyDefinition.ReadAssembly(path);

// Read the assembly's TargetFramework attribute
var targetFrameworkAttribute = assemblyDefinition.CustomAttributes.SingleOrDefault(x => x.AttributeType.FullName == "System.Runtime.Versioning.TargetFrameworkAttribute");

if (targetFrameworkAttribute is null)
return null;

return (string)targetFrameworkAttribute.ConstructorArguments.Single().Value;
}

}
}

0 comments on commit 1e01884

Please sign in to comment.