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
1 change: 1 addition & 0 deletions TUnit.Core/Data/GetOnlyDictionary.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Threading;

namespace TUnit.Core.Data;

Expand Down
57 changes: 57 additions & 0 deletions TUnit.Engine.Tests/FirstEventReceiversRegressionTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.Collections.Concurrent;
using Shouldly;
using TUnit.Core;
using TUnit.Core.Interfaces;

namespace TUnit.Engine.Tests;

/// <summary>
/// Test to validate that IFirstTestInAssemblyEventReceiver and IFirstTestInClassEventReceiver
/// are called exactly once per scope, addressing the regression reported in issue #2916.
/// </summary>
public class FirstEventReceiversRegressionTest : IFirstTestInAssemblyEventReceiver, IFirstTestInClassEventReceiver
{
private static readonly ConcurrentBag<string> _assemblyEvents = new();
private static readonly ConcurrentBag<string> _classEvents = new();

public int Order => 0;

ValueTask IFirstTestInAssemblyEventReceiver.OnFirstTestInAssembly(AssemblyHookContext context, TestContext testContext)
{
var assemblyName = context.Assembly.GetName().FullName ?? "Unknown";
var eventInfo = $"Assembly: {assemblyName}";
_assemblyEvents.Add(eventInfo);
return ValueTask.CompletedTask;
}

ValueTask IFirstTestInClassEventReceiver.OnFirstTestInClass(ClassHookContext context, TestContext testContext)
{
var className = context.ClassType.FullName ?? "Unknown";
var eventInfo = $"Class: {className}";
_classEvents.Add(eventInfo);
return ValueTask.CompletedTask;
}

[Test]
public void EventReceiversCalledOncePerScope()
{
// This test validates that the fix is working correctly
// The events should be called exactly once per assembly and once per test class

var assemblyEventCount = _assemblyEvents.Count;
var classEventCount = _classEvents.Count;

// We expect exactly one assembly event for this test assembly
assemblyEventCount.ShouldBe(1);

// We expect exactly one class event for this test class
classEventCount.ShouldBe(1);

// Verify the content makes sense
var assemblyEvent = _assemblyEvents.Single();
assemblyEvent.ShouldContain("TUnit.Engine.Tests");

var classEvent = _classEvents.Single();
classEvent.ShouldContain("FirstEventReceiversRegressionTest");
}
}
42 changes: 24 additions & 18 deletions TUnit.Engine/Services/EventReceiverOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Diagnostics;
using System.Runtime.CompilerServices;
using TUnit.Core;
using TUnit.Core.Data;
using TUnit.Core.Interfaces;
using TUnit.Engine.Events;
using TUnit.Engine.Extensions;
Expand All @@ -17,9 +18,9 @@ internal sealed class EventReceiverOrchestrator : IDisposable
private readonly TUnitFrameworkLogger _logger;

// Track which assemblies/classes/sessions have had their "first" event invoked
private readonly ConcurrentDictionary<string, bool> _firstTestInAssemblyInvoked = new();
private readonly ConcurrentDictionary<Type, bool> _firstTestInClassInvoked = new();
private int _firstTestInSessionInvoked;
private GetOnlyDictionary<string, Task> _firstTestInAssemblyTasks = new();
private GetOnlyDictionary<Type, Task> _firstTestInClassTasks = new();
private GetOnlyDictionary<string, Task> _firstTestInSessionTasks = new();

// Track remaining test counts for "last" events
private readonly ConcurrentDictionary<string, int> _assemblyTestCounts = new();
Expand Down Expand Up @@ -240,13 +241,13 @@ public async ValueTask InvokeFirstTestInSessionEventReceiversAsync(
return;
}

if (Interlocked.CompareExchange(ref _firstTestInSessionInvoked, 1, 0) == 0)
{
await InvokeFirstTestInSessionEventReceiversCore(context, sessionContext, cancellationToken);
}
// Use GetOrAdd to ensure exactly one task is created per session and all tests await it
var task = _firstTestInSessionTasks.GetOrAdd("session",
_ => InvokeFirstTestInSessionEventReceiversCoreAsync(context, sessionContext, cancellationToken));
await task;
}

private async ValueTask InvokeFirstTestInSessionEventReceiversCore(
private async Task InvokeFirstTestInSessionEventReceiversCoreAsync(
TestContext context,
TestSessionContext sessionContext,
CancellationToken cancellationToken)
Expand Down Expand Up @@ -278,13 +279,13 @@ public async ValueTask InvokeFirstTestInAssemblyEventReceiversAsync(
}

var assemblyName = assemblyContext.Assembly.GetName().FullName ?? "";
if (_firstTestInAssemblyInvoked.TryAdd(assemblyName, true))
{
await InvokeFirstTestInAssemblyEventReceiversCore(context, assemblyContext, cancellationToken);
}
// Use GetOrAdd to ensure exactly one task is created per assembly and all tests await it
var task = _firstTestInAssemblyTasks.GetOrAdd(assemblyName,
_ => InvokeFirstTestInAssemblyEventReceiversCoreAsync(context, assemblyContext, cancellationToken));
await task;
}

private async ValueTask InvokeFirstTestInAssemblyEventReceiversCore(
private async Task InvokeFirstTestInAssemblyEventReceiversCoreAsync(
TestContext context,
AssemblyHookContext assemblyContext,
CancellationToken cancellationToken)
Expand Down Expand Up @@ -316,13 +317,13 @@ public async ValueTask InvokeFirstTestInClassEventReceiversAsync(
}

var classType = classContext.ClassType;
if (_firstTestInClassInvoked.TryAdd(classType, true))
{
await InvokeFirstTestInClassEventReceiversCore(context, classContext, cancellationToken);
}
// Use GetOrAdd to ensure exactly one task is created per class and all tests await it
var task = _firstTestInClassTasks.GetOrAdd(classType,
_ => InvokeFirstTestInClassEventReceiversCoreAsync(context, classContext, cancellationToken));
await task;
}

private async ValueTask InvokeFirstTestInClassEventReceiversCore(
private async Task InvokeFirstTestInClassEventReceiversCoreAsync(
TestContext context,
ClassHookContext classContext,
CancellationToken cancellationToken)
Expand Down Expand Up @@ -480,6 +481,11 @@ public void InitializeTestCounts(IEnumerable<TestContext> allTestContexts)
var contexts = allTestContexts.ToList();
_sessionTestCount = contexts.Count;

// Clear first-event tracking to ensure clean state for each test execution
_firstTestInAssemblyTasks = new GetOnlyDictionary<string, Task>();
_firstTestInClassTasks = new GetOnlyDictionary<Type, Task>();
_firstTestInSessionTasks = new GetOnlyDictionary<string, Task>();

foreach (var group in contexts.Where(c => c.ClassContext != null).GroupBy(c => c.ClassContext!.AssemblyContext.Assembly.GetName().FullName))
{
if (group.Key != null)
Expand Down
106 changes: 106 additions & 0 deletions TUnit.TestProject/FirstEventTrackerTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using System.Collections.Concurrent;
using TUnit.Core;
using TUnit.Core.Interfaces;

namespace TUnit.TestProject;

public class FirstEventTracker : IFirstTestInAssemblyEventReceiver, IFirstTestInClassEventReceiver
{
public static readonly ConcurrentBag<(string EventType, string Assembly, string Class, string Test, DateTime Timestamp)> Events = new();

public int Order => 0;

public ValueTask OnFirstTestInAssembly(AssemblyHookContext context, TestContext testContext)
{
var assembly = context.Assembly.GetName().FullName ?? "Unknown";
var className = testContext.TestDetails.ClassType.FullName ?? "Unknown";
var testName = testContext.TestDetails.TestName;

Events.Add(("FirstAssembly", assembly, className, testName, DateTime.UtcNow));
Console.WriteLine($"[{DateTime.UtcNow:HH:mm:ss.fff}] FirstTestInAssembly: {assembly} - {className}.{testName}");
return default;
}

public ValueTask OnFirstTestInClass(ClassHookContext context, TestContext testContext)
{
var assembly = context.AssemblyContext.Assembly.GetName().FullName ?? "Unknown";
var className = context.ClassType.FullName ?? "Unknown";
var testName = testContext.TestDetails.TestName;

Events.Add(("FirstClass", assembly, className, testName, DateTime.UtcNow));
Console.WriteLine($"[{DateTime.UtcNow:HH:mm:ss.fff}] FirstTestInClass: {className} - {testName}");
return default;
}
}

public class TestClassA
{
[Test]
public Task TestA1()
{
Console.WriteLine($"[{DateTime.UtcNow:HH:mm:ss.fff}] TestClassA.TestA1");
return Task.CompletedTask;
}

[Test]
public Task TestA2()
{
Console.WriteLine($"[{DateTime.UtcNow:HH:mm:ss.fff}] TestClassA.TestA2");
return Task.CompletedTask;
}
}

public class TestClassB
{
[Test]
public Task TestB1()
{
Console.WriteLine($"[{DateTime.UtcNow:HH:mm:ss.fff}] TestClassB.TestB1");
return Task.CompletedTask;
}

[Test]
public Task TestB2()
{
Console.WriteLine($"[{DateTime.UtcNow:HH:mm:ss.fff}] TestClassB.TestB2");
return Task.CompletedTask;
}
}

public class EventValidationTest
{
[Test]
public void ValidateEventCallCounts()
{
var events = FirstEventTracker.Events.ToList();

Console.WriteLine($"\nTotal events recorded: {events.Count}");
foreach (var evt in events.OrderBy(e => e.Timestamp))
{
Console.WriteLine($" {evt.EventType}: {evt.Assembly} - {evt.Class}.{evt.Test} at {evt.Timestamp:HH:mm:ss.fff}");
}

var assemblyEvents = events.Where(e => e.EventType == "FirstAssembly").ToList();
var classEvents = events.Where(e => e.EventType == "FirstClass").ToList();

Console.WriteLine($"\nAssembly events: {assemblyEvents.Count}");
Console.WriteLine($"Class events: {classEvents.Count}");

var uniqueAssemblies = assemblyEvents.Select(e => e.Assembly).Distinct().Count();
var uniqueClasses = classEvents.Select(e => e.Class).Distinct().Count();

Console.WriteLine($"Unique assemblies: {uniqueAssemblies}");
Console.WriteLine($"Unique classes: {uniqueClasses}");

// Validate expectations
if (assemblyEvents.Count != uniqueAssemblies)
{
throw new InvalidOperationException($"Expected {uniqueAssemblies} assembly events but got {assemblyEvents.Count}");
}

if (classEvents.Count != uniqueClasses)
{
throw new InvalidOperationException($"Expected {uniqueClasses} class events but got {classEvents.Count}");
}
}
}
Loading