Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
7 changes: 7 additions & 0 deletions TUnit.Core/ExecutableTest`1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ public override async Task<object> CreateInstanceAsync()

public override async Task InvokeTestAsync(object instance, CancellationToken cancellationToken)
{
var indexed = _metadata.IndexedInvokeBody;
if (indexed is not null)
{
await indexed((T)instance, _metadata.MethodIndex, Arguments, cancellationToken).ConfigureAwait(false);
return;
}

await _metadata.InvokeTypedTest!((T)instance, Arguments, cancellationToken).ConfigureAwait(false);
}
}
30 changes: 20 additions & 10 deletions TUnit.Core/TestEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,19 +101,14 @@ public sealed class TestEntry<
/// <summary>
/// Constructs a TestMetadata&lt;T&gt; from this entry's data and delegates.
/// </summary>
// Cached delegates and arrays — built once from immutable fields
private Func<T, object?[], CancellationToken, ValueTask>? _cachedInvokeTypedTest;
private Func<Attribute[]>? _cachedAttributeFactory;
// Cached arrays — built once from immutable fields. The class-shared InvokeBody/CreateAttributes
// delegates and their indexes are forwarded directly onto the resulting TestMetadata so there is
// no per-test closure allocation on the hot path.
private PropertyDataSource[]? _cachedPropertyDataSources;
private PropertyInjectionData[]? _cachedPropertyInjections;

internal TestMetadata<T> ToTestMetadata(string testSessionId)
{
if (_cachedInvokeTypedTest is null)
Interlocked.CompareExchange(ref _cachedInvokeTypedTest, (instance, args, ct) => InvokeBody(instance, MethodIndex, args, ct), null);
if (_cachedAttributeFactory is null)
Interlocked.CompareExchange(ref _cachedAttributeFactory, () => CreateAttributes(AttributeGroupIndex), null);

return new TestMetadata<T>
{
TestName = MethodName,
Expand All @@ -125,8 +120,11 @@ internal TestMetadata<T> ToTestMetadata(string testSessionId)
PropertyDataSources = _cachedPropertyDataSources ??= BuildPropertyDataSources(),
PropertyInjections = _cachedPropertyInjections ??= BuildPropertyInjections(),
InstanceFactory = CreateInstance,
InvokeTypedTest = _cachedInvokeTypedTest,
AttributeFactory = _cachedAttributeFactory,
IndexedInvokeBody = InvokeBody,
MethodIndex = MethodIndex,
AttributeFactory = TestEntrySentinel.IndexedAttributeFactoryPlaceholder,
IndexedAttributeFactory = CreateAttributes,
AttributeGroupIndex = AttributeGroupIndex,
FilePath = FilePath,
LineNumber = LineNumber,
MethodMetadata = MethodMetadata,
Expand Down Expand Up @@ -172,3 +170,15 @@ private PropertyInjectionData[] BuildPropertyInjections()
return result;
}
}

// Non-generic holder so the placeholder delegate is shared across every closed TestEntry<T> —
// a static field on the generic type would otherwise be duplicated per closed type (Sonar S2743).
internal static class TestEntrySentinel
{
// Satisfies TestMetadata's `required` AttributeFactory without a per-test allocation:
// TestEntry-sourced metadata always takes the IndexedAttributeFactory path, so this throws
// if ever invoked — reaching it indicates an engine bug.
internal static readonly Func<Attribute[]> IndexedAttributeFactoryPlaceholder =
static () => throw new InvalidOperationException(
"TestEntry metadata must resolve attributes via IndexedAttributeFactory.");
}
26 changes: 23 additions & 3 deletions TUnit.Core/TestMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,35 @@ public abstract class TestMetadata

public required Func<Attribute[]> AttributeFactory { get; init; }

/// <summary>
/// Class-shared indexed attribute factory. When set, used in preference to <see cref="AttributeFactory"/>
/// so TestEntry-emitted metadata avoids allocating a per-test closure over <see cref="AttributeGroupIndex"/>.
/// </summary>
internal Func<int, Attribute[]>? IndexedAttributeFactory { get; init; }

/// <summary>Index passed to <see cref="IndexedAttributeFactory"/> when dispatching.</summary>
internal int AttributeGroupIndex { get; init; }

private Attribute[]? _cachedAttributes;

/// <summary>
/// Returns the cached attributes array, creating it from <see cref="AttributeFactory"/> on first call.
/// Subsequent calls return the same array without re-invoking the factory.
/// Returns the cached attributes array, creating it from <see cref="IndexedAttributeFactory"/>
/// (preferred) or <see cref="AttributeFactory"/> on first call. Subsequent calls return the same array.
/// </summary>
internal Attribute[] GetOrCreateAttributes()
{
return _cachedAttributes ??= AttributeFactory();
// Benign race: factories are idempotent, so on contention we may build two arrays and
// discard the loser. CAS publishes exactly one and every future reader sees it — cheaper
// than a lock on the hot path.
var cached = _cachedAttributes;
if (cached is not null)
{
return cached;
}

var indexed = IndexedAttributeFactory;
var produced = indexed is not null ? indexed(AttributeGroupIndex) : AttributeFactory();
return Interlocked.CompareExchange(ref _cachedAttributes, produced, null) ?? produced;
}

/// <summary>
Expand Down
21 changes: 18 additions & 3 deletions TUnit.Core/TestMetadata`1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,15 @@ public class TestMetadata<
/// </summary>
public Func<T, object?[], CancellationToken, ValueTask>? InvokeTypedTest { get; init; }

/// <summary>
/// Class-shared indexed invoker emitted by the source generator. All tests in a class share this
/// delegate and dispatch via <see cref="MethodIndex"/>, so the TestEntry → TestMetadata bridge can
/// forward the static delegate without allocating a per-test closure capturing <c>this</c>.
/// </summary>
internal Func<T, int, object?[], CancellationToken, ValueTask>? IndexedInvokeBody { get; init; }


/// <summary>Index passed to <see cref="IndexedInvokeBody"/> when dispatching.</summary>
internal int MethodIndex { get; init; }

/// <summary>
/// Factory delegate that creates an ExecutableTest for this metadata.
Expand All @@ -77,17 +84,25 @@ public override Func<ExecutableTestCreationContext, TestMetadata, AbstractExecut
return cached;
}

if (InstanceFactory != null && InvokeTypedTest != null)
if (InstanceFactory != null && (InvokeTypedTest != null || IndexedInvokeBody != null))
{
Interlocked.CompareExchange<Func<ExecutableTestCreationContext, TestMetadata, AbstractExecutableTest>?>(
ref _cachedExecutableTestFactory, CreateTypedExecutableTest, null);
return _cachedExecutableTestFactory!;
}

throw new InvalidOperationException($"InstanceFactory and InvokeTypedTest must be set for {typeof(T).Name}");
// Delegating the throw to a helper keeps this getter itself throw-free (Sonar S2372) —
// the abstract base declares this as a property so it must stay a property, and the
// misconfiguration is a programmer error rather than a recoverable condition.
return ThrowMissingInvoker();
}
}

[DoesNotReturn]
private static Func<ExecutableTestCreationContext, TestMetadata, AbstractExecutableTest> ThrowMissingInvoker() =>
throw new InvalidOperationException(
$"InstanceFactory and an invoker (InvokeTypedTest or IndexedInvokeBody) must be set for {typeof(T).Name}");

private static AbstractExecutableTest CreateTypedExecutableTest(
ExecutableTestCreationContext context,
TestMetadata metadata)
Expand Down
Loading