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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion TUnit.Core/AbstractDynamicTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public class DynamicDiscoveryResult : DiscoveryResult

public Enums.TestRelationship? Relationship { get; set; }

public Dictionary<string, object?>? Properties { get; set; }
public IReadOnlyDictionary<string, object?>? Properties { get; set; }

public string? DisplayName { get; set; }

Expand Down
2 changes: 1 addition & 1 deletion TUnit.Core/DynamicTestMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public DynamicTestMetadata(DynamicDiscoveryResult dynamicResult)
/// <summary>
/// Custom properties for test variants.
/// </summary>
public Dictionary<string, object?>? Properties => _dynamicResult.Properties;
public IReadOnlyDictionary<string, object?>? Properties => _dynamicResult.Properties;

[field: AllowNull, MaybeNull]
public override Func<ExecutableTestCreationContext, TestMetadata, AbstractExecutableTest> CreateExecutableTestFactory
Expand Down
20 changes: 14 additions & 6 deletions TUnit.Core/Extensions/TestContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ internal static string GetNestedTypeName(Type type)
return prefix != null ? $"{prefix}+{type.Name}" : type.Name;
}

/// <summary>
/// Gets whether this test is a variant created at runtime via <see cref="CreateTestVariant"/>.
/// Use this to guard against infinite recursion when a test creates variants of itself.
/// </summary>
public static bool IsVariant(this Interfaces.ITestDependencies dependencies) => dependencies.ParentTestId != null;

#if NET6_0_OR_GREATER
[RequiresUnreferencedCode("Dynamic test metadata creation uses reflection")]
#endif
Expand All @@ -106,21 +112,23 @@ internal static string GetNestedTypeName(Type type)
/// This is the primary mechanism for implementing property-based test shrinking and retry logic.
/// </summary>
/// <param name="context">The current test context</param>
/// <param name="arguments">Method arguments for the variant (null to reuse current arguments)</param>
/// <param name="methodArguments">Method arguments for the variant (null to reuse current arguments)</param>
/// <param name="classArguments">Constructor arguments for the variant's test class (null to reuse current arguments)</param>
/// <param name="properties">Key-value pairs for user-defined metadata (e.g., attempt count, custom data)</param>
/// <param name="relationship">The relationship category of this variant to its parent test (defaults to Derived)</param>
/// <param name="displayName">Optional user-facing display name for the variant (e.g., "Shrink Attempt", "Mutant")</param>
/// <returns>A task that completes when the variant has been queued</returns>
/// <returns>Information about the created test variant, including its TestId and DisplayName</returns>
#if NET6_0_OR_GREATER
[RequiresUnreferencedCode("Creating test variants requires runtime compilation and reflection")]
#endif
public static async Task CreateTestVariant(
public static async Task<TestVariantInfo> CreateTestVariant(
this TestContext context,
object?[]? arguments = null,
Dictionary<string, object?>? properties = null,
object?[]? methodArguments = null,
object?[]? classArguments = null,
IReadOnlyDictionary<string, object?>? properties = null,
Enums.TestRelationship relationship = Enums.TestRelationship.Derived,
string? displayName = null)
{
await context.Services.GetService<ITestRegistry>()!.CreateTestVariant(context, arguments, properties, relationship, displayName);
return await context.Services.GetService<ITestRegistry>()!.CreateTestVariant(context, methodArguments, classArguments, properties, relationship, displayName);
}
}
12 changes: 7 additions & 5 deletions TUnit.Core/Interfaces/ITestRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,20 @@ public interface ITestRegistry
/// This is the primary mechanism for implementing property-based test shrinking and retry logic.
/// </summary>
/// <param name="currentContext">The current test context to base the variant on</param>
/// <param name="arguments">Method arguments for the variant (null to reuse current arguments)</param>
/// <param name="methodArguments">Method arguments for the variant (null to reuse current arguments)</param>
/// <param name="classArguments">Constructor arguments for the variant's test class (null to reuse current arguments)</param>
/// <param name="properties">Key-value pairs for user-defined metadata (e.g., attempt count, custom data)</param>
/// <param name="relationship">The relationship category of this variant to its parent test</param>
/// <param name="displayName">Optional user-facing display name for the variant (e.g., "Shrink Attempt", "Mutant")</param>
/// <returns>A task that completes when the variant has been queued</returns>
/// <returns>A task containing information about the created test variant</returns>
#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<TestVariantInfo> CreateTestVariant(
TestContext currentContext,
object?[]? arguments,
Dictionary<string, object?>? properties,
object?[]? methodArguments,
object?[]? classArguments,
IReadOnlyDictionary<string, object?>? properties,
Enums.TestRelationship relationship,
string? displayName);
}
24 changes: 24 additions & 0 deletions TUnit.Core/TestVariantInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace TUnit.Core;

/// <summary>
/// Information about a test variant that was created at runtime.
/// Returned by <see cref="Extensions.TestContextExtensions.CreateTestVariant"/>.
/// </summary>
public sealed record TestVariantInfo
{
internal TestVariantInfo(string testId, string displayName)
{
TestId = testId;
DisplayName = displayName;
}

/// <summary>
/// The unique identifier assigned to this test variant.
/// </summary>
public string TestId { get; }

/// <summary>
/// The display name of this test variant as it appears in test explorers.
/// </summary>
public string DisplayName { get; }
}
20 changes: 20 additions & 0 deletions TUnit.Engine.Tests/TestVariantReturnTypeTests.cs
Original file line number Diff line number Diff line change
@@ -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)
]);
}
}
45 changes: 3 additions & 42 deletions TUnit.Engine/Building/Collectors/AotTestDataCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -314,22 +315,7 @@ private Task<TestMetadata> 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;

Expand Down Expand Up @@ -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");

Expand Down
44 changes: 2 additions & 42 deletions TUnit.Engine/Discovery/ReflectionTestDataCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2201,22 +2201,7 @@ private Task<TestMetadata> 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);

Expand Down Expand Up @@ -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");

Expand Down
56 changes: 56 additions & 0 deletions TUnit.Engine/Helpers/ExpressionHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Linq.Expressions;
using System.Reflection;

namespace TUnit.Engine.Helpers;

internal static class ExpressionHelper
{
/// <summary>
/// Extracts the test <see cref="MethodInfo"/> from a dynamic test expression tree.
/// Handles all expression shapes produced by CreateTestVariantInternal:
/// <list type="bullet">
/// <item>Task-returning: direct MethodCallExpression</item>
/// <item>void-returning: BlockExpression containing the call</item>
/// <item>Task&lt;T&gt;-returning: UnaryExpression (Convert) wrapping the call</item>
/// <item>ValueTask-returning: MethodCallExpression wrapping AsTask() on the call</item>
/// <item>ValueTask&lt;T&gt;-returning: UnaryExpression wrapping AsTask() on the call</item>
/// </list>
/// </summary>
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;
}
}
Loading
Loading