diff --git a/TUnit.Core/AbstractDynamicTest.cs b/TUnit.Core/AbstractDynamicTest.cs index 6097c24001..144ef2e311 100644 --- a/TUnit.Core/AbstractDynamicTest.cs +++ b/TUnit.Core/AbstractDynamicTest.cs @@ -44,7 +44,7 @@ public class DynamicDiscoveryResult : DiscoveryResult public Enums.TestRelationship? Relationship { get; set; } - public Dictionary? Properties { get; set; } + public IReadOnlyDictionary? Properties { get; set; } public string? DisplayName { get; set; } diff --git a/TUnit.Core/DynamicTestMetadata.cs b/TUnit.Core/DynamicTestMetadata.cs index eb3d4e77f3..7759d17601 100644 --- a/TUnit.Core/DynamicTestMetadata.cs +++ b/TUnit.Core/DynamicTestMetadata.cs @@ -33,7 +33,7 @@ public DynamicTestMetadata(DynamicDiscoveryResult dynamicResult) /// /// Custom properties for test variants. /// - public Dictionary? Properties => _dynamicResult.Properties; + public IReadOnlyDictionary? Properties => _dynamicResult.Properties; [field: AllowNull, MaybeNull] public override Func CreateExecutableTestFactory diff --git a/TUnit.Core/Extensions/TestContextExtensions.cs b/TUnit.Core/Extensions/TestContextExtensions.cs index af39629ed0..cf842a8684 100644 --- a/TUnit.Core/Extensions/TestContextExtensions.cs +++ b/TUnit.Core/Extensions/TestContextExtensions.cs @@ -85,6 +85,12 @@ internal static string GetNestedTypeName(Type type) return prefix != null ? $"{prefix}+{type.Name}" : type.Name; } + /// + /// Gets whether this test is a variant created at runtime via . + /// Use this to guard against infinite recursion when a test creates variants of itself. + /// + public static bool IsVariant(this Interfaces.ITestDependencies dependencies) => dependencies.ParentTestId != null; + #if NET6_0_OR_GREATER [RequiresUnreferencedCode("Dynamic test metadata creation uses reflection")] #endif @@ -106,21 +112,23 @@ internal static string GetNestedTypeName(Type type) /// This is the primary mechanism for implementing property-based test shrinking and retry logic. /// /// The current test context - /// Method arguments for the variant (null to reuse current arguments) + /// Method arguments for the variant (null to reuse current arguments) + /// Constructor arguments for the variant's test class (null to reuse current arguments) /// Key-value pairs for user-defined metadata (e.g., attempt count, custom data) /// The relationship category of this variant to its parent test (defaults to Derived) /// Optional user-facing display name for the variant (e.g., "Shrink Attempt", "Mutant") - /// A task that completes when the variant has been queued + /// Information about the created test variant, including its TestId and DisplayName #if NET6_0_OR_GREATER [RequiresUnreferencedCode("Creating test variants requires runtime compilation and reflection")] #endif - public static async Task CreateTestVariant( + public static async Task CreateTestVariant( this TestContext context, - object?[]? arguments = null, - Dictionary? properties = null, + object?[]? methodArguments = null, + object?[]? classArguments = null, + IReadOnlyDictionary? properties = null, Enums.TestRelationship relationship = Enums.TestRelationship.Derived, string? displayName = null) { - await context.Services.GetService()!.CreateTestVariant(context, arguments, properties, relationship, displayName); + return await context.Services.GetService()!.CreateTestVariant(context, methodArguments, classArguments, properties, relationship, displayName); } } diff --git a/TUnit.Core/Interfaces/ITestRegistry.cs b/TUnit.Core/Interfaces/ITestRegistry.cs index 931ada871e..ed6321af1b 100644 --- a/TUnit.Core/Interfaces/ITestRegistry.cs +++ b/TUnit.Core/Interfaces/ITestRegistry.cs @@ -33,18 +33,20 @@ public interface ITestRegistry /// This is the primary mechanism for implementing property-based test shrinking and retry logic. /// /// The current test context to base the variant on - /// Method arguments for the variant (null to reuse current arguments) + /// Method arguments for the variant (null to reuse current arguments) + /// Constructor arguments for the variant's test class (null to reuse current arguments) /// Key-value pairs for user-defined metadata (e.g., attempt count, custom data) /// The relationship category of this variant to its parent test /// Optional user-facing display name for the variant (e.g., "Shrink Attempt", "Mutant") - /// A task that completes when the variant has been queued + /// A task containing information about the created test variant #if NET6_0_OR_GREATER [RequiresUnreferencedCode("Creating test variants requires runtime compilation and reflection which are not supported in native AOT scenarios.")] #endif - Task CreateTestVariant( + Task CreateTestVariant( TestContext currentContext, - object?[]? arguments, - Dictionary? properties, + object?[]? methodArguments, + object?[]? classArguments, + IReadOnlyDictionary? properties, Enums.TestRelationship relationship, string? displayName); } diff --git a/TUnit.Core/TestVariantInfo.cs b/TUnit.Core/TestVariantInfo.cs new file mode 100644 index 0000000000..347e752f78 --- /dev/null +++ b/TUnit.Core/TestVariantInfo.cs @@ -0,0 +1,24 @@ +namespace TUnit.Core; + +/// +/// Information about a test variant that was created at runtime. +/// Returned by . +/// +public sealed record TestVariantInfo +{ + internal TestVariantInfo(string testId, string displayName) + { + TestId = testId; + DisplayName = displayName; + } + + /// + /// The unique identifier assigned to this test variant. + /// + public string TestId { get; } + + /// + /// The display name of this test variant as it appears in test explorers. + /// + public string DisplayName { get; } +} diff --git a/TUnit.Engine.Tests/TestVariantReturnTypeTests.cs b/TUnit.Engine.Tests/TestVariantReturnTypeTests.cs new file mode 100644 index 0000000000..d1bd5c5fa7 --- /dev/null +++ b/TUnit.Engine.Tests/TestVariantReturnTypeTests.cs @@ -0,0 +1,20 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +public class TestVariantReturnTypeTests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + public async Task Test() + { + await RunTestsWithFilter( + "/*/*/TestVariant*/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Passed.ShouldBeGreaterThanOrEqualTo(10), + result => result.ResultSummary.Counters.Failed.ShouldBe(0), + result => result.ResultSummary.Counters.NotExecuted.ShouldBe(0) + ]); + } +} diff --git a/TUnit.Engine/Building/Collectors/AotTestDataCollector.cs b/TUnit.Engine/Building/Collectors/AotTestDataCollector.cs index cff2bdb3b3..9a89c8e8f1 100644 --- a/TUnit.Engine/Building/Collectors/AotTestDataCollector.cs +++ b/TUnit.Engine/Building/Collectors/AotTestDataCollector.cs @@ -9,6 +9,7 @@ using TUnit.Core.Interfaces; using TUnit.Core.Interfaces.SourceGenerator; using TUnit.Engine.Building.Interfaces; +using TUnit.Engine.Helpers; using TUnit.Engine.Services; namespace TUnit.Engine.Building.Collectors; @@ -314,22 +315,7 @@ private Task CreateMetadataFromDynamicDiscoveryResult(DynamicDisco throw new InvalidOperationException("Dynamic test discovery result must have a test class type and method"); } - // Extract method info from the expression - MethodInfo? methodInfo = null; - var lambdaExpression = result.TestMethod as LambdaExpression; - if (lambdaExpression?.Body is MethodCallExpression methodCall) - { - methodInfo = methodCall.Method; - } - else if (lambdaExpression?.Body is UnaryExpression { Operand: MethodCallExpression unaryMethodCall }) - { - methodInfo = unaryMethodCall.Method; - } - - if (methodInfo == null) - { - throw new InvalidOperationException("Could not extract method info from dynamic test expression"); - } + var methodInfo = ExpressionHelper.ExtractMethodInfo(result.TestMethod); var testName = methodInfo.Name; @@ -413,32 +399,7 @@ private static Attribute[] GetDynamicTestAttributes(DynamicDiscoveryResult resul { try { - if (result.TestMethod == null) - { - throw new InvalidOperationException("Dynamic test method expression is null"); - } - - // Extract method info from the expression - var lambdaExpression = result.TestMethod as LambdaExpression; - if (lambdaExpression == null) - { - throw new InvalidOperationException("Dynamic test method must be a lambda expression"); - } - - MethodInfo? methodInfo = null; - if (lambdaExpression.Body is MethodCallExpression methodCall) - { - methodInfo = methodCall.Method; - } - else if (lambdaExpression.Body is UnaryExpression { Operand: MethodCallExpression unaryMethodCall }) - { - methodInfo = unaryMethodCall.Method; - } - - if (methodInfo == null) - { - throw new InvalidOperationException("Could not extract method info from dynamic test expression"); - } + var methodInfo = ExpressionHelper.ExtractMethodInfo(result.TestMethod); var testInstance = instance ?? throw new InvalidOperationException("Test instance is null"); diff --git a/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs b/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs index c1281a6fc6..bb5e24024f 100644 --- a/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs +++ b/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs @@ -2201,22 +2201,7 @@ private Task CreateMetadataFromDynamicDiscoveryResult(DynamicDisco throw new InvalidOperationException("Dynamic test discovery result must have a test class type and method"); } - // Extract method info from the expression - MethodInfo? methodInfo = null; - var lambdaExpression = result.TestMethod as System.Linq.Expressions.LambdaExpression; - if (lambdaExpression?.Body is System.Linq.Expressions.MethodCallExpression methodCall) - { - methodInfo = methodCall.Method; - } - else if (lambdaExpression?.Body is System.Linq.Expressions.UnaryExpression { Operand: System.Linq.Expressions.MethodCallExpression unaryMethodCall }) - { - methodInfo = unaryMethodCall.Method; - } - - if (methodInfo == null) - { - throw new InvalidOperationException("Could not extract method info from dynamic test expression"); - } + var methodInfo = ExpressionHelper.ExtractMethodInfo(result.TestMethod); var testName = GenerateTestName(result.TestClassType, methodInfo); @@ -2293,32 +2278,7 @@ private static Attribute[] GetDynamicTestAttributes(DynamicDiscoveryResult resul { try { - if (result.TestMethod == null) - { - throw new InvalidOperationException("Dynamic test method expression is null"); - } - - // Extract method info from the expression - var lambdaExpression = result.TestMethod as System.Linq.Expressions.LambdaExpression; - if (lambdaExpression == null) - { - throw new InvalidOperationException("Dynamic test method must be a lambda expression"); - } - - MethodInfo? methodInfo = null; - if (lambdaExpression.Body is System.Linq.Expressions.MethodCallExpression methodCall) - { - methodInfo = methodCall.Method; - } - else if (lambdaExpression.Body is System.Linq.Expressions.UnaryExpression { Operand: System.Linq.Expressions.MethodCallExpression unaryMethodCall }) - { - methodInfo = unaryMethodCall.Method; - } - - if (methodInfo == null) - { - throw new InvalidOperationException("Could not extract method info from dynamic test expression"); - } + var methodInfo = ExpressionHelper.ExtractMethodInfo(result.TestMethod); var testInstance = instance ?? throw new InvalidOperationException("Test instance is null"); diff --git a/TUnit.Engine/Helpers/ExpressionHelper.cs b/TUnit.Engine/Helpers/ExpressionHelper.cs new file mode 100644 index 0000000000..573869ee76 --- /dev/null +++ b/TUnit.Engine/Helpers/ExpressionHelper.cs @@ -0,0 +1,56 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace TUnit.Engine.Helpers; + +internal static class ExpressionHelper +{ + /// + /// Extracts the test from a dynamic test expression tree. + /// Handles all expression shapes produced by CreateTestVariantInternal: + /// + /// Task-returning: direct MethodCallExpression + /// void-returning: BlockExpression containing the call + /// Task<T>-returning: UnaryExpression (Convert) wrapping the call + /// ValueTask-returning: MethodCallExpression wrapping AsTask() on the call + /// ValueTask<T>-returning: UnaryExpression wrapping AsTask() on the call + /// + /// + public static MethodInfo ExtractMethodInfo(Expression? testMethod) + { + var lambdaExpression = testMethod as LambdaExpression; + + var methodCall = lambdaExpression?.Body switch + { + MethodCallExpression mc => mc, + UnaryExpression { Operand: MethodCallExpression umc } => umc, + BlockExpression block => FindMethodCall(block), + _ => null, + }; + + // Unwrap wrapper calls like ValueTask.AsTask() to find the actual test method call. + // The test method call has a ParameterExpression as its Object (the test instance). + while (methodCall is { Object: MethodCallExpression inner }) + { + methodCall = inner; + } + + return methodCall?.Method + ?? throw new InvalidOperationException("Could not extract method info from dynamic test expression"); + } + + // Void-returning block shape: Block(Call(instance, testMethod), Constant(Task.CompletedTask)) + // The test method call is always the first MethodCallExpression in the block. + private static MethodCallExpression? FindMethodCall(BlockExpression blockExpression) + { + foreach (var expr in blockExpression.Expressions) + { + if (expr is MethodCallExpression methodCall) + { + return methodCall; + } + } + + return null; + } +} diff --git a/TUnit.Engine/Services/TestRegistry.cs b/TUnit.Engine/Services/TestRegistry.cs index 0fd6ee844f..c5c63206e1 100644 --- a/TUnit.Engine/Services/TestRegistry.cs +++ b/TUnit.Engine/Services/TestRegistry.cs @@ -6,8 +6,8 @@ using TUnit.Core; using TUnit.Core.Interfaces; using TUnit.Engine.Building; +using TUnit.Engine.Helpers; using TUnit.Engine.Interfaces; -using Expression = System.Linq.Expressions.Expression; namespace TUnit.Engine.Services; @@ -91,7 +91,7 @@ private async Task ProcessPendingDynamicTests() foreach (var pendingTest in testsToProcess) { var result = pendingTest.DiscoveryResult; - var metadata = await CreateMetadataFromDynamicDiscoveryResult(result); + var metadata = CreateMetadataFromDynamicDiscoveryResult(result); testMetadataList.Add(metadata); } @@ -115,18 +115,29 @@ private async Task ProcessPendingDynamicTests() [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling", Justification = "Dynamic test variants require runtime compilation")] - public async Task CreateTestVariant( + public async Task CreateTestVariant( TestContext currentContext, - object?[]? arguments, - Dictionary? properties, + object?[]? methodArguments, + object?[]? classArguments, + IReadOnlyDictionary? properties, TUnit.Core.Enums.TestRelationship relationship, string? displayName) { var testDetails = currentContext.Metadata.TestDetails; var testClassType = testDetails.ClassType; - var variantMethodArguments = arguments ?? testDetails.TestMethodArguments; + var variantMethodArguments = methodArguments ?? testDetails.TestMethodArguments; + var variantClassArguments = classArguments ?? testDetails.TestClassArguments; var methodMetadata = testDetails.MethodMetadata; + + // Validate argument count matches method parameter count + if (variantMethodArguments.Length != methodMetadata.Parameters.Length) + { + throw new ArgumentException( + $"Method '{methodMetadata.Name}' expects {methodMetadata.Parameters.Length} argument(s) but {variantMethodArguments.Length} were provided.", + nameof(methodArguments)); + } + var parameterTypes = methodMetadata.Parameters.Select(p => p.Type).ToArray(); var methodInfo = methodMetadata.Type.GetMethod( methodMetadata.Name, @@ -149,12 +160,12 @@ public async Task CreateTestVariant( throw new InvalidOperationException("Failed to resolve CreateTestVariantInternal method"); } - await ((Task)genericAddDynamicTestMethod.Invoke(this, - [currentContext, methodInfo, variantMethodArguments, testDetails.TestClassArguments, properties, relationship, displayName])!); + return await ((Task)genericAddDynamicTestMethod.Invoke(this, + [currentContext, methodInfo, variantMethodArguments, variantClassArguments, properties, relationship, displayName])!); } [RequiresUnreferencedCode("Creating test variants requires reflection which is not supported in native AOT scenarios.")] - private async Task CreateTestVariantInternal<[DynamicallyAccessedMembers( + private async Task CreateTestVariantInternal<[DynamicallyAccessedMembers( DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties @@ -166,7 +177,7 @@ public async Task CreateTestVariant( MethodInfo methodInfo, object?[] variantMethodArguments, object?[] classArguments, - Dictionary? properties, + IReadOnlyDictionary? properties, TUnit.Core.Enums.TestRelationship relationship, string? displayName) where T : class { @@ -176,8 +187,7 @@ public async Task CreateTestVariant( for (int i = 0; i < methodParameters.Length; i++) { - var argValue = i < variantMethodArguments.Length ? variantMethodArguments[i] : null; - argumentExpressions[i] = Expression.Constant(argValue, methodParameters[i].ParameterType); + argumentExpressions[i] = Expression.Constant(variantMethodArguments[i], methodParameters[i].ParameterType); } var methodCall = Expression.Call(parameter, methodInfo, argumentExpressions); @@ -196,6 +206,19 @@ public async Task CreateTestVariant( { body = Expression.Convert(methodCall, typeof(Task)); } + else if (methodInfo.ReturnType == typeof(ValueTask)) + { + // ValueTask.AsTask() converts to Task for proper awaiting + var asTaskMethod = typeof(ValueTask).GetMethod(nameof(ValueTask.AsTask))!; + body = Expression.Call(methodCall, asTaskMethod); + } + else if (methodInfo.ReturnType.IsGenericType && + methodInfo.ReturnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) + { + // ValueTask.AsTask() returns Task, then convert to Task + var asTaskMethod = methodInfo.ReturnType.GetMethod(nameof(ValueTask.AsTask))!; + body = Expression.Convert(Expression.Call(methodCall, asTaskMethod), typeof(Task)); + } else { body = Expression.Block(methodCall, Expression.Constant(Task.CompletedTask)); @@ -219,46 +242,43 @@ public async Task CreateTestVariant( DisplayName = displayName }; - _pendingTests.Enqueue(new PendingDynamicTest + // Process the variant inline (bypassing _pendingTests queue and ProcessPendingDynamicTests) + // so we can capture the built test and return TestVariantInfo to the caller. + // AddDynamicTest uses the queue-based path instead. If batching/ordering logic is added + // to ProcessPendingDynamicTests, consider whether it should also apply here. + if (_sessionId == null || _testBuilderPipeline == null) { - DiscoveryResult = discoveryResult, - SourceContext = currentContext, - TestClassType = typeof(T) - }); + throw new InvalidOperationException("Cannot create test variant: TestRegistry is not fully initialized"); + } - await ProcessPendingDynamicTests(); + var metadata = CreateMetadataFromDynamicDiscoveryResult(discoveryResult); + + // Use IsForExecution: false to bypass filtering — this variant was explicitly requested + // by the test and should always be registered, regardless of any active test filter. + var buildingContext = new Building.TestBuildingContext(IsForExecution: false, Filter: null); + var builtTests = await _testBuilderPipeline.BuildTestsFromMetadataAsync([metadata], buildingContext); + + var builtTest = builtTests.FirstOrDefault() + ?? throw new InvalidOperationException("Failed to build test variant"); + + _dynamicTestQueue.Enqueue(builtTest); + + return new TestVariantInfo(builtTest.TestId, builtTest.Context.GetDisplayName()); } [RequiresUnreferencedCode("Dynamic test metadata creation requires reflection which is not supported in native AOT scenarios.")] - private async Task CreateMetadataFromDynamicDiscoveryResult(DynamicDiscoveryResult result) + private TestMetadata CreateMetadataFromDynamicDiscoveryResult(DynamicDiscoveryResult result) { if (result.TestClassType == null || result.TestMethod == null) { throw new InvalidOperationException("Dynamic test discovery result must have a test class type and method"); } - // Extract method info from the expression - MethodInfo? methodInfo = null; - var lambdaExpression = result.TestMethod as LambdaExpression; - if (lambdaExpression?.Body is MethodCallExpression methodCall) - { - methodInfo = methodCall.Method; - } - else if (lambdaExpression?.Body is UnaryExpression { Operand: MethodCallExpression unaryMethodCall }) - { - methodInfo = unaryMethodCall.Method; - } - - if (methodInfo == null) - { - throw new InvalidOperationException("Could not extract method info from dynamic test expression"); - } - - var testName = methodInfo.Name; + var methodInfo = ExpressionHelper.ExtractMethodInfo(result.TestMethod); - return await Task.FromResult(new DynamicTestMetadata(result) + return new DynamicTestMetadata(result) { - TestName = testName, + TestName = methodInfo.Name, TestClassType = result.TestClassType, TestMethodName = methodInfo.Name, Dependencies = result.Attributes.OfType() @@ -275,9 +295,9 @@ private async Task CreateMetadataFromDynamicDiscoveryResult(Dynami GenericTypeInfo = null, GenericMethodInfo = null, GenericMethodTypeArguments = null, - AttributeFactory = () => GetAttributesOptimized(result.Attributes), + AttributeFactory = () => result.Attributes.ToArray(), PropertyInjections = PropertySourceRegistry.DiscoverInjectableProperties(result.TestClassType) - }); + }; } [RequiresUnreferencedCode("Dynamic test instance creation requires Activator.CreateInstance which is not supported in native AOT scenarios.")] @@ -304,23 +324,20 @@ private async Task CreateMetadataFromDynamicDiscoveryResult(Dynami [RequiresUnreferencedCode("Dynamic test invocation requires LambdaExpression.Compile() which is not supported in native AOT scenarios.")] private static Func CreateRuntimeTestInvoker(DynamicDiscoveryResult result) { - return async (instance, args) => + if (result.TestMethod == null) { - if (result.TestMethod == null) - { - throw new InvalidOperationException("Dynamic test method expression is null"); - } + throw new InvalidOperationException("Dynamic test method expression is null"); + } - var lambdaExpression = result.TestMethod as LambdaExpression; - if (lambdaExpression == null) - { - throw new InvalidOperationException("Dynamic test method must be a lambda expression"); - } + var lambdaExpression = result.TestMethod as LambdaExpression + ?? throw new InvalidOperationException("Dynamic test method must be a lambda expression"); - var compiledExpression = lambdaExpression.Compile(); - var testInstance = instance ?? throw new InvalidOperationException("Test instance is null"); + var compiledExpression = lambdaExpression.Compile(); + var invokeMethod = compiledExpression.GetType().GetMethod("Invoke")!; - var invokeMethod = compiledExpression.GetType().GetMethod("Invoke")!; + return async (instance, args) => + { + var testInstance = instance ?? throw new InvalidOperationException("Test instance is null"); var invokeResult = invokeMethod.Invoke(compiledExpression, [testInstance]); if (invokeResult is Task task) @@ -341,17 +358,4 @@ private sealed class PendingDynamicTest public required Type TestClassType { get; init; } } - /// - /// Optimized method to convert attributes to array without LINQ allocations - /// - private static Attribute[] GetAttributesOptimized(ICollection attributes) - { - var result = new Attribute[attributes.Count]; - var index = 0; - foreach (var attr in attributes) - { - result[index++] = attr; - } - return result; - } } 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 fd608e5231..40d0c07e34 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 @@ -1650,6 +1650,11 @@ namespace public static bool IsSuccess(this .TestState state) { } public static bool IsTransient(this .TestState state) { } } + public sealed class TestVariantInfo : <.TestVariantInfo> + { + public string DisplayName { get; } + public string TestId { get; } + } [(.Assembly | .Class | .Method)] public class TimeoutAttribute : .TUnitAttribute, .IScopedAttribute, ., ., . { @@ -2058,8 +2063,9 @@ namespace .Extensions public static . AddDynamicTest<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicMethods | ..NonPublicMethods | ..PublicFields | ..NonPublicFields | ..PublicProperties)] T>(this .TestContext context, .DynamicTest dynamicTest) where T : class { } [.("Creating test variants requires runtime compilation and reflection")] - public static . CreateTestVariant(this .TestContext context, object?[]? arguments = null, .? properties = null, . relationship = 3, string? displayName = null) { } + public static .<.TestVariantInfo> CreateTestVariant(this .TestContext context, object?[]? methodArguments = null, object?[]? classArguments = null, .? properties = null, . relationship = 3, string? displayName = null) { } public static string GetClassTypeName(this .TestContext context) { } + public static bool IsVariant(this . dependencies) { } } } namespace .Helpers @@ -2610,7 +2616,7 @@ namespace .Interfaces where T : class; [.("Creating test variants requires runtime compilation and reflection which are not " + "supported in native AOT scenarios.")] - . CreateTestVariant(.TestContext currentContext, object?[]? arguments, .? properties, . relationship, string? displayName); + .<.TestVariantInfo> CreateTestVariant(.TestContext currentContext, object?[]? methodArguments, object?[]? classArguments, .? properties, . relationship, string? displayName); } public interface ITestRetryEventReceiver : . { 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 b1c5ab08e1..9fb37db4dd 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 @@ -1650,6 +1650,11 @@ namespace public static bool IsSuccess(this .TestState state) { } public static bool IsTransient(this .TestState state) { } } + public sealed class TestVariantInfo : <.TestVariantInfo> + { + public string DisplayName { get; } + public string TestId { get; } + } [(.Assembly | .Class | .Method)] public class TimeoutAttribute : .TUnitAttribute, .IScopedAttribute, ., ., . { @@ -2058,8 +2063,9 @@ namespace .Extensions public static . AddDynamicTest<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicMethods | ..NonPublicMethods | ..PublicFields | ..NonPublicFields | ..PublicProperties)] T>(this .TestContext context, .DynamicTest dynamicTest) where T : class { } [.("Creating test variants requires runtime compilation and reflection")] - public static . CreateTestVariant(this .TestContext context, object?[]? arguments = null, .? properties = null, . relationship = 3, string? displayName = null) { } + public static .<.TestVariantInfo> CreateTestVariant(this .TestContext context, object?[]? methodArguments = null, object?[]? classArguments = null, .? properties = null, . relationship = 3, string? displayName = null) { } public static string GetClassTypeName(this .TestContext context) { } + public static bool IsVariant(this . dependencies) { } } } namespace .Helpers @@ -2610,7 +2616,7 @@ namespace .Interfaces where T : class; [.("Creating test variants requires runtime compilation and reflection which are not " + "supported in native AOT scenarios.")] - . CreateTestVariant(.TestContext currentContext, object?[]? arguments, .? properties, . relationship, string? displayName); + .<.TestVariantInfo> CreateTestVariant(.TestContext currentContext, object?[]? methodArguments, object?[]? classArguments, .? properties, . relationship, string? displayName); } public interface ITestRetryEventReceiver : . { 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 ac33389868..993b818cff 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 @@ -1650,6 +1650,11 @@ namespace public static bool IsSuccess(this .TestState state) { } public static bool IsTransient(this .TestState state) { } } + public sealed class TestVariantInfo : <.TestVariantInfo> + { + public string DisplayName { get; } + public string TestId { get; } + } [(.Assembly | .Class | .Method)] public class TimeoutAttribute : .TUnitAttribute, .IScopedAttribute, ., ., . { @@ -2058,8 +2063,9 @@ namespace .Extensions public static . AddDynamicTest<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicMethods | ..NonPublicMethods | ..PublicFields | ..NonPublicFields | ..PublicProperties)] T>(this .TestContext context, .DynamicTest dynamicTest) where T : class { } [.("Creating test variants requires runtime compilation and reflection")] - public static . CreateTestVariant(this .TestContext context, object?[]? arguments = null, .? properties = null, . relationship = 3, string? displayName = null) { } + public static .<.TestVariantInfo> CreateTestVariant(this .TestContext context, object?[]? methodArguments = null, object?[]? classArguments = null, .? properties = null, . relationship = 3, string? displayName = null) { } public static string GetClassTypeName(this .TestContext context) { } + public static bool IsVariant(this . dependencies) { } } } namespace .Helpers @@ -2610,7 +2616,7 @@ namespace .Interfaces where T : class; [.("Creating test variants requires runtime compilation and reflection which are not " + "supported in native AOT scenarios.")] - . CreateTestVariant(.TestContext currentContext, object?[]? arguments, .? properties, . relationship, string? displayName); + .<.TestVariantInfo> CreateTestVariant(.TestContext currentContext, object?[]? methodArguments, object?[]? classArguments, .? properties, . relationship, string? displayName); } public interface ITestRetryEventReceiver : . { 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 4d6ce2cde8..2d5ec6f128 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 @@ -1596,6 +1596,11 @@ namespace public static bool IsSuccess(this .TestState state) { } public static bool IsTransient(this .TestState state) { } } + public sealed class TestVariantInfo : <.TestVariantInfo> + { + public string DisplayName { get; } + public string TestId { get; } + } [(.Assembly | .Class | .Method)] public class TimeoutAttribute : .TUnitAttribute, .IScopedAttribute, ., ., . { @@ -2001,8 +2006,9 @@ namespace .Extensions { public static . AddDynamicTest(this .TestContext context, .DynamicTest dynamicTest) where T : class { } - public static . CreateTestVariant(this .TestContext context, object?[]? arguments = null, .? properties = null, . relationship = 3, string? displayName = null) { } + public static .<.TestVariantInfo> CreateTestVariant(this .TestContext context, object?[]? methodArguments = null, object?[]? classArguments = null, .? properties = null, . relationship = 3, string? displayName = null) { } public static string GetClassTypeName(this .TestContext context) { } + public static bool IsVariant(this . dependencies) { } } } namespace .Helpers @@ -2550,7 +2556,7 @@ namespace .Interfaces { . AddDynamicTest(.TestContext context, .DynamicTest dynamicTest) where T : class; - . CreateTestVariant(.TestContext currentContext, object?[]? arguments, .? properties, . relationship, string? displayName); + .<.TestVariantInfo> CreateTestVariant(.TestContext currentContext, object?[]? methodArguments, object?[]? classArguments, .? properties, . relationship, string? displayName); } public interface ITestRetryEventReceiver : . { diff --git a/TUnit.TestProject/TestVariantTests.cs b/TUnit.TestProject/TestVariantTests.cs index 85d36ddf6e..2bbe51ba08 100644 --- a/TUnit.TestProject/TestVariantTests.cs +++ b/TUnit.TestProject/TestVariantTests.cs @@ -6,35 +6,33 @@ namespace TUnit.TestProject; public class TestVariantTests { [Test] - public async Task CreateTestVariant_ShouldCreateVariantWithDifferentArguments() + [Arguments(10)] + public async Task CreateTestVariant_ShouldCreateVariantWithDifferentArguments(int value) { - var context = TestContext.Current; + var context = TestContext.Current!; - if (context == null) + // Only the original test creates a variant; variants skip to avoid infinite recursion + if (!context.Dependencies.IsVariant()) { - throw new InvalidOperationException("TestContext.Current is null"); - } + var variantInfo = await context.CreateTestVariant( + methodArguments: [42], + properties: new Dictionary + { + { "AttemptNumber", 1 } + }, + relationship: TUnit.Core.Enums.TestRelationship.Derived, + displayName: "Shrink Attempt" + ); - await context.CreateTestVariant( - arguments: new object?[] { 42 }, - properties: new Dictionary + if (string.IsNullOrEmpty(variantInfo.TestId)) { - { "AttemptNumber", 1 } - }, - relationship: TUnit.Core.Enums.TestRelationship.Derived, - displayName: "Shrink Attempt" - ); - } - - [Test] - [Arguments(10)] - public async Task VariantTarget_WithArguments(int value) - { - var context = TestContext.Current; + throw new InvalidOperationException("Expected TestVariantInfo.TestId to be set"); + } - if (context == null) - { - throw new InvalidOperationException("TestContext.Current is null"); + if (variantInfo.DisplayName != "Shrink Attempt") + { + throw new InvalidOperationException($"Expected DisplayName 'Shrink Attempt' but got '{variantInfo.DisplayName}'"); + } } if (value < 0) @@ -42,10 +40,12 @@ public async Task VariantTarget_WithArguments(int value) throw new InvalidOperationException($"Expected non-negative value but got {value}"); } - if (context.StateBag.Items.ContainsKey("AttemptNumber")) + if (context.Dependencies.IsVariant()) { - var attemptNumber = context.StateBag.Items["AttemptNumber"]; - context.Output.StandardOutput.WriteLine($"Shrink attempt {attemptNumber} with value {value}"); + if (context.StateBag.Items.TryGetValue("AttemptNumber", out var attemptNumber)) + { + context.Output.StandardOutput.WriteLine($"Shrink attempt {attemptNumber} with value {value}"); + } if (context.Dependencies.Relationship != TUnit.Core.Enums.TestRelationship.Derived) { @@ -58,4 +58,81 @@ public async Task VariantTarget_WithArguments(int value) } } } + + // Regression tests for #5093 - CreateTestVariant must handle all test return types. + // Each return type produces a different expression tree shape: + // Task → direct MethodCallExpression + // ValueTask → MethodCallExpression wrapped in AsTask() call + // Task → UnaryExpression (Convert) wrapping MethodCallExpression + // void → BlockExpression (only reachable via AddDynamicTest, not CreateTestVariant) + // ValueTask→ source generator doesn't support this return type yet + // void and ValueTask are covered by unit tests in ExpressionHelperTests. + + [Test] + public async Task CreateTestVariant_FromTaskMethod() + { + if (!TestContext.Current!.Dependencies.IsVariant()) + { + await TestContext.Current!.CreateTestVariant( + displayName: "VariantFromTaskMethod", + relationship: TUnit.Core.Enums.TestRelationship.Generated + ); + } + } + + [Test] + public async ValueTask CreateTestVariant_FromValueTaskMethod() + { + if (!TestContext.Current!.Dependencies.IsVariant()) + { + await TestContext.Current!.CreateTestVariant( + displayName: "VariantFromValueTaskMethod", + relationship: TUnit.Core.Enums.TestRelationship.Generated + ); + } + } + + [Test] + public async Task CreateTestVariant_FromGenericTaskMethod() + { + if (!TestContext.Current!.Dependencies.IsVariant()) + { + await TestContext.Current!.CreateTestVariant( + displayName: "VariantFromGenericTaskMethod", + relationship: TUnit.Core.Enums.TestRelationship.Generated + ); + } + + return 42; + } + +} + +[Arguments("original")] +public class TestVariantWithClassArgsTests(string label) +{ + [Test] + public async Task CreateTestVariant_ShouldPassClassArguments() + { + var context = TestContext.Current!; + + if (!context.Dependencies.IsVariant()) + { + await context.CreateTestVariant( + classArguments: ["variant-label"], + displayName: "VariantWithClassArgs" + ); + } + + // Both original and variant should have a non-null label + if (string.IsNullOrEmpty(label)) + { + throw new InvalidOperationException("Expected label to be set"); + } + + if (context.Dependencies.IsVariant() && label != "variant-label") + { + throw new InvalidOperationException($"Expected variant label 'variant-label' but got '{label}'"); + } + } } diff --git a/TUnit.UnitTests/ExpressionHelperTests.cs b/TUnit.UnitTests/ExpressionHelperTests.cs new file mode 100644 index 0000000000..9641b6ccab --- /dev/null +++ b/TUnit.UnitTests/ExpressionHelperTests.cs @@ -0,0 +1,106 @@ +using System.Linq.Expressions; +using TUnit.Engine.Helpers; + +namespace TUnit.UnitTests; + +/// +/// Unit tests for ExpressionHelper.ExtractMethodInfo covering all expression tree shapes +/// produced by CreateTestVariantInternal for different test method return types. +/// Regression tests for https://github.com/thomhurst/TUnit/issues/5093 +/// +public class ExpressionHelperTests +{ + // Dummy test class used as expression target + private class FakeTestClass + { + public Task TaskMethod() => Task.CompletedTask; + public void VoidMethod() { } + public ValueTask ValueTaskMethod() => default; + public Task GenericTaskMethod() => Task.FromResult(42); + public ValueTask GenericValueTaskMethod() => new(42); + } + + [Test] + public async Task ExtractMethodInfo_FromMethodCallExpression_ForTaskReturn() + { + // Task-returning: body = Call(instance, TestMethod) + var param = Expression.Parameter(typeof(FakeTestClass), "instance"); + var methodInfo = typeof(FakeTestClass).GetMethod(nameof(FakeTestClass.TaskMethod))!; + var body = Expression.Call(param, methodInfo); + var lambda = Expression.Lambda>(body, param); + + var result = ExpressionHelper.ExtractMethodInfo(lambda); + + await Assert.That(result.Name).IsEqualTo(nameof(FakeTestClass.TaskMethod)); + } + + [Test] + public async Task ExtractMethodInfo_FromBlockExpression_ForVoidReturn() + { + // void-returning: body = Block(Call(instance, TestMethod), Constant(Task.CompletedTask)) + var param = Expression.Parameter(typeof(FakeTestClass), "instance"); + var methodInfo = typeof(FakeTestClass).GetMethod(nameof(FakeTestClass.VoidMethod))!; + var methodCall = Expression.Call(param, methodInfo); + var body = Expression.Block(methodCall, Expression.Constant(Task.CompletedTask)); + var lambda = Expression.Lambda>(body, param); + + var result = ExpressionHelper.ExtractMethodInfo(lambda); + + await Assert.That(result.Name).IsEqualTo(nameof(FakeTestClass.VoidMethod)); + } + + [Test] + public async Task ExtractMethodInfo_FromUnaryExpression_ForGenericTaskReturn() + { + // Task-returning: body = Convert(Call(instance, TestMethod), Task) + var param = Expression.Parameter(typeof(FakeTestClass), "instance"); + var methodInfo = typeof(FakeTestClass).GetMethod(nameof(FakeTestClass.GenericTaskMethod))!; + var methodCall = Expression.Call(param, methodInfo); + var body = Expression.Convert(methodCall, typeof(Task)); + var lambda = Expression.Lambda>(body, param); + + var result = ExpressionHelper.ExtractMethodInfo(lambda); + + await Assert.That(result.Name).IsEqualTo(nameof(FakeTestClass.GenericTaskMethod)); + } + + [Test] + public async Task ExtractMethodInfo_FromAsTaskCall_ForValueTaskReturn() + { + // ValueTask-returning: body = Call(Call(instance, TestMethod), AsTask) + var param = Expression.Parameter(typeof(FakeTestClass), "instance"); + var methodInfo = typeof(FakeTestClass).GetMethod(nameof(FakeTestClass.ValueTaskMethod))!; + var methodCall = Expression.Call(param, methodInfo); + var asTaskMethod = typeof(ValueTask).GetMethod(nameof(ValueTask.AsTask))!; + var body = Expression.Call(methodCall, asTaskMethod); + var lambda = Expression.Lambda>(body, param); + + var result = ExpressionHelper.ExtractMethodInfo(lambda); + + await Assert.That(result.Name).IsEqualTo(nameof(FakeTestClass.ValueTaskMethod)); + } + + [Test] + public async Task ExtractMethodInfo_FromConvertedAsTaskCall_ForGenericValueTaskReturn() + { + // ValueTask-returning: body = Convert(Call(Call(instance, TestMethod), AsTask), Task) + var param = Expression.Parameter(typeof(FakeTestClass), "instance"); + var methodInfo = typeof(FakeTestClass).GetMethod(nameof(FakeTestClass.GenericValueTaskMethod))!; + var methodCall = Expression.Call(param, methodInfo); + var asTaskMethod = typeof(ValueTask).GetMethod(nameof(ValueTask.AsTask))!; + var asTaskCall = Expression.Call(methodCall, asTaskMethod); + var body = Expression.Convert(asTaskCall, typeof(Task)); + var lambda = Expression.Lambda>(body, param); + + var result = ExpressionHelper.ExtractMethodInfo(lambda); + + await Assert.That(result.Name).IsEqualTo(nameof(FakeTestClass.GenericValueTaskMethod)); + } + + [Test] + public async Task ExtractMethodInfo_ThrowsForNullExpression() + { + await Assert.That(() => ExpressionHelper.ExtractMethodInfo(null)) + .ThrowsExactly(); + } +}