diff --git a/TUnit.Core/Extensions/TestContextExtensions.cs b/TUnit.Core/Extensions/TestContextExtensions.cs index c51dd14951..0a3381d515 100644 --- a/TUnit.Core/Extensions/TestContextExtensions.cs +++ b/TUnit.Core/Extensions/TestContextExtensions.cs @@ -15,23 +15,29 @@ public static string GetClassTypeName(this TestContext context) return context.Metadata.TestDetails.ClassType.Name; } - // Optimize: Use StringBuilder to avoid string concatenation allocations var args = context.Metadata.TestDetails.TestClassArguments; - var sb = new System.Text.StringBuilder(); - sb.Append(context.Metadata.TestDetails.ClassType.Name); - sb.Append('('); - - for (int i = 0; i < args.Length; i++) + var sb = StringBuilderPool.Get(); + try { - if (i > 0) + sb.Append(context.Metadata.TestDetails.ClassType.Name); + sb.Append('('); + + for (var i = 0; i < args.Length; i++) { - sb.Append(", "); + if (i > 0) + { + sb.Append(", "); + } + sb.Append(ArgumentFormatter.Format(args[i], context.ArgumentDisplayFormatters)); } - sb.Append(ArgumentFormatter.Format(args[i], context.ArgumentDisplayFormatters)); - } - sb.Append(')'); - return sb.ToString(); + sb.Append(')'); + return sb.ToString(); + } + finally + { + StringBuilderPool.Return(sb); + } } #if NET6_0_OR_GREATER diff --git a/TUnit.Engine/Extensions/TestExtensions.cs b/TUnit.Engine/Extensions/TestExtensions.cs index 920ce5c96c..26bed87851 100644 --- a/TUnit.Engine/Extensions/TestExtensions.cs +++ b/TUnit.Engine/Extensions/TestExtensions.cs @@ -1,4 +1,6 @@ -using Microsoft.Testing.Extensions.TrxReport.Abstractions; +using System.Collections.Concurrent; +using System.Reflection; +using Microsoft.Testing.Extensions.TrxReport.Abstractions; using Microsoft.Testing.Platform.Capabilities.TestFramework; using Microsoft.Testing.Platform.Extensions.Messages; using TUnit.Core; @@ -14,51 +16,139 @@ internal static class TestExtensions private static bool? _cachedIsTrxEnabled; private static bool? _cachedIsDetailedOutput; - internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateProperty stateProperty) + private static readonly ConcurrentDictionary AssemblyFullNameCache = new(); + private static readonly ConcurrentDictionary TestNodePropertiesCache = new(); + + private sealed class CachedTestNodeProperties { - var testDetails = testContext.Metadata.TestDetails ?? throw new ArgumentNullException(nameof(testContext.Metadata.TestDetails)); + public required TestFileLocationProperty FileLocation { get; init; } + public required TestMethodIdentifierProperty MethodIdentifier { get; init; } + public TestMetadataProperty[]? CategoryProperties { get; init; } + public TestMetadataProperty[]? CustomProperties { get; init; } + public string? TrxFullyQualifiedTypeName { get; init; } + public TrxCategoriesProperty? TrxCategories { get; init; } + } - var isFinalState = stateProperty is not DiscoveredTestNodeStateProperty and not InProgressTestNodeStateProperty; + internal static void ClearCaches() + { + AssemblyFullNameCache.Clear(); + TestNodePropertiesCache.Clear(); + _cachedIsTrxEnabled = null; + _cachedIsDetailedOutput = null; + } - var isTrxEnabled = isFinalState && IsTrxEnabled(testContext); + private static string GetCachedAssemblyFullName(Assembly assembly) + { + return AssemblyFullNameCache.GetOrAdd(assembly, static a => a.GetName().FullName); + } - var estimatedCount = EstimateCount(testContext, stateProperty, isTrxEnabled); + private static CachedTestNodeProperties GetOrCreateCachedProperties(TestContext testContext) + { + var testDetails = testContext.Metadata.TestDetails; + var testId = testDetails.TestId; - var properties = new List(estimatedCount) + return TestNodePropertiesCache.GetOrAdd(testId, _ => { - stateProperty, - - new TestFileLocationProperty(testDetails.TestFilePath, new LinePositionSpan( + var fileLocation = new TestFileLocationProperty(testDetails.TestFilePath, new LinePositionSpan( new LinePosition(testDetails.TestLineNumber, 0), new LinePosition(testDetails.TestLineNumber, 0) - )), + )); - new TestMethodIdentifierProperty( + var methodIdentifier = new TestMethodIdentifierProperty( @namespace: testDetails.MethodMetadata.Class.Type.Namespace ?? "", - assemblyFullName: testDetails.MethodMetadata.Class.Type.Assembly.GetName().FullName, + assemblyFullName: GetCachedAssemblyFullName(testDetails.MethodMetadata.Class.Type.Assembly), typeName: testContext.GetClassTypeName(), methodName: testDetails.MethodName, parameterTypeFullNames: CreateParameterTypeArray(testDetails.MethodMetadata.Parameters), returnTypeFullName: testDetails.ReturnType.FullName ?? typeof(void).FullName!, methodArity: testDetails.MethodMetadata.GenericTypeCount - ) + ); + + TestMetadataProperty[]? categoryProps = null; + if (testDetails.Categories.Count > 0) + { + categoryProps = new TestMetadataProperty[testDetails.Categories.Count]; + for (var i = 0; i < testDetails.Categories.Count; i++) + { + categoryProps[i] = new TestMetadataProperty(testDetails.Categories[i]); + } + } + + TestMetadataProperty[]? customProps = null; + if (testDetails.CustomProperties.Count > 0) + { + var count = 0; + foreach (var prop in testDetails.CustomProperties) + { + count += prop.Value.Count; + } + + customProps = new TestMetadataProperty[count]; + var idx = 0; + foreach (var prop in testDetails.CustomProperties) + { + foreach (var value in prop.Value) + { + customProps[idx++] = new TestMetadataProperty(prop.Key, value); + } + } + } + + var trxTypeName = testDetails.MethodMetadata.Class.Type.FullName ?? testDetails.ClassType.FullName ?? "UnknownType"; + + TrxCategoriesProperty? trxCategories = null; + if (testDetails.Categories.Count > 0) + { + trxCategories = new TrxCategoriesProperty([..testDetails.Categories]); + } + + return new CachedTestNodeProperties + { + FileLocation = fileLocation, + MethodIdentifier = methodIdentifier, + CategoryProperties = categoryProps, + CustomProperties = customProps, + TrxFullyQualifiedTypeName = trxTypeName, + TrxCategories = trxCategories + }; + }); + } + + internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateProperty stateProperty) + { + var testDetails = testContext.Metadata.TestDetails ?? throw new ArgumentNullException(nameof(testContext.Metadata.TestDetails)); + + var isFinalState = stateProperty is not DiscoveredTestNodeStateProperty and not InProgressTestNodeStateProperty; + + var isTrxEnabled = isFinalState && IsTrxEnabled(testContext); + + var cachedProps = GetOrCreateCachedProperties(testContext); + + var estimatedCount = EstimateCount(testContext, stateProperty, isTrxEnabled); + + var properties = new List(estimatedCount) + { + stateProperty, + cachedProps.FileLocation, + cachedProps.MethodIdentifier }; - // Custom TUnit Properties - if (testDetails.Categories.Count > 0) + if (cachedProps.CategoryProperties != null) { - properties.AddRange(testDetails.Categories.Select(static category => new TestMetadataProperty(category))); + properties.AddRange(cachedProps.CategoryProperties); } - if (testDetails.CustomProperties.Count > 0) + if (cachedProps.CustomProperties != null) { - properties.AddRange(ExtractProperties(testDetails)); + properties.AddRange(cachedProps.CustomProperties); } - // Artifacts - if(isFinalState && testContext.Output.Artifacts.Count > 0) + if (isFinalState && testContext.Output.Artifacts.Count > 0) { - properties.AddRange(testContext.Artifacts.Select(static x => new FileArtifactProperty(x.File, x.DisplayName, x.Description))); + foreach (var artifact in testContext.Artifacts) + { + properties.Add(new FileArtifactProperty(artifact.File, artifact.DisplayName, artifact.Description)); + } } string? output = null; @@ -69,8 +159,6 @@ internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateP output = testContext.GetStandardOutput(); error = testContext.GetErrorOutput(); - // Only add output properties when NOT in detailed output mode to avoid duplication - // In detailed mode, the output is already shown in real-time by the platform if (!IsDetailedOutput(testContext)) { if (!string.IsNullOrEmpty(output)) @@ -85,22 +173,21 @@ internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateP } } - // TRX Report Properties if (isFinalState && isTrxEnabled) { - properties.Add(new TrxFullyQualifiedTypeNameProperty(testDetails.MethodMetadata.Class.Type.FullName ?? testDetails.ClassType.FullName ?? "UnknownType")); + properties.Add(new TrxFullyQualifiedTypeNameProperty(cachedProps.TrxFullyQualifiedTypeName!)); - if(testDetails.Categories.Count > 0) + if (cachedProps.TrxCategories != null) { - properties.Add(new TrxCategoriesProperty([..testDetails.Categories])); + properties.Add(cachedProps.TrxCategories); } - if (isFinalState && GetTrxMessages(testContext, output, error).ToArray() is { Length: > 0 } trxMessages) + if (GetTrxMessages(testContext, output, error).ToArray() is { Length: > 0 } trxMessages) { properties.Add(new TrxMessagesProperty(trxMessages)); } - if(stateProperty is ErrorTestNodeStateProperty or FailedTestNodeStateProperty or TimeoutTestNodeStateProperty) + if (stateProperty is ErrorTestNodeStateProperty or FailedTestNodeStateProperty or TimeoutTestNodeStateProperty) { var (exception, explanation) = GetException(stateProperty); @@ -115,7 +202,7 @@ internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateP } } - if(isFinalState) + if (isFinalState) { properties.Add(GetTimingProperty(testContext, testContext.Execution.TestStart.GetValueOrDefault())); } @@ -170,12 +257,12 @@ private static int EstimateCount(TestContext testContext, TestNodeStateProperty { count += 2; // TRX TypeName + TRX Messages - if(testDetails.Categories.Count > 0) + if (testDetails.Categories.Count > 0) { count += 1; // TRX Categories } - if(stateProperty is ErrorTestNodeStateProperty or FailedTestNodeStateProperty or TimeoutTestNodeStateProperty) + if (stateProperty is ErrorTestNodeStateProperty or FailedTestNodeStateProperty or TimeoutTestNodeStateProperty) { count += 1; // Trx Exception } @@ -191,13 +278,13 @@ private static bool IsTrxEnabled(TestContext testContext) return _cachedIsTrxEnabled.Value; } - if(testContext.Services.GetService() is not {} capabilities) + if (testContext.Services.GetService() is not {} capabilities) { _cachedIsTrxEnabled = false; return false; } - if(capabilities.GetCapability() is not TrxReportCapability trxCapability) + if (capabilities.GetCapability() is not TrxReportCapability trxCapability) { _cachedIsTrxEnabled = false; return false; @@ -224,17 +311,6 @@ private static bool IsDetailedOutput(TestContext testContext) return _cachedIsDetailedOutput.Value; } - private static IEnumerable ExtractProperties(TestDetails testDetails) - { - foreach (var propertyGroup in testDetails.CustomProperties) - { - foreach (var propertyValue in propertyGroup.Value) - { - yield return new TestMetadataProperty(propertyGroup.Key, propertyValue); - } - } - } - private static TimingProperty GetTimingProperty(TestContext testContext, DateTimeOffset overallStart) { if (overallStart == default(DateTimeOffset)) @@ -272,9 +348,6 @@ private static IEnumerable GetTrxMessages(TestContext testContext, s } } - /// - /// Efficiently create parameter type array without LINQ materialization - /// private static string[] CreateParameterTypeArray(ParameterMetadata[] parameters) { if (parameters.Length == 0) diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs index d74193b0cc..bff64d228a 100644 --- a/TUnit.Engine/Framework/TUnitServiceProvider.cs +++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs @@ -17,6 +17,7 @@ using TUnit.Engine.Building.Interfaces; using TUnit.Engine.CommandLineProviders; using TUnit.Engine.Discovery; +using TUnit.Engine.Extensions; using TUnit.Engine.Helpers; using TUnit.Engine.Interfaces; using TUnit.Engine.Logging; @@ -305,5 +306,7 @@ public async ValueTask DisposeAsync() } _services.Clear(); + + TestExtensions.ClearCaches(); } }