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
5 changes: 5 additions & 0 deletions TUnit.Core/TestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,11 @@ public ContextScope MakeCurrent()
/// <returns>The matching <see cref="TestContext"/>, or <c>null</c>.</returns>
public static TestContext? GetById(string id) => _testContextsById.GetValueOrDefault(id);

/// <summary>
/// Removes a test context from the static registry. Called when test execution completes.
/// </summary>
internal static void RemoveById(string id) => _testContextsById.TryRemove(id, out _);

/// <summary>
/// Gets the dictionary of test parameters indexed by parameter name.
/// </summary>
Expand Down
33 changes: 20 additions & 13 deletions TUnit.Engine/Models/ConstraintKeysCollection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,19 @@ public bool Equals(ConstraintKeysCollection? other)
return false;
}

return _constraintKeys.Intersect(other._constraintKeys).Any();
// Constraint key lists are typically 1-2 items; nested loop beats HashSet allocation
foreach (var key in _constraintKeys)
{
foreach (var otherKey in other._constraintKeys)
{
if (StringComparer.Ordinal.Equals(key, otherKey))
{
return true;
}
}
}

return false;
}

public int CompareTo(ConstraintKeysCollection? other)
Expand Down Expand Up @@ -62,7 +74,10 @@ public override bool Equals(object? obj)

public override int GetHashCode()
{
return 1;
// Intentionally constant: Equals uses intersection semantics (shares any key),
// so two "equal" collections can have entirely different key sets.
// A content-based hash would violate the hash/equals contract.
return 0;
}

public int CompareTo(object? obj)
Expand Down Expand Up @@ -93,23 +108,15 @@ public bool Equals(ConstraintKeysCollection? x, ConstraintKeysCollection? y)
return true;
}

if (x == null)
if (x == null || y == null)
{
return false;
}

if (y == null)
{
return false;
}

return x._constraintKeys.Intersect(y._constraintKeys).Any();
return x.Equals(y);
}

public int GetHashCode(ConstraintKeysCollection obj)
{
return 1;
}
public int GetHashCode(ConstraintKeysCollection obj) => obj.GetHashCode();
}

public static IEqualityComparer<ConstraintKeysCollection> ConstraintKeysCollectionComparer { get; } = new ConstraintKeysCollectionEqualityComparer();
Expand Down
119 changes: 64 additions & 55 deletions TUnit.Engine/Reporters/GitHubReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,23 @@ public async Task<bool> IsEnabledAsync()

public string Description => extension.Description;

private readonly ConcurrentDictionary<string, List<TestNodeUpdateMessage>> _updates = [];
// Counts terminal state transitions per test UID (for flaky detection).
private readonly ConcurrentDictionary<string, int> _terminalStateCounts = [];
private readonly ConcurrentDictionary<string, TestNodeUpdateMessage> _latestUpdates = [];

public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken)
{
var testNodeUpdateMessage = (TestNodeUpdateMessage) value;

_updates.GetOrAdd(testNodeUpdateMessage.TestNode.Uid.Value, []).Add(testNodeUpdateMessage);
var uid = testNodeUpdateMessage.TestNode.Uid.Value;

var state = testNodeUpdateMessage.TestNode.Properties.OfType<TestNodeStateProperty>().FirstOrDefault();
if (state is not null and not InProgressTestNodeStateProperty and not DiscoveredTestNodeStateProperty)
{
_terminalStateCounts.AddOrUpdate(uid, 1, static (_, count) => count + 1);
}

_latestUpdates[uid] = testNodeUpdateMessage;

return Task.CompletedTask;
}
Expand All @@ -93,7 +103,7 @@ public Task BeforeRunAsync(CancellationToken cancellationToken)

public Task AfterRunAsync(int exitCode, CancellationToken cancellation)
{
if (_updates.Count is 0)
if (_latestUpdates.IsEmpty)
{
return Task.CompletedTask;
}
Expand All @@ -104,42 +114,51 @@ public Task AfterRunAsync(int exitCode, CancellationToken cancellation)
?.FrameworkDisplayName
?? RuntimeInformation.FrameworkDescription;

var last = new Dictionary<string, TestNodeUpdateMessage>(_updates.Count);
foreach (var kvp in _updates)
var last = new Dictionary<string, TestNodeUpdateMessage>(_latestUpdates.Count);
foreach (var kvp in _latestUpdates)
{
if (kvp.Value.Count > 0)
{
last[kvp.Key] = kvp.Value[kvp.Value.Count - 1];
}
last[kvp.Key] = kvp.Value;
}

var passedCount = last.Count(x =>
x.Value.TestNode.Properties.AsEnumerable().Any(p => p is PassedTestNodeStateProperty));

var failed = last.Where(x =>
x.Value.TestNode.Properties.AsEnumerable()
.Any(p => p is FailedTestNodeStateProperty or ErrorTestNodeStateProperty)).ToArray();
var passedCount = 0;
var failed = new List<KeyValuePair<string, TestNodeUpdateMessage>>();
var cancelled = new List<KeyValuePair<string, TestNodeUpdateMessage>>();
var timeout = new List<KeyValuePair<string, TestNodeUpdateMessage>>();
var skipped = new List<KeyValuePair<string, TestNodeUpdateMessage>>();
var inProgress = new List<KeyValuePair<string, TestNodeUpdateMessage>>();

#pragma warning disable CS0618 // CancelledTestNodeStateProperty is obsolete
var cancelled = last.Where(x =>
x.Value.TestNode.Properties.AsEnumerable().Any(p => p is CancelledTestNodeStateProperty)).ToArray();
foreach (var kvp in last)
{
var state = kvp.Value.TestNode.Properties.OfType<TestNodeStateProperty>().FirstOrDefault();
switch (state)
{
case PassedTestNodeStateProperty:
passedCount++;
break;
case FailedTestNodeStateProperty or ErrorTestNodeStateProperty:
failed.Add(kvp);
break;
case TimeoutTestNodeStateProperty:
timeout.Add(kvp);
break;
case SkippedTestNodeStateProperty:
skipped.Add(kvp);
break;
case InProgressTestNodeStateProperty:
inProgress.Add(kvp);
break;
#pragma warning disable CS0618
case CancelledTestNodeStateProperty:
#pragma warning restore CS0618

var timeout = last
.Where(x => x.Value.TestNode.Properties.AsEnumerable().Any(p => p is TimeoutTestNodeStateProperty))
.ToArray();

var skipped = last
.Where(x => x.Value.TestNode.Properties.AsEnumerable().Any(p => p is SkippedTestNodeStateProperty))
.ToArray();

var inProgress = last.Where(x =>
x.Value.TestNode.Properties.AsEnumerable().Any(p => p is InProgressTestNodeStateProperty)).ToArray();
cancelled.Add(kvp);
break;
}
}

_runStopwatch?.Stop();
var elapsed = _runStopwatch?.Elapsed;

var hasFailures = failed.Length > 0 || timeout.Length > 0 || cancelled.Length > 0;
var hasFailures = failed.Count > 0 || timeout.Count > 0 || cancelled.Count > 0;
var statusEmoji = hasFailures ? "\u274C" : "\u2705";

var stringBuilder = new StringBuilder();
Expand Down Expand Up @@ -172,56 +191,46 @@ public Task AfterRunAsync(int exitCode, CancellationToken cancellation)
{
var segments = new List<string> { $"\u2705 {passedCount} passed" };

if (failed.Length > 0)
if (failed.Count > 0)
{
segments.Add($"\u274C {failed.Length} failed");
segments.Add($"\u274C {failed.Count} failed");
}

if (skipped.Length > 0)
if (skipped.Count > 0)
{
segments.Add($"\u23ED\uFE0F {skipped.Length} skipped");
segments.Add($"\u23ED\uFE0F {skipped.Count} skipped");
}

if (timeout.Length > 0)
if (timeout.Count > 0)
{
segments.Add($"\u23F1\uFE0F {timeout.Length} timed out");
segments.Add($"\u23F1\uFE0F {timeout.Count} timed out");
}

if (cancelled.Length > 0)
if (cancelled.Count > 0)
{
segments.Add($"\uD83D\uDEAB {cancelled.Length} cancelled");
segments.Add($"\uD83D\uDEAB {cancelled.Count} cancelled");
}

if (inProgress.Length > 0)
if (inProgress.Count > 0)
{
segments.Add($"\u26A0\uFE0F {inProgress.Length} in progress");
segments.Add($"\u26A0\uFE0F {inProgress.Count} in progress");
}

stringBuilder.AppendLine(string.Join(" \u00B7 ", segments));
}

// Detect flaky tests (passed after retry)
var flakyTests = new List<(string Name, int Attempts, TimeSpan? Duration)>();
foreach (var kvp in _updates)
foreach (var kvp in _terminalStateCounts)
{
var finalStateCount = 0;
foreach (var update in kvp.Value)
{
var state = update.TestNode.Properties.SingleOrDefault<TestNodeStateProperty>();
if (state is not null and not InProgressTestNodeStateProperty and not DiscoveredTestNodeStateProperty)
{
finalStateCount++;
}
}

if (finalStateCount > 1 && last.TryGetValue(kvp.Key, out var lastUpdate))
if (kvp.Value > 1 && last.TryGetValue(kvp.Key, out var lastUpdate))
{
var props = lastUpdate.TestNode.Properties.AsEnumerable();
if (props.Any(p => p is PassedTestNodeStateProperty))
{
var name = GetTestDisplayName(lastUpdate.TestNode);
var timing = props.OfType<TimingProperty>().FirstOrDefault();
flakyTests.Add((name, finalStateCount, timing?.GlobalTiming.Duration));
flakyTests.Add((name, kvp.Value, timing?.GlobalTiming.Duration));
}
}
}
Expand All @@ -236,7 +245,7 @@ public Task AfterRunAsync(int exitCode, CancellationToken cancellation)
}
}

if (skipped.Length > 0)
if (skipped.Count > 0)
{
var skipGroups = skipped
.Select(x => x.Value.TestNode.Properties.AsEnumerable()
Expand All @@ -246,7 +255,7 @@ public Task AfterRunAsync(int exitCode, CancellationToken cancellation)

stringBuilder.AppendLine();
stringBuilder.AppendLine("<details>");
stringBuilder.AppendLine($"<summary>\u23ed\ufe0f {skipped.Length} skipped {(skipped.Length == 1 ? "test" : "tests")}</summary>");
stringBuilder.AppendLine($"<summary>\u23ed\ufe0f {skipped.Count} skipped {(skipped.Count == 1 ? "test" : "tests")}</summary>");
stringBuilder.AppendLine();
foreach (var group in skipGroups)
{
Expand Down
12 changes: 6 additions & 6 deletions TUnit.Engine/Reporters/JUnitReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,13 @@ public async Task<bool> IsEnabledAsync()

public string Description => extension.Description;

private readonly ConcurrentDictionary<string, List<TestNodeUpdateMessage>> _updates = [];
private readonly ConcurrentDictionary<string, TestNodeUpdateMessage> _latestUpdates = [];

public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken)
{
var testNodeUpdateMessage = (TestNodeUpdateMessage)value;

_updates.GetOrAdd(testNodeUpdateMessage.TestNode.Uid.Value, []).Add(testNodeUpdateMessage);
_latestUpdates[testNodeUpdateMessage.TestNode.Uid.Value] = testNodeUpdateMessage;

return Task.CompletedTask;
}
Expand All @@ -68,16 +68,16 @@ public Task BeforeRunAsync(CancellationToken cancellationToken)

public async Task AfterRunAsync(int exitCode, CancellationToken cancellation)
{
if (!_isEnabled || _updates.Count == 0)
if (!_isEnabled || _latestUpdates.IsEmpty)
{
return;
}

// Get the last update for each test
var lastUpdates = new List<TestNodeUpdateMessage>(_updates.Count);
foreach (var kvp in _updates.Where(kvp => kvp.Value.Count > 0))
var lastUpdates = new List<TestNodeUpdateMessage>(_latestUpdates.Count);
foreach (var kvp in _latestUpdates)
{
lastUpdates.Add(kvp.Value[kvp.Value.Count - 1]);
lastUpdates.Add(kvp.Value);
}

// Generate JUnit XML
Expand Down
13 changes: 9 additions & 4 deletions TUnit.Engine/Scheduling/TestScheduler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -176,12 +176,17 @@ private async Task ExecuteGroupedTestsAsync(

foreach (var group in groupedTests.ParallelGroups)
{
var orderedTests = new List<AbstractExecutableTest>();
foreach (var kvp in group.Value.OrderBy(t => t.Key))
var totalCount = 0;
foreach (var list in group.Value.Values) totalCount += list.Count;
var orderedTestsArray = new AbstractExecutableTest[totalCount];
var idx = 0;
foreach (var list in group.Value.Values)
{
orderedTests.AddRange(kvp.Value);
foreach (var t in list)
{
orderedTestsArray[idx++] = t;
}
}
var orderedTestsArray = orderedTests.ToArray();

if (_logger.IsTraceEnabled)
await _logger.LogTraceAsync($"Starting parallel group '{group.Key}' with {orderedTestsArray.Length} orders").ConfigureAwait(false);
Expand Down
Loading
Loading