From 09476cecf9e8ef0e73938c017d5f6c21943946d3 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:36:25 +0100 Subject: [PATCH 1/4] perf(core): eliminate per-test closure + GetOrAdd factory alloc (#5710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TestEntry.ToTestMetadata` previously allocated a closure over `this` (lambda capturing `InvokeBody`/`MethodIndex` and `CreateAttributes`/ `AttributeGroupIndex`) the first time each entry was materialized, and the cached delegates were then invoked per test. That closure was the top user-code self-time hit in CPU sampling (2.62% incl / 2.11% self on `b__80_0`). Forward the class-shared static delegates and their indexes directly onto `TestMetadata` via new `IndexedInvokeBody`/`MethodIndex` and `IndexedAttributeFactory`/`AttributeGroupIndex` fields. `ExecutableTest. InvokeTestAsync` and `TestMetadata.GetOrCreateAttributes` prefer the indexed form, falling back to the legacy delegates. Result: zero closure allocations on the TestEntry path — the JIT inlines the static dispatch and there is no `this`-capturing `<>c__DisplayClass` emitted. `AttributeFactory` loses `required` so callers can pick either form; existing reflection/test callers continue to set it unchanged. Snapshot tests updated for the three affected TFMs. --- TUnit.Core/ExecutableTest`1.cs | 7 +++++ TUnit.Core/TestEntry.cs | 17 +++++------ TUnit.Core/TestMetadata.cs | 30 ++++++++++++++++--- TUnit.Core/TestMetadata`1.cs | 14 +++++++-- ...Has_No_API_Changes.DotNet10_0.verified.txt | 6 +++- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 6 +++- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 6 +++- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 6 +++- 8 files changed, 72 insertions(+), 20 deletions(-) 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..a86562b6e9 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,10 @@ internal TestMetadata ToTestMetadata(string testSessionId) PropertyDataSources = _cachedPropertyDataSources ??= BuildPropertyDataSources(), PropertyInjections = _cachedPropertyInjections ??= BuildPropertyInjections(), InstanceFactory = CreateInstance, - InvokeTypedTest = _cachedInvokeTypedTest, - AttributeFactory = _cachedAttributeFactory, + IndexedInvokeBody = InvokeBody, + MethodIndex = MethodIndex, + IndexedAttributeFactory = CreateAttributes, + AttributeGroupIndex = AttributeGroupIndex, FilePath = FilePath, LineNumber = LineNumber, MethodMetadata = MethodMetadata, diff --git a/TUnit.Core/TestMetadata.cs b/TUnit.Core/TestMetadata.cs index 80e6f0504f..97f66d2e44 100644 --- a/TUnit.Core/TestMetadata.cs +++ b/TUnit.Core/TestMetadata.cs @@ -47,17 +47,39 @@ public abstract class TestMetadata public Type[]? GenericMethodTypeArguments { get; init; } - public required Func AttributeFactory { get; init; } + public 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 . + /// + public Func? IndexedAttributeFactory { get; init; } + + /// Index passed to when dispatching. + public 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(); + if (_cachedAttributes is not null) + { + return _cachedAttributes; + } + + var indexed = IndexedAttributeFactory; + if (indexed is not null) + { + return _cachedAttributes = indexed(AttributeGroupIndex); + } + + var factory = AttributeFactory + ?? throw new InvalidOperationException($"No attribute factory configured for test '{TestName}'."); + return _cachedAttributes = factory(); } /// diff --git a/TUnit.Core/TestMetadata`1.cs b/TUnit.Core/TestMetadata`1.cs index 711a27d80e..4c988f2c45 100644 --- a/TUnit.Core/TestMetadata`1.cs +++ b/TUnit.Core/TestMetadata`1.cs @@ -54,6 +54,16 @@ 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. + /// + public Func? IndexedInvokeBody { get; init; } + + /// Index passed to when dispatching. + public int MethodIndex { get; init; } + @@ -77,14 +87,14 @@ public override Func?>( ref _cachedExecutableTestFactory, CreateTypedExecutableTest, null); return _cachedExecutableTestFactory!; } - throw new InvalidOperationException($"InstanceFactory and InvokeTypedTest must be set for {typeof(T).Name}"); + throw new InvalidOperationException($"InstanceFactory and an invoker (InvokeTypedTest or IndexedInvokeBody) must be set for {typeof(T).Name}"); } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 7cf82a3f02..6ece4b70f0 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1587,7 +1587,8 @@ namespace public abstract class TestMetadata { protected TestMetadata() { } - public required <[]> AttributeFactory { get; init; } + public <[]>? AttributeFactory { get; init; } + public int AttributeGroupIndex { get; init; } public required .IDataSourceAttribute[] ClassDataSources { get; init; } public abstract <.ExecutableTestCreationContext, .TestMetadata, .AbstractExecutableTest> CreateExecutableTestFactory { get; } public required .IDataSourceAttribute[] DataSources { get; init; } @@ -1596,6 +1597,7 @@ namespace public .GenericMethodInfo? GenericMethodInfo { get; init; } public []? GenericMethodTypeArguments { get; init; } public .GenericTypeInfo? GenericTypeInfo { get; init; } + public ? IndexedAttributeFactory { get; init; } public int InheritanceDepth { get; set; } public <[], object?[], object> InstanceFactory { get; init; } public required int LineNumber { get; init; } @@ -1638,8 +1640,10 @@ namespace { public TestMetadata() { } public override <.ExecutableTestCreationContext, .TestMetadata, .AbstractExecutableTest> CreateExecutableTestFactory { get; } + public ? IndexedInvokeBody { get; init; } public new <[], object?[], T>? InstanceFactory { get; init; } public ? InvokeTypedTest { get; init; } + public int MethodIndex { get; init; } public new ? TestInvoker { get; init; } public void UseRuntimeDataGeneration(string testSessionId) { } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 6c1506df32..3fbb1840a6 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1587,7 +1587,8 @@ namespace public abstract class TestMetadata { protected TestMetadata() { } - public required <[]> AttributeFactory { get; init; } + public <[]>? AttributeFactory { get; init; } + public int AttributeGroupIndex { get; init; } public required .IDataSourceAttribute[] ClassDataSources { get; init; } public abstract <.ExecutableTestCreationContext, .TestMetadata, .AbstractExecutableTest> CreateExecutableTestFactory { get; } public required .IDataSourceAttribute[] DataSources { get; init; } @@ -1596,6 +1597,7 @@ namespace public .GenericMethodInfo? GenericMethodInfo { get; init; } public []? GenericMethodTypeArguments { get; init; } public .GenericTypeInfo? GenericTypeInfo { get; init; } + public ? IndexedAttributeFactory { get; init; } public int InheritanceDepth { get; set; } public <[], object?[], object> InstanceFactory { get; init; } public required int LineNumber { get; init; } @@ -1638,8 +1640,10 @@ namespace { public TestMetadata() { } public override <.ExecutableTestCreationContext, .TestMetadata, .AbstractExecutableTest> CreateExecutableTestFactory { get; } + public ? IndexedInvokeBody { get; init; } public new <[], object?[], T>? InstanceFactory { get; init; } public ? InvokeTypedTest { get; init; } + public int MethodIndex { get; init; } public new ? TestInvoker { get; init; } public void UseRuntimeDataGeneration(string testSessionId) { } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 0a3813cb57..031efcc1bc 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1587,7 +1587,8 @@ namespace public abstract class TestMetadata { protected TestMetadata() { } - public required <[]> AttributeFactory { get; init; } + public <[]>? AttributeFactory { get; init; } + public int AttributeGroupIndex { get; init; } public required .IDataSourceAttribute[] ClassDataSources { get; init; } public abstract <.ExecutableTestCreationContext, .TestMetadata, .AbstractExecutableTest> CreateExecutableTestFactory { get; } public required .IDataSourceAttribute[] DataSources { get; init; } @@ -1596,6 +1597,7 @@ namespace public .GenericMethodInfo? GenericMethodInfo { get; init; } public []? GenericMethodTypeArguments { get; init; } public .GenericTypeInfo? GenericTypeInfo { get; init; } + public ? IndexedAttributeFactory { get; init; } public int InheritanceDepth { get; set; } public <[], object?[], object> InstanceFactory { get; init; } public required int LineNumber { get; init; } @@ -1638,8 +1640,10 @@ namespace { public TestMetadata() { } public override <.ExecutableTestCreationContext, .TestMetadata, .AbstractExecutableTest> CreateExecutableTestFactory { get; } + public ? IndexedInvokeBody { get; init; } public new <[], object?[], T>? InstanceFactory { get; init; } public ? InvokeTypedTest { get; init; } + public int MethodIndex { get; init; } public new ? TestInvoker { get; init; } public void UseRuntimeDataGeneration(string testSessionId) { } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index 68b6a760e0..2fe1647da2 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1528,7 +1528,8 @@ namespace public abstract class TestMetadata { protected TestMetadata() { } - public required <[]> AttributeFactory { get; init; } + public <[]>? AttributeFactory { get; init; } + public int AttributeGroupIndex { get; init; } public required .IDataSourceAttribute[] ClassDataSources { get; init; } public abstract <.ExecutableTestCreationContext, .TestMetadata, .AbstractExecutableTest> CreateExecutableTestFactory { get; } public required .IDataSourceAttribute[] DataSources { get; init; } @@ -1537,6 +1538,7 @@ namespace public .GenericMethodInfo? GenericMethodInfo { get; init; } public []? GenericMethodTypeArguments { get; init; } public .GenericTypeInfo? GenericTypeInfo { get; init; } + public ? IndexedAttributeFactory { get; init; } public int InheritanceDepth { get; set; } public <[], object?[], object> InstanceFactory { get; init; } public required int LineNumber { get; init; } @@ -1576,8 +1578,10 @@ namespace { public TestMetadata() { } public override <.ExecutableTestCreationContext, .TestMetadata, .AbstractExecutableTest> CreateExecutableTestFactory { get; } + public ? IndexedInvokeBody { get; init; } public new <[], object?[], T>? InstanceFactory { get; init; } public ? InvokeTypedTest { get; init; } + public int MethodIndex { get; init; } public new ? TestInvoker { get; init; } public void UseRuntimeDataGeneration(string testSessionId) { } } From 726910b36e156d6d6613d2a1af7da3a72f665e21 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:09:32 +0100 Subject: [PATCH 2/4] =?UTF-8?q?perf(core):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20narrow=20API=20+=20safer=20attribute=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore `AttributeFactory` as `required` so external TestMetadata consumers keep compile-time enforcement; TestEntry supplies a static throwing sentinel since its metadata always resolves via the indexed factory. - Make `IndexedAttributeFactory`, `AttributeGroupIndex`, `IndexedInvokeBody`, and `MethodIndex` internal — only set/read inside TUnit.Core, so they no longer inflate the public API surface or leak the "MethodIndex = 0 means unset" ambiguity. - Replace the read-then-write in `GetOrCreateAttributes` with `Interlocked.CompareExchange` so the benign factory race publishes exactly one array without a lock. - Trim trailing blank lines in TestMetadata`1.cs and shrink PublicAPI snapshots accordingly. --- TUnit.Core/TestEntry.cs | 8 +++++++ TUnit.Core/TestMetadata.cs | 24 +++++++++---------- TUnit.Core/TestMetadata`1.cs | 7 ++---- ...Has_No_API_Changes.DotNet10_0.verified.txt | 6 +---- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 6 +---- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 6 +---- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 6 +---- 7 files changed, 25 insertions(+), 38 deletions(-) diff --git a/TUnit.Core/TestEntry.cs b/TUnit.Core/TestEntry.cs index a86562b6e9..0b4ca25774 100644 --- a/TUnit.Core/TestEntry.cs +++ b/TUnit.Core/TestEntry.cs @@ -107,6 +107,13 @@ public sealed class TestEntry< private PropertyDataSource[]? _cachedPropertyDataSources; private PropertyInjectionData[]? _cachedPropertyInjections; + // 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. + private static readonly Func s_indexedAttributeFactoryPlaceholder = + static () => throw new InvalidOperationException( + "TestEntry metadata must resolve attributes via IndexedAttributeFactory."); + internal TestMetadata ToTestMetadata(string testSessionId) { return new TestMetadata @@ -122,6 +129,7 @@ internal TestMetadata ToTestMetadata(string testSessionId) InstanceFactory = CreateInstance, IndexedInvokeBody = InvokeBody, MethodIndex = MethodIndex, + AttributeFactory = s_indexedAttributeFactoryPlaceholder, IndexedAttributeFactory = CreateAttributes, AttributeGroupIndex = AttributeGroupIndex, FilePath = FilePath, diff --git a/TUnit.Core/TestMetadata.cs b/TUnit.Core/TestMetadata.cs index 97f66d2e44..2f8835b02c 100644 --- a/TUnit.Core/TestMetadata.cs +++ b/TUnit.Core/TestMetadata.cs @@ -47,16 +47,16 @@ public abstract class TestMetadata public Type[]? GenericMethodTypeArguments { get; init; } - public Func? AttributeFactory { get; init; } + 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 . /// - public Func? IndexedAttributeFactory { get; init; } + internal Func? IndexedAttributeFactory { get; init; } /// Index passed to when dispatching. - public int AttributeGroupIndex { get; init; } + internal int AttributeGroupIndex { get; init; } private Attribute[]? _cachedAttributes; @@ -66,20 +66,18 @@ public abstract class TestMetadata /// internal Attribute[] GetOrCreateAttributes() { - if (_cachedAttributes is not null) + // 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 _cachedAttributes; + return cached; } var indexed = IndexedAttributeFactory; - if (indexed is not null) - { - return _cachedAttributes = indexed(AttributeGroupIndex); - } - - var factory = AttributeFactory - ?? throw new InvalidOperationException($"No attribute factory configured for test '{TestName}'."); - return _cachedAttributes = factory(); + var produced = indexed is not null ? indexed(AttributeGroupIndex) : AttributeFactory(); + return Interlocked.CompareExchange(ref _cachedAttributes, produced, null) ?? produced; } /// diff --git a/TUnit.Core/TestMetadata`1.cs b/TUnit.Core/TestMetadata`1.cs index 4c988f2c45..5a45925511 100644 --- a/TUnit.Core/TestMetadata`1.cs +++ b/TUnit.Core/TestMetadata`1.cs @@ -59,13 +59,10 @@ public class TestMetadata< /// delegate and dispatch via , so the TestEntry → TestMetadata bridge can /// forward the static delegate without allocating a per-test closure capturing this. /// - public Func? IndexedInvokeBody { get; init; } + internal Func? IndexedInvokeBody { get; init; } /// Index passed to when dispatching. - public int MethodIndex { get; init; } - - - + internal int MethodIndex { get; init; } /// /// Factory delegate that creates an ExecutableTest for this metadata. diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 6ece4b70f0..7cf82a3f02 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1587,8 +1587,7 @@ namespace public abstract class TestMetadata { protected TestMetadata() { } - public <[]>? AttributeFactory { get; init; } - public int AttributeGroupIndex { get; init; } + public required <[]> AttributeFactory { get; init; } public required .IDataSourceAttribute[] ClassDataSources { get; init; } public abstract <.ExecutableTestCreationContext, .TestMetadata, .AbstractExecutableTest> CreateExecutableTestFactory { get; } public required .IDataSourceAttribute[] DataSources { get; init; } @@ -1597,7 +1596,6 @@ namespace public .GenericMethodInfo? GenericMethodInfo { get; init; } public []? GenericMethodTypeArguments { get; init; } public .GenericTypeInfo? GenericTypeInfo { get; init; } - public ? IndexedAttributeFactory { get; init; } public int InheritanceDepth { get; set; } public <[], object?[], object> InstanceFactory { get; init; } public required int LineNumber { get; init; } @@ -1640,10 +1638,8 @@ namespace { public TestMetadata() { } public override <.ExecutableTestCreationContext, .TestMetadata, .AbstractExecutableTest> CreateExecutableTestFactory { get; } - public ? IndexedInvokeBody { get; init; } public new <[], object?[], T>? InstanceFactory { get; init; } public ? InvokeTypedTest { get; init; } - public int MethodIndex { get; init; } public new ? TestInvoker { get; init; } public void UseRuntimeDataGeneration(string testSessionId) { } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 3fbb1840a6..6c1506df32 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1587,8 +1587,7 @@ namespace public abstract class TestMetadata { protected TestMetadata() { } - public <[]>? AttributeFactory { get; init; } - public int AttributeGroupIndex { get; init; } + public required <[]> AttributeFactory { get; init; } public required .IDataSourceAttribute[] ClassDataSources { get; init; } public abstract <.ExecutableTestCreationContext, .TestMetadata, .AbstractExecutableTest> CreateExecutableTestFactory { get; } public required .IDataSourceAttribute[] DataSources { get; init; } @@ -1597,7 +1596,6 @@ namespace public .GenericMethodInfo? GenericMethodInfo { get; init; } public []? GenericMethodTypeArguments { get; init; } public .GenericTypeInfo? GenericTypeInfo { get; init; } - public ? IndexedAttributeFactory { get; init; } public int InheritanceDepth { get; set; } public <[], object?[], object> InstanceFactory { get; init; } public required int LineNumber { get; init; } @@ -1640,10 +1638,8 @@ namespace { public TestMetadata() { } public override <.ExecutableTestCreationContext, .TestMetadata, .AbstractExecutableTest> CreateExecutableTestFactory { get; } - public ? IndexedInvokeBody { get; init; } public new <[], object?[], T>? InstanceFactory { get; init; } public ? InvokeTypedTest { get; init; } - public int MethodIndex { get; init; } public new ? TestInvoker { get; init; } public void UseRuntimeDataGeneration(string testSessionId) { } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 031efcc1bc..0a3813cb57 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1587,8 +1587,7 @@ namespace public abstract class TestMetadata { protected TestMetadata() { } - public <[]>? AttributeFactory { get; init; } - public int AttributeGroupIndex { get; init; } + public required <[]> AttributeFactory { get; init; } public required .IDataSourceAttribute[] ClassDataSources { get; init; } public abstract <.ExecutableTestCreationContext, .TestMetadata, .AbstractExecutableTest> CreateExecutableTestFactory { get; } public required .IDataSourceAttribute[] DataSources { get; init; } @@ -1597,7 +1596,6 @@ namespace public .GenericMethodInfo? GenericMethodInfo { get; init; } public []? GenericMethodTypeArguments { get; init; } public .GenericTypeInfo? GenericTypeInfo { get; init; } - public ? IndexedAttributeFactory { get; init; } public int InheritanceDepth { get; set; } public <[], object?[], object> InstanceFactory { get; init; } public required int LineNumber { get; init; } @@ -1640,10 +1638,8 @@ namespace { public TestMetadata() { } public override <.ExecutableTestCreationContext, .TestMetadata, .AbstractExecutableTest> CreateExecutableTestFactory { get; } - public ? IndexedInvokeBody { get; init; } public new <[], object?[], T>? InstanceFactory { get; init; } public ? InvokeTypedTest { get; init; } - public int MethodIndex { get; init; } public new ? TestInvoker { get; init; } public void UseRuntimeDataGeneration(string testSessionId) { } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index 2fe1647da2..68b6a760e0 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1528,8 +1528,7 @@ namespace public abstract class TestMetadata { protected TestMetadata() { } - public <[]>? AttributeFactory { get; init; } - public int AttributeGroupIndex { get; init; } + public required <[]> AttributeFactory { get; init; } public required .IDataSourceAttribute[] ClassDataSources { get; init; } public abstract <.ExecutableTestCreationContext, .TestMetadata, .AbstractExecutableTest> CreateExecutableTestFactory { get; } public required .IDataSourceAttribute[] DataSources { get; init; } @@ -1538,7 +1537,6 @@ namespace public .GenericMethodInfo? GenericMethodInfo { get; init; } public []? GenericMethodTypeArguments { get; init; } public .GenericTypeInfo? GenericTypeInfo { get; init; } - public ? IndexedAttributeFactory { get; init; } public int InheritanceDepth { get; set; } public <[], object?[], object> InstanceFactory { get; init; } public required int LineNumber { get; init; } @@ -1578,10 +1576,8 @@ namespace { public TestMetadata() { } public override <.ExecutableTestCreationContext, .TestMetadata, .AbstractExecutableTest> CreateExecutableTestFactory { get; } - public ? IndexedInvokeBody { get; init; } public new <[], object?[], T>? InstanceFactory { get; init; } public ? InvokeTypedTest { get; init; } - public int MethodIndex { get; init; } public new ? TestInvoker { get; init; } public void UseRuntimeDataGeneration(string testSessionId) { } } From 883e5bad4f29edbbf098204f25906a11457ae96f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:19:52 +0100 Subject: [PATCH 3/4] perf(core): fix Codacy S2743 + S2372 from PR review - S2743 (ErrorProne): move `s_indexedAttributeFactoryPlaceholder` off the generic `TestEntry` into a non-generic `TestEntrySentinel` holder so the placeholder delegate is shared across every closed generic instantiation instead of duplicated per closed type. - S2372 (BestPractice): extract the throwing path in `TestMetadata.CreateExecutableTestFactory` into a `[DoesNotReturn]` helper so the property getter itself is throw-free while preserving the T-qualified diagnostic. --- TUnit.Core/TestEntry.cs | 21 +++++++++++++-------- TUnit.Core/TestMetadata`1.cs | 10 +++++++++- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/TUnit.Core/TestEntry.cs b/TUnit.Core/TestEntry.cs index 0b4ca25774..b6d0dcfa42 100644 --- a/TUnit.Core/TestEntry.cs +++ b/TUnit.Core/TestEntry.cs @@ -107,13 +107,6 @@ public sealed class TestEntry< private PropertyDataSource[]? _cachedPropertyDataSources; private PropertyInjectionData[]? _cachedPropertyInjections; - // 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. - private static readonly Func s_indexedAttributeFactoryPlaceholder = - static () => throw new InvalidOperationException( - "TestEntry metadata must resolve attributes via IndexedAttributeFactory."); - internal TestMetadata ToTestMetadata(string testSessionId) { return new TestMetadata @@ -129,7 +122,7 @@ internal TestMetadata ToTestMetadata(string testSessionId) InstanceFactory = CreateInstance, IndexedInvokeBody = InvokeBody, MethodIndex = MethodIndex, - AttributeFactory = s_indexedAttributeFactoryPlaceholder, + AttributeFactory = TestEntrySentinel.IndexedAttributeFactoryPlaceholder, IndexedAttributeFactory = CreateAttributes, AttributeGroupIndex = AttributeGroupIndex, FilePath = FilePath, @@ -177,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: + // TestEntry-sourced metadata always takes the IndexedAttributeFactory path, so this throws + // if ever invoked — reaching it indicates an engine bug. + internal static readonly Func IndexedAttributeFactoryPlaceholder = + static () => throw new InvalidOperationException( + "TestEntry metadata must resolve attributes via IndexedAttributeFactory."); +} diff --git a/TUnit.Core/TestMetadata`1.cs b/TUnit.Core/TestMetadata`1.cs index 5a45925511..7841d44f7e 100644 --- a/TUnit.Core/TestMetadata`1.cs +++ b/TUnit.Core/TestMetadata`1.cs @@ -91,10 +91,18 @@ public override 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) From 15f74795c363b02a7a8991a4b9fa3777c9b7030b Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:39:15 +0100 Subject: [PATCH 4/4] perf(core): enrich sentinel error + clarify index doc comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address third-pass PR review: - Throw at GetOrCreateAttributes() call site with TestName/TestClassType context when IndexedAttributeFactory is null and AttributeFactory is the sentinel — the sentinel delegate itself has no access to that context. - Relax the sentinel's fallback message (guarded path); CAS publication preserved. - Expand XML docs on MethodIndex/AttributeGroupIndex to note they are only read when the paired indexed delegate is non-null. --- TUnit.Core/TestEntry.cs | 8 ++++---- TUnit.Core/TestMetadata.cs | 21 +++++++++++++++++++-- TUnit.Core/TestMetadata`1.cs | 4 +++- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/TUnit.Core/TestEntry.cs b/TUnit.Core/TestEntry.cs index b6d0dcfa42..a16d5a2fdc 100644 --- a/TUnit.Core/TestEntry.cs +++ b/TUnit.Core/TestEntry.cs @@ -175,10 +175,10 @@ private PropertyInjectionData[] BuildPropertyInjections() // 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. + // 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 metadata must resolve attributes via IndexedAttributeFactory."); + "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 2f8835b02c..c87581862e 100644 --- a/TUnit.Core/TestMetadata.cs +++ b/TUnit.Core/TestMetadata.cs @@ -55,7 +55,9 @@ public abstract class TestMetadata /// internal Func? IndexedAttributeFactory { get; init; } - /// Index passed to when dispatching. + /// + /// Index passed to . Unused unless that property is set. + /// internal int AttributeGroupIndex { get; init; } private Attribute[]? _cachedAttributes; @@ -76,7 +78,22 @@ internal Attribute[] GetOrCreateAttributes() } var indexed = IndexedAttributeFactory; - var produced = indexed is not null ? indexed(AttributeGroupIndex) : AttributeFactory(); + 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 7841d44f7e..96036c8700 100644 --- a/TUnit.Core/TestMetadata`1.cs +++ b/TUnit.Core/TestMetadata`1.cs @@ -61,7 +61,9 @@ public class TestMetadata< /// internal Func? IndexedInvokeBody { get; init; } - /// Index passed to when dispatching. + /// + /// Index passed to . Unused unless that property is set. + /// internal int MethodIndex { get; init; } ///