Skip to content

perf: self-contained TestEntry<T> with consolidated switch invokers eliminates per-test JIT#5223

Merged
thomhurst merged 21 commits intomainfrom
001-sourcegen-startup-perf
Mar 23, 2026
Merged

perf: self-contained TestEntry<T> with consolidated switch invokers eliminates per-test JIT#5223
thomhurst merged 21 commits intomainfrom
001-sourcegen-startup-perf

Conversation

@thomhurst
Copy link
Owner

@thomhurst thomhurst commented Mar 22, 2026

Summary

Source generation mode was slower than reflection for large test suites due to O(N) JIT compilations at startup (~16K methods for 91K tests). This PR introduces TestEntry<T> — a self-contained per-test registration object with class-level shared switch delegates — reducing generated methods from O(N) per class to O(1).

Design

Each test is a TestEntry<T> with everything pre-built by the source generator:

TestEntry<T>
├── Data (zero JIT, for filtering)
│   ├── MethodName, FullyQualifiedName, FilePath, LineNumber
│   ├── Categories[], CustomProperties[], DependsOn[]
│   ├── HasDataSource, RepeatCount
│   └── Dependencies[] (pre-built TestDependency objects)
├── Pre-built objects (shared per class, constructed in .cctor)
│   └── MethodMetadata
├── Class-level shared delegates (4 methods total per class)
│   ├── InvokeBody — switch dispatches by MethodIndex (1 per class)
│   ├── CreateAttributes — switch dispatches by AttributeGroupIndex (1 per class)
│   └── CreateInstance — shared factory (1 per class)
├── Pre-separated data sources (no runtime scanning)
│   ├── TestDataSources[] (method-level IDataSourceAttribute)
│   └── ClassDataSources[] (class-level IDataSourceAttribute)
└── Properties[] (InjectableProperty descriptors for injection)

Key: all entries in a class share the same 3 delegate references. Per-test differentiation is via MethodIndex and AttributeGroupIndex indices. Zero per-test methods generated.

What was eliminated

Component Before After
Methods per class O(N) — GetTests, EnumerateDescriptors, N×InvokeTest, N×Materialize, M×CreateAttributes, CreateInstance 4 — __Invoke switch, __Attributes switch, __CreateInstance, __InitMethodMetadatas
Runtime data source scanning is IDataSourceAttribute loop per test 0 — pre-separated by source gen
Runtime class data source reflection typeof(T).GetCustomAttributes() per test 0 — pre-built by source gen
Runtime dependency extraction DependsOnAttribute scanning per test 0 — pre-built TestDependency[]
TestMetadata construction Source generator Materialize() switch Engine copies fields from TestEntry

Results (91,452-test benchmark)

Metric Before After Change
Assembly size 44.6 MB 27.1 MB -39%
Generated methods per class ~16 (for 6 test methods) 4 -75%
Runtime reflection per-test 0 eliminated
All tests pass zero regressions

New types

  • TestEntry<T> — self-contained per-test registration unit with shared delegates
  • TestEntrySource<T> / ITestEntrySource — thin non-generic wrapper for engine access
  • InjectableProperty — describes properties with data source attributes
  • TestEntryFilterData — lightweight struct for pure-data filtering

Test plan

  • BasicTests pass (3/3)
  • DataDrivenTests pass (15/15)
  • Full benchmark pass (91,452/91,452)
  • Snapshot tests need updating (.verified.txt files)
  • Full CI suite
  • AOT publish verification

Add PowerShell scripts for measuring and comparing TUnit startup
performance, useful for tracking JIT overhead improvements:

- measure-startup.ps1: wall-clock timing with statistical analysis
- count-generated-methods.ps1: static analysis of generated method counts
- compare-branches.ps1: side-by-side branch comparison
- measure-jit.ps1: JIT trace collection and generated method analysis
…onsolidated switch invokers

Source generation mode was slower than reflection for large test suites
because the JIT compiler had to compile thousands of small per-test methods
at startup (GetTests, EnumerateTestDescriptors, __InvokeTest_*, __CreateAttributes_*,
__Materialize_*, CreateInstance — ~16K methods for 91K tests).

This redesigns the source generator output into three layers:

1. **Static data tables** (zero JIT): Each class emits a
   `TestRegistrationEntry[]` array with pure data (test names, line numbers,
   categories, etc.). The engine filters on this data without triggering
   any JIT compilation.

2. **Per-class Materialize switch** (1 JIT per class, deferred): A single
   switch statement replaces N per-test __Materialize_* methods. Only JIT'd
   when the engine actually materializes a test from that class.

3. **Per-class consolidated invokers** (1 JIT per class, deferred):
   __InvokeByIndex and __CreateAttributesByIndex switch methods replace
   N per-test __InvokeTest_* stubs and M per-group __CreateAttributes_*
   methods. Uses index-based dispatch via new internal ClassInvoker and
   ClassAttributeFactory properties on TestMetadata<T>.

Results on 91,452-test benchmark:
- Assembly size: 44.6 MB → 29.0 MB (-35%)
- Per-test methods eliminated: ~16K GetTests/EnumerateDescriptors/InvokeTest → 0
- Methods per class: O(N) → O(1) (4 methods regardless of test count)
- Filtered run (60 tests from 91K): 1.5s → 1.0s (-33%)
- Generic/inherited tests unchanged (still use ITestSource path)
…estEntry<T>

Redesigns the source generator output from consolidated switch methods
to self-contained TestEntry<T> objects. Each test entry holds everything
needed to filter, materialize, and execute a test:

- Pure data for filtering (MethodName, Categories, DependsOn, etc.)
- Pre-built MethodMetadata (shared per class)
- Per-test static delegates (InvokeBody, CreateAttributes, CreateInstance)
- Pre-separated data sources (TestDataSources, ClassDataSources)
- Pre-built TestDependency[] for the dependency resolver
- InjectableProperty[] descriptors for property injection

This eliminates:
- Materialize() switch method (was 1 JIT per class)
- __InvokeByIndex consolidated switch (was 1 JIT per class)
- __CreateAttributesByIndex consolidated switch (was 1 JIT per class)
- Runtime attribute scanning for data sources (now pre-separated)
- Runtime reflection for class data sources (now source-generated)
- Runtime DependsOnAttribute extraction (now pre-built)

The engine's TestEntrySource<T>.Materialize() is now a trivial
field-copy operation delegating to TestEntry<T>.ToTestMetadata().
Thanks to .NET reference-type generic code sharing, this is JIT'd
exactly once regardless of how many test classes exist.

Old types removed: TestRegistrationEntry, TestClassTable,
TestClassRegistration<T>, ITestClassRegistration, ITestRegistrationTable

Results on 91,452-test benchmark:
- Assembly size: 44.6 MB → 28.9 MB (-35%)
- All 91,452 tests pass, 0 failures
- Zero runtime reflection for data source/dependency discovery
@thomhurst thomhurst changed the title perf: data-table registration with consolidated switch invokers for O(1) JIT startup perf: self-contained TestEntry<T> eliminates per-test JIT methods and runtime reflection Mar 22, 2026
…es on TestEntry

Changes TestEntry<T> delegate signatures from per-test to index-based:
- InvokeBody: Func<T, object?[], CancellationToken, ValueTask>
  → Func<T, int, object?[], CancellationToken, ValueTask> + MethodIndex
- CreateAttributes: Func<Attribute[]>
  → Func<int, Attribute[]> + AttributeGroupIndex

All entries in a class now share the SAME two delegate references
(pointing to class-level switch methods), differentiated only by index.
This eliminates all per-test generated methods:

Before: 2N+2 methods per class (N invoke + N attribute + CreateInstance + InitMetadatas)
After:  4 methods per class (__Invoke switch + __Attributes switch + __CreateInstance + __InitMethodMetadatas)

For 2,731 classes: ~35K methods → ~11K methods

Assembly: 28.9 MB → 27.1 MB
All 91,452 tests pass
@thomhurst thomhurst changed the title perf: self-contained TestEntry<T> eliminates per-test JIT methods and runtime reflection perf: self-contained TestEntry<T> with consolidated switch invokers eliminates per-test JIT Mar 22, 2026
- Fix TestEntrySource._className to use GetNestedTypeName() instead of
  typeof(T).Name — nested classes now correctly resolve to "Outer+Inner"
- Rename CustomProperties → Properties on TestEntry/TestEntryFilterData
  for consistency with TestDescriptor
- Rename Properties → InjectableProperties on TestEntry to avoid name
  collision with the filter-data Properties field
- Pre-size matching list in CollectTestsFromTestEntries to avoid resizes
Keep our 3-phase collection (TestEntries → ITestSource → Dynamic)
while adopting main's parallel CollectTests (Parallel.For batching)
for the ITestSource fallback path.
Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: perf: self-contained TestEntry with consolidated switch invokers

This is a well-designed, ambitious performance optimization. The design document in the PR body is excellent and the benchmarks are compelling. The core idea — replacing O(N) per-test JIT methods with a 4-method-per-class design using index-based dispatch — is architecturally sound. Below are observations ranging from blocking concerns to suggestions.


Blocking: Snapshot tests not updated

The PR explicitly marks "Snapshot tests need updating (.verified.txt files)" as unchecked. Per the project's CLAUDE.md rules, this is a hard requirement before merging:

Changes to source generator output or public APIs require running snapshot tests. Commit .verified.txt files. NEVER commit .received.txt.

The source generator output has fundamentally changed (from GetTests()/EnumerateTestDescriptors() to Entries[]), so every snapshot will be different. These must be updated and committed before this can land.


Potential Bug: __Attributes referenced unconditionally, generated conditionally

In TestMetadataGenerator.cs, the __Attributes switch method is only emitted when classGroup.AttributeGroups.Length > 0:

if (classGroup.AttributeGroups.Length > 0)
{
    writer.AppendLine("private static global::System.Attribute[] __Attributes(int groupIndex)");
    ...
}

But every TestEntry<T> initializer unconditionally writes:

writer.AppendLine($"CreateAttributes = __Attributes,");

If a test class somehow has zero attribute groups, this would generate code that references an undefined method — a compile error in the generated source. In practice, every test probably has at least [Test], making this always non-zero. But the guard should either be removed (generate a throwing stub unconditionally) or the entry writer should handle the null case. A defensive stub would be safer:

// Always emit, even if no groups
writer.AppendLine("private static global::System.Attribute[] __Attributes(int groupIndex)");
writer.AppendLine("    => throw new global::System.ArgumentOutOfRangeException(nameof(groupIndex));");

Suspicious: PropertyInjectionData.ValueFactory = static () => null

In TestEntry<T>.BuildPropertyInjections():

result[i] = new PropertyInjectionData
{
    PropertyName = prop.Name,
    PropertyType = prop.Type,
    Setter = prop.SetValue,
    ValueFactory = static () => null,  // ← always null?
};

ValueFactory always returns null — this would mean injected properties never receive a value from the factory. If ValueFactory is supposed to invoke the DataSource to produce the actual value, this is a bug that would silently produce null-injected properties. Either:

  • ValueFactory is populated elsewhere by the engine (in which case, a comment explaining this is needed), or
  • This should be () => prop.DataSource.GetValue(...) or similar.

The pre-existing BuildPropertyDataSources() does correctly expose DataSource, so the engine may use that separately — but the relationship between PropertyDataSources and PropertyInjections in TestMetadata<T> should be clarified.


Architectural Concern: Dual-path coupling via internal on TestMetadata<T>

The PR adds four internal properties to TestMetadata<T>:

internal Func<T, int, object?[], CancellationToken, ValueTask>? ClassInvoker { get; init; }
internal int InvokeMethodIndex { get; init; } = -1;
internal Func<int, Attribute[]>? ClassAttributeFactory { get; init; }
internal int AttributeGroupIndex { get; init; } = -1;

These exist solely to support the new code path, but TestMetadata<T> is a public class (used by the ITestSource path). This creates a hidden coupling where the class secretly supports two different execution models selected by null-checking at runtime. Over time, this makes TestMetadata<T> harder to reason about.

A cleaner approach would be to have TestEntrySource<T>.Materialize() produce a subclass or a dedicated type that carries this dispatch info, rather than polluting the base class. Alternatively, the ToTestMetadata() on TestEntry<T> could construct InvokeTypedTest by closing over the index — keeping the existing single-invoker contract:

InvokeTypedTest = (instance, args, ct) => InvokeBody(instance, MethodIndex, args, ct),

This would eliminate the 4 internal properties entirely while keeping the consolidated switch benefit.


Minor: Sequential filtering could bottleneck large suites

CollectTestsFromTestEntries Phase 1 (filter scan) is sequential:

foreach (var kvp in Sources.TestEntries)
{
    for (var i = 0; i < source.Count; i++)
    {
        var filterData = source.GetFilterData(i);
        ...
    }
}

For 91K tests, this is fine today. But the existing CollectTests path already uses Parallel.For batching (referenced in the merge commit message). Consider using Parallel.ForEach on the outer dictionary enumeration for consistency with the other path, especially since GetFilterData is a pure struct copy with no side effects.


Minor: GetFilterData allocates on every call during filtering

TestEntrySource<T>.GetFilterData(int index) copies 8 fields from TestEntry<T> into a new TestEntryFilterData struct per call. For the 91K-test filtering scan, this is 91K struct constructions. Since TestEntry<T> already has all these fields, consider either:

  1. Making TestEntry<T> directly implement a filtering interface (IFilterable), or
  2. Caching FilterData on TestEntry<T> as a lazily built field.

This is a minor point — struct copies on the stack are cheap — but given the stated goal of minimizing JIT and allocation pressure, it's worth tracking.


Minor: GetAllAttributes vs GetTestAttributes comment mismatch

The new PreGenerateAttributeFactoryBody (for the old ITestSource path) was given a comment saying it returns "ALL attributes including data source attributes" — but this modifies the behavior of the old method from what it was before. It's worth confirming this doesn't break the existing ITestSource-registered tests by including data sources in the attribute array that the engine then double-counts (once from the array, once from whatever data source scanning the old path still does).


Overall Assessment

The design is excellent and the performance numbers justify the complexity. The main blockers are:

  1. Snapshot tests must be updated
  2. The __Attributes conditional generation vs unconditional reference should be reconciled
  3. The ValueFactory = static () => null in property injection needs clarification or fixing

The internal dual-path coupling is a design debt item worth addressing before this becomes established — it'll be much easier to clean up now than after other code starts depending on the shape of TestMetadata<T>.

Generic test classes and generic test methods now emit TestEntry<T>
instead of ITestSource sealed classes. The source generator resolves
all concrete type instantiations at compile time and emits one
TestEntry<ConcreteType> per instantiation.

Changes:
- GenerateTestMetadata rewritten to emit TestEntry registrations
  instead of ITestSource classes for generic tests
- CollectConcreteInstantiations resolves all type arguments from
  Arguments, GenerateGenericTest, MethodDataSource, and IInfersType
- RegisterEntries is now additive (multiple calls for same T merge)
- TestEntrySource.AddEntries for thread-safe entry accumulation
- Non-generic inherited tests now routed through per-class TestEntry

Known issue: 2 generic method tests have swapped type arguments
in data source filtering — needs investigation.

91,452 benchmark tests pass, 0 failures.
Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Review: perf: self-contained TestEntry with consolidated switch invokers

The last commit (1009d2b2 — "perf: convert generic tests from ITestSource to TestEntry") extends the TestEntry<T> pattern to cover generic and inherited tests, completing the optimization story. This is a substantial addition that merits dedicated review.


Status of Previous Review Issues

Still Blocking:

1. Snapshot tests still unchecked — The PR checklist still shows [ ] Snapshot tests need updating. The source generator output has changed fundamentally again (generic tests now emit TestEntry<T>[] instead of ITestSource), so all related snapshots must be updated. Per CLAUDE.md this is a hard requirement.

2. ValueFactory = static () => null still unresolvedTestEntry<T>.BuildPropertyInjections() still sets ValueFactory = static () => null. The previous review flagged this as a potential silent null-injection bug. No change or clarifying comment was added. If the engine uses PropertyDataSources exclusively for injection and PropertyInjections.ValueFactory is truly unused/overwritten, a comment is needed. If not, this is a bug.

3. internal dual-path coupling still presentTestMetadata<T> still exposes ClassInvoker, InvokeMethodIndex, ClassAttributeFactory, and AttributeGroupIndex as internal properties solely to support the new path. The suggestion from the prior review to close over the index in ToTestMetadata() instead would eliminate all four:

// In TestEntry<T>.ToTestMetadata():
InvokeTypedTest = (instance, args, ct) => InvokeBody(instance, MethodIndex, args, ct),
AttributeFactory = () => CreateAttributes(AttributeGroupIndex),
// Remove ClassInvoker, InvokeMethodIndex, ClassAttributeFactory, AttributeGroupIndex from TestMetadata<T>

This keeps TestMetadata<T>'s contract clean without any architectural change.

4. __Attributes conditionally generated, unconditionally referenced (per-class path) — Still present in the existing per-class code path:

// Emitting the entry:
writer.AppendLine($"CreateAttributes = __Attributes,");  // always emitted

// But the method is only generated when:
if (classGroup.AttributeGroups.Length > 0)
{
    writer.AppendLine("private static global::System.Attribute[] __Attributes(int groupIndex)");
    ...
}

The generic path (new code) is fine — it uses __Attributes_{groupIndex} which is always emitted per group. But the original per-class path still has this mismatch.


New Issues in the Generic Test Conversion

5. Duplicate processing in CollectConcreteInstantiations — The method processes class-level ArgumentsAttribute twice:

  • First in the early if (testMethod is { IsGenericType: true, IsGenericMethod: true }) / else block, which calls TryAddInstantiation for classArgumentsAttributes
  • Then again later in the explicit "Process class-level Arguments attributes for generic classes" section at the bottom

The processedTypeCombinations HashSet prevents actual duplicate test entries, but the double-iteration adds cognitive overhead and signals that the method's control flow may be unintentionally overlapping. This method is already ~150 lines of branching logic — if the HashSet guard is the only protection against duplicates, any future change that misses one of the branches silently produces wrong results rather than a compile error.

A cleaner approach would be to define non-overlapping, mutually exclusive collection phases and document exactly what each handles.

6. EmitConcreteInstanceCreation throws for ClassConstructorAttribute — When the test class has [ClassConstructor], the generated code unconditionally throws:

if (InstanceFactoryGenerator.HasClassConstructorAttribute(testMethod.TypeSymbol))
{
    writer.AppendLine("throw new global::System.NotSupportedException(\"Instance creation for classes with ClassConstructor attribute is handled at runtime\");");
}

This means any generic test class decorated with [ClassConstructor] will silently compile but throw at runtime when instantiation is attempted. This is a regression from the legacy path which handled ClassConstructor correctly. Either:

  • Skip these tests in CollectConcreteInstantiations and fall back to the legacy ITestSource path (preferable — keeps behavior correct), or
  • Emit a source generator error (context.ReportDiagnostic(...)) so the failure is surfaced at build time rather than at test runtime.

7. Missing helper method visibility in diff — Several methods called in CollectConcreteInstantiations are not visible in this diff:

  • InferClassTypesFromMethodDataSource
  • InferTypesFromTypedDataSourceForClass
  • InferClassTypesFromMethodArguments
  • ValidateClassTypeConstraints
  • BuildTypeKey

These presumably exist in the existing TestMetadataGenerator.cs, but since the file is 800+ lines of additions, it's worth verifying these methods exist and are not dead stubs that always return null (which would silently skip test registrations for generic classes with these attribute combinations).

8. Registration index mismatch in generic path — In GenerateTestMetadata, the outer class is closed before the registration calls:

writer.Unindent();
writer.AppendLine("}");  // closes the class

// Registration calls
registrationIndex = 0;
foreach (var group in groupedByConcreteClass)
{
    var concreteClassName = group.Key;
    EmitRegistrationField(writer, $"{uniqueClassName}_{registrationIndex}",
        $"global::TUnit.Core.SourceRegistrar.RegisterEntries({uniqueClassName}.Entries_{registrationIndex})");
    registrationIndex++;
}

The registration fields appear to be emitted outside the class body, which would not compile unless EmitRegistrationField emits a top-level field or there is a wrapping class higher up the call chain. This is worth verifying — if EmitRegistrationField emits a module-level or outer-class field then this is fine, but if it expects to be inside the class body, there's a structural issue.


What's Good in the New Commit

  • Extending TestEntry<T> to generic tests completes the optimization end-to-end rather than leaving a two-tier system indefinitely
  • The cartesian product approach for class × method type argument combinations is correct and clean
  • processedTypeCombinations HashSet correctly prevents duplicate registrations even when multiple attribute processing paths could produce the same type combination
  • The GenerateTestMetadataLegacyFallback escape hatch when no concrete types can be resolved is a good defensive measure

Priority

Before merging:

  1. Update snapshot tests (hard requirement)
  2. Clarify or fix ValueFactory = static () => null
  3. Fix [ClassConstructor] + generic test combination (silent runtime throw)
  4. Verify registration fields are emitted inside a valid scope

Design debt (can follow up):
5. Eliminate the 4 internal properties from TestMetadata<T> via delegate closure in ToTestMetadata()
6. Refactor CollectConcreteInstantiations to eliminate overlapping processing paths

All tests now go through the TestEntry path. Removed:
- CollectTestsWithTwoPhaseDiscovery (~130 lines of BFS/descriptor logic)
- CollectTests (parallel ITestSource batching)
- EnumerateDescriptors, MaterializeFromDescriptors, GetLegacyTestSources
- CouldDescriptorMatch from FilterHints
- EnumerateDescriptors/MaterializeFromDescriptors from ITestDataCollector
- Same methods from ReflectionTestDataCollector

AotTestDataCollector.cs: 683 → 421 lines (-262)
Total: -452 lines across 4 files
91,452 benchmark tests pass, 0 failures
Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Review: Final Commit — "refactor: remove ITestSource collection path from engine"

This review covers commit 2ee3b19 which removes the legacy ITestSource collection path from the engine. The refactor is clean and the engine simplification is real (-452 lines across 4 files). However, it introduces a new blocking issue and leaves all four previously flagged items unresolved.


New Blocking Issue: GenerateTestMetadataLegacyFallback now generates unreachable tests

The source generator still emits ITestSource classes for the fallback path (when no concrete type instantiations can be resolved for a generic test):

// TestMetadataGenerator.cs — still present in this PR
private static void GenerateTestMetadataLegacyFallback(...)
{
    writer.AppendLine($"internal sealed class {uniqueClassName} : global::TUnit.Core.Interfaces.SourceGenerator.ITestSource, ...");
    ...
}

These classes register into Sources.TestSources. But AotTestDataCollector.CollectTestsAsync now only processes Sources.TestEntries:

if (!Sources.TestEntries.IsEmpty)
{
    allResults.AddRange(CollectTestsFromTestEntries(testSessionId, filterHints));
}
// Sources.TestSources is never checked — legacy fallback tests are silently dropped

Any generic test class where CollectConcreteInstantiations returns empty will hit the fallback, compile successfully, register into TestSources, and then silently not run. The benchmark suite passes because none of its generic tests hit the fallback — but user tests with unusual generic patterns could silently disappear.

The fix is either:

  1. Keep minimal Sources.TestSources processing for the fallback case (a few lines), or
  2. Remove GenerateTestMetadataLegacyFallback entirely and emit a source generator error diagnostic instead, which is the safer approach:
context.ReportDiagnostic(Diagnostic.Create(
    new DiagnosticDescriptor("TUNIT001", "Cannot resolve generic test", 
        "Could not resolve concrete type arguments for {0} — test will not run",
        "TUnit", DiagnosticSeverity.Error, true),
    testMethod.MethodSymbol.Locations.First(),
    testMethod.MethodSymbol.Name));
return; // emit nothing, no silent fallback

This surfaces the problem at build time rather than silently dropping tests at runtime.


Unresolved from Previous Reviews

1. Snapshot tests still unchecked — PR checklist still shows [ ] Snapshot tests need updating. Per CLAUDE.md this is a hard requirement before merging. The source generator output has changed fundamentally across multiple commits — all related snapshots must be regenerated and committed.

2. ValueFactory = static () => null still unresolvedTestEntry<T>.BuildPropertyInjections() still sets ValueFactory = static () => null. Three reviews have flagged this. It either needs a clarifying comment explaining why the engine overwrites this value, or it needs to be corrected to invoke the data source.

3. internal dual-path coupling still presentTestMetadata<T> still carries four internal properties (ClassInvoker, InvokeMethodIndex, ClassAttributeFactory, AttributeGroupIndex) used exclusively by the new path. The suggested fix from the previous two reviews is still the cleanest resolution — close over the index in ToTestMetadata():

InvokeTypedTest = (instance, args, ct) => InvokeBody(instance, MethodIndex, args, ct),
AttributeFactory = () => CreateAttributes(AttributeGroupIndex),

This eliminates all four internal properties with no behavioral change.

4. __Attributes conditionally generated but unconditionally referenced (per-class path) — The per-class code path still generates CreateAttributes = __Attributes, unconditionally but only emits the __Attributes method when classGroup.AttributeGroups.Length > 0. In practice every test has at least [Test], so this may never fire — but it is an invariant violation that should be guarded defensively.


Minor Concern in New Code: Dependency BFS re-scans all entries

CollectTestsFromTestEntries Phase 2 (dependency expansion) builds the byClassAndMethod lookup by re-scanning all entries across Sources.TestEntries:

foreach (var kvp in Sources.TestEntries)
{
    var source = kvp.Value;
    for (var i = 0; i < source.Count; i++)
    {
        var fd = source.GetFilterData(i);
        byClassAndMethod[(fd.ClassName, fd.MethodName)] = (source, i);
        // ...
    }
}

The old two-phase code built this index during Phase 1 (single pass). The new code does Phase 1 (filter), then if hasDependencies, does a full second scan to build the lookup before the BFS. For the 91K benchmark this is 91K extra GetFilterData calls in the dependency case. Consider building the byClassAndMethod index lazily during Phase 1 (unconditionally) so Phase 2 can use it without re-scanning, matching the previous approach's efficiency.


What's Good in This Commit

  • Removing CollectTestsWithTwoPhaseDiscovery, CollectTests, EnumerateDescriptors, and MaterializeFromDescriptors from both AotTestDataCollector and ReflectionTestDataCollector is the right call — these were added as part of this PR's own earlier iterations and are cleanly superseded.
  • The new CollectTestsFromTestEntries is significantly more readable than the two-phase method it replaces.
  • The ITestDataCollector interface simplification (down to just CollectTestsAsync) is correct.

Priority Summary

Issue Severity
Legacy fallback tests silently dropped Blocking
Snapshot tests not updated Blocking
ValueFactory = null Blocking (or needs comment)
[ClassConstructor] on generic classes throws at runtime Blocking
internal dual-path coupling Design debt
__Attributes guard missing Low risk (defensive fix)
Dependency BFS double-scan Performance concern

… filtering

When CollectConcreteInstantiations processes MethodDataSource attributes
for generic methods, it was not passing the attribute as specificAttr to
TryAddInstantiation. This caused EmitConcreteDataSources to fall through
to the "include all data sources" branch, giving each generic instantiation
ALL data sources instead of just the one that resolved its type arguments.

Fix: pass mdsAttr to TryAddInstantiation so AreSameAttribute can correctly
filter data sources per concrete instantiation.

GenericMethodTests: 0/2 passing → 2/2 passing
Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: perf: self-contained TestEntry with consolidated switch invokers (follow-up)

This is a follow-up to the previous review. Checking which prior concerns have been addressed and adding new observations from a closer read of the diff.


Prior Issues — Status

1. Snapshot tests not updated — still unresolved (explicitly unchecked in PR description). Per project rules, .verified.txt files must be committed before merge.

2. __Attributes referenced unconditionally, generated conditionally — still present. The switch method is only emitted when classGroup.AttributeGroups.Length > 0, but every TestEntry<T> initializer unconditionally emits CreateAttributes = __Attributes,. A class with zero attribute groups would produce a compile error in generated code. A defensive always-emitted stub (even just => throw new ArgumentOutOfRangeException(...)) would close this gap.

3. ValueFactory = static () => null — still present in TestEntry<T>.BuildPropertyInjections(). If ValueFactory is supposed to produce injection values, this will silently inject null into all properties. Needs either a comment explaining the engine populates it separately, or the correct factory.

4. internal properties on TestMetadata<T>ClassInvoker, InvokeMethodIndex, ClassAttributeFactory, AttributeGroupIndex are still added as internal nullable sentinel-checked fields. The simpler fix (closing over the index in ToTestMetadata to produce a standard InvokeTypedTest) would avoid this coupling entirely:

InvokeTypedTest = (instance, args, ct) => InvokeBody(instance, MethodIndex, args, ct),

New Finding: Silent data loss — legacy fallback tests dropped

AotTestDataCollector.CollectTestsAsync now only reads from Sources.TestEntries:

if (!Sources.TestEntries.IsEmpty)
{
    allResults.AddRange(CollectTestsFromTestEntries(testSessionId, filterHints));
}
// Dynamic tests only after this — Sources.TestSources is never read

The old CollectTests/CollectTestsWithTwoPhaseDiscovery paths (which read Sources.TestSources) are completely removed. But GenerateTestMetadataLegacyFallback still exists and still emits a GenerateTestRegistrationField call that invokes SourceRegistrar.RegisterReturn — which writes to Sources.TestSources, not Sources.TestEntries.

Result: any generic test that falls into the legacy fallback path (no concrete types resolvable at source-gen time) registers to Sources.TestSources but is never read, so it silently vanishes from the test run. Users would see those tests disappear with no error.

Fix options:

  • Add a Sources.TestSources drain back into allResults after the TestEntries block, or
  • Make GenerateTestMetadataLegacyFallback emit RegisterEntries instead of RegisterReturn (requires a non-generic ITestEntrySource wrapper).

New Finding: FilePath double-escaping bug

In both EmitTestEntryFields and PreGenerateTestEntryDataFields:

writer.AppendLine($"FilePath = @\"{(testMethod.FilePath ?? "").Replace("\\", "\\\\")}\",");

This emits a verbatim string literal (@"...") in the generated code, but also escapes backslashes as if for a regular string. In a C# verbatim string, \ is already literal — no escaping needed. The Replace("\\", "\\\\") turns every \ into \\, so on Windows a path like C:\tests\Foo.cs would be emitted as:

FilePath = @"C:\\tests\\Foo.cs",  // wrong: C:\\tests\\Foo.cs

Either remove the .Replace(...) (verbatim string handles it), or switch to a regular string with proper escaping. This is a Windows-only correctness regression.


New Finding: Unbounded spin loop in RegisterEntries

while (true)
{
    if (Sources.TestEntries.TryGetValue(key, out var existing))
    {
        if (existing is TestEntrySource<T> existingSource)
        {
            existingSource.AddEntries(entries);
            return 0;
        }
    }

    if (Sources.TestEntries.TryAdd(key, new TestEntrySource<T>(entries)))
        return 0;
    // retry
}

If existing is not a TestEntrySource<T> (type mismatch), the loop never exits — it will spin forever. The comment says "Another thread added it concurrently — retry to merge", but a type mismatch is not a concurrency issue, it's a programmer error. The is check failure should throw rather than loop. Also, since this runs from .cctor-triggered static field initializers (which the CLR serializes per-type), true concurrency between the check and TryAdd for the same T is unlikely — the while(true) adds complexity for a case that cannot occur in practice for identical T.


Minor: Materialize-always vs. lazy under filter

CollectTestsFromTestEntries builds the full matching list (with capacity = total entry count) before any materialization. For the common case of filtered runs, this could over-allocate. Using new List<>(capacity: 64) or a more conservative starting size would reduce memory pressure for large suites with narrow filters. Low priority.


Summary

Issue Severity Status
Snapshot tests Blocking Not resolved
__Attributes compile error High Not resolved
Sources.TestSources data loss High New
FilePath double-escaping Medium New
ValueFactory = null Medium Not resolved
Unbounded spin loop Low New
internal properties on TestMetadata<T> Low Not resolved

The performance design is excellent, but the data-loss regression and compile-error risk should be addressed before landing.

… generics

- Inherited non-generic tests (InheritanceDepth > 0) now go through
  GenerateTestMetadata instead of being silently skipped. They get a
  single ConcreteInstantiation with the concrete class type.
- EmitConcreteInstanceCreation now uses InstanceFactoryGenerator for
  non-generic inherited tests (handles required properties) and
  Construct(typeArgs) for generic classes (avoids open generic names).
- Unresolvable generic tests (no data sources, no [GenerateGenericTest])
  are skipped instead of falling back to ITestSource.
- MethodDataSource attributes for generic classes now pass specificAttr
  for proper data source filtering.

Results:
- BasicTests: 3/3 pass
- DataDrivenTests: 15/15 pass
- GenericMethodTests: 2/2 pass
- GenericClass tests: 40/40 pass
- ConcreteClass1: 1 discovered (expected failure — test has wrong assertion)
- ConcreteClass2: 2/2 pass
- Full benchmark: 91,452/91,452 pass
This was referenced Mar 23, 2026
github-actions bot pushed a commit to IntelliTect/CodingGuidelines that referenced this pull request Mar 23, 2026
[//]: # (dependabot-start)
⚠️  **Dependabot is rebasing this PR** ⚠️ 

Rebasing might not happen immediately, so don't worry if this takes some
time.

Note: if you make any changes to this PR yourself, they will take
precedence over the rebase.

---

[//]: # (dependabot-end)

Updated [TUnit.Core](https://github.com/thomhurst/TUnit) from 1.19.57 to
1.21.6.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit.Core's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.21.6

<!-- Release notes generated using configuration in .github/release.yml
at v1.21.6 -->

## What's Changed
### Other Changes
* perf: replace object locks with Lock type for efficient
synchronization by @​thomhurst in
thomhurst/TUnit#5219
* perf: parallelize test metadata collection for source-generated tests
by @​thomhurst in thomhurst/TUnit#5221
* perf: use GetOrAdd args overload to eliminate closure allocations in
event receivers by @​thomhurst in
thomhurst/TUnit#5222
* perf: self-contained TestEntry<T> with consolidated switch invokers
eliminates per-test JIT by @​thomhurst in
thomhurst/TUnit#5223
### Dependencies
* chore(deps): update tunit to 1.21.0 by @​thomhurst in
thomhurst/TUnit#5220


**Full Changelog**:
thomhurst/TUnit@v1.21.0...v1.21.6

## 1.21.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.21.0 -->

## What's Changed
### Other Changes
* perf: reduce ConcurrentDictionary closure allocations in hot paths by
@​thomhurst in thomhurst/TUnit#5210
* perf: reduce async state machine overhead in test execution pipeline
by @​thomhurst in thomhurst/TUnit#5214
* perf: reduce allocations in EventReceiverOrchestrator and
TestContextExtensions by @​thomhurst in
thomhurst/TUnit#5212
* perf: skip timeout machinery when no timeout configured by @​thomhurst
in thomhurst/TUnit#5211
* perf: reduce allocations and lock contention in ObjectTracker by
@​thomhurst in thomhurst/TUnit#5213
* Feat/numeric tolerance by @​agray in
thomhurst/TUnit#5110
* perf: remove unnecessary lock in ObjectTracker.TrackObjects by
@​thomhurst in thomhurst/TUnit#5217
* perf: eliminate async state machine in
TestCoordinator.ExecuteTestAsync by @​thomhurst in
thomhurst/TUnit#5216
* perf: eliminate LINQ allocation in ObjectTracker.UntrackObjectsAsync
by @​thomhurst in thomhurst/TUnit#5215
* perf: consolidate module initializers into single .cctor via partial
class by @​thomhurst in thomhurst/TUnit#5218
### Dependencies
* chore(deps): update tunit to 1.20.0 by @​thomhurst in
thomhurst/TUnit#5205
* chore(deps): update dependency nunit3testadapter to 6.2.0 by
@​thomhurst in thomhurst/TUnit#5206
* chore(deps): update dependency cliwrap to 3.10.1 by @​thomhurst in
thomhurst/TUnit#5207


**Full Changelog**:
thomhurst/TUnit@v1.20.0...v1.21.0

## 1.20.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.20.0 -->

## What's Changed
### Other Changes
* Fix inverted colors in HTML report ring chart due to locale-dependent
decimal formatting by @​Copilot in
thomhurst/TUnit#5185
* Fix nullable warnings when using Member() on nullable properties by
@​Copilot in thomhurst/TUnit#5191
* Add CS8629 suppression and member access expression matching to
IsNotNullAssertionSuppressor by @​Copilot in
thomhurst/TUnit#5201
* feat: add ConfigureAppHost hook to AspireFixture by @​thomhurst in
thomhurst/TUnit#5202
* Fix ConfigureTestConfiguration being invoked twice by @​thomhurst in
thomhurst/TUnit#5203
* Add IsEquivalentTo assertion for Memory<T> and ReadOnlyMemory<T> by
@​thomhurst in thomhurst/TUnit#5204
### Dependencies
* chore(deps): update dependency gitversion.tool to v6.6.2 by
@​thomhurst in thomhurst/TUnit#5181
* chore(deps): update dependency gitversion.msbuild to 6.6.2 by
@​thomhurst in thomhurst/TUnit#5180
* chore(deps): update tunit to 1.19.74 by @​thomhurst in
thomhurst/TUnit#5179
* chore(deps): update verify to 31.13.3 by @​thomhurst in
thomhurst/TUnit#5182
* chore(deps): update verify to 31.13.5 by @​thomhurst in
thomhurst/TUnit#5183
* chore(deps): update aspire to 13.1.3 by @​thomhurst in
thomhurst/TUnit#5189
* chore(deps): update dependency stackexchange.redis to 2.12.4 by
@​thomhurst in thomhurst/TUnit#5193
* chore(deps): update microsoft/setup-msbuild action to v3 by
@​thomhurst in thomhurst/TUnit#5197


**Full Changelog**:
thomhurst/TUnit@v1.19.74...v1.20.0

## 1.19.74

<!-- Release notes generated using configuration in .github/release.yml
at v1.19.74 -->

## What's Changed
### Other Changes
* feat: per-hook activity spans with method names by @​thomhurst in
thomhurst/TUnit#5159
* fix: add tooltip to truncated span names in HTML report by @​thomhurst
in thomhurst/TUnit#5164
* Use enum names instead of numeric values in test display names by
@​Copilot in thomhurst/TUnit#5178
* fix: resolve CS8920 when mocking interfaces whose members return
static-abstract interfaces by @​lucaxchaves in
thomhurst/TUnit#5154
### Dependencies
* chore(deps): update tunit to 1.19.57 by @​thomhurst in
thomhurst/TUnit#5157
* chore(deps): update dependency gitversion.msbuild to 6.6.1 by
@​thomhurst in thomhurst/TUnit#5160
* chore(deps): update dependency gitversion.tool to v6.6.1 by
@​thomhurst in thomhurst/TUnit#5161
* chore(deps): update dependency polyfill to 9.20.0 by @​thomhurst in
thomhurst/TUnit#5163
* chore(deps): update dependency polyfill to 9.20.0 by @​thomhurst in
thomhurst/TUnit#5162
* chore(deps): update dependency polyfill to 9.21.0 by @​thomhurst in
thomhurst/TUnit#5166
* chore(deps): update dependency polyfill to 9.21.0 by @​thomhurst in
thomhurst/TUnit#5167
* chore(deps): update dependency polyfill to 9.22.0 by @​thomhurst in
thomhurst/TUnit#5168
* chore(deps): update dependency polyfill to 9.22.0 by @​thomhurst in
thomhurst/TUnit#5169
* chore(deps): update dependency coverlet.collector to 8.0.1 by
@​thomhurst in thomhurst/TUnit#5177

## New Contributors
* @​lucaxchaves made their first contribution in
thomhurst/TUnit#5154

**Full Changelog**:
thomhurst/TUnit@v1.19.57...v1.19.74

Commits viewable in [compare
view](thomhurst/TUnit@v1.19.57...v1.21.6).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit.Core&package-manager=nuget&previous-version=1.19.57&new-version=1.21.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant