diff --git a/TUnit.Core/AbstractDynamicTest.cs b/TUnit.Core/AbstractDynamicTest.cs index afa495adcb..e086176bc6 100644 --- a/TUnit.Core/AbstractDynamicTest.cs +++ b/TUnit.Core/AbstractDynamicTest.cs @@ -36,15 +36,17 @@ public class DynamicDiscoveryResult : DiscoveryResult | DynamicallyAccessedMemberTypes.NonPublicFields)] public Type? TestClassType { get; set; } - /// - /// The file path where the dynamic test was created - /// public string? CreatorFilePath { get; set; } - /// - /// The line number where the dynamic test was created - /// public int? CreatorLineNumber { get; set; } + + public string? ParentTestId { get; set; } + + public Enums.TestRelationship? Relationship { get; set; } + + public Dictionary? Properties { get; set; } + + public string? DisplayName { get; set; } } public abstract class AbstractDynamicTest diff --git a/TUnit.Core/Enums/TestRelationship.cs b/TUnit.Core/Enums/TestRelationship.cs new file mode 100644 index 0000000000..e545f2af6f --- /dev/null +++ b/TUnit.Core/Enums/TestRelationship.cs @@ -0,0 +1,31 @@ +namespace TUnit.Core.Enums; + +/// +/// 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. +/// +public enum TestRelationship +{ + /// + /// This test is independent and has no parent. + /// + None, + + /// + /// An identical re-run of a test, typically following a failure. + /// + Retry, + + /// + /// 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. + /// + Generated, + + /// + /// 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. + /// + Derived +} diff --git a/TUnit.Core/Extensions/TestContextExtensions.cs b/TUnit.Core/Extensions/TestContextExtensions.cs index 4361cf0f50..11f1b4b038 100644 --- a/TUnit.Core/Extensions/TestContextExtensions.cs +++ b/TUnit.Core/Extensions/TestContextExtensions.cs @@ -45,4 +45,28 @@ public static string GetClassTypeName(this TestContext context) { await context.GetService()!.AddDynamicTest(context, dynamicTest);; } + + /// + /// 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. + /// + /// The current test context + /// Method arguments for the variant (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 + #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? properties = null, + Enums.TestRelationship relationship = Enums.TestRelationship.Derived, + string? displayName = null) + { + await context.GetService()!.CreateTestVariant(context, arguments, properties, relationship, displayName); + } } diff --git a/TUnit.Core/Interfaces/ITestRegistry.cs b/TUnit.Core/Interfaces/ITestRegistry.cs index 9dbfcd5da0..931ada871e 100644 --- a/TUnit.Core/Interfaces/ITestRegistry.cs +++ b/TUnit.Core/Interfaces/ITestRegistry.cs @@ -26,4 +26,25 @@ public interface ITestRegistry | DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.NonPublicFields)] T>(TestContext context, DynamicTest dynamicTest) where T : class; + + /// + /// 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. + /// + /// The current test context to base the variant on + /// Method arguments for the variant (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 + #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? properties, + Enums.TestRelationship relationship, + string? displayName); } diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs index b5acc046c2..4f3dabc706 100644 --- a/TUnit.Core/TestContext.cs +++ b/TUnit.Core/TestContext.cs @@ -153,6 +153,18 @@ public void AddParallelConstraint(IParallelConstraint constraint) public Priority ExecutionPriority { get; set; } = Priority.Normal; + /// + /// 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. + /// + public string? ParentTestId { get; set; } + + /// + /// Defines the relationship between this test and its parent test (if ParentTestId is set). + /// Used by test explorers to display hierarchical relationships. + /// + public TestRelationship Relationship { get; set; } = TestRelationship.None; + /// /// Will be null until initialized by TestOrchestrator /// diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs index 673e73c91a..11e0547cab 100644 --- a/TUnit.Engine/Framework/TUnitServiceProvider.cs +++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs @@ -221,6 +221,8 @@ public TUnitServiceProvider(IExtension extension, var staticPropertyHandler = Register(new StaticPropertyHandler(Logger, objectTracker, trackableObjectGraphProvider, disposer)); + var dynamicTestQueue = Register(new DynamicTestQueue(MessageBus)); + var testScheduler = Register(new TestScheduler( Logger, testGroupingService, @@ -232,7 +234,8 @@ public TUnitServiceProvider(IExtension extension, circularDependencyDetector, constraintKeyScheduler, hookExecutor, - staticPropertyHandler)); + staticPropertyHandler, + dynamicTestQueue)); TestSessionCoordinator = Register(new TestSessionCoordinator(EventReceiverOrchestrator, Logger, @@ -243,7 +246,7 @@ public TUnitServiceProvider(IExtension extension, MessageBus, staticPropertyInitializer)); - Register(new TestRegistry(TestBuilderPipeline, testCoordinator, TestSessionId, CancellationToken.Token)); + Register(new TestRegistry(TestBuilderPipeline, testCoordinator, dynamicTestQueue, TestSessionId, CancellationToken.Token)); InitializeConsoleInterceptors(); } diff --git a/TUnit.Engine/Interfaces/IDynamicTestQueue.cs b/TUnit.Engine/Interfaces/IDynamicTestQueue.cs new file mode 100644 index 0000000000..56c8f4498d --- /dev/null +++ b/TUnit.Engine/Interfaces/IDynamicTestQueue.cs @@ -0,0 +1,40 @@ +using TUnit.Core; + +namespace TUnit.Engine.Interfaces; + +/// +/// 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. +/// +internal interface IDynamicTestQueue +{ + /// + /// Enqueues a test for execution and notifies the message bus. Thread-safe. + /// + /// The test to enqueue + /// Task that completes when the test is enqueued and discovery is notified + Task EnqueueAsync(AbstractExecutableTest test); + + /// + /// Attempts to dequeue the next test. Thread-safe. + /// + /// The dequeued test, or null if queue is empty + /// True if a test was dequeued, false if queue is empty + bool TryDequeue(out AbstractExecutableTest? test); + + /// + /// Gets the number of pending tests in the queue. + /// + int PendingCount { get; } + + /// + /// Indicates whether the queue has been completed and no more tests will be added. + /// + bool IsCompleted { get; } + + /// + /// Marks the queue as complete, indicating no more tests will be added. + /// + void Complete(); +} diff --git a/TUnit.Engine/Scheduling/TestScheduler.cs b/TUnit.Engine/Scheduling/TestScheduler.cs index b009bea717..5fca724fd1 100644 --- a/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/TUnit.Engine/Scheduling/TestScheduler.cs @@ -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; @@ -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; @@ -37,7 +39,8 @@ public TestScheduler( CircularDependencyDetector circularDependencyDetector, IConstraintKeyScheduler constraintKeyScheduler, HookExecutor hookExecutor, - StaticPropertyHandler staticPropertyHandler) + StaticPropertyHandler staticPropertyHandler, + IDynamicTestQueue dynamicTestQueue) { _logger = logger; _groupingService = groupingService; @@ -49,6 +52,7 @@ public TestScheduler( _constraintKeyScheduler = constraintKeyScheduler; _hookExecutor = hookExecutor; _staticPropertyHandler = staticPropertyHandler; + _dynamicTestQueue = dynamicTestQueue; _maxParallelism = GetMaxParallelism(logger, commandLineOptions); @@ -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); @@ -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(); + + 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 diff --git a/TUnit.Engine/Services/DynamicTestQueue.cs b/TUnit.Engine/Services/DynamicTestQueue.cs new file mode 100644 index 0000000000..8536119942 --- /dev/null +++ b/TUnit.Engine/Services/DynamicTestQueue.cs @@ -0,0 +1,65 @@ +using System.Threading.Channels; +using TUnit.Core; +using TUnit.Engine.Interfaces; + +namespace TUnit.Engine.Services; + +/// +/// 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. +/// +internal sealed class DynamicTestQueue : IDynamicTestQueue +{ + private readonly Channel _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(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; + } +} diff --git a/TUnit.Engine/Services/TestRegistry.cs b/TUnit.Engine/Services/TestRegistry.cs index 737998f8f5..79352774b3 100644 --- a/TUnit.Engine/Services/TestRegistry.cs +++ b/TUnit.Engine/Services/TestRegistry.cs @@ -1,11 +1,13 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Linq.Expressions; using System.Reflection; using TUnit.Core; using TUnit.Core.Interfaces; using TUnit.Engine.Building; using TUnit.Engine.Interfaces; +using Expression = System.Linq.Expressions.Expression; namespace TUnit.Engine.Services; @@ -17,16 +19,19 @@ internal sealed class TestRegistry : ITestRegistry private readonly ConcurrentQueue _pendingTests = new(); private readonly TestBuilderPipeline? _testBuilderPipeline; private readonly ITestCoordinator _testCoordinator; + private readonly IDynamicTestQueue _dynamicTestQueue; private readonly CancellationToken _sessionCancellationToken; private readonly string? _sessionId; public TestRegistry(TestBuilderPipeline testBuilderPipeline, ITestCoordinator testCoordinator, + IDynamicTestQueue dynamicTestQueue, string sessionId, CancellationToken sessionCancellationToken) { _testBuilderPipeline = testBuilderPipeline; _testCoordinator = testCoordinator; + _dynamicTestQueue = dynamicTestQueue; _sessionId = sessionId; _sessionCancellationToken = sessionCancellationToken; } @@ -96,10 +101,134 @@ private async Task ProcessPendingDynamicTests() foreach (var test in builtTests) { - await _testCoordinator.ExecuteTestAsync(test, _sessionCancellationToken); + await _dynamicTestQueue.EnqueueAsync(test); } } + [RequiresUnreferencedCode("Creating test variants requires reflection which is not supported in native AOT scenarios.")] + [UnconditionalSuppressMessage("Trimming", + "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", + Justification = "Dynamic test variants require reflection")] + [UnconditionalSuppressMessage("Trimming", + "IL2067:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call", + Justification = "Dynamic test variants require reflection")] + [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( + TestContext currentContext, + object?[]? arguments, + Dictionary? properties, + TUnit.Core.Enums.TestRelationship relationship, + string? displayName) + { + var testDetails = currentContext.TestDetails; + var testClassType = testDetails.ClassType; + var variantMethodArguments = arguments ?? testDetails.TestMethodArguments; + + var methodMetadata = testDetails.MethodMetadata; + var parameterTypes = methodMetadata.Parameters.Select(p => p.Type).ToArray(); + var methodInfo = methodMetadata.Type.GetMethod( + methodMetadata.Name, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static, + null, + parameterTypes, + null); + + if (methodInfo == null) + { + throw new InvalidOperationException($"Cannot create test variant: method '{methodMetadata.Name}' not found"); + } + + var genericAddDynamicTestMethod = typeof(TestRegistry) + .GetMethod(nameof(CreateTestVariantInternal), BindingFlags.NonPublic | BindingFlags.Instance) + ?.MakeGenericMethod(testClassType); + + if (genericAddDynamicTestMethod == null) + { + throw new InvalidOperationException("Failed to resolve CreateTestVariantInternal method"); + } + + await ((Task)genericAddDynamicTestMethod.Invoke(this, + [currentContext, methodInfo, variantMethodArguments, testDetails.TestClassArguments, properties, relationship, displayName])!); + } + + [RequiresUnreferencedCode("Creating test variants requires reflection which is not supported in native AOT scenarios.")] + private async Task CreateTestVariantInternal<[DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicConstructors + | DynamicallyAccessedMemberTypes.NonPublicConstructors + | DynamicallyAccessedMemberTypes.PublicProperties + | DynamicallyAccessedMemberTypes.PublicMethods + | DynamicallyAccessedMemberTypes.NonPublicMethods + | DynamicallyAccessedMemberTypes.PublicFields + | DynamicallyAccessedMemberTypes.NonPublicFields)] T>( + TestContext currentContext, + MethodInfo methodInfo, + object?[] variantMethodArguments, + object?[] classArguments, + Dictionary? properties, + TUnit.Core.Enums.TestRelationship relationship, + string? displayName) where T : class + { + var parameter = Expression.Parameter(typeof(T), "instance"); + var methodParameters = methodInfo.GetParameters(); + var argumentExpressions = new Expression[methodParameters.Length]; + + for (int i = 0; i < methodParameters.Length; i++) + { + var argValue = i < variantMethodArguments.Length ? variantMethodArguments[i] : null; + argumentExpressions[i] = Expression.Constant(argValue, methodParameters[i].ParameterType); + } + + var methodCall = Expression.Call(parameter, methodInfo, argumentExpressions); + + Expression body; + if (methodInfo.ReturnType == typeof(Task)) + { + body = methodCall; + } + else if (methodInfo.ReturnType == typeof(void)) + { + body = Expression.Block(methodCall, Expression.Constant(Task.CompletedTask)); + } + else if (methodInfo.ReturnType.IsGenericType && + methodInfo.ReturnType.GetGenericTypeDefinition() == typeof(Task<>)) + { + body = Expression.Convert(methodCall, typeof(Task)); + } + else + { + body = Expression.Block(methodCall, Expression.Constant(Task.CompletedTask)); + } + + var lambda = Expression.Lambda>(body, parameter); + var attributes = new List(currentContext.TestDetails.Attributes); + + var discoveryResult = new DynamicDiscoveryResult + { + TestClassType = typeof(T), + TestClassArguments = classArguments, + TestMethodArguments = variantMethodArguments, + TestMethod = lambda, + Attributes = attributes, + CreatorFilePath = currentContext.TestDetails.TestFilePath, + CreatorLineNumber = currentContext.TestDetails.TestLineNumber, + ParentTestId = currentContext.TestDetails.TestId, + Relationship = relationship, + Properties = properties, + DisplayName = displayName + }; + + _pendingTests.Enqueue(new PendingDynamicTest + { + DiscoveryResult = discoveryResult, + SourceContext = currentContext, + TestClassType = typeof(T) + }); + + await ProcessPendingDynamicTests(); + } + [RequiresUnreferencedCode("Dynamic test metadata creation requires reflection which is not supported in native AOT scenarios.")] private async Task CreateMetadataFromDynamicDiscoveryResult(DynamicDiscoveryResult result) { @@ -231,12 +360,30 @@ public override Func { var instance = metadata.InstanceFactory(Type.EmptyTypes, modifiedContext.ClassArguments); 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 4352c6266d..4c6d041a8d 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 @@ -612,6 +612,10 @@ namespace public .<> Attributes { get; set; } public string? CreatorFilePath { get; set; } public int? CreatorLineNumber { get; set; } + public string? DisplayName { get; set; } + public string? ParentTestId { get; set; } + public .? Properties { get; set; } + public .? Relationship { get; set; } public object?[]? TestClassArguments { get; set; } [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicMethods | ..NonPublicMethods | ..PublicFields | ..NonPublicFields | ..PublicProperties)] public ? TestClassType { get; set; } @@ -1284,7 +1288,9 @@ namespace public .? ParallelConstraint { get; set; } public .<.> ParallelConstraints { get; } public .? ParallelLimiter { get; } + public string? ParentTestId { get; set; } public .TestPhase Phase { get; set; } + public . Relationship { get; set; } public bool ReportResult { get; set; } public .TestResult? Result { get; set; } public <.TestContext, , int, .>? RetryFunc { get; set; } @@ -1704,6 +1710,13 @@ namespace .Enums High = 4, Critical = 5, } + public enum TestRelationship + { + None = 0, + Retry = 1, + Generated = 2, + Derived = 3, + } } namespace .Events { @@ -1889,6 +1902,8 @@ namespace .Extensions [.("Dynamic test metadata creation uses reflection")] 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 string GetClassTypeName(this .TestContext context) { } public static T? GetService(this .TestContext context) where T : class { } @@ -2313,6 +2328,9 @@ namespace .Interfaces "pported in native AOT scenarios.")] . AddDynamicTest<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicMethods | ..NonPublicMethods | ..PublicFields | ..NonPublicFields | ..PublicProperties)] T>(.TestContext context, .DynamicTest dynamicTest) 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); } 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 f64342699b..1ccc7971a8 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 @@ -612,6 +612,10 @@ namespace public .<> Attributes { get; set; } public string? CreatorFilePath { get; set; } public int? CreatorLineNumber { get; set; } + public string? DisplayName { get; set; } + public string? ParentTestId { get; set; } + public .? Properties { get; set; } + public .? Relationship { get; set; } public object?[]? TestClassArguments { get; set; } [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicMethods | ..NonPublicMethods | ..PublicFields | ..NonPublicFields | ..PublicProperties)] public ? TestClassType { get; set; } @@ -1284,7 +1288,9 @@ namespace public .? ParallelConstraint { get; set; } public .<.> ParallelConstraints { get; } public .? ParallelLimiter { get; } + public string? ParentTestId { get; set; } public .TestPhase Phase { get; set; } + public . Relationship { get; set; } public bool ReportResult { get; set; } public .TestResult? Result { get; set; } public <.TestContext, , int, .>? RetryFunc { get; set; } @@ -1704,6 +1710,13 @@ namespace .Enums High = 4, Critical = 5, } + public enum TestRelationship + { + None = 0, + Retry = 1, + Generated = 2, + Derived = 3, + } } namespace .Events { @@ -1889,6 +1902,8 @@ namespace .Extensions [.("Dynamic test metadata creation uses reflection")] 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 string GetClassTypeName(this .TestContext context) { } public static T? GetService(this .TestContext context) where T : class { } @@ -2313,6 +2328,9 @@ namespace .Interfaces "pported in native AOT scenarios.")] . AddDynamicTest<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicMethods | ..NonPublicMethods | ..PublicFields | ..NonPublicFields | ..PublicProperties)] T>(.TestContext context, .DynamicTest dynamicTest) 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); } 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 443782f791..d1c3f9b3e9 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 @@ -612,6 +612,10 @@ namespace public .<> Attributes { get; set; } public string? CreatorFilePath { get; set; } public int? CreatorLineNumber { get; set; } + public string? DisplayName { get; set; } + public string? ParentTestId { get; set; } + public .? Properties { get; set; } + public .? Relationship { get; set; } public object?[]? TestClassArguments { get; set; } [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicMethods | ..NonPublicMethods | ..PublicFields | ..NonPublicFields | ..PublicProperties)] public ? TestClassType { get; set; } @@ -1284,7 +1288,9 @@ namespace public .? ParallelConstraint { get; set; } public .<.> ParallelConstraints { get; } public .? ParallelLimiter { get; } + public string? ParentTestId { get; set; } public .TestPhase Phase { get; set; } + public . Relationship { get; set; } public bool ReportResult { get; set; } public .TestResult? Result { get; set; } public <.TestContext, , int, .>? RetryFunc { get; set; } @@ -1704,6 +1710,13 @@ namespace .Enums High = 4, Critical = 5, } + public enum TestRelationship + { + None = 0, + Retry = 1, + Generated = 2, + Derived = 3, + } } namespace .Events { @@ -1889,6 +1902,8 @@ namespace .Extensions [.("Dynamic test metadata creation uses reflection")] 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 string GetClassTypeName(this .TestContext context) { } public static T? GetService(this .TestContext context) where T : class { } @@ -2313,6 +2328,9 @@ namespace .Interfaces "pported in native AOT scenarios.")] . AddDynamicTest<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicMethods | ..NonPublicMethods | ..PublicFields | ..NonPublicFields | ..PublicProperties)] T>(.TestContext context, .DynamicTest dynamicTest) 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); } 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 44febaf0b5..b6a0ad1953 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 @@ -592,6 +592,10 @@ namespace public .<> Attributes { get; set; } public string? CreatorFilePath { get; set; } public int? CreatorLineNumber { get; set; } + public string? DisplayName { get; set; } + public string? ParentTestId { get; set; } + public .? Properties { get; set; } + public .? Relationship { get; set; } public object?[]? TestClassArguments { get; set; } public ? TestClassType { get; set; } public .? TestMethod { get; set; } @@ -1238,7 +1242,9 @@ namespace public .? ParallelConstraint { get; set; } public .<.> ParallelConstraints { get; } public .? ParallelLimiter { get; } + public string? ParentTestId { get; set; } public .TestPhase Phase { get; set; } + public . Relationship { get; set; } public bool ReportResult { get; set; } public .TestResult? Result { get; set; } public <.TestContext, , int, .>? RetryFunc { get; set; } @@ -1656,6 +1662,13 @@ namespace .Enums High = 4, Critical = 5, } + public enum TestRelationship + { + None = 0, + Retry = 1, + Generated = 2, + Derived = 3, + } } namespace .Events { @@ -1839,6 +1852,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 string GetClassTypeName(this .TestContext context) { } public static T? GetService(this .TestContext context) where T : class { } @@ -2243,6 +2257,7 @@ namespace .Interfaces { . AddDynamicTest(.TestContext context, .DynamicTest dynamicTest) where T : class; + . CreateTestVariant(.TestContext currentContext, object?[]? arguments, .? properties, . relationship, string? displayName); } public interface ITestRetryEventReceiver : . { diff --git a/TUnit.TestProject/TestVariantTests.cs b/TUnit.TestProject/TestVariantTests.cs new file mode 100644 index 0000000000..db1007848a --- /dev/null +++ b/TUnit.TestProject/TestVariantTests.cs @@ -0,0 +1,61 @@ +using TUnit.Core; +using TUnit.Core.Extensions; + +namespace TUnit.TestProject; + +public class TestVariantTests +{ + [Test] + public async Task CreateTestVariant_ShouldCreateVariantWithDifferentArguments() + { + var context = TestContext.Current; + + if (context == null) + { + throw new InvalidOperationException("TestContext.Current is null"); + } + + await context.CreateTestVariant( + arguments: new object?[] { 42 }, + properties: new Dictionary + { + { "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; + + if (context == null) + { + throw new InvalidOperationException("TestContext.Current is null"); + } + + if (value < 0) + { + throw new InvalidOperationException($"Expected non-negative value but got {value}"); + } + + if (context.ObjectBag.ContainsKey("AttemptNumber")) + { + var attemptNumber = context.ObjectBag["AttemptNumber"]; + context.WriteLine($"Shrink attempt {attemptNumber} with value {value}"); + + if (context.Relationship != TUnit.Core.Enums.TestRelationship.Derived) + { + throw new InvalidOperationException($"Expected Derived relationship but got {context.Relationship}"); + } + + if (context.ParentTestId == null) + { + throw new InvalidOperationException("Expected ParentTestId to be set for shrink attempt"); + } + } + } +} diff --git a/docs/docs/advanced/test-variants.md b/docs/docs/advanced/test-variants.md new file mode 100644 index 0000000000..7862a0327c --- /dev/null +++ b/docs/docs/advanced/test-variants.md @@ -0,0 +1,483 @@ +# Test Variants + +Test variants enable you to dynamically create additional test cases during test execution based on runtime results. This powerful feature unlocks advanced testing patterns like property-based testing shrinking, mutation testing, adaptive stress testing, and intelligent retry strategies. + +## What Are Test Variants? + +Test variants are tests that are created **during the execution** of a parent test, inheriting the parent's test method template but potentially using different arguments, properties, or display names. They appear as distinct tests in the test explorer and can have their own outcomes. + +### Test Variants vs Dynamic Tests + +| Feature | Test Variants (`CreateTestVariant`) | Dynamic Tests (`AddDynamicTest`) | +|---------|-------------------------------------|----------------------------------| +| **Created** | During test execution | During test discovery | +| **Parent** | Always has a parent test | Standalone tests | +| **Template** | Reuses parent's test method | Requires explicit method definition | +| **Use Case** | Runtime adaptation (shrinking, mutation, stress) | Pre-generation of test cases | +| **AOT Compatible** | No (requires reflection) | Yes (with source generators) | + +## Core Concepts + +### TestRelationship Enum + +The `TestRelationship` enum categorizes how a variant relates to its parent, informing the test runner about execution semantics: + +```csharp +public enum TestRelationship +{ + None, // Independent test (no parent) + Retry, // Identical re-run after failure + Generated, // Pre-execution exploration (e.g., initial PBT cases) + Derived // Post-execution analysis (e.g., shrinking, mutation) +} +``` + +**When to use each:** +- **`Retry`**: For identical re-runs, typically handled by `[Retry]` attribute +- **`Generated`**: For upfront test case generation before execution +- **`Derived`**: For runtime analysis based on parent results (most common for variants) + +### DisplayName Parameter + +The optional `displayName` parameter provides user-facing labels in test explorers and reports. While the `TestRelationship` informs the framework about execution semantics, `displayName` communicates intent to humans: + +```csharp +await context.CreateTestVariant( + arguments: new object[] { smallerInput }, + relationship: TestRelationship.Derived, + displayName: "Shrink Attempt #3" // Shows in test explorer +); +``` + +### Properties Dictionary + +Store metadata for filtering, reporting, or variant logic: + +```csharp +await context.CreateTestVariant( + arguments: new object[] { mutatedValue }, + properties: new Dictionary + { + { "AttemptNumber", 3 }, + { "ShrinkStrategy", "Binary" }, + { "OriginalValue", originalInput } + }, + relationship: TestRelationship.Derived, + displayName: "Shrink #3 (Binary)" +); +``` + +## Use Cases + +### 1. Property-Based Testing (PBT) - Shrinking + +When a property-based test fails with a complex input, create variants with progressively simpler inputs to find the minimal failing case. Use a custom attribute implementing `ITestEndEventReceiver` to automatically shrink on failure: + +```csharp +// Custom attribute that shrinks inputs on test failure +public class ShrinkOnFailureAttribute : Attribute, ITestEndEventReceiver +{ + private readonly int _maxAttempts; + + public ShrinkOnFailureAttribute(int maxAttempts = 5) + { + _maxAttempts = maxAttempts; + } + + public async ValueTask OnTestEnd(TestContext testContext) + { + // Only shrink if test failed and it's not already a shrink attempt + if (testContext.Result?.Status != TestStatus.Failed) + return; + + if (testContext.Relationship == TestRelationship.Derived) + return; // Don't shrink shrink attempts + + // Get the test's numeric argument to shrink + var args = testContext.TestDetails.TestMethodArguments; + if (args.Length == 0 || args[0] is not int size) + return; + + if (size <= 1) + return; // Can't shrink further + + // Create shrink variants + var shrinkSize = size / 2; + for (int attempt = 1; attempt <= _maxAttempts && shrinkSize > 0; attempt++) + { + await testContext.CreateTestVariant( + arguments: new object[] { shrinkSize }, + properties: new Dictionary + { + { "AttemptNumber", attempt }, + { "OriginalSize", size }, + { "ShrinkStrategy", "Binary" } + }, + relationship: TestRelationship.Derived, + displayName: $"Shrink #{attempt} (size={shrinkSize})" + ); + + shrinkSize /= 2; + } + } +} + +// Usage: Just add the attribute - shrinking happens automatically on failure +[Test] +[ShrinkOnFailure(maxAttempts: 5)] +[Arguments(1000)] +[Arguments(500)] +[Arguments(100)] +public async Task PropertyTest_ListReversal(int size) +{ + var list = Enumerable.Range(0, size).ToList(); + + // Property: reversing twice should return original + var reversed = list.Reverse().Reverse().ToList(); + await Assert.That(reversed).IsEquivalentTo(list); + + // If this fails, the attribute automatically creates shrink variants +} +``` + +**Why this pattern is better:** +- **Separation of concerns**: Test logic stays clean, shrinking is in the attribute +- **Reusable**: Apply `[ShrinkOnFailure]` to any test with numeric inputs +- **Declarative**: Intent is clear from the attribute +- **Automatic**: No try-catch or manual failure detection needed + +### 2. Mutation Testing + +Create variants that test your test's ability to catch bugs by introducing controlled mutations: + +```csharp +[Test] +[Arguments(5, 10)] +public async Task CalculatorTest_Addition(int a, int b) +{ + var context = TestContext.Current!; + var calculator = new Calculator(); + + var result = calculator.Add(a, b); + await Assert.That(result).IsEqualTo(a + b); + + // After test passes, create mutants to verify test quality + var mutations = new[] + { + (a + 1, b, "Mutant: Boundary +1 on first arg"), + (a, b + 1, "Mutant: Boundary +1 on second arg"), + (a - 1, b, "Mutant: Boundary -1 on first arg"), + (0, 0, "Mutant: Zero case") + }; + + foreach (var (mutA, mutB, name) in mutations) + { + await context.CreateTestVariant( + arguments: new object[] { mutA, mutB }, + relationship: TestRelationship.Derived, + displayName: name + ); + } +} +``` + +### 3. Adaptive Stress Testing + +Progressively increase load based on system performance: + +```csharp +[Test] +[Arguments(10)] // Start with low load +public async Task LoadTest_ApiEndpoint(int concurrentUsers) +{ + var context = TestContext.Current!; + var stopwatch = Stopwatch.StartNew(); + + // Simulate load + var tasks = Enumerable.Range(0, concurrentUsers) + .Select(_ => CallApiAsync()) + .ToArray(); + + await Task.WhenAll(tasks); + stopwatch.Stop(); + + var avgResponseTime = stopwatch.ElapsedMilliseconds / (double)concurrentUsers; + context.WriteLine($"Users: {concurrentUsers}, Avg response: {avgResponseTime}ms"); + + // If system handled load well, increase it + if (avgResponseTime < 200 && concurrentUsers < 1000) + { + var nextLoad = concurrentUsers * 2; + await context.CreateTestVariant( + arguments: new object[] { nextLoad }, + properties: new Dictionary + { + { "PreviousLoad", concurrentUsers }, + { "PreviousAvgResponseTime", avgResponseTime } + }, + relationship: TestRelationship.Derived, + displayName: $"Load Test ({nextLoad} users)" + ); + } + + await Assert.That(avgResponseTime).IsLessThan(500); +} +``` + +### 4. Exploratory Fuzzing + +Generate additional test cases when edge cases are discovered: + +```csharp +[Test] +[Arguments("normal text")] +public async Task InputValidation_SpecialCharacters(string input) +{ + var context = TestContext.Current!; + var validator = new InputValidator(); + + var result = validator.Validate(input); + await Assert.That(result.IsValid).IsTrue(); + + // If we haven't tested special characters yet, generate variants + if (!context.ObjectBag.ContainsKey("TestedSpecialChars")) + { + context.ObjectBag["TestedSpecialChars"] = true; + + var specialInputs = new[] + { + "", + "'; DROP TABLE users; --", + "../../../etc/passwd", + "\0\0\0null bytes\0", + new string('A', 10000) // Buffer overflow attempt + }; + + foreach (var specialInput in specialInputs) + { + await context.CreateTestVariant( + arguments: new object[] { specialInput }, + relationship: TestRelationship.Derived, + displayName: $"Fuzz: {specialInput.Substring(0, Math.Min(30, specialInput.Length))}" + ); + } + } +} +``` + +### 5. Smart Retry with Parameter Adjustment + +Retry failed tests with adjusted parameters to differentiate transient failures from persistent bugs: + +```csharp +[Test] +[Arguments(TimeSpan.FromSeconds(5))] +public async Task ExternalService_WithTimeout(TimeSpan timeout) +{ + var context = TestContext.Current!; + + try + { + using var cts = new CancellationTokenSource(timeout); + var result = await _externalService.FetchDataAsync(cts.Token); + await Assert.That(result).IsNotNull(); + } + catch (TimeoutException ex) + { + // If timeout, try with longer timeout to see if it's a transient issue + if (timeout < TimeSpan.FromSeconds(30)) + { + var longerTimeout = timeout.Add(TimeSpan.FromSeconds(5)); + + await context.CreateTestVariant( + arguments: new object[] { longerTimeout }, + properties: new Dictionary + { + { "OriginalTimeout", timeout }, + { "RetryReason", "Timeout" } + }, + relationship: TestRelationship.Derived, + displayName: $"Retry with {longerTimeout.TotalSeconds}s timeout" + ); + } + + throw; + } +} +``` + +### 6. Chaos Engineering + +Inject faults and verify system resilience: + +```csharp +[Test] +public async Task Resilience_DatabaseFailover() +{ + var context = TestContext.Current!; + var system = new DistributedSystem(); + + // Normal operation test + var result = await system.ProcessRequestAsync(); + await Assert.That(result.Success).IsTrue(); + + // Create chaos variants + var chaosScenarios = new[] + { + ("primary-db-down", "Primary DB Failure"), + ("network-latency-500ms", "High Network Latency"), + ("replica-lag-10s", "Replica Lag"), + ("cascading-failure", "Cascading Failure") + }; + + foreach (var (faultType, displayName) in chaosScenarios) + { + await context.CreateTestVariant( + arguments: new object[] { faultType }, + properties: new Dictionary + { + { "ChaosType", faultType }, + { "InjectionPoint", "AfterSuccess" } + }, + relationship: TestRelationship.Derived, + displayName: $"Chaos: {displayName}" + ); + } +} +``` + +## API Reference + +### Method Signature + +```csharp +public static async Task CreateTestVariant( + this TestContext context, + object?[]? arguments = null, + Dictionary? properties = null, + TestRelationship relationship = TestRelationship.Derived, + string? displayName = null) +``` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `context` | `TestContext` | Yes | - | The current test context | +| `arguments` | `object?[]?` | No | `null` | Method arguments for the variant. If `null`, reuses parent's arguments | +| `properties` | `Dictionary?` | No | `null` | Custom metadata stored in the variant's `TestContext.ObjectBag` | +| `relationship` | `TestRelationship` | No | `Derived` | Categorizes the variant's relationship to its parent | +| `displayName` | `string?` | No | `null` | User-facing label shown in test explorers. If `null`, uses default format | + +### Return Value + +Returns `Task` that completes when the variant has been queued for execution. + +### Exceptions + +- `InvalidOperationException`: Thrown if `TestContext.Current` is null +- `InvalidOperationException`: Thrown if the test method cannot be resolved + +## Best Practices + +### 1. Choose Appropriate TestRelationship + +```csharp +// ✅ Good: Derived for post-execution analysis +await context.CreateTestVariant( + arguments: [smallerInput], + relationship: TestRelationship.Derived, + displayName: "Shrink Attempt" +); + +// ❌ Bad: Using None loses parent relationship +await context.CreateTestVariant( + arguments: [smallerInput], + relationship: TestRelationship.None // Parent link lost! +); +``` + +### 2. Provide Descriptive Display Names + +```csharp +// ✅ Good: Clear, specific, actionable +displayName: "Shrink #3 (Binary Search, size=125)" + +// ⚠️ Okay: Somewhat clear +displayName: "Shrink Attempt 3" + +// ❌ Bad: Vague, unhelpful +displayName: "Variant" +``` + +### 3. Avoid Infinite Recursion + +```csharp +[Test] +public async Task RecursiveVariant() +{ + var context = TestContext.Current!; + + // ✅ Good: Check depth + var depth = context.ObjectBag.TryGetValue("Depth", out var d) ? (int)d : 0; + if (depth < 5) + { + await context.CreateTestVariant( + properties: new Dictionary { { "Depth", depth + 1 } }, + relationship: TestRelationship.Derived + ); + } + + // ❌ Bad: Infinite loop! + // await context.CreateTestVariant(relationship: TestRelationship.Derived); +} +``` + +### 4. Use Properties for Metadata + +```csharp +// ✅ Good: Structured metadata +properties: new Dictionary +{ + { "AttemptNumber", 3 }, + { "Strategy", "BinarySearch" }, + { "OriginalValue", largeInput }, + { "Timestamp", DateTime.UtcNow } +} + +// ❌ Bad: Encoding metadata in displayName +displayName: "Attempt=3,Strategy=Binary,Original=1000,Time=2024-01-01" +``` + +### 5. Consider Performance + +Creating many variants has overhead. Be strategic: + +```csharp +// ✅ Good: Limited, strategic variants +if (shouldShrink && attemptCount < 10) +{ + await context.CreateTestVariant(...); +} + +// ❌ Bad: Explosion of variants +for (int i = 0; i < 10000; i++) // Creates 10,000 tests! +{ + await context.CreateTestVariant(...); +} +``` + +## Limitations + +- **Not AOT Compatible**: Test variants require runtime reflection and expression compilation +- **Requires Reflection Mode**: Must run with reflection-based discovery (not source-generated) +- **Performance Overhead**: Each variant is a full test execution with its own lifecycle +- **No Source Generator Support**: Cannot be used in AOT-compiled scenarios + +## See Also + +- [Test Context](../test-lifecycle/test-context.md) - Understanding TestContext and ObjectBag +- [Dynamic Tests](../experimental/dynamic-tests.md) - Pre-execution test generation +- [Retrying](../execution/retrying.md) - Built-in retry mechanism comparison +- [Properties](../test-lifecycle/properties.md) - Test metadata and custom properties +- [Event Subscribing](../test-lifecycle/event-subscribing.md) - Test lifecycle event receivers diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 04ae12559b..7133a2bd13 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -153,6 +153,7 @@ const sidebars: SidebarsConfig = { items: [ 'advanced/exception-handling', 'advanced/extension-points', + 'advanced/test-variants', 'advanced/performance-best-practices', ], },