Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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