From 984b19c07ac7634ef5c3413ef07df75c2ffea504 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 10 Jan 2026 22:31:10 +0000 Subject: [PATCH 1/4] chore: add .worktrees/ to gitignore Co-Authored-By: Claude Opus 4.5 --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c7af869a73..dd1360bb1a 100644 --- a/.gitignore +++ b/.gitignore @@ -435,4 +435,7 @@ doc/plans/ *speedscope*.json # Dotnet trace files -*.nettrace \ No newline at end of file +*.nettrace + +# Git worktrees +.worktrees/ \ No newline at end of file From b4169ebdb9dbc62a97aea512818f238ab192e94b Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 10 Jan 2026 23:19:15 +0000 Subject: [PATCH 2/4] perf: cache TestNode properties to reduce allocations ToTestNode is called 3+ times per test (discovered, in-progress, passed/failed). Before this change, it was creating dozens of objects per call including: - Assembly.GetName().FullName which allocates AssemblyName - TestFileLocationProperty - TestMethodIdentifierProperty - TestMetadataProperty[] for categories and custom properties - TrxCategoriesProperty This change: - Caches AssemblyName.FullName per assembly using ConcurrentDictionary - Caches all static TestNode properties per test ID that never change between state transitions - Uses StringBuilderPool in GetClassTypeName instead of new StringBuilder Profiling shows ToTestNode dropped from 3.06% exclusive CPU time to effectively negligible (not in top 50 hotspots). Async overhead also improved from ~4.13% to ~3.28% due to reduced GC pressure. For a 1000 test suite, this eliminates approximately 6000+ object allocations per run. Co-Authored-By: Claude Opus 4.5 --- .../Extensions/TestContextExtensions.cs | 31 +-- TUnit.Engine/Extensions/TestExtensions.cs | 179 +++++++++++++----- 2 files changed, 154 insertions(+), 56 deletions(-) diff --git a/TUnit.Core/Extensions/TestContextExtensions.cs b/TUnit.Core/Extensions/TestContextExtensions.cs index c51dd14951..8a08b8b35e 100644 --- a/TUnit.Core/Extensions/TestContextExtensions.cs +++ b/TUnit.Core/Extensions/TestContextExtensions.cs @@ -15,23 +15,30 @@ public static string GetClassTypeName(this TestContext context) return context.Metadata.TestDetails.ClassType.Name; } - // Optimize: Use StringBuilder to avoid string concatenation allocations + // PERFORMANCE: Use pooled StringBuilder to avoid 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..734b572c53 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,151 @@ internal static class TestExtensions private static bool? _cachedIsTrxEnabled; private static bool? _cachedIsDetailedOutput; - internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateProperty stateProperty) - { - var testDetails = testContext.Metadata.TestDetails ?? throw new ArgumentNullException(nameof(testContext.Metadata.TestDetails)); + // PERFORMANCE: Cache assembly full names to avoid repeated GetName() allocations + // Assembly.GetName() allocates a new AssemblyName object each time - this was a major hotspot + private static readonly ConcurrentDictionary AssemblyFullNameCache = new(); - var isFinalState = stateProperty is not DiscoveredTestNodeStateProperty and not InProgressTestNodeStateProperty; + // PERFORMANCE: Cache static properties per test that never change between state transitions + // ToTestNode is called 3+ times per test (discovered, in-progress, passed/failed) + // These properties are identical each time, so caching eliminates redundant allocations + private static readonly ConcurrentDictionary TestNodePropertiesCache = new(); - var isTrxEnabled = isFinalState && IsTrxEnabled(testContext); + /// + /// Cached properties that don't change between test state transitions. + /// This significantly reduces allocations since ToTestNode is called multiple times per test. + /// + private sealed class CachedTestNodeProperties + { + 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 estimatedCount = EstimateCount(testContext, stateProperty, isTrxEnabled); + private static string GetCachedAssemblyFullName(Assembly assembly) + { + return AssemblyFullNameCache.GetOrAdd(assembly, static a => a.GetName().FullName); + } - var properties = new List(estimatedCount) - { - stateProperty, + private static CachedTestNodeProperties GetOrCreateCachedProperties(TestContext testContext) + { + var testDetails = testContext.Metadata.TestDetails; + var testId = testDetails.TestId; - new TestFileLocationProperty(testDetails.TestFilePath, new LinePositionSpan( + return TestNodePropertiesCache.GetOrAdd(testId, _ => + { + // Create file location property (never changes) + var fileLocation = new TestFileLocationProperty(testDetails.TestFilePath, new LinePositionSpan( new LinePosition(testDetails.TestLineNumber, 0), new LinePosition(testDetails.TestLineNumber, 0) - )), + )); - new TestMethodIdentifierProperty( + // Create method identifier property (never changes) + 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 - ) + ); + + // Cache category properties (never change) + 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]); + } + } + + // Cache custom properties (never change) + 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); + } + } + } + + // Cache TRX type name (never changes) + var trxTypeName = testDetails.MethodMetadata.Class.Type.FullName ?? testDetails.ClassType.FullName ?? "UnknownType"; + + // Cache TRX categories (never change) + 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); + + // Get cached properties that don't change between state transitions + 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) + // Add cached category properties + if (cachedProps.CategoryProperties != null) { - properties.AddRange(testDetails.Categories.Select(static category => new TestMetadataProperty(category))); + properties.AddRange(cachedProps.CategoryProperties); } - if (testDetails.CustomProperties.Count > 0) + // Add cached custom properties + if (cachedProps.CustomProperties != null) { - properties.AddRange(ExtractProperties(testDetails)); + properties.AddRange(cachedProps.CustomProperties); } - // Artifacts - if(isFinalState && testContext.Output.Artifacts.Count > 0) + // Artifacts (only in final state, and these are dynamic) + 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; @@ -88,19 +190,19 @@ 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 +217,7 @@ internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateP } } - if(isFinalState) + if (isFinalState) { properties.Add(GetTimingProperty(testContext, testContext.Execution.TestStart.GetValueOrDefault())); } @@ -170,12 +272,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 +293,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 +326,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)) From 319cdd99795110664108fa7db3bd25b544c4ceb8 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 10 Jan 2026 23:22:30 +0000 Subject: [PATCH 3/4] chore: remove redundant comments Co-Authored-By: Claude Opus 4.5 --- .../Extensions/TestContextExtensions.cs | 1 - TUnit.Engine/Extensions/TestExtensions.cs | 26 ------------------- 2 files changed, 27 deletions(-) diff --git a/TUnit.Core/Extensions/TestContextExtensions.cs b/TUnit.Core/Extensions/TestContextExtensions.cs index 8a08b8b35e..0a3381d515 100644 --- a/TUnit.Core/Extensions/TestContextExtensions.cs +++ b/TUnit.Core/Extensions/TestContextExtensions.cs @@ -15,7 +15,6 @@ public static string GetClassTypeName(this TestContext context) return context.Metadata.TestDetails.ClassType.Name; } - // PERFORMANCE: Use pooled StringBuilder to avoid allocations var args = context.Metadata.TestDetails.TestClassArguments; var sb = StringBuilderPool.Get(); try diff --git a/TUnit.Engine/Extensions/TestExtensions.cs b/TUnit.Engine/Extensions/TestExtensions.cs index 734b572c53..8a241de03a 100644 --- a/TUnit.Engine/Extensions/TestExtensions.cs +++ b/TUnit.Engine/Extensions/TestExtensions.cs @@ -16,19 +16,9 @@ internal static class TestExtensions private static bool? _cachedIsTrxEnabled; private static bool? _cachedIsDetailedOutput; - // PERFORMANCE: Cache assembly full names to avoid repeated GetName() allocations - // Assembly.GetName() allocates a new AssemblyName object each time - this was a major hotspot private static readonly ConcurrentDictionary AssemblyFullNameCache = new(); - - // PERFORMANCE: Cache static properties per test that never change between state transitions - // ToTestNode is called 3+ times per test (discovered, in-progress, passed/failed) - // These properties are identical each time, so caching eliminates redundant allocations private static readonly ConcurrentDictionary TestNodePropertiesCache = new(); - /// - /// Cached properties that don't change between test state transitions. - /// This significantly reduces allocations since ToTestNode is called multiple times per test. - /// private sealed class CachedTestNodeProperties { public required TestFileLocationProperty FileLocation { get; init; } @@ -51,13 +41,11 @@ private static CachedTestNodeProperties GetOrCreateCachedProperties(TestContext return TestNodePropertiesCache.GetOrAdd(testId, _ => { - // Create file location property (never changes) var fileLocation = new TestFileLocationProperty(testDetails.TestFilePath, new LinePositionSpan( new LinePosition(testDetails.TestLineNumber, 0), new LinePosition(testDetails.TestLineNumber, 0) )); - // Create method identifier property (never changes) var methodIdentifier = new TestMethodIdentifierProperty( @namespace: testDetails.MethodMetadata.Class.Type.Namespace ?? "", assemblyFullName: GetCachedAssemblyFullName(testDetails.MethodMetadata.Class.Type.Assembly), @@ -68,7 +56,6 @@ private static CachedTestNodeProperties GetOrCreateCachedProperties(TestContext methodArity: testDetails.MethodMetadata.GenericTypeCount ); - // Cache category properties (never change) TestMetadataProperty[]? categoryProps = null; if (testDetails.Categories.Count > 0) { @@ -79,7 +66,6 @@ private static CachedTestNodeProperties GetOrCreateCachedProperties(TestContext } } - // Cache custom properties (never change) TestMetadataProperty[]? customProps = null; if (testDetails.CustomProperties.Count > 0) { @@ -100,10 +86,8 @@ private static CachedTestNodeProperties GetOrCreateCachedProperties(TestContext } } - // Cache TRX type name (never changes) var trxTypeName = testDetails.MethodMetadata.Class.Type.FullName ?? testDetails.ClassType.FullName ?? "UnknownType"; - // Cache TRX categories (never change) TrxCategoriesProperty? trxCategories = null; if (testDetails.Categories.Count > 0) { @@ -130,7 +114,6 @@ internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateP var isTrxEnabled = isFinalState && IsTrxEnabled(testContext); - // Get cached properties that don't change between state transitions var cachedProps = GetOrCreateCachedProperties(testContext); var estimatedCount = EstimateCount(testContext, stateProperty, isTrxEnabled); @@ -142,19 +125,16 @@ internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateP cachedProps.MethodIdentifier }; - // Add cached category properties if (cachedProps.CategoryProperties != null) { properties.AddRange(cachedProps.CategoryProperties); } - // Add cached custom properties if (cachedProps.CustomProperties != null) { properties.AddRange(cachedProps.CustomProperties); } - // Artifacts (only in final state, and these are dynamic) if (isFinalState && testContext.Output.Artifacts.Count > 0) { foreach (var artifact in testContext.Artifacts) @@ -171,8 +151,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)) @@ -187,7 +165,6 @@ internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateP } } - // TRX Report Properties if (isFinalState && isTrxEnabled) { properties.Add(new TrxFullyQualifiedTypeNameProperty(cachedProps.TrxFullyQualifiedTypeName!)); @@ -363,9 +340,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) From 12517e29bcbc6eedb5dca97d172a23bbdc6b5412 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 10 Jan 2026 23:24:42 +0000 Subject: [PATCH 4/4] fix: clear caches at end of test session Add ClearCaches() method to TestExtensions and call it during TUnitServiceProvider.DisposeAsync() to prevent unbounded cache growth in long-running scenarios. Co-Authored-By: Claude Opus 4.5 --- TUnit.Engine/Extensions/TestExtensions.cs | 8 ++++++++ TUnit.Engine/Framework/TUnitServiceProvider.cs | 3 +++ 2 files changed, 11 insertions(+) diff --git a/TUnit.Engine/Extensions/TestExtensions.cs b/TUnit.Engine/Extensions/TestExtensions.cs index 8a241de03a..26bed87851 100644 --- a/TUnit.Engine/Extensions/TestExtensions.cs +++ b/TUnit.Engine/Extensions/TestExtensions.cs @@ -29,6 +29,14 @@ private sealed class CachedTestNodeProperties public TrxCategoriesProperty? TrxCategories { get; init; } } + internal static void ClearCaches() + { + AssemblyFullNameCache.Clear(); + TestNodePropertiesCache.Clear(); + _cachedIsTrxEnabled = null; + _cachedIsDetailedOutput = null; + } + private static string GetCachedAssemblyFullName(Assembly assembly) { return AssemblyFullNameCache.GetOrAdd(assembly, static a => a.GetName().FullName); 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(); } }