Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
feat: implement dynamic test variant creation and management with rel…
…ationships
  • Loading branch information
thomhurst committed Oct 28, 2025
commit dee9da9898eaea4e642ade773ed9b04c7d2c58cc
12 changes: 6 additions & 6 deletions TUnit.Core/AbstractDynamicTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ 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 abstract class AbstractDynamicTest
Expand Down
34 changes: 34 additions & 0 deletions TUnit.Core/Enums/TestRelationship.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
namespace TUnit.Core.Enums;

/// <summary>
/// Defines the relationship between a test and its parent test, if any.
/// Used for tracking test hierarchies in scenarios like property-based testing shrinking and retry logic.
/// </summary>
public enum TestRelationship
{
/// <summary>
/// This test is independent and has no parent.
/// </summary>
None,

/// <summary>
/// This test is a retry of a failed test with the same or modified arguments.
/// </summary>
Retry,

/// <summary>
/// This test was created during the shrinking phase of property-based testing,
/// attempting to find a minimal reproduction with smaller inputs.
/// </summary>
ShrinkAttempt,

/// <summary>
/// This test was generated from a property test template (initial generation phase).
/// </summary>
Generated,

/// <summary>
/// This test was dynamically created at runtime for other purposes.
/// </summary>
Dynamic
}
20 changes: 20 additions & 0 deletions TUnit.Core/Extensions/TestContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,24 @@ 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 tracking context (e.g., shrink attempt, retry count)</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)
{
await context.GetService<ITestRegistry>()!.CreateTestVariant(context, arguments, properties);
}
}
17 changes: 17 additions & 0 deletions TUnit.Core/Interfaces/ITestRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,21 @@ 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 tracking context (e.g., shrink attempt, retry count)</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);
}
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