Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 68 additions & 24 deletions src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2536,12 +2536,16 @@ private static void GenerateConcreteTestInvoker(CodeWriter writer, string method
writer.AppendLine("},");
}

private static void GenerateConcreteTestInvokerBody(CodeWriter writer, string methodName, TestReturnPattern returnPattern, bool hasCancellationToken, IParameterSymbol[] parametersFromArgs)
private static void GenerateConcreteTestInvokerBody(CodeWriter writer, string methodName, TestReturnPattern returnPattern, bool hasCancellationToken, IParameterSymbol[] parametersFromArgs, bool wrapInTryCatch = true)
{
// Wrap entire body in try-catch to handle synchronous exceptions
writer.AppendLine("try");
writer.AppendLine("{");
writer.Indent();
// Wrap entire body in try-catch to handle synchronous exceptions. The class-level
// __Invoke switch opts out and wraps the whole switch in a single handler instead.
if (wrapInTryCatch)
{
writer.AppendLine("try");
writer.AppendLine("{");
writer.Indent();
}

// Only declare context if it's needed (when hasCancellationToken is true and there are parameters)
if (hasCancellationToken && parametersFromArgs.Length > 0)
Expand Down Expand Up @@ -2692,8 +2696,16 @@ private static void GenerateConcreteTestInvokerBody(CodeWriter writer, string me
writer.AppendLine("}");
}

writer.Unindent();
writer.AppendLine("}");
if (wrapInTryCatch)
{
writer.Unindent();
writer.AppendLine("}");
WriteSynchronousExceptionHandler(writer);
}
}

private static void WriteSynchronousExceptionHandler(CodeWriter writer)
{
writer.AppendLine("catch (global::System.Exception ex)");
writer.AppendLine("{");
writer.Indent();
Expand Down Expand Up @@ -3044,7 +3056,7 @@ private static string PreGenerateInvokeSwitchCase(TestMethodMetadata testMethod,
writer.AppendLine($"case {methodIndex}:");
writer.AppendLine("{");
writer.Indent();
GenerateConcreteTestInvokerBody(writer, testMethod.MethodSymbol.Name, returnPattern, hasCancellationToken, parametersFromArgs);
GenerateConcreteTestInvokerBody(writer, testMethod.MethodSymbol.Name, returnPattern, hasCancellationToken, parametersFromArgs, wrapInTryCatch: false);
writer.Unindent();
writer.AppendLine("}");

Expand Down Expand Up @@ -3122,12 +3134,34 @@ private static string PreGenerateSharedFields(INamedTypeSymbol typeSymbol, strin
}

/// <summary>
/// Pre-generates a MethodMetadataFactory.Create(...) expression for one method.
/// Pre-generates the named arguments that let TestEntryFactory.CreateWithClassMetadata build the method's
/// MethodMetadata itself (return type, generic arity, parameters), omitting factory defaults.
/// Called during the transform step where ISymbol is available.
/// </summary>
private static string PreGenerateMethodMetadataExpression(TestMethodMetadata testMethod)
private static string PreGenerateMethodMetadataArguments(IMethodSymbol methodSymbol)
{
return GenerateMethodMetadataFactoryCall(testMethod.MethodSymbol);
var writer = new CodeWriter(includeHeader: false);

if (!methodSymbol.ReturnsVoid)
{
writer.AppendLine($"returnType: typeof({methodSymbol.ReturnType.GloballyQualified()}),");
}

if (methodSymbol.TypeParameters.Length > 0)
{
writer.AppendLine($"genericTypeCount: {methodSymbol.TypeParameters.Length},");
}

if (methodSymbol.Parameters.Length > 0)
{
var paramExpr = MetadataGenerationHelper.GenerateParameterMetadataArrayForMethodExpression(methodSymbol);
if (paramExpr != null)
{
writer.AppendLine($"parameters: {paramExpr},");
}
}

return writer.ToString();
}

/// <summary>
Expand Down Expand Up @@ -3420,7 +3454,7 @@ private static IEnumerable<ClassTestGroup> GroupMethodsByClass(ImmutableArray<Te
MethodId = methodId,
MethodIndex = i,
AttributeGroupIndex = attrIndexMap[i],
MethodMetadataCode = PreGenerateMethodMetadataExpression(m),
MethodMetadataArgumentsCode = PreGenerateMethodMetadataArguments(m.MethodSymbol),
InvokeSwitchCaseCode = PreGenerateInvokeSwitchCase(m, i),
TestEntryDataFieldsCode = PreGenerateTestEntryDataFields(m),
TestDataSourcesCode = PreGenerateMethodDataSourcesExpression(m),
Expand Down Expand Up @@ -3465,12 +3499,6 @@ private static void GeneratePerClassTestSource(SourceProductionContext context,
// Shared ClassMetadata + classType as static fields (no separate method needed)
writer.AppendRaw(classGroup.SharedFieldsCode);

// Per-method MethodMetadata as individual static fields (inlined, no array)
foreach (var method in classGroup.Methods)
{
writer.AppendLine($"private static readonly global::TUnit.Core.MethodMetadata __mm_{method.MethodIndex} = {method.MethodMetadataCode};");
}

// CreateInstance — shared across all entries (1 per class)
writer.AppendLine($"private static {classGroup.ClassFullyQualified} __CreateInstance(global::System.Type[] typeArgs, object?[] args)");
writer.AppendLine("{");
Expand All @@ -3483,6 +3511,11 @@ private static void GeneratePerClassTestSource(SourceProductionContext context,
writer.AppendLine($"private static global::System.Threading.Tasks.ValueTask __Invoke({classGroup.ClassFullyQualified} instance, int methodIndex, object?[] args, global::System.Threading.CancellationToken cancellationToken)");
writer.AppendLine("{");
writer.Indent();
// One exception handler around the whole switch: a handler per case multiplies the
// method's IL and exception clauses by the number of tests in the class.
writer.AppendLine("try");
writer.AppendLine("{");
writer.Indent();
writer.AppendLine("switch (methodIndex)");
writer.AppendLine("{");
writer.Indent();
Expand All @@ -3498,6 +3531,9 @@ private static void GeneratePerClassTestSource(SourceProductionContext context,
writer.AppendLine("}");
writer.Unindent();
writer.AppendLine("}");
WriteSynchronousExceptionHandler(writer);
writer.Unindent();
writer.AppendLine("}");

// Consolidated __Attributes switch — 1 method for ALL attribute groups in this class
// Always emitted (even if no groups) since TestEntry references it unconditionally
Expand Down Expand Up @@ -3532,15 +3568,22 @@ private static void GeneratePerClassTestSource(SourceProductionContext context,
writer.Unindent();
writer.AppendLine("}");

// The three class-shared delegates are created once. Converting the method groups at each
// entry would allocate three delegates per test and emit a ldftn/newobj pair for each in
// the static constructor, which dominated its JIT cost for large classes.
writer.AppendLine($"private static readonly global::System.Func<global::System.Type[], object?[], {classGroup.ClassFullyQualified}> __createInstance = __CreateInstance;");
writer.AppendLine($"private static readonly global::System.Func<{classGroup.ClassFullyQualified}, int, object?[], global::System.Threading.CancellationToken, global::System.Threading.Tasks.ValueTask> __invoke = __Invoke;");
writer.AppendLine("private static readonly global::System.Func<int, global::System.Attribute[]> __attributes = __Attributes;");

// TestEntry<T>[] array — all entries share the same 3 delegates and are built via the
// shared TestEntryFactory so each call site is a single factory call instead of a
// large object initializer (#6227)
// large object initializer (#6227). The factory also builds each entry's MethodMetadata.
writer.AppendLine($"public static readonly global::TUnit.Core.TestEntry<{classGroup.ClassFullyQualified}>[] Entries = new global::TUnit.Core.TestEntry<{classGroup.ClassFullyQualified}>[]");
writer.AppendLine("{");
writer.Indent();
foreach (var method in classGroup.Methods)
{
writer.AppendLine($"global::TUnit.Core.TestEntryFactory.Create<{classGroup.ClassFullyQualified}>(");
writer.AppendLine($"global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata<{classGroup.ClassFullyQualified}>(");
writer.Indent();
writer.AppendRaw(method.TestEntryDataFieldsCode);
if (method.TestDataSourcesCode != null)
Expand All @@ -3555,11 +3598,12 @@ private static void GeneratePerClassTestSource(SourceProductionContext context,
{
writer.AppendLine($"dependencies: {method.DependenciesCode},");
}
writer.AppendLine($"methodMetadata: __mm_{method.MethodIndex},");
writer.AppendLine($"createInstance: __CreateInstance,");
writer.AppendLine($"invokeBody: __Invoke,");
writer.AppendRaw(method.MethodMetadataArgumentsCode);
writer.AppendLine("classMetadata: __classMetadata,");
writer.AppendLine("createInstance: __createInstance,");
writer.AppendLine("invokeBody: __invoke,");
writer.AppendLine($"methodIndex: {method.MethodIndex},");
writer.AppendLine($"createAttributes: __Attributes,");
writer.AppendLine("createAttributes: __attributes,");
writer.AppendLine($"attributeGroupIndex: {method.AttributeGroupIndex}),");
writer.Unindent();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ public sealed record TestMethodSourceCode
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>Named TestEntryFactory arguments describing the method (return type, generic arity, parameters).</summary>
public required string MethodMetadataArgumentsCode { get; init; }

/// <summary>Switch case body for the class-level __Invoke method.</summary>
public required string InvokeSwitchCaseCode { get; init; }
Expand Down
72 changes: 72 additions & 0 deletions src/TUnit.Core/TestEntryFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,76 @@ public static TestEntry<T> Create<
InjectableProperties = injectableProperties ?? [],
};
}

/// <summary>
/// Creates a <see cref="TestEntry{T}"/> together with its <see cref="MethodMetadata"/>.
/// </summary>
/// <remarks>
/// Building the method metadata here rather than at each generated call site keeps the
/// generated static constructor small: every test otherwise needs its own static field plus a
/// separate metadata factory call, and that IL is JIT-compiled once per test class at startup.
/// <paramref name="returnType"/> defaults to <see cref="void"/>.
/// </remarks>
public static TestEntry<T> CreateWithClassMetadata<
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors
| DynamicallyAccessedMemberTypes.PublicProperties
| DynamicallyAccessedMemberTypes.PublicMethods)] T>(
string methodName,
string fullyQualifiedName,
string filePath,
int lineNumber,
ClassMetadata classMetadata,
Func<Type[], object?[], T> createInstance,
Func<T, int, object?[], CancellationToken, ValueTask> invokeBody,
int methodIndex,
Func<int, Attribute[]> createAttributes,
int attributeGroupIndex,
Type? returnType = null,
int genericTypeCount = 0,
ParameterMetadata[]? parameters = null,
int startColumnNumber = 0,
int endLineNumber = 0,
int endColumnNumber = 0,
bool hasDataSource = false,
int repeatCount = 0,
string[]? categories = null,
string[]? properties = null,
string[]? dependsOn = null,
TestDependency[]? dependencies = null,
IDataSourceAttribute[]? testDataSources = null,
IDataSourceAttribute[]? classDataSources = null,
InjectableProperty[]? injectableProperties = null) where T : class
{
var methodMetadata = MethodMetadataFactory.Create(
methodName,
typeof(T),
returnType ?? typeof(void),
classMetadata,
genericTypeCount,
parameters);

return Create(
methodName,
fullyQualifiedName,
filePath,
lineNumber,
methodMetadata,
createInstance,
invokeBody,
methodIndex,
createAttributes,
attributeGroupIndex,
startColumnNumber,
endLineNumber,
endColumnNumber,
hasDataSource,
repeatCount,
categories,
properties,
dependsOn,
dependencies,
testDataSources,
classDataSources,
injectableProperties);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// <auto-generated/>
// <auto-generated/>
#pragma warning disable

#nullable enable
Expand All @@ -19,29 +19,28 @@ internal static class TUnit_TestProject_AbstractTests_ConcreteClass2__TestSource
Parent = null
});
private static readonly global::System.Type __classType = typeof(global::TUnit.TestProject.AbstractTests.ConcreteClass2);
private static readonly global::TUnit.Core.MethodMetadata __mm_0 = global::TUnit.Core.MethodMetadataFactory.Create("SecondTest", __classType, typeof(void), __classMetadata);
private static global::TUnit.TestProject.AbstractTests.ConcreteClass2 __CreateInstance(global::System.Type[] typeArgs, object?[] args)
{
return new global::TUnit.TestProject.AbstractTests.ConcreteClass2();
}
private static global::System.Threading.Tasks.ValueTask __Invoke(global::TUnit.TestProject.AbstractTests.ConcreteClass2 instance, int methodIndex, object?[] args, global::System.Threading.CancellationToken cancellationToken)
{
switch (methodIndex)
try
{
case 0:
switch (methodIndex)
{
try
case 0:
{
instance.SecondTest();
return default(global::System.Threading.Tasks.ValueTask);
}
catch (global::System.Exception ex)
{
return new global::System.Threading.Tasks.ValueTask(global::System.Threading.Tasks.Task.FromException(ex));
}
default:
throw new global::System.ArgumentOutOfRangeException(nameof(methodIndex));
}
default:
throw new global::System.ArgumentOutOfRangeException(nameof(methodIndex));
}
catch (global::System.Exception ex)
{
return new global::System.Threading.Tasks.ValueTask(global::System.Threading.Tasks.Task.FromException(ex));
}
}
private static global::System.Attribute[] __Attributes(int groupIndex)
Expand All @@ -61,18 +60,21 @@ internal static class TUnit_TestProject_AbstractTests_ConcreteClass2__TestSource
throw new global::System.ArgumentOutOfRangeException(nameof(groupIndex));
}
}
private static readonly global::System.Func<global::System.Type[], object?[], global::TUnit.TestProject.AbstractTests.ConcreteClass2> __createInstance = __CreateInstance;
private static readonly global::System.Func<global::TUnit.TestProject.AbstractTests.ConcreteClass2, int, object?[], global::System.Threading.CancellationToken, global::System.Threading.Tasks.ValueTask> __invoke = __Invoke;
private static readonly global::System.Func<int, global::System.Attribute[]> __attributes = __Attributes;
public static readonly global::TUnit.Core.TestEntry<global::TUnit.TestProject.AbstractTests.ConcreteClass2>[] Entries = new global::TUnit.Core.TestEntry<global::TUnit.TestProject.AbstractTests.ConcreteClass2>[]
{
global::TUnit.Core.TestEntryFactory.Create<global::TUnit.TestProject.AbstractTests.ConcreteClass2>(
global::TUnit.Core.TestEntryFactory.CreateWithClassMetadata<global::TUnit.TestProject.AbstractTests.ConcreteClass2>(
methodName: "SecondTest",
fullyQualifiedName: "TUnit.TestProject.AbstractTests.ConcreteClass2.SecondTest",
filePath: "",
lineNumber: 11,
methodMetadata: __mm_0,
createInstance: __CreateInstance,
invokeBody: __Invoke,
classMetadata: __classMetadata,
createInstance: __createInstance,
invokeBody: __invoke,
methodIndex: 0,
createAttributes: __Attributes,
createAttributes: __attributes,
attributeGroupIndex: 0),
};
}
Expand Down
Loading
Loading