diff --git a/TUnit.Core/ExecutableTest`1.cs b/TUnit.Core/ExecutableTest`1.cs index 8b1c022281..1706889f5b 100644 --- a/TUnit.Core/ExecutableTest`1.cs +++ b/TUnit.Core/ExecutableTest`1.cs @@ -66,6 +66,13 @@ public override async Task 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); } } diff --git a/TUnit.Core/TestEntry.cs b/TUnit.Core/TestEntry.cs index 19aefa7c79..a16d5a2fdc 100644 --- a/TUnit.Core/TestEntry.cs +++ b/TUnit.Core/TestEntry.cs @@ -101,19 +101,14 @@ public sealed class TestEntry< /// /// Constructs a TestMetadata<T> from this entry's data and delegates. /// - // Cached delegates and arrays — built once from immutable fields - private Func? _cachedInvokeTypedTest; - private Func? _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 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 { TestName = MethodName, @@ -125,8 +120,11 @@ internal TestMetadata 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, @@ -172,3 +170,15 @@ private PropertyInjectionData[] BuildPropertyInjections() return result; } } + +// Non-generic holder so the placeholder delegate is shared across every closed TestEntry — +// 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. + // GetOrCreateAttributes guards against invocation by checking this reference and throws with + // TestName/TestClassType context; this fallback message only surfaces if that guard is bypassed. + internal static readonly Func IndexedAttributeFactoryPlaceholder = + static () => throw new InvalidOperationException( + "TestEntry attribute factory placeholder invoked without test context; call site should have dispatched via IndexedAttributeFactory."); +} diff --git a/TUnit.Core/TestMetadata.cs b/TUnit.Core/TestMetadata.cs index 80e6f0504f..c87581862e 100644 --- a/TUnit.Core/TestMetadata.cs +++ b/TUnit.Core/TestMetadata.cs @@ -49,15 +49,52 @@ public abstract class TestMetadata public required Func AttributeFactory { get; init; } + /// + /// Class-shared indexed attribute factory. When set, used in preference to + /// so TestEntry-emitted metadata avoids allocating a per-test closure over . + /// + internal Func? IndexedAttributeFactory { get; init; } + + /// + /// Index passed to . Unused unless that property is set. + /// + internal int AttributeGroupIndex { get; init; } + private Attribute[]? _cachedAttributes; /// - /// Returns the cached attributes array, creating it from on first call. - /// Subsequent calls return the same array without re-invoking the factory. + /// Returns the cached attributes array, creating it from + /// (preferred) or on first call. Subsequent calls return the same array. /// 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; + Attribute[] produced; + if (indexed is not null) + { + produced = indexed(AttributeGroupIndex); + } + else + { + // Throw with test context at the call site so diagnostics identify the offending + // metadata — the sentinel delegate itself has no access to TestName/TestClassType. + if (ReferenceEquals(AttributeFactory, TestEntrySentinel.IndexedAttributeFactoryPlaceholder)) + { + throw new InvalidOperationException( + $"Test metadata for '{TestName}' on '{TestClassType?.FullName}' is missing an attribute factory. Either IndexedAttributeFactory or AttributeFactory must be supplied."); + } + produced = AttributeFactory(); + } + return Interlocked.CompareExchange(ref _cachedAttributes, produced, null) ?? produced; } /// diff --git a/TUnit.Core/TestMetadata`1.cs b/TUnit.Core/TestMetadata`1.cs index 711a27d80e..96036c8700 100644 --- a/TUnit.Core/TestMetadata`1.cs +++ b/TUnit.Core/TestMetadata`1.cs @@ -54,8 +54,17 @@ public class TestMetadata< /// public Func? InvokeTypedTest { get; init; } + /// + /// Class-shared indexed invoker emitted by the source generator. All tests in a class share this + /// delegate and dispatch via , so the TestEntry → TestMetadata bridge can + /// forward the static delegate without allocating a per-test closure capturing this. + /// + internal Func? IndexedInvokeBody { get; init; } - + /// + /// Index passed to . Unused unless that property is set. + /// + internal int MethodIndex { get; init; } /// /// Factory delegate that creates an ExecutableTest for this metadata. @@ -77,17 +86,25 @@ public override Func?>( 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 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)