Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
93cd430
perf: add startup performance measurement scripts
thomhurst Mar 22, 2026
cac3033
perf: replace per-test JIT methods with data-table registration and c…
thomhurst Mar 22, 2026
9403dcf
perf: replace Materialize/consolidated switches with self-contained T…
thomhurst Mar 22, 2026
56bd124
chore: remove orphaned old type files
thomhurst Mar 22, 2026
84eefb9
perf: consolidate InvokeBody/CreateAttributes into class-level switch…
thomhurst Mar 22, 2026
3e4ea68
fix: nested class name resolution, rename CustomProperties to Properties
thomhurst Mar 22, 2026
75f25d8
merge: resolve conflict with main's parallel CollectTests
thomhurst Mar 22, 2026
1009d2b
perf: convert generic tests from ITestSource to TestEntry
thomhurst Mar 22, 2026
2ee3b19
refactor: remove ITestSource collection path from engine
thomhurst Mar 22, 2026
7a444c6
fix: pass MethodDataSource attribute as specificAttr for generic type…
thomhurst Mar 22, 2026
4284e40
fix: inherited tests now emit TestEntry, proper instance creation for…
thomhurst Mar 22, 2026
73786a1
perf: cache PropertyDataSources/PropertyInjections arrays in TestEntry
thomhurst Mar 22, 2026
07daf86
refactor: remove dual-path coupling from TestMetadata<T>
thomhurst Mar 22, 2026
d15ab46
test: update source generator snapshot tests for TestEntry pattern
thomhurst Mar 22, 2026
6b6318b
test: update public API snapshots for new TestEntry types
thomhurst Mar 22, 2026
fa61c1b
fix: address claude-review items 2/4/5
thomhurst Mar 22, 2026
53997a0
perf: cache InvokeTypedTest and AttributeFactory closures in TestEntry
thomhurst Mar 22, 2026
b8440ba
refactor: remove all ITestSource/TestDescriptor legacy code paths
thomhurst Mar 22, 2026
b81a900
perf: inline MethodMetadata into static fields, eliminate __InitMetho…
thomhurst Mar 22, 2026
32bcdc5
test: update public API snapshots for inlined MethodMetadata
thomhurst Mar 23, 2026
aebf007
fix: address code review — thread-safe delegate caching, remove unuse…
thomhurst Mar 23, 2026
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
250 changes: 202 additions & 48 deletions TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

Large diffs are not rendered by default.

33 changes: 2 additions & 31 deletions TUnit.Core.SourceGenerator/Models/ClassTestGroup.cs
Original file line number Diff line number Diff line change
@@ -1,50 +1,21 @@
namespace TUnit.Core.SourceGenerator.Models;

/// <summary>
/// A per-class grouping model for generating a consolidated TestSource.
/// A per-class grouping model for generating a TestSource with TestEntry&lt;T&gt; array.
/// Contains only primitives/strings (no ISymbol references) for incremental caching.
/// </summary>
public sealed record ClassTestGroup
{
/// <summary>
/// The fully qualified class name with global:: prefix.
/// e.g. "global::MyNamespace.MyClass"
/// </summary>
public required string ClassFullyQualified { get; init; }

/// <summary>
/// The safe identifier name for the per-class TestSource.
/// e.g. "MyNamespace_MyClass__TestSource"
/// </summary>
public required string TestSourceName { get; init; }

/// <summary>
/// Pre-generated code for each test method in this class.
/// </summary>
public required EquatableArray<TestMethodSourceCode> Methods { get; init; }

/// <summary>
/// Deduplicated attribute factory method bodies.
/// Index corresponds to TestMethodSourceCode.AttributeGroupIndex.
/// Methods with identical attributes share the same body.
/// Deduplicated attribute factory bodies. Methods with identical attributes share the same index.
/// </summary>
public required EquatableArray<string> AttributeGroups { get; init; }

/// <summary>
/// Pre-generated C# code for the CreateInstance method body.
/// Generated during the transform step where ISymbol is available.
/// </summary>
public required string InstanceFactoryBodyCode { get; init; }

/// <summary>
/// Pre-generated UnsafeAccessor declarations for init-only properties with data sources.
/// Empty string if none needed.
/// </summary>
public required string ReflectionFieldAccessorsCode { get; init; }

/// <summary>
/// Pre-generated shared local variable declarations (ClassMetadata, classType)
/// emitted once at the top of GetTests().
/// </summary>
public required string SharedLocalsCode { get; init; }
}
43 changes: 10 additions & 33 deletions TUnit.Core.SourceGenerator/Models/TestMethodSourceCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,41 +6,18 @@ namespace TUnit.Core.SourceGenerator.Models;
/// </summary>
public sealed record TestMethodSourceCode
{
/// <summary>
/// Safe unique identifier within the class (handles overloads via parameter types).
/// e.g. "MyMethod" or "MyMethod__Int32_String"
/// </summary>
public required string MethodId { get; init; }
public required int MethodIndex { get; init; }
public required int AttributeGroupIndex { get; init; }
public required string MethodMetadataCode { get; init; }

/// <summary>
/// Pre-generated metadata block for GetTests().
/// Contains: var metadata_{id} = new TestMetadata&lt;T&gt; { ... }; metadata_{id}.UseRuntimeDataGeneration(...);
/// </summary>
public required string MetadataCode { get; init; }

/// <summary>
/// Pre-generated descriptor block for EnumerateTestDescriptors().
/// Contains: yield return new TestDescriptor { ..., Materializer = __Materialize_{id} };
/// </summary>
public required string DescriptorCode { get; init; }

/// <summary>
/// Pre-generated full __InvokeTest_{id} static method (signature + body).
/// </summary>
public required string InvokeTestMethod { get; init; }
/// <summary>Switch case body for the class-level __Invoke method.</summary>
public required string InvokeSwitchCaseCode { get; init; }

/// <summary>
/// Pre-generated full __Materialize_{id} static method (signature + body).
/// The metadata code here intentionally duplicates MetadataCode rather than extracting a shared
/// BuildMetadata_{id} helper, because such helpers would each be JIT-compiled (adding 10,000 JITs
/// in the repro scenario), negating the optimization. Materializers are never JIT'd in normal runs
/// (only when --treenode-filter is used), so the duplication has zero runtime cost.
/// </summary>
public required string MaterializerMethod { get; init; }
/// <summary>TestEntry data fields (MethodName, FilePath, etc.).</summary>
public required string TestEntryDataFieldsCode { get; init; }

/// <summary>
/// Index into the shared AttributeGroups array in ClassTestGroup.
/// Methods with identical attribute factory bodies share the same index.
/// </summary>
public required int AttributeGroupIndex { get; init; }
public string? TestDataSourcesCode { get; init; }
public string? ClassDataSourcesCode { get; init; }
public string? DependenciesCode { get; init; }
}
43 changes: 43 additions & 0 deletions TUnit.Core/ITestEntrySource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.ComponentModel;

namespace TUnit.Core;

/// <summary>
/// Non-generic interface for the engine to access test entries without knowing T.
/// Each TestEntrySource&lt;T&gt; implements this to provide materialization.
/// </summary>
#if !DEBUG
[EditorBrowsable(EditorBrowsableState.Never)]
#endif
public interface ITestEntrySource
{
/// <summary>Number of test entries.</summary>
int Count { get; }

/// <summary>The test class type.</summary>
Type ClassType { get; }

/// <summary>Get the class name for filtering (from the first entry).</summary>
string ClassName { get; }

/// <summary>Get entry data for filtering at the given index.</summary>
TestEntryFilterData GetFilterData(int index);

/// <summary>Materialize a TestMetadata for the entry at the given index.</summary>
IReadOnlyList<TestMetadata> Materialize(int index, string testSessionId);
}

/// <summary>
/// Lightweight data extracted from a TestEntry for filtering — no delegates, no JIT.
/// </summary>
public readonly struct TestEntryFilterData
{
public required string MethodName { get; init; }
public required string FullyQualifiedName { get; init; }
public required string ClassName { get; init; }
public required string[] Categories { get; init; }
public required string[] Properties { get; init; }
public required string[] DependsOn { get; init; }
public required bool HasDataSource { get; init; }
public required int RepeatCount { get; init; }
}
35 changes: 35 additions & 0 deletions TUnit.Core/InjectableProperty.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.ComponentModel;

namespace TUnit.Core;

/// <summary>
/// Describes a property on a test class that has an IDataSourceAttribute and needs injection.
/// Source-generated: the source generator identifies these at compile time and emits the setter delegate,
/// eliminating reflection-based property discovery at runtime.
/// </summary>
#if !DEBUG
[EditorBrowsable(EditorBrowsableState.Never)]
#endif
public sealed class InjectableProperty
{
/// <summary>
/// The property name.
/// </summary>
public required string Name { get; init; }

/// <summary>
/// The property type.
/// </summary>
public required Type Type { get; init; }

/// <summary>
/// The data source attribute on this property.
/// </summary>
public required IDataSourceAttribute DataSource { get; init; }

/// <summary>
/// AOT-safe setter delegate. Calls the property setter without reflection.
/// Signature: (object instance, object? value) — the instance is cast internally.
/// </summary>
public required Action<object, object?> SetValue { get; init; }
}
17 changes: 17 additions & 0 deletions TUnit.Core/SourceRegistrar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,21 @@ public static int RegisterReturn(
Register(testClassType, getTests, enumerateDescriptors);
return 0;
}

/// <summary>
/// Registers test entries for a class using the TestEntry pattern.
/// Returns a dummy value for use as a static field initializer.
/// </summary>
public static int RegisterEntries<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors | System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicMethods)] T>(TestEntry<T>[] entries) where T : class
{
if (entries is null || entries.Length == 0)
{
throw new InvalidOperationException(
$"Source-generated test registration failed: no entries for '{typeof(T).FullName}'. " +
"This indicates a source generator bug. Please report this issue.");
}

Sources.TestEntries[typeof(T)] = new TestEntrySource<T>(entries);
return 0;
}
}
3 changes: 3 additions & 0 deletions TUnit.Core/Sources.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,7 @@ public static class Sources

public static readonly ConcurrentQueue<Func<Task>> GlobalInitializers = [];
public static readonly ConcurrentQueue<IPropertySource> PropertySources = [];

// TestEntry registration path (source-gen startup performance optimization)
public static readonly ConcurrentDictionary<Type, ITestEntrySource> TestEntries = new();
}
163 changes: 163 additions & 0 deletions TUnit.Core/TestEntry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;

namespace TUnit.Core;

/// <summary>
/// A self-contained test registration unit emitted by the source generator.
/// Contains everything needed to filter, materialize, and execute a single test:
/// pure data for filtering, behavioral delegates for execution, and property
/// descriptors for injection — all without reflection at runtime.
/// </summary>
/// <remarks>
/// <para>
/// Delegate properties (InvokeBody, CreateAttributes, CreateInstance) are shared
/// across all entries in a class — they point to class-level switch methods.
/// Per-test differentiation is via MethodIndex and AttributeGroupIndex.
/// This means zero per-test methods in the generated assembly.
/// </para>
/// </remarks>
#if !DEBUG
[EditorBrowsable(EditorBrowsableState.Never)]
#endif
public sealed class TestEntry<
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors
| DynamicallyAccessedMemberTypes.PublicProperties
| DynamicallyAccessedMemberTypes.PublicMethods)] T> where T : class
{
// --- Identity (pure data for filtering — no JIT needed) ---

/// <summary>Test method name.</summary>
public required string MethodName { get; init; }

/// <summary>Fully qualified name: Namespace.Class.Method</summary>
public required string FullyQualifiedName { get; init; }

/// <summary>Source file path.</summary>
public required string FilePath { get; init; }

/// <summary>Source line number.</summary>
public required int LineNumber { get; init; }

/// <summary>Pre-extracted categories for fast filtering.</summary>
public string[] Categories { get; init; } = [];

/// <summary>Pre-extracted "key=value" property pairs for fast filtering.</summary>
public string[] Properties { get; init; } = [];

/// <summary>Dependency strings for fast BFS filtering: "ClassName:MethodName".</summary>
public string[] DependsOn { get; init; } = [];

/// <summary>Pre-built TestDependency objects for the engine's dependency resolver.</summary>
public TestDependency[] Dependencies { get; init; } = [];

/// <summary>Whether this test has data sources.</summary>
public bool HasDataSource { get; init; }

/// <summary>Repeat count from RepeatAttribute, or 0.</summary>
public int RepeatCount { get; init; }

// --- Structural metadata (pre-built by source generator in .cctor) ---

/// <summary>Pre-built method metadata (name, return type, parameters, class metadata).</summary>
public required MethodMetadata MethodMetadata { get; init; }

// --- Class-level shared delegates (1 JIT each, shared by ALL entries in this class) ---

/// <summary>
/// Consolidated class-level invoker. All tests in the class share this delegate;
/// each test dispatches via its MethodIndex. One JIT per class.
/// </summary>
public required Func<T, int, object?[], CancellationToken, ValueTask> InvokeBody { get; init; }

/// <summary>Method index for InvokeBody dispatch.</summary>
public required int MethodIndex { get; init; }

/// <summary>
/// Consolidated class-level attribute factory. All tests in the class share this delegate;
/// each test dispatches via its AttributeGroupIndex. One JIT per class.
/// </summary>
public required Func<int, Attribute[]> CreateAttributes { get; init; }

/// <summary>Attribute group index for CreateAttributes dispatch.</summary>
public required int AttributeGroupIndex { get; init; }

/// <summary>AOT-safe factory to create test class instances. Shared by all entries in class.</summary>
public required Func<Type[], object?[], T> CreateInstance { get; init; }

// --- Data sources (pre-separated by source generator — no runtime scanning needed) ---

/// <summary>Method-level data source attributes (Arguments, MethodDataSource, etc.).</summary>
public IDataSourceAttribute[] TestDataSources { get; init; } = [];

/// <summary>Class-level data source attributes.</summary>
public IDataSourceAttribute[] ClassDataSources { get; init; } = [];

// --- Property injection (source-generated, no reflection needed) ---

/// <summary>Properties with data source attributes that need injection.</summary>
public InjectableProperty[] InjectableProperties { get; init; } = [];

/// <summary>
/// Constructs a TestMetadata&lt;T&gt; from this entry's data and delegates.
/// </summary>
internal TestMetadata<T> ToTestMetadata(string testSessionId)
{
return new TestMetadata<T>
{
TestName = MethodName,
TestClassType = typeof(T),
TestMethodName = MethodName,
Dependencies = Dependencies,
DataSources = TestDataSources,
ClassDataSources = ClassDataSources,
PropertyDataSources = BuildPropertyDataSources(),
PropertyInjections = BuildPropertyInjections(),
InstanceFactory = CreateInstance,
ClassInvoker = InvokeBody,
InvokeMethodIndex = MethodIndex,
ClassAttributeFactory = CreateAttributes,
AttributeGroupIndex = AttributeGroupIndex,
AttributeFactory = () => CreateAttributes(AttributeGroupIndex),
FilePath = FilePath,
LineNumber = LineNumber,
MethodMetadata = MethodMetadata,
RepeatCount = RepeatCount > 0 ? RepeatCount : null,
TestSessionId = testSessionId,
};
}

private PropertyDataSource[] BuildPropertyDataSources()
{
if (InjectableProperties.Length == 0) return [];
var result = new PropertyDataSource[InjectableProperties.Length];
for (var i = 0; i < InjectableProperties.Length; i++)
{
result[i] = new PropertyDataSource
{
PropertyName = InjectableProperties[i].Name,
PropertyType = InjectableProperties[i].Type,
DataSource = InjectableProperties[i].DataSource,
};
}
return result;
}

private PropertyInjectionData[] BuildPropertyInjections()
{
if (InjectableProperties.Length == 0) return [];
var result = new PropertyInjectionData[InjectableProperties.Length];
for (var i = 0; i < InjectableProperties.Length; i++)
{
var prop = InjectableProperties[i];
result[i] = new PropertyInjectionData
{
PropertyName = prop.Name,
PropertyType = prop.Type,
Setter = prop.SetValue,
ValueFactory = static () => null,
};
}
return result;
}
}
Loading
Loading