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/Attributes/ParallelGroupAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ public class ParallelGroupAttribute(string group) : TUnitAttribute, ITestDiscove
/// <inheritdoc />
public ValueTask OnTestDiscovered(DiscoveredTestContext context)
{
context.SetParallelConstraint(new ParallelGroupConstraint(Group, Order));
context.AddParallelConstraint(new ParallelGroupConstraint(Group, Order));
return default(ValueTask);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public NotInParallelAttribute(string[] constraintKeys)

public ValueTask OnTestDiscovered(DiscoveredTestContext context)
{
context.SetParallelConstraint(new NotInParallelConstraint(ConstraintKeys)
context.AddParallelConstraint(new NotInParallelConstraint(ConstraintKeys)
{
Order = Order
});
Expand Down
14 changes: 14 additions & 0 deletions TUnit.Core/Contexts/DiscoveredTestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,20 @@ public void SetRetryLimit(int retryCount, Func<TestContext, Exception, int, Task
TestContext.TestDetails.RetryLimit = retryCount;
}

/// <summary>
/// Adds a parallel constraint to the test context.
/// Multiple constraints can be combined (e.g., ParallelGroup + NotInParallel).
/// </summary>
public void AddParallelConstraint(IParallelConstraint constraint)
{
TestContext.AddParallelConstraint(constraint);
}

/// <summary>
/// Sets the parallel constraint, replacing any existing constraints.
/// Maintained for backward compatibility.
/// </summary>
[Obsolete("Use AddParallelConstraint to support multiple constraints. This method replaces all existing constraints.")]
public void SetParallelConstraint(IParallelConstraint constraint)
{
TestContext.ParallelConstraint = constraint;
Expand Down
40 changes: 39 additions & 1 deletion TUnit.Core/TestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,45 @@ public static string WorkingDirectory

public Func<TestContext, Exception, int, Task<bool>>? RetryFunc { get; set; }

public IParallelConstraint? ParallelConstraint { get; set; }
// New: Support multiple parallel constraints
private readonly List<IParallelConstraint> _parallelConstraints = [];

/// <summary>
/// Gets the collection of parallel constraints applied to this test.
/// Multiple constraints can be combined (e.g., ParallelGroup + NotInParallel).
/// </summary>
public IReadOnlyList<IParallelConstraint> ParallelConstraints => _parallelConstraints;

/// <summary>
/// Gets or sets the primary parallel constraint for backward compatibility.
/// When setting, this replaces all existing constraints.
/// When getting, returns the first constraint or null if none exist.
/// </summary>
[Obsolete("Use ParallelConstraints collection instead. This property is maintained for backward compatibility.")]
public IParallelConstraint? ParallelConstraint
{
get => _parallelConstraints.FirstOrDefault();
set
{
_parallelConstraints.Clear();
if (value != null)
{
_parallelConstraints.Add(value);
}
}
}

/// <summary>
/// Adds a parallel constraint to this test context.
/// Multiple constraints can be combined to create complex parallelization rules.
/// </summary>
public void AddParallelConstraint(IParallelConstraint constraint)
{
if (constraint != null)
{
_parallelConstraints.Add(constraint);
}
}

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

Expand Down
21 changes: 21 additions & 0 deletions TUnit.Engine/Models/GroupedTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,25 @@ internal record GroupedTests
// Array of groups with nested arrays for maximum iteration performance
// Tests are grouped by order, ready for parallel execution
public required Dictionary<string, SortedDictionary<int, List<AbstractExecutableTest>>> ParallelGroups { get; init; }

// New: For tests that have both ParallelGroup and keyed NotInParallel constraints
// Key is the parallel group name, value contains tests partitioned by constraints
public required Dictionary<string, GroupedConstrainedTests> ConstrainedParallelGroups { get; init; }
}

/// <summary>
/// Represents tests within a parallel group that have additional constraints
/// </summary>
internal record GroupedConstrainedTests
{
/// <summary>
/// Tests that only have ParallelGroup constraint (can run in parallel within the group)
/// </summary>
public required AbstractExecutableTest[] UnconstrainedTests { get; init; }

/// <summary>
/// Tests that have both ParallelGroup and NotInParallel constraints
/// These must respect their constraint keys even within the parallel group
/// </summary>
public required (AbstractExecutableTest Test, IReadOnlyList<string> ConstraintKeys, int Priority)[] KeyedTests { get; init; }
}
66 changes: 66 additions & 0 deletions TUnit.Engine/Scheduling/TestScheduler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,17 @@ private async Task ExecuteGroupedTestsAsync(

await ExecuteParallelGroupAsync(groupName, orderedTests, maxParallelism, cancellationToken).ConfigureAwait(false);
}

// 2b. Execute constrained parallel groups (groups with both ParallelGroup and NotInParallel)
foreach (var kvp in groupedTests.ConstrainedParallelGroups)
{
var groupName = kvp.Key;
var constrainedTests = kvp.Value;

await _logger.LogDebugAsync($"Starting constrained parallel group '{groupName}' with {constrainedTests.UnconstrainedTests.Length} unconstrained and {constrainedTests.KeyedTests.Length} keyed tests").ConfigureAwait(false);

await ExecuteConstrainedParallelGroupAsync(groupName, constrainedTests, maxParallelism, cancellationToken).ConfigureAwait(false);
}

// 3. Execute keyed NotInParallel tests using ConstraintKeyScheduler for proper coordination
if (groupedTests.KeyedNotInParallel.Length > 0)
Expand Down Expand Up @@ -226,6 +237,61 @@ private async Task ExecuteParallelGroupAsync(
await WaitForTasksWithFailFastHandling(orderTasks, cancellationToken).ConfigureAwait(false);
}
}

private async Task ExecuteConstrainedParallelGroupAsync(
string groupName,
GroupedConstrainedTests constrainedTests,
int? maxParallelism,
CancellationToken cancellationToken)
{
await _logger.LogDebugAsync($"Executing constrained parallel group '{groupName}'").ConfigureAwait(false);

// Start unconstrained tests (can run in parallel)
var unconstrainedTasks = new List<Task>();
if (constrainedTests.UnconstrainedTests.Length > 0)
{
if (maxParallelism is > 0)
{
// Respect maximum parallel tests limit for unconstrained tests
var unconstrainedTask = ExecuteParallelTestsWithLimitAsync(
constrainedTests.UnconstrainedTests,
maxParallelism.Value,
cancellationToken);
unconstrainedTasks.Add(unconstrainedTask);
}
else
{
// No limit - start all unconstrained tests at once
foreach (var test in constrainedTests.UnconstrainedTests)
{
var task = ExecuteTestWithParallelLimitAsync(test, cancellationToken);
test.ExecutionTask = task;
unconstrainedTasks.Add(task);
}
}
}

// Execute keyed tests using the constraint key scheduler
Task? keyedTask = null;
if (constrainedTests.KeyedTests.Length > 0)
{
keyedTask = _constraintKeyScheduler.ExecuteTestsWithConstraintsAsync(
constrainedTests.KeyedTests,
cancellationToken).AsTask();
}

// Wait for both unconstrained and keyed tests to complete
var allTasks = unconstrainedTasks.ToList();
if (keyedTask != null)
{
allTasks.Add(keyedTask);
}

if (allTasks.Count > 0)
{
await WaitForTasksWithFailFastHandling(allTasks.ToArray(), cancellationToken).ConfigureAwait(false);
}
}

private async Task ExecuteSequentiallyAsync(
string groupName,
Expand Down
94 changes: 78 additions & 16 deletions TUnit.Engine/Services/TestGroupingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,31 +19,42 @@ public ValueTask<GroupedTests> GroupTestsByConstraintsAsync(IEnumerable<Abstract
var orderedTests = tests
.OrderByDescending(t => t.Context.ExecutionPriority)
.ThenBy(x => x.Context.ClassContext?.ClassType?.FullName ?? string.Empty)
.ThenBy(t => (t.Context.ParallelConstraint as NotInParallelConstraint)?.Order ?? int.MaxValue);
.ThenBy(t => t.Context.ParallelConstraints.OfType<NotInParallelConstraint>().FirstOrDefault()?.Order ?? int.MaxValue);

var notInParallelList = new List<(AbstractExecutableTest Test, TestPriority Priority)>();
var keyedNotInParallelList = new List<(AbstractExecutableTest Test, IReadOnlyList<string> ConstraintKeys, TestPriority Priority)>();
var parallelTests = new List<AbstractExecutableTest>();
var parallelGroups = new Dictionary<string, SortedDictionary<int, List<AbstractExecutableTest>>>();
var constrainedParallelGroups = new Dictionary<string, (List<AbstractExecutableTest> Unconstrained, List<(AbstractExecutableTest, IReadOnlyList<string>, TestPriority)> Keyed)>();

// Process each class group sequentially to maintain class ordering for NotInParallel tests
foreach (var test in orderedTests)
{
var constraint = test.Context.ParallelConstraint;

switch (constraint)
var constraints = test.Context.ParallelConstraints;

// Handle tests with multiple constraints
var parallelGroup = constraints.OfType<ParallelGroupConstraint>().FirstOrDefault();
var notInParallel = constraints.OfType<NotInParallelConstraint>().FirstOrDefault();

if (parallelGroup != null && notInParallel != null)
{
case NotInParallelConstraint notInParallel:
ProcessNotInParallelConstraint(test, notInParallel, notInParallelList, keyedNotInParallelList);
break;

case ParallelGroupConstraint parallelGroup:
ProcessParallelGroupConstraint(test, parallelGroup, parallelGroups);
break;

default:
parallelTests.Add(test);
break;
// Test has both ParallelGroup and NotInParallel constraints
ProcessCombinedConstraints(test, parallelGroup, notInParallel, constrainedParallelGroups);
}
else if (parallelGroup != null)
{
// Only ParallelGroup constraint
ProcessParallelGroupConstraint(test, parallelGroup, parallelGroups);
}
else if (notInParallel != null)
{
// Only NotInParallel constraint
ProcessNotInParallelConstraint(test, notInParallel, notInParallelList, keyedNotInParallelList);
}
else
{
// No constraints - can run in parallel
parallelTests.Add(test);
}
}

Expand All @@ -64,12 +75,35 @@ public ValueTask<GroupedTests> GroupTestsByConstraintsAsync(IEnumerable<Abstract
.Select(t => (t.Test, t.ConstraintKeys, t.Priority.GetHashCode()))
.ToArray();

// Convert constrained parallel groups to the final format
var finalConstrainedGroups = new Dictionary<string, GroupedConstrainedTests>();
foreach (var kvp in constrainedParallelGroups)
{
var groupName = kvp.Key;
var unconstrained = kvp.Value.Unconstrained;
var keyed = kvp.Value.Keyed;

var sortedKeyed = keyed
.OrderBy(t => t.Item1.Context.ClassContext?.ClassType?.FullName ?? string.Empty)
.ThenByDescending(t => t.Item3.Priority)
.ThenBy(t => t.Item3.Order)
.Select(t => (t.Item1, t.Item2, t.Item3.GetHashCode()))
.ToArray();

finalConstrainedGroups[groupName] = new GroupedConstrainedTests
{
UnconstrainedTests = unconstrained.ToArray(),
KeyedTests = sortedKeyed
};
}

var result = new GroupedTests
{
Parallel = parallelTests.ToArray(),
NotInParallel = sortedNotInParallel,
KeyedNotInParallel = keyedArrays,
ParallelGroups = parallelGroups
ParallelGroups = parallelGroups,
ConstrainedParallelGroups = finalConstrainedGroups
};

return new ValueTask<GroupedTests>(result);
Expand Down Expand Up @@ -115,4 +149,32 @@ private static void ProcessParallelGroupConstraint(

tests.Add(test);
}

private static void ProcessCombinedConstraints(
AbstractExecutableTest test,
ParallelGroupConstraint parallelGroup,
NotInParallelConstraint notInParallel,
Dictionary<string, (List<AbstractExecutableTest> Unconstrained, List<(AbstractExecutableTest, IReadOnlyList<string>, TestPriority)> Keyed)> constrainedGroups)
{
if (!constrainedGroups.TryGetValue(parallelGroup.Group, out var group))
{
group = (new List<AbstractExecutableTest>(), new List<(AbstractExecutableTest, IReadOnlyList<string>, TestPriority)>());
constrainedGroups[parallelGroup.Group] = group;
}

// Add to keyed tests within the parallel group
var order = notInParallel.Order;
var priority = test.Context.ExecutionPriority;
var testPriority = new TestPriority(priority, order);

if (notInParallel.NotInParallelConstraintKeys.Count > 0)
{
group.Keyed.Add((test, notInParallel.NotInParallelConstraintKeys, testPriority));
}
else
{
// NotInParallel without keys means sequential within the group
group.Keyed.Add((test, new List<string> { "__global__" }, testPriority));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -553,10 +553,13 @@ namespace
public string TestName { get; }
public void AddArgumentDisplayFormatter(.ArgumentDisplayFormatter formatter) { }
public void AddCategory(string category) { }
public void AddParallelConstraint(. constraint) { }
public void AddProperty(string key, string value) { }
public string GetDisplayName() { }
public void SetDisplayName(string displayName) { }
public void SetDisplayNameFormatter( formatterType) { }
[("Use AddParallelConstraint to support multiple constraints. This method replaces a" +
"ll existing constraints.")]
public void SetParallelConstraint(. constraint) { }
public void SetPriority(. priority) { }
public void SetRetryLimit(int retryLimit) { }
Expand Down Expand Up @@ -1246,7 +1249,10 @@ namespace
public .CancellationTokenSource? LinkedCancellationTokens { get; set; }
public object Lock { get; }
public .<string, object?> ObjectBag { get; }
[("Use ParallelConstraints collection instead. This property is maintained for backw" +
"ard compatibility.")]
public .? ParallelConstraint { get; set; }
public .<.> ParallelConstraints { get; }
public .? ParallelLimiter { get; }
public .TestPhase Phase { get; set; }
public bool ReportResult { get; set; }
Expand All @@ -1266,6 +1272,7 @@ namespace
public static string WorkingDirectory { get; set; }
public void AddArtifact(.Artifact artifact) { }
public void AddLinkedCancellationToken(.CancellationToken cancellationToken) { }
public void AddParallelConstraint(. constraint) { }
public string GetDisplayName() { }
public new string GetErrorOutput() { }
public string GetOutput() { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -553,10 +553,13 @@ namespace
public string TestName { get; }
public void AddArgumentDisplayFormatter(.ArgumentDisplayFormatter formatter) { }
public void AddCategory(string category) { }
public void AddParallelConstraint(. constraint) { }
public void AddProperty(string key, string value) { }
public string GetDisplayName() { }
public void SetDisplayName(string displayName) { }
public void SetDisplayNameFormatter( formatterType) { }
[("Use AddParallelConstraint to support multiple constraints. This method replaces a" +
"ll existing constraints.")]
public void SetParallelConstraint(. constraint) { }
public void SetPriority(. priority) { }
public void SetRetryLimit(int retryLimit) { }
Expand Down Expand Up @@ -1246,7 +1249,10 @@ namespace
public .CancellationTokenSource? LinkedCancellationTokens { get; set; }
public object Lock { get; }
public .<string, object?> ObjectBag { get; }
[("Use ParallelConstraints collection instead. This property is maintained for backw" +
"ard compatibility.")]
public .? ParallelConstraint { get; set; }
public .<.> ParallelConstraints { get; }
public .? ParallelLimiter { get; }
public .TestPhase Phase { get; set; }
public bool ReportResult { get; set; }
Expand All @@ -1266,6 +1272,7 @@ namespace
public static string WorkingDirectory { get; set; }
public void AddArtifact(.Artifact artifact) { }
public void AddLinkedCancellationToken(.CancellationToken cancellationToken) { }
public void AddParallelConstraint(. constraint) { }
public string GetDisplayName() { }
public new string GetErrorOutput() { }
public string GetOutput() { }
Expand Down
Loading
Loading