From 38fa10dda5b4d8c0668eb89dd4e0faf00576ec97 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:55:37 +0000 Subject: [PATCH 1/7] fix: improve CreateTestVariant API and fix void/ValueTask return types (#5093) - Fix CreateTestVariant for void and ValueTask-returning test methods by adding proper expression tree handling (AsTask() wrapping for ValueTask) - Extract shared ExpressionHelper to eliminate 5-way code duplication across TestRegistry, AotTestDataCollector, and ReflectionTestDataCollector - Return TestVariantInfo (TestId + DisplayName) instead of Task - Add IsVariant property to ITestDependencies for infinite recursion guard - Rename 'arguments' to 'methodArguments' and add 'classArguments' parameter - Use IReadOnlyDictionary for properties parameter - Add argument count validation against method parameter count - Hoist Compile()/GetMethod("Invoke") out of per-invocation lambda - Remove async/await Task.FromResult anti-pattern in metadata creation - Replace illusory GetAttributesOptimized with List.ToArray() --- TUnit.Core/AbstractDynamicTest.cs | 2 +- TUnit.Core/DynamicTestMetadata.cs | 2 +- .../Extensions/TestContextExtensions.cs | 14 +- TUnit.Core/Interfaces/ITestDependencies.cs | 7 + TUnit.Core/Interfaces/ITestRegistry.cs | 12 +- TUnit.Core/TestContext.Dependencies.cs | 1 + TUnit.Core/TestVariantInfo.cs | 24 ++++ .../TestVariantReturnTypeTests.cs | 20 +++ .../Collectors/AotTestDataCollector.cs | 45 +----- .../Discovery/ReflectionTestDataCollector.cs | 44 +----- TUnit.Engine/Helpers/ExpressionHelper.cs | 54 +++++++ TUnit.Engine/Services/TestRegistry.cs | 132 +++++++++--------- ...Has_No_API_Changes.DotNet10_0.verified.txt | 10 +- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 10 +- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 10 +- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 10 +- TUnit.TestProject/TestVariantTests.cs | 70 ++++++---- TUnit.UnitTests/ExpressionHelperTests.cs | 106 ++++++++++++++ 18 files changed, 377 insertions(+), 196 deletions(-) create mode 100644 TUnit.Core/TestVariantInfo.cs create mode 100644 TUnit.Engine.Tests/TestVariantReturnTypeTests.cs create mode 100644 TUnit.Engine/Helpers/ExpressionHelper.cs create mode 100644 TUnit.UnitTests/ExpressionHelperTests.cs 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..784a7ce20c 100644 --- a/TUnit.Core/Extensions/TestContextExtensions.cs +++ b/TUnit.Core/Extensions/TestContextExtensions.cs @@ -106,21 +106,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/ITestDependencies.cs b/TUnit.Core/Interfaces/ITestDependencies.cs index 8f4ae67be6..21fca26393 100644 --- a/TUnit.Core/Interfaces/ITestDependencies.cs +++ b/TUnit.Core/Interfaces/ITestDependencies.cs @@ -1,4 +1,5 @@ using TUnit.Core.Enums; +using TUnit.Core.Extensions; namespace TUnit.Core.Interfaces; @@ -14,6 +15,12 @@ public interface ITestDependencies /// IReadOnlyList DependsOn { get; } + /// + /// 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. + /// + bool IsVariant { get; } + /// /// Gets the parent test ID if this test is part of a relationship. /// 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/TestContext.Dependencies.cs b/TUnit.Core/TestContext.Dependencies.cs index 5618ffb933..567ee5f9f4 100644 --- a/TUnit.Core/TestContext.Dependencies.cs +++ b/TUnit.Core/TestContext.Dependencies.cs @@ -12,6 +12,7 @@ public partial class TestContext internal TestRelationship Relationship { get; set; } = TestRelationship.None; IReadOnlyList ITestDependencies.DependsOn => _dependencies; + bool ITestDependencies.IsVariant => ParentTestId != null; string? ITestDependencies.ParentTestId => ParentTestId; TestRelationship ITestDependencies.Relationship => Relationship; diff --git a/TUnit.Core/TestVariantInfo.cs b/TUnit.Core/TestVariantInfo.cs new file mode 100644 index 0000000000..66cf2bdc16 --- /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 class 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..09d9879ba4 --- /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( + "/*/*/TestVariantTests/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Passed.ShouldBeGreaterThanOrEqualTo(6), + 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..36badaa802 --- /dev/null +++ b/TUnit.Engine/Helpers/ExpressionHelper.cs @@ -0,0 +1,54 @@ +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"); + } + + 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..c9b2c0675c 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,37 @@ public async Task CreateTestVariant( DisplayName = displayName }; - _pendingTests.Enqueue(new PendingDynamicTest + // Process the variant inline so we can return its info + if (_sessionId == null) { - DiscoveryResult = discoveryResult, - SourceContext = currentContext, - TestClassType = typeof(T) - }); + throw new InvalidOperationException("Cannot create test variant: session ID is not set"); + } - await ProcessPendingDynamicTests(); + var metadata = CreateMetadataFromDynamicDiscoveryResult(discoveryResult); + 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 +289,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 +318,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 +352,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..304946f7a1 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 + { + public string DisplayName { get; } + public string TestId { get; } + } [(.Assembly | .Class | .Method)] public class TimeoutAttribute : .TUnitAttribute, .IScopedAttribute, ., ., . { @@ -2058,7 +2063,7 @@ 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) { } } } @@ -2471,6 +2476,7 @@ namespace .Interfaces public interface ITestDependencies { .<.TestDetails> DependsOn { get; } + bool IsVariant { get; } string? ParentTestId { get; } . Relationship { get; } .<.TestContext> GetTests(<.TestContext, bool> predicate); @@ -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..912ecf4d1c 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 + { + public string DisplayName { get; } + public string TestId { get; } + } [(.Assembly | .Class | .Method)] public class TimeoutAttribute : .TUnitAttribute, .IScopedAttribute, ., ., . { @@ -2058,7 +2063,7 @@ 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) { } } } @@ -2471,6 +2476,7 @@ namespace .Interfaces public interface ITestDependencies { .<.TestDetails> DependsOn { get; } + bool IsVariant { get; } string? ParentTestId { get; } . Relationship { get; } .<.TestContext> GetTests(<.TestContext, bool> predicate); @@ -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..eadc4a7da8 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 + { + public string DisplayName { get; } + public string TestId { get; } + } [(.Assembly | .Class | .Method)] public class TimeoutAttribute : .TUnitAttribute, .IScopedAttribute, ., ., . { @@ -2058,7 +2063,7 @@ 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) { } } } @@ -2471,6 +2476,7 @@ namespace .Interfaces public interface ITestDependencies { .<.TestDetails> DependsOn { get; } + bool IsVariant { get; } string? ParentTestId { get; } . Relationship { get; } .<.TestContext> GetTests(<.TestContext, bool> predicate); @@ -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..8bd55796b3 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 + { + public string DisplayName { get; } + public string TestId { get; } + } [(.Assembly | .Class | .Method)] public class TimeoutAttribute : .TUnitAttribute, .IScopedAttribute, ., ., . { @@ -2001,7 +2006,7 @@ 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) { } } } @@ -2416,6 +2421,7 @@ namespace .Interfaces public interface ITestDependencies { .<.TestDetails> DependsOn { get; } + bool IsVariant { get; } string? ParentTestId { get; } . Relationship { get; } .<.TestContext> GetTests(<.TestContext, bool> predicate); @@ -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..1616d726d6 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,7 +40,7 @@ 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}"); @@ -58,4 +56,28 @@ 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 + // void → BlockExpression (tested via unit tests in ExpressionHelperTests) + + [Test] + public async Task CreateTestVariant_FromTaskMethod() + { + await TestContext.Current!.CreateTestVariant( + displayName: "VariantFromTaskMethod", + relationship: TUnit.Core.Enums.TestRelationship.Generated + ); + } + + [Test] + public async ValueTask CreateTestVariant_FromValueTaskMethod() + { + await TestContext.Current!.CreateTestVariant( + displayName: "VariantFromValueTaskMethod", + relationship: TUnit.Core.Enums.TestRelationship.Generated + ); + } } diff --git a/TUnit.UnitTests/ExpressionHelperTests.cs b/TUnit.UnitTests/ExpressionHelperTests.cs new file mode 100644 index 0000000000..1838f2a684 --- /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() => ValueTask.CompletedTask; + public Task GenericTaskMethod() => Task.FromResult(42); + public ValueTask GenericValueTaskMethod() => ValueTask.FromResult(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(); + } +} From 3ac4b901dbf6564cc3ed696f2136e9b428e27de0 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:03:28 +0000 Subject: [PATCH 2/7] fix: address code review feedback - Add IsVariant guard to CreateTestVariant_FromTaskMethod and CreateTestVariant_FromValueTaskMethod to prevent infinite recursion - Add default interface implementation for IsVariant on .NET 6+ (netstandard2.0 keeps abstract member for compatibility) - Document FindMethodCall ordering assumption in ExpressionHelper --- TUnit.Core/Interfaces/ITestDependencies.cs | 4 ++++ TUnit.Engine/Helpers/ExpressionHelper.cs | 2 ++ TUnit.TestProject/TestVariantTests.cs | 22 ++++++++++++++-------- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/TUnit.Core/Interfaces/ITestDependencies.cs b/TUnit.Core/Interfaces/ITestDependencies.cs index 21fca26393..8e4b684a1b 100644 --- a/TUnit.Core/Interfaces/ITestDependencies.cs +++ b/TUnit.Core/Interfaces/ITestDependencies.cs @@ -19,7 +19,11 @@ public interface ITestDependencies /// 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. /// +#if NET6_0_OR_GREATER + bool IsVariant => ParentTestId != null; +#else bool IsVariant { get; } +#endif /// /// Gets the parent test ID if this test is part of a relationship. diff --git a/TUnit.Engine/Helpers/ExpressionHelper.cs b/TUnit.Engine/Helpers/ExpressionHelper.cs index 36badaa802..573869ee76 100644 --- a/TUnit.Engine/Helpers/ExpressionHelper.cs +++ b/TUnit.Engine/Helpers/ExpressionHelper.cs @@ -39,6 +39,8 @@ public static MethodInfo ExtractMethodInfo(Expression? testMethod) ?? 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) diff --git a/TUnit.TestProject/TestVariantTests.cs b/TUnit.TestProject/TestVariantTests.cs index 1616d726d6..b3e65a8041 100644 --- a/TUnit.TestProject/TestVariantTests.cs +++ b/TUnit.TestProject/TestVariantTests.cs @@ -66,18 +66,24 @@ public async Task CreateTestVariant_ShouldCreateVariantWithDifferentArguments(in [Test] public async Task CreateTestVariant_FromTaskMethod() { - await TestContext.Current!.CreateTestVariant( - displayName: "VariantFromTaskMethod", - relationship: TUnit.Core.Enums.TestRelationship.Generated - ); + if (!TestContext.Current!.Dependencies.IsVariant) + { + await TestContext.Current!.CreateTestVariant( + displayName: "VariantFromTaskMethod", + relationship: TUnit.Core.Enums.TestRelationship.Generated + ); + } } [Test] public async ValueTask CreateTestVariant_FromValueTaskMethod() { - await TestContext.Current!.CreateTestVariant( - displayName: "VariantFromValueTaskMethod", - relationship: TUnit.Core.Enums.TestRelationship.Generated - ); + if (!TestContext.Current!.Dependencies.IsVariant) + { + await TestContext.Current!.CreateTestVariant( + displayName: "VariantFromValueTaskMethod", + relationship: TUnit.Core.Enums.TestRelationship.Generated + ); + } } } From f24d073a5de092c4169ba327fe49f283f8793963 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:29:12 +0000 Subject: [PATCH 3/7] refactor: address second review round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert TestVariantInfo from sealed class to sealed record - Add comment explaining why CreateTestVariant bypasses the queue - Update public API snapshots for class→record change --- TUnit.Core/TestVariantInfo.cs | 2 +- TUnit.Engine/Services/TestRegistry.cs | 5 ++++- ...s.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt | 2 +- ...ts.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt | 2 +- ...ts.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt | 2 +- ...Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt | 2 +- 6 files changed, 9 insertions(+), 6 deletions(-) diff --git a/TUnit.Core/TestVariantInfo.cs b/TUnit.Core/TestVariantInfo.cs index 66cf2bdc16..347e752f78 100644 --- a/TUnit.Core/TestVariantInfo.cs +++ b/TUnit.Core/TestVariantInfo.cs @@ -4,7 +4,7 @@ namespace TUnit.Core; /// Information about a test variant that was created at runtime. /// Returned by . /// -public sealed class TestVariantInfo +public sealed record TestVariantInfo { internal TestVariantInfo(string testId, string displayName) { diff --git a/TUnit.Engine/Services/TestRegistry.cs b/TUnit.Engine/Services/TestRegistry.cs index c9b2c0675c..b4fde4de25 100644 --- a/TUnit.Engine/Services/TestRegistry.cs +++ b/TUnit.Engine/Services/TestRegistry.cs @@ -242,7 +242,10 @@ public async Task CreateTestVariant( DisplayName = displayName }; - // Process the variant inline so we can return its info + // 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) { throw new InvalidOperationException("Cannot create test variant: session ID is not set"); 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 304946f7a1..0ac05f3631 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,7 +1650,7 @@ namespace public static bool IsSuccess(this .TestState state) { } public static bool IsTransient(this .TestState state) { } } - public sealed class TestVariantInfo + public sealed class TestVariantInfo : <.TestVariantInfo> { public string DisplayName { get; } public string TestId { get; } 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 912ecf4d1c..eec21de3b2 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,7 +1650,7 @@ namespace public static bool IsSuccess(this .TestState state) { } public static bool IsTransient(this .TestState state) { } } - public sealed class TestVariantInfo + public sealed class TestVariantInfo : <.TestVariantInfo> { public string DisplayName { get; } public string TestId { get; } 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 eadc4a7da8..dfda148142 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,7 +1650,7 @@ namespace public static bool IsSuccess(this .TestState state) { } public static bool IsTransient(this .TestState state) { } } - public sealed class TestVariantInfo + public sealed class TestVariantInfo : <.TestVariantInfo> { public string DisplayName { get; } public string TestId { get; } 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 8bd55796b3..c5d3e7bfcb 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,7 +1596,7 @@ namespace public static bool IsSuccess(this .TestState state) { } public static bool IsTransient(this .TestState state) { } } - public sealed class TestVariantInfo + public sealed class TestVariantInfo : <.TestVariantInfo> { public string DisplayName { get; } public string TestId { get; } From 0c873a2faf6945606a041e105fed56c41a9b9f69 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:41:27 +0000 Subject: [PATCH 4/7] refactor: address third review round - Move IsVariant from interface member to extension method (non-breaking) - Combine null check for _sessionId and _testBuilderPipeline - Add classArguments integration test with parameterized test class - Update public API snapshots for all 4 target frameworks --- .../Extensions/TestContextExtensions.cs | 6 +++ TUnit.Core/Interfaces/ITestDependencies.cs | 11 ------ TUnit.Core/TestContext.Dependencies.cs | 1 - .../TestVariantReturnTypeTests.cs | 4 +- TUnit.Engine/Services/TestRegistry.cs | 6 +-- ...Has_No_API_Changes.DotNet10_0.verified.txt | 2 +- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 2 +- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 2 +- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 2 +- TUnit.TestProject/TestVariantTests.cs | 37 +++++++++++++++++-- 10 files changed, 48 insertions(+), 25 deletions(-) diff --git a/TUnit.Core/Extensions/TestContextExtensions.cs b/TUnit.Core/Extensions/TestContextExtensions.cs index 784a7ce20c..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 diff --git a/TUnit.Core/Interfaces/ITestDependencies.cs b/TUnit.Core/Interfaces/ITestDependencies.cs index 8e4b684a1b..8f4ae67be6 100644 --- a/TUnit.Core/Interfaces/ITestDependencies.cs +++ b/TUnit.Core/Interfaces/ITestDependencies.cs @@ -1,5 +1,4 @@ using TUnit.Core.Enums; -using TUnit.Core.Extensions; namespace TUnit.Core.Interfaces; @@ -15,16 +14,6 @@ public interface ITestDependencies /// IReadOnlyList DependsOn { get; } - /// - /// 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. - /// -#if NET6_0_OR_GREATER - bool IsVariant => ParentTestId != null; -#else - bool IsVariant { get; } -#endif - /// /// Gets the parent test ID if this test is part of a relationship. /// diff --git a/TUnit.Core/TestContext.Dependencies.cs b/TUnit.Core/TestContext.Dependencies.cs index 567ee5f9f4..5618ffb933 100644 --- a/TUnit.Core/TestContext.Dependencies.cs +++ b/TUnit.Core/TestContext.Dependencies.cs @@ -12,7 +12,6 @@ public partial class TestContext internal TestRelationship Relationship { get; set; } = TestRelationship.None; IReadOnlyList ITestDependencies.DependsOn => _dependencies; - bool ITestDependencies.IsVariant => ParentTestId != null; string? ITestDependencies.ParentTestId => ParentTestId; TestRelationship ITestDependencies.Relationship => Relationship; diff --git a/TUnit.Engine.Tests/TestVariantReturnTypeTests.cs b/TUnit.Engine.Tests/TestVariantReturnTypeTests.cs index 09d9879ba4..a9fd4781b8 100644 --- a/TUnit.Engine.Tests/TestVariantReturnTypeTests.cs +++ b/TUnit.Engine.Tests/TestVariantReturnTypeTests.cs @@ -9,10 +9,10 @@ public class TestVariantReturnTypeTests(TestMode testMode) : InvokableTestBase(t public async Task Test() { await RunTestsWithFilter( - "/*/*/TestVariantTests/*", + "/*/*/TestVariant*/*", [ result => result.ResultSummary.Outcome.ShouldBe("Completed"), - result => result.ResultSummary.Counters.Passed.ShouldBeGreaterThanOrEqualTo(6), + result => result.ResultSummary.Counters.Passed.ShouldBeGreaterThanOrEqualTo(8), result => result.ResultSummary.Counters.Failed.ShouldBe(0), result => result.ResultSummary.Counters.NotExecuted.ShouldBe(0) ]); diff --git a/TUnit.Engine/Services/TestRegistry.cs b/TUnit.Engine/Services/TestRegistry.cs index b4fde4de25..4bff0b684c 100644 --- a/TUnit.Engine/Services/TestRegistry.cs +++ b/TUnit.Engine/Services/TestRegistry.cs @@ -246,14 +246,14 @@ public async Task CreateTestVariant( // 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) + if (_sessionId == null || _testBuilderPipeline == null) { - throw new InvalidOperationException("Cannot create test variant: session ID is not set"); + throw new InvalidOperationException("Cannot create test variant: TestRegistry is not fully initialized"); } var metadata = CreateMetadataFromDynamicDiscoveryResult(discoveryResult); var buildingContext = new Building.TestBuildingContext(IsForExecution: false, Filter: null); - var builtTests = await _testBuilderPipeline!.BuildTestsFromMetadataAsync([metadata], buildingContext); + var builtTests = await _testBuilderPipeline.BuildTestsFromMetadataAsync([metadata], buildingContext); var builtTest = builtTests.FirstOrDefault() ?? throw new InvalidOperationException("Failed to build test variant"); 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 0ac05f3631..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 @@ -2065,6 +2065,7 @@ namespace .Extensions [.("Creating test variants requires runtime compilation and reflection")] 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 @@ -2476,7 +2477,6 @@ namespace .Interfaces public interface ITestDependencies { .<.TestDetails> DependsOn { get; } - bool IsVariant { get; } string? ParentTestId { get; } . Relationship { get; } .<.TestContext> GetTests(<.TestContext, bool> predicate); 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 eec21de3b2..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 @@ -2065,6 +2065,7 @@ namespace .Extensions [.("Creating test variants requires runtime compilation and reflection")] 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 @@ -2476,7 +2477,6 @@ namespace .Interfaces public interface ITestDependencies { .<.TestDetails> DependsOn { get; } - bool IsVariant { get; } string? ParentTestId { get; } . Relationship { get; } .<.TestContext> GetTests(<.TestContext, bool> predicate); 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 dfda148142..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 @@ -2065,6 +2065,7 @@ namespace .Extensions [.("Creating test variants requires runtime compilation and reflection")] 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 @@ -2476,7 +2477,6 @@ namespace .Interfaces public interface ITestDependencies { .<.TestDetails> DependsOn { get; } - bool IsVariant { get; } string? ParentTestId { get; } . Relationship { get; } .<.TestContext> GetTests(<.TestContext, bool> predicate); 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 c5d3e7bfcb..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 @@ -2008,6 +2008,7 @@ namespace .Extensions where T : class { } 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 @@ -2421,7 +2422,6 @@ namespace .Interfaces public interface ITestDependencies { .<.TestDetails> DependsOn { get; } - bool IsVariant { get; } string? ParentTestId { get; } . Relationship { get; } .<.TestContext> GetTests(<.TestContext, bool> predicate); diff --git a/TUnit.TestProject/TestVariantTests.cs b/TUnit.TestProject/TestVariantTests.cs index b3e65a8041..ca7b667956 100644 --- a/TUnit.TestProject/TestVariantTests.cs +++ b/TUnit.TestProject/TestVariantTests.cs @@ -12,7 +12,7 @@ public async Task CreateTestVariant_ShouldCreateVariantWithDifferentArguments(in var context = TestContext.Current!; // Only the original test creates a variant; variants skip to avoid infinite recursion - if (!context.Dependencies.IsVariant) + if (!context.Dependencies.IsVariant()) { var variantInfo = await context.CreateTestVariant( methodArguments: [42], @@ -40,7 +40,7 @@ public async Task CreateTestVariant_ShouldCreateVariantWithDifferentArguments(in throw new InvalidOperationException($"Expected non-negative value but got {value}"); } - if (context.Dependencies.IsVariant) + if (context.Dependencies.IsVariant()) { var attemptNumber = context.StateBag.Items["AttemptNumber"]; context.Output.StandardOutput.WriteLine($"Shrink attempt {attemptNumber} with value {value}"); @@ -66,7 +66,7 @@ public async Task CreateTestVariant_ShouldCreateVariantWithDifferentArguments(in [Test] public async Task CreateTestVariant_FromTaskMethod() { - if (!TestContext.Current!.Dependencies.IsVariant) + if (!TestContext.Current!.Dependencies.IsVariant()) { await TestContext.Current!.CreateTestVariant( displayName: "VariantFromTaskMethod", @@ -78,7 +78,7 @@ public async Task CreateTestVariant_FromTaskMethod() [Test] public async ValueTask CreateTestVariant_FromValueTaskMethod() { - if (!TestContext.Current!.Dependencies.IsVariant) + if (!TestContext.Current!.Dependencies.IsVariant()) { await TestContext.Current!.CreateTestVariant( displayName: "VariantFromValueTaskMethod", @@ -87,3 +87,32 @@ public async ValueTask CreateTestVariant_FromValueTaskMethod() } } } + +[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}'"); + } + } +} From 66a28d7aed006a947ac89c599a4d862ef439c9fe Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:51:45 +0000 Subject: [PATCH 5/7] refactor: address round 4 review feedback - Use TryGetValue for StateBag.Items["AttemptNumber"] (defensive) - Add comment explaining IsForExecution: false intent --- TUnit.Engine/Services/TestRegistry.cs | 3 +++ TUnit.TestProject/TestVariantTests.cs | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/TUnit.Engine/Services/TestRegistry.cs b/TUnit.Engine/Services/TestRegistry.cs index 4bff0b684c..c5c63206e1 100644 --- a/TUnit.Engine/Services/TestRegistry.cs +++ b/TUnit.Engine/Services/TestRegistry.cs @@ -252,6 +252,9 @@ public async Task CreateTestVariant( } 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); diff --git a/TUnit.TestProject/TestVariantTests.cs b/TUnit.TestProject/TestVariantTests.cs index ca7b667956..bb15d671ca 100644 --- a/TUnit.TestProject/TestVariantTests.cs +++ b/TUnit.TestProject/TestVariantTests.cs @@ -42,8 +42,10 @@ public async Task CreateTestVariant_ShouldCreateVariantWithDifferentArguments(in 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) { From 88fe71b61a0699be85804bf4c437e5a24d0f625f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:12:33 +0000 Subject: [PATCH 6/7] test: add Task return type integration test for CreateTestVariant Covers the Convert(MethodCallExpression) expression tree path end-to-end. ValueTask cannot be tested as the source generator doesn't support it. void is only reachable via AddDynamicTest, not CreateTestVariant. Both are covered by ExpressionHelper unit tests. --- .../TestVariantReturnTypeTests.cs | 2 +- TUnit.TestProject/TestVariantTests.cs | 24 ++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/TUnit.Engine.Tests/TestVariantReturnTypeTests.cs b/TUnit.Engine.Tests/TestVariantReturnTypeTests.cs index a9fd4781b8..d1bd5c5fa7 100644 --- a/TUnit.Engine.Tests/TestVariantReturnTypeTests.cs +++ b/TUnit.Engine.Tests/TestVariantReturnTypeTests.cs @@ -12,7 +12,7 @@ await RunTestsWithFilter( "/*/*/TestVariant*/*", [ result => result.ResultSummary.Outcome.ShouldBe("Completed"), - result => result.ResultSummary.Counters.Passed.ShouldBeGreaterThanOrEqualTo(8), + 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.TestProject/TestVariantTests.cs b/TUnit.TestProject/TestVariantTests.cs index bb15d671ca..2bbe51ba08 100644 --- a/TUnit.TestProject/TestVariantTests.cs +++ b/TUnit.TestProject/TestVariantTests.cs @@ -61,9 +61,12 @@ public async Task CreateTestVariant_ShouldCreateVariantWithDifferentArguments(in // 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 - // void → BlockExpression (tested via unit tests in ExpressionHelperTests) + // 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() @@ -88,6 +91,21 @@ public async ValueTask CreateTestVariant_FromValueTaskMethod() ); } } + + [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")] From 62bce4852cd40f3ca4f75829bd73f0500f279951 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:31:01 +0000 Subject: [PATCH 7/7] fix: use net472-compatible ValueTask APIs in ExpressionHelperTests ValueTask.FromResult and ValueTask.CompletedTask are .NET 5+ only. Use default(ValueTask) and new ValueTask(42) instead. --- TUnit.UnitTests/ExpressionHelperTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TUnit.UnitTests/ExpressionHelperTests.cs b/TUnit.UnitTests/ExpressionHelperTests.cs index 1838f2a684..9641b6ccab 100644 --- a/TUnit.UnitTests/ExpressionHelperTests.cs +++ b/TUnit.UnitTests/ExpressionHelperTests.cs @@ -15,9 +15,9 @@ private class FakeTestClass { public Task TaskMethod() => Task.CompletedTask; public void VoidMethod() { } - public ValueTask ValueTaskMethod() => ValueTask.CompletedTask; + public ValueTask ValueTaskMethod() => default; public Task GenericTaskMethod() => Task.FromResult(42); - public ValueTask GenericValueTaskMethod() => ValueTask.FromResult(42); + public ValueTask GenericValueTaskMethod() => new(42); } [Test]