diff --git a/TUnit.Core/AbstractExecutableTest.cs b/TUnit.Core/AbstractExecutableTest.cs index 38ff5dc886..d28e7fa3de 100644 --- a/TUnit.Core/AbstractExecutableTest.cs +++ b/TUnit.Core/AbstractExecutableTest.cs @@ -72,14 +72,18 @@ public void SetResult(TestState state, Exception? exception = null) { State = state; - // Lazy output building - avoid string allocation when there's no output - var output = Context.GetOutput(); - var errorOutput = Context.GetErrorOutput(); + // Skip the reader/writer lock + StringBuilder dance entirely when nothing + // was ever written — the common passing-test case. var combinedOutput = string.Empty; - - if (output.Length > 0 || errorOutput.Length > 0) + if (Context.HasCapturedOutput) { - combinedOutput = string.Concat(output, Environment.NewLine, Environment.NewLine, errorOutput); + var output = Context.GetOutput(); + var errorOutput = Context.GetErrorOutput(); + + if (output.Length > 0 || errorOutput.Length > 0) + { + combinedOutput = string.Concat(output, Environment.NewLine, Environment.NewLine, errorOutput); + } } Context.Execution.Result ??= new TestResult diff --git a/TUnit.Core/Context.cs b/TUnit.Core/Context.cs index b266d4c055..a4d00352ce 100644 --- a/TUnit.Core/Context.cs +++ b/TUnit.Core/Context.cs @@ -110,6 +110,11 @@ public void AddAsyncLocalValues() public virtual string GetErrorOutput() => _errorOutputWriter?.GetContent() ?? string.Empty; + // Fast path for callers that need to know whether anything was ever captured — + // lets the result-building code skip the reader/writer lock acquisition entirely + // for the (very common) case of a passing test with no output. + internal virtual bool HasCapturedOutput => _outputWriter != null || _errorOutputWriter != null; + public DefaultLogger GetDefaultLogger() { return _defaultLogger ??= new DefaultLogger(this); diff --git a/TUnit.Core/TestContext.Output.cs b/TUnit.Core/TestContext.Output.cs index 57e3a8bc01..92ff273b95 100644 --- a/TUnit.Core/TestContext.Output.cs +++ b/TUnit.Core/TestContext.Output.cs @@ -100,6 +100,9 @@ public override string GetErrorOutput() internal string GetOutputError() => CombineOutputs(_buildTimeErrorOutput, base.GetErrorOutput()); + internal override bool HasCapturedOutput => + base.HasCapturedOutput || _buildTimeOutput != null || _buildTimeErrorOutput != null; + private static string CombineOutputs(string? buildTimeOutput, string runtimeOutput) { if (string.IsNullOrEmpty(buildTimeOutput)) diff --git a/TUnit.Core/TestDetails.cs b/TUnit.Core/TestDetails.cs index a6ff6c2a7c..8c25a03311 100644 --- a/TUnit.Core/TestDetails.cs +++ b/TUnit.Core/TestDetails.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using TUnit.Core.Interfaces; @@ -49,7 +50,39 @@ public partial class TestDetails : ITestIdentity, ITestClass, ITestMethod, ITest public string TestFilePath { get; set; } = ""; public int TestLineNumber { get; set; } public required Type ReturnType { get; set; } - public IDictionary TestClassInjectedPropertyArguments { get; init; } = new ConcurrentDictionary(); + // Lazy — the vast majority of tests use zero injected properties, so we skip the + // per-test ConcurrentDictionary allocation (~200+ bytes) until there's actually + // something to store. + private ConcurrentDictionary? _testClassInjectedPropertyArguments; + + public IDictionary TestClassInjectedPropertyArguments + { + get => _testClassInjectedPropertyArguments ?? EmptyInjectedPropertyArguments.Instance; + init => _testClassInjectedPropertyArguments = value switch + { + null => null, + ConcurrentDictionary cd => cd, + _ => new ConcurrentDictionary(value) + }; + } + + internal ConcurrentDictionary GetOrCreateInjectedPropertyArguments() + { + var existing = _testClassInjectedPropertyArguments; + if (existing != null) + { + return existing; + } + + var created = new ConcurrentDictionary(); + return Interlocked.CompareExchange(ref _testClassInjectedPropertyArguments, created, null) ?? created; + } + + private static class EmptyInjectedPropertyArguments + { + public static readonly IDictionary Instance = + new ReadOnlyDictionary(new Dictionary(0)); + } public List Categories { get; } = []; public Dictionary> CustomProperties { get; } = new(); public Type[]? TestClassParameterTypes { get; set; } diff --git a/TUnit.Core/Tracking/ObjectTracker.cs b/TUnit.Core/Tracking/ObjectTracker.cs index 89c9df2dbb..85e2c930de 100644 --- a/TUnit.Core/Tracking/ObjectTracker.cs +++ b/TUnit.Core/Tracking/ObjectTracker.cs @@ -99,23 +99,24 @@ public void TrackObjects(TestContext testContext) } } - public ValueTask UntrackObjects(TestContext testContext, List cleanupExceptions) + public ValueTask?> UntrackObjects(TestContext testContext) { var trackedObjects = testContext.TrackedObjects; if (CountTrackedObjects(trackedObjects) == 0) { - return ValueTask.CompletedTask; + return new ValueTask?>((List?)null); } - return UntrackObjectsAsync(cleanupExceptions, trackedObjects); + return UntrackObjectsAsync(trackedObjects); } - private async ValueTask UntrackObjectsAsync(List cleanupExceptions, SortedList> trackedObjects) + private async ValueTask?> UntrackObjectsAsync(SortedList> trackedObjects) { // SortedList keeps keys in ascending order; iterate in ascending order (shallowest depth first). // This ensures disposal happens in reverse order of initialization (which goes deepest first). // Dependents (shallow) are disposed before their dependencies (deep). + List? cleanupExceptions = null; var values = trackedObjects.Values; for (var i = 0; i < values.Count; i++) @@ -137,7 +138,7 @@ private async ValueTask UntrackObjectsAsync(List cleanupExceptions, S } catch (Exception e) { - cleanupExceptions.Add(e); + (cleanupExceptions ??= []).Add(e); } } @@ -150,13 +151,12 @@ private async ValueTask UntrackObjectsAsync(List cleanupExceptions, S } catch { - foreach (var e in whenAllTask.Exception!.InnerExceptions) - { - cleanupExceptions.Add(e); - } + (cleanupExceptions ??= []).AddRange(whenAllTask.Exception!.InnerExceptions); } } } + + return cleanupExceptions; } /// diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs index b170c0375d..c8433af372 100644 --- a/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -552,9 +552,9 @@ private async Task InitializeNestedObjectsAsync( /// Cleans up after test execution. /// Decrements reference counts and disposes objects when count reaches zero. /// - public Task CleanupTestAsync(TestContext testContext, List cleanupExceptions) + public Task?> CleanupTestAsync(TestContext testContext) { - return _objectTracker.UntrackObjects(testContext, cleanupExceptions).AsTask(); + return _objectTracker.UntrackObjects(testContext).AsTask(); } #endregion diff --git a/TUnit.Engine/Services/PropertyInjector.cs b/TUnit.Engine/Services/PropertyInjector.cs index 58a63a5450..7037082b48 100644 --- a/TUnit.Engine/Services/PropertyInjector.cs +++ b/TUnit.Engine/Services/PropertyInjector.cs @@ -299,8 +299,7 @@ private async Task InjectSourceGeneratedPropertyAsync( // SetCachedPropertiesOnInstance can use the value directly without re-converting. if (testContext != null) { - ((ConcurrentDictionary)testContext.Metadata.TestDetails.TestClassInjectedPropertyArguments) - [cacheKey] = resolvedValue; + testContext.Metadata.TestDetails.GetOrCreateInjectedPropertyArguments()[cacheKey] = resolvedValue; } } @@ -459,9 +458,7 @@ private async Task ResolveAndCacheSourceGeneratedPropertyAsync( if (resolvedValue != null) { - // Cache the resolved value - ((ConcurrentDictionary)testContext.Metadata.TestDetails.TestClassInjectedPropertyArguments) - .TryAdd(cacheKey, resolvedValue); + testContext.Metadata.TestDetails.GetOrCreateInjectedPropertyArguments().TryAdd(cacheKey, resolvedValue); } } @@ -504,9 +501,7 @@ private async Task ResolveAndCacheReflectionPropertyAsync( if (resolvedValue != null) { - // Cache the resolved value - ((ConcurrentDictionary)testContext.Metadata.TestDetails.TestClassInjectedPropertyArguments) - .TryAdd(cacheKey, resolvedValue); + testContext.Metadata.TestDetails.GetOrCreateInjectedPropertyArguments().TryAdd(cacheKey, resolvedValue); } } diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index a54aedbf6a..37643e9a21 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -134,8 +134,6 @@ await RetryHelper.ExecuteWithRetry(test.Context, } finally { - List? cleanupExceptions = null; - // Flush console interceptors to ensure all buffered output is captured // This is critical for output from Console.Write() without newline try @@ -148,7 +146,9 @@ await RetryHelper.ExecuteWithRetry(test.Context, await _logger.LogErrorAsync($"Error flushing console output for {test.TestId}: {flushEx}").ConfigureAwait(false); } - await _objectTracker.UntrackObjects(test.Context, cleanupExceptions ??= []).ConfigureAwait(false); + // Stay null on the success path — materializing the list only when something actually fails + // saves ~56 bytes per passing test (and passing is the overwhelming majority). + var cleanupExceptions = await _objectTracker.UntrackObjects(test.Context).ConfigureAwait(false); #if NET // Per-test cleanup has completed. Keep the test activity open for final status @@ -161,13 +161,21 @@ await RetryHelper.ExecuteWithRetry(test.Context, var testAssembly = testClass.Assembly; var hookExceptions = await _testExecutor.ExecuteAfterClassAssemblyHooks(test, testClass, testAssembly, CancellationToken.None).ConfigureAwait(false); - if (hookExceptions.Count > 0) + if (hookExceptions is { Count: > 0 }) { foreach (var ex in hookExceptions) { await _logger.LogErrorAsync($"Error executing After hooks for {test.TestId}: {ex}").ConfigureAwait(false); } - (cleanupExceptions ??= []).AddRange(hookExceptions); + + if (cleanupExceptions is null) + { + cleanupExceptions = hookExceptions; + } + else + { + cleanupExceptions.AddRange(hookExceptions); + } } // Invoke Last event receivers for class and assembly diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs index c21315e63d..d8f048f0cf 100644 --- a/TUnit.Engine/TestExecutor.cs +++ b/TUnit.Engine/TestExecutor.cs @@ -393,19 +393,28 @@ await testExecutor.ExecuteTest(executableTest.Context, } } - internal async Task> ExecuteAfterClassAssemblyHooks(AbstractExecutableTest executableTest, + internal async Task?> ExecuteAfterClassAssemblyHooks(AbstractExecutableTest executableTest, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)] Type testClass, Assembly testAssembly, CancellationToken cancellationToken) { - var exceptions = new List(); var flags = _lifecycleCoordinator.DecrementAndCheckAfterHooks(testClass, testAssembly); + if (!flags.ShouldExecuteAfterClass && !flags.ShouldExecuteAfterAssembly) + { + return null; + } + + List? exceptions = null; + if (flags.ShouldExecuteAfterClass) { // Use AfterHookPairTracker to prevent double execution if already triggered by cancellation var classExceptions = await _afterHookPairTracker.GetOrCreateAfterClassTask(testClass, _hookExecutor, cancellationToken).ConfigureAwait(false); - exceptions.AddRange(classExceptions); + if (classExceptions.Count > 0) + { + (exceptions ??= []).AddRange(classExceptions); + } } if (flags.ShouldExecuteAfterAssembly) @@ -414,7 +423,10 @@ internal async Task> ExecuteAfterClassAssemblyHooks(AbstractExec var assemblyExceptions = await _afterHookPairTracker.GetOrCreateAfterAssemblyTask( testAssembly, (assembly) => _hookExecutor.ExecuteAfterAssemblyHooksAsync(assembly, cancellationToken)).ConfigureAwait(false); - exceptions.AddRange(assemblyExceptions); + if (assemblyExceptions.Count > 0) + { + (exceptions ??= []).AddRange(assemblyExceptions); + } } return exceptions;