Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions TUnit.Core/AbstractExecutableTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions TUnit.Core/Context.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions TUnit.Core/TestContext.Output.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
35 changes: 34 additions & 1 deletion TUnit.Core/TestDetails.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using TUnit.Core.Interfaces;

Expand Down Expand Up @@ -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<string, object?> TestClassInjectedPropertyArguments { get; init; } = new ConcurrentDictionary<string, object?>();
// 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<string, object?>? _testClassInjectedPropertyArguments;

public IDictionary<string, object?> TestClassInjectedPropertyArguments
{
get => _testClassInjectedPropertyArguments ?? EmptyInjectedPropertyArguments.Instance;
init => _testClassInjectedPropertyArguments = value switch
{
null => null,
ConcurrentDictionary<string, object?> cd => cd,
_ => new ConcurrentDictionary<string, object?>(value)
};
}

internal ConcurrentDictionary<string, object?> GetOrCreateInjectedPropertyArguments()
{
var existing = _testClassInjectedPropertyArguments;
if (existing != null)
{
return existing;
}

var created = new ConcurrentDictionary<string, object?>();
return Interlocked.CompareExchange(ref _testClassInjectedPropertyArguments, created, null) ?? created;
}

private static class EmptyInjectedPropertyArguments
{
public static readonly IDictionary<string, object?> Instance =
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(0));
}
public List<string> Categories { get; } = [];
public Dictionary<string, List<string>> CustomProperties { get; } = new();
public Type[]? TestClassParameterTypes { get; set; }
Expand Down
18 changes: 9 additions & 9 deletions TUnit.Core/Tracking/ObjectTracker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,23 +99,24 @@ public void TrackObjects(TestContext testContext)
}
}

public ValueTask UntrackObjects(TestContext testContext, List<Exception> cleanupExceptions)
public ValueTask<List<Exception>?> UntrackObjects(TestContext testContext)
{
var trackedObjects = testContext.TrackedObjects;

if (CountTrackedObjects(trackedObjects) == 0)
{
return ValueTask.CompletedTask;
return new ValueTask<List<Exception>?>((List<Exception>?)null);
}

return UntrackObjectsAsync(cleanupExceptions, trackedObjects);
return UntrackObjectsAsync(trackedObjects);
}

private async ValueTask UntrackObjectsAsync(List<Exception> cleanupExceptions, SortedList<int, HashSet<object>> trackedObjects)
private async ValueTask<List<Exception>?> UntrackObjectsAsync(SortedList<int, HashSet<object>> 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<Exception>? cleanupExceptions = null;
var values = trackedObjects.Values;

for (var i = 0; i < values.Count; i++)
Expand All @@ -137,7 +138,7 @@ private async ValueTask UntrackObjectsAsync(List<Exception> cleanupExceptions, S
}
catch (Exception e)
{
cleanupExceptions.Add(e);
(cleanupExceptions ??= []).Add(e);
}
}

Expand All @@ -150,13 +151,12 @@ private async ValueTask UntrackObjectsAsync(List<Exception> cleanupExceptions, S
}
catch
{
foreach (var e in whenAllTask.Exception!.InnerExceptions)
{
cleanupExceptions.Add(e);
}
(cleanupExceptions ??= []).AddRange(whenAllTask.Exception!.InnerExceptions);
}
}
}

return cleanupExceptions;
}

/// <summary>
Expand Down
4 changes: 2 additions & 2 deletions TUnit.Engine/Services/ObjectLifecycleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -552,9 +552,9 @@ private async Task InitializeNestedObjectsAsync(
/// Cleans up after test execution.
/// Decrements reference counts and disposes objects when count reaches zero.
/// </summary>
public Task CleanupTestAsync(TestContext testContext, List<Exception> cleanupExceptions)
public Task<List<Exception>?> CleanupTestAsync(TestContext testContext)
{
return _objectTracker.UntrackObjects(testContext, cleanupExceptions).AsTask();
return _objectTracker.UntrackObjects(testContext).AsTask();
}

#endregion
Expand Down
11 changes: 3 additions & 8 deletions TUnit.Engine/Services/PropertyInjector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -299,8 +299,7 @@ private async Task InjectSourceGeneratedPropertyAsync(
// SetCachedPropertiesOnInstance can use the value directly without re-converting.
if (testContext != null)
{
((ConcurrentDictionary<string, object?>)testContext.Metadata.TestDetails.TestClassInjectedPropertyArguments)
[cacheKey] = resolvedValue;
testContext.Metadata.TestDetails.GetOrCreateInjectedPropertyArguments()[cacheKey] = resolvedValue;
}
}

Expand Down Expand Up @@ -459,9 +458,7 @@ private async Task ResolveAndCacheSourceGeneratedPropertyAsync(

if (resolvedValue != null)
{
// Cache the resolved value
((ConcurrentDictionary<string, object?>)testContext.Metadata.TestDetails.TestClassInjectedPropertyArguments)
.TryAdd(cacheKey, resolvedValue);
testContext.Metadata.TestDetails.GetOrCreateInjectedPropertyArguments().TryAdd(cacheKey, resolvedValue);
}
}

Expand Down Expand Up @@ -504,9 +501,7 @@ private async Task ResolveAndCacheReflectionPropertyAsync(

if (resolvedValue != null)
{
// Cache the resolved value
((ConcurrentDictionary<string, object?>)testContext.Metadata.TestDetails.TestClassInjectedPropertyArguments)
.TryAdd(cacheKey, resolvedValue);
testContext.Metadata.TestDetails.GetOrCreateInjectedPropertyArguments().TryAdd(cacheKey, resolvedValue);
}
}

Expand Down
18 changes: 13 additions & 5 deletions TUnit.Engine/Services/TestExecution/TestCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,6 @@ await RetryHelper.ExecuteWithRetry(test.Context,
}
finally
{
List<Exception>? cleanupExceptions = null;

// Flush console interceptors to ensure all buffered output is captured
// This is critical for output from Console.Write() without newline
try
Expand All @@ -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
Expand All @@ -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
Expand Down
20 changes: 16 additions & 4 deletions TUnit.Engine/TestExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -393,19 +393,28 @@ await testExecutor.ExecuteTest(executableTest.Context,
}
}

internal async Task<List<Exception>> ExecuteAfterClassAssemblyHooks(AbstractExecutableTest executableTest,
internal async Task<List<Exception>?> ExecuteAfterClassAssemblyHooks(AbstractExecutableTest executableTest,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties
| DynamicallyAccessedMemberTypes.PublicMethods)]
Type testClass, Assembly testAssembly, CancellationToken cancellationToken)
{
var exceptions = new List<Exception>();
var flags = _lifecycleCoordinator.DecrementAndCheckAfterHooks(testClass, testAssembly);

if (!flags.ShouldExecuteAfterClass && !flags.ShouldExecuteAfterAssembly)
{
return null;
}

List<Exception>? 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)
Expand All @@ -414,7 +423,10 @@ internal async Task<List<Exception>> 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;
Expand Down
Loading