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
14 changes: 8 additions & 6 deletions TUnit.Core/AbstractDynamicTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,17 @@ public class DynamicDiscoveryResult : DiscoveryResult
| DynamicallyAccessedMemberTypes.NonPublicFields)]
public Type? TestClassType { get; set; }

/// <summary>
/// The file path where the dynamic test was created
/// </summary>
public string? CreatorFilePath { get; set; }

/// <summary>
/// The line number where the dynamic test was created
/// </summary>
public int? CreatorLineNumber { get; set; }

public string? ParentTestId { get; set; }

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

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

public string? DisplayName { get; set; }
}

public abstract class AbstractDynamicTest
Expand Down
31 changes: 31 additions & 0 deletions TUnit.Core/Enums/TestRelationship.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace TUnit.Core.Enums;

/// <summary>
/// Defines the relationship between a test and its parent test, if any.
/// Used for tracking test hierarchies and informing the test runner about the category of relationship.
/// </summary>
public enum TestRelationship
{
/// <summary>
/// This test is independent and has no parent.
/// </summary>
None,

/// <summary>
/// An identical re-run of a test, typically following a failure.
/// </summary>
Retry,

/// <summary>
/// A test case generated as part of an initial set to explore a solution space.
/// For example, the initial random inputs for a property-based test.
/// </summary>
Generated,

/// <summary>
/// A test case derived during the execution of a parent test, often in response to its outcome.
/// This is the appropriate category for property-based testing shrink attempts, mutation testing variants,
/// and other analytical test variations created at runtime based on parent test results.
/// </summary>
Derived
}
24 changes: 24 additions & 0 deletions TUnit.Core/Extensions/TestContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,28 @@ public static string GetClassTypeName(this TestContext context)
{
await context.GetService<ITestRegistry>()!.AddDynamicTest(context, dynamicTest);;
}

/// <summary>
/// Creates a new test variant based on the current test's template.
/// The new test is queued for execution and will appear as a distinct test in the test explorer.
/// 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="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>
#if NET6_0_OR_GREATER
[RequiresUnreferencedCode("Creating test variants requires runtime compilation and reflection")]
#endif
public static async Task CreateTestVariant(
this TestContext context,
object?[]? arguments = null,
Dictionary<string, object?>? properties = null,
Enums.TestRelationship relationship = Enums.TestRelationship.Derived,
string? displayName = null)
{
await context.GetService<ITestRegistry>()!.CreateTestVariant(context, arguments, properties, relationship, displayName);
}
}
21 changes: 21 additions & 0 deletions TUnit.Core/Interfaces/ITestRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,25 @@ public interface ITestRegistry
| DynamicallyAccessedMemberTypes.PublicFields
| DynamicallyAccessedMemberTypes.NonPublicFields)] T>(TestContext context, DynamicTest<T> dynamicTest)
where T : class;

/// <summary>
/// Creates a new test variant based on the current test's template.
/// The new test is queued for execution and will appear as a distinct test in the test explorer.
/// 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="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>
#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(
TestContext currentContext,
object?[]? arguments,
Dictionary<string, object?>? properties,
Enums.TestRelationship relationship,
string? displayName);
}
12 changes: 12 additions & 0 deletions TUnit.Core/TestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,18 @@ public void AddParallelConstraint(IParallelConstraint constraint)

public Priority ExecutionPriority { get; set; } = Priority.Normal;

/// <summary>
/// The test ID of the parent test, if this test is a variant or child of another test.
/// Used for tracking test hierarchies in property-based testing shrinking and retry scenarios.
/// </summary>
public string? ParentTestId { get; set; }

/// <summary>
/// Defines the relationship between this test and its parent test (if ParentTestId is set).
/// Used by test explorers to display hierarchical relationships.
/// </summary>
public TestRelationship Relationship { get; set; } = TestRelationship.None;

/// <summary>
/// Will be null until initialized by TestOrchestrator
/// </summary>
Expand Down
7 changes: 5 additions & 2 deletions TUnit.Engine/Framework/TUnitServiceProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@ public TUnitServiceProvider(IExtension extension,

var staticPropertyHandler = Register(new StaticPropertyHandler(Logger, objectTracker, trackableObjectGraphProvider, disposer));

var dynamicTestQueue = Register<IDynamicTestQueue>(new DynamicTestQueue(MessageBus));

var testScheduler = Register<ITestScheduler>(new TestScheduler(
Logger,
testGroupingService,
Expand All @@ -232,7 +234,8 @@ public TUnitServiceProvider(IExtension extension,
circularDependencyDetector,
constraintKeyScheduler,
hookExecutor,
staticPropertyHandler));
staticPropertyHandler,
dynamicTestQueue));

TestSessionCoordinator = Register(new TestSessionCoordinator(EventReceiverOrchestrator,
Logger,
Expand All @@ -243,7 +246,7 @@ public TUnitServiceProvider(IExtension extension,
MessageBus,
staticPropertyInitializer));

Register<ITestRegistry>(new TestRegistry(TestBuilderPipeline, testCoordinator, TestSessionId, CancellationToken.Token));
Register<ITestRegistry>(new TestRegistry(TestBuilderPipeline, testCoordinator, dynamicTestQueue, TestSessionId, CancellationToken.Token));

InitializeConsoleInterceptors();
}
Expand Down
40 changes: 40 additions & 0 deletions TUnit.Engine/Interfaces/IDynamicTestQueue.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using TUnit.Core;

namespace TUnit.Engine.Interfaces;

/// <summary>
/// Thread-safe queue for managing dynamically created tests during execution.
/// Ensures tests created at runtime (via CreateTestVariant or AddDynamicTest) are properly scheduled.
/// Handles discovery notification internally to keep all dynamic test logic in one place.
/// </summary>
internal interface IDynamicTestQueue
{
/// <summary>
/// Enqueues a test for execution and notifies the message bus. Thread-safe.
/// </summary>
/// <param name="test">The test to enqueue</param>
/// <returns>Task that completes when the test is enqueued and discovery is notified</returns>
Task EnqueueAsync(AbstractExecutableTest test);

/// <summary>
/// Attempts to dequeue the next test. Thread-safe.
/// </summary>
/// <param name="test">The dequeued test, or null if queue is empty</param>
/// <returns>True if a test was dequeued, false if queue is empty</returns>
bool TryDequeue(out AbstractExecutableTest? test);

/// <summary>
/// Gets the number of pending tests in the queue.
/// </summary>
int PendingCount { get; }

/// <summary>
/// Indicates whether the queue has been completed and no more tests will be added.
/// </summary>
bool IsCompleted { get; }

/// <summary>
/// Marks the queue as complete, indicating no more tests will be added.
/// </summary>
void Complete();
}
62 changes: 61 additions & 1 deletion TUnit.Engine/Scheduling/TestScheduler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using TUnit.Core.Exceptions;
using TUnit.Core.Logging;
using TUnit.Engine.CommandLineProviders;
using TUnit.Engine.Interfaces;
using TUnit.Engine.Logging;
using TUnit.Engine.Models;
using TUnit.Engine.Services;
Expand All @@ -23,6 +24,7 @@ internal sealed class TestScheduler : ITestScheduler
private readonly IConstraintKeyScheduler _constraintKeyScheduler;
private readonly HookExecutor _hookExecutor;
private readonly StaticPropertyHandler _staticPropertyHandler;
private readonly IDynamicTestQueue _dynamicTestQueue;
private readonly int _maxParallelism;
private readonly SemaphoreSlim? _maxParallelismSemaphore;

Expand All @@ -37,7 +39,8 @@ public TestScheduler(
CircularDependencyDetector circularDependencyDetector,
IConstraintKeyScheduler constraintKeyScheduler,
HookExecutor hookExecutor,
StaticPropertyHandler staticPropertyHandler)
StaticPropertyHandler staticPropertyHandler,
IDynamicTestQueue dynamicTestQueue)
{
_logger = logger;
_groupingService = groupingService;
Expand All @@ -49,6 +52,7 @@ public TestScheduler(
_constraintKeyScheduler = constraintKeyScheduler;
_hookExecutor = hookExecutor;
_staticPropertyHandler = staticPropertyHandler;
_dynamicTestQueue = dynamicTestQueue;

_maxParallelism = GetMaxParallelism(logger, commandLineOptions);

Expand Down Expand Up @@ -155,6 +159,9 @@ private async Task ExecuteGroupedTestsAsync(
GroupedTests groupedTests,
CancellationToken cancellationToken)
{
// Start dynamic test queue processing in background
var dynamicTestProcessingTask = ProcessDynamicTestQueueAsync(cancellationToken);

if (groupedTests.Parallel.Length > 0)
{
await _logger.LogDebugAsync($"Starting {groupedTests.Parallel.Length} parallel tests").ConfigureAwait(false);
Expand Down Expand Up @@ -205,6 +212,59 @@ private async Task ExecuteGroupedTestsAsync(
await _logger.LogDebugAsync($"Starting {groupedTests.NotInParallel.Length} global NotInParallel tests").ConfigureAwait(false);
await ExecuteSequentiallyAsync(groupedTests.NotInParallel, cancellationToken).ConfigureAwait(false);
}

// Mark the queue as complete and wait for remaining dynamic tests to finish
_dynamicTestQueue.Complete();
await dynamicTestProcessingTask.ConfigureAwait(false);
}

#if NET6_0_OR_GREATER
[System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Test execution involves reflection for hooks and initialization")]
#endif
private async Task ProcessDynamicTestQueueAsync(CancellationToken cancellationToken)
{
var dynamicTests = new List<AbstractExecutableTest>();

while (!_dynamicTestQueue.IsCompleted || _dynamicTestQueue.PendingCount > 0)
{
// Dequeue all currently pending tests
while (_dynamicTestQueue.TryDequeue(out var test))
{
if (test != null)
{
dynamicTests.Add(test);
}
}

// Execute the batch of dynamic tests if any were found
if (dynamicTests.Count > 0)
{
await _logger.LogDebugAsync($"Executing {dynamicTests.Count} dynamic test(s)").ConfigureAwait(false);

// Group and execute just like regular tests
var dynamicTestsArray = dynamicTests.ToArray();
var groupedDynamicTests = await _groupingService.GroupTestsByConstraintsAsync(dynamicTestsArray).ConfigureAwait(false);

// Execute the grouped dynamic tests (recursive call handles sub-dynamics)
if (groupedDynamicTests.Parallel.Length > 0)
{
await ExecuteTestsAsync(groupedDynamicTests.Parallel, cancellationToken).ConfigureAwait(false);
}

if (groupedDynamicTests.NotInParallel.Length > 0)
{
await ExecuteSequentiallyAsync(groupedDynamicTests.NotInParallel, cancellationToken).ConfigureAwait(false);
}

dynamicTests.Clear();
}

// If queue is not complete, wait a short time before checking again
if (!_dynamicTestQueue.IsCompleted)
{
await Task.Delay(50, cancellationToken).ConfigureAwait(false);
}
}
}

#if NET6_0_OR_GREATER
Expand Down
65 changes: 65 additions & 0 deletions TUnit.Engine/Services/DynamicTestQueue.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System.Threading.Channels;
using TUnit.Core;
using TUnit.Engine.Interfaces;

namespace TUnit.Engine.Services;

/// <summary>
/// Thread-safe queue implementation for managing dynamically created tests using System.Threading.Channels.
/// Provides efficient async support for queuing tests created at runtime.
/// Handles discovery notification internally to keep all dynamic test logic in one place.
/// </summary>
internal sealed class DynamicTestQueue : IDynamicTestQueue
{
private readonly Channel<AbstractExecutableTest> _channel;
private readonly ITUnitMessageBus _messageBus;
private int _pendingCount;
private bool _isCompleted;

public DynamicTestQueue(ITUnitMessageBus messageBus)
{
_messageBus = messageBus ?? throw new ArgumentNullException(nameof(messageBus));

// Unbounded channel for maximum flexibility
// Tests can be added at any time during execution
_channel = Channel.CreateUnbounded<AbstractExecutableTest>(new UnboundedChannelOptions
{
SingleReader = false, // Multiple test runners may dequeue
SingleWriter = false // Multiple sources may enqueue (AddDynamicTest, CreateTestVariant)
});
}

public async Task EnqueueAsync(AbstractExecutableTest test)
{
Interlocked.Increment(ref _pendingCount);

if (!_channel.Writer.TryWrite(test))
{
Interlocked.Decrement(ref _pendingCount);
throw new InvalidOperationException("Failed to enqueue test to dynamic test queue.");
}

await _messageBus.Discovered(test.Context);
}

public bool TryDequeue(out AbstractExecutableTest? test)
{
if (_channel.Reader.TryRead(out test))
{
Interlocked.Decrement(ref _pendingCount);
return true;
}

test = null;
return false;
}

public int PendingCount => _pendingCount;

public bool IsCompleted => _isCompleted;

public void Complete()
{
_isCompleted = true;
}
}
Loading
Loading