diff --git a/TUnit.Core.SourceGenerator/CodeGenerators/InfrastructureGenerator.cs b/TUnit.Core.SourceGenerator/CodeGenerators/InfrastructureGenerator.cs
index 2836281261..bb1767cc61 100644
--- a/TUnit.Core.SourceGenerator/CodeGenerators/InfrastructureGenerator.cs
+++ b/TUnit.Core.SourceGenerator/CodeGenerators/InfrastructureGenerator.cs
@@ -305,6 +305,10 @@ private static void GenerateCode(SourceProductionContext context, AssemblyInfoMo
{
var sourceBuilder = new CodeWriter();
+ // Add using directive for LogDebug extension method
+ sourceBuilder.AppendLine("using TUnit.Core.Logging;");
+ sourceBuilder.AppendLine();
+
sourceBuilder.AppendLine("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute]");
sourceBuilder.AppendLine($"[global::System.CodeDom.Compiler.GeneratedCode(\"TUnit\", \"{typeof(InfrastructureGenerator).Assembly.GetName().Version}\")]");
@@ -314,25 +318,40 @@ private static void GenerateCode(SourceProductionContext context, AssemblyInfoMo
sourceBuilder.AppendLine("[global::System.Runtime.CompilerServices.ModuleInitializer]");
using (sourceBuilder.BeginBlock("public static void Initialize()"))
{
+ // Log module initializer start (buffered until logger is ready)
+ sourceBuilder.AppendLine("global::TUnit.Core.GlobalContext.Current.GlobalLogger.LogDebug(\"[ModuleInitializer] TUnit infrastructure initializing...\");");
+ sourceBuilder.AppendLine();
+
// Disable reflection scanner for source-generated assemblies
sourceBuilder.AppendLine("global::TUnit.Core.SourceRegistrar.IsEnabled = true;");
+ sourceBuilder.AppendLine("global::TUnit.Core.GlobalContext.Current.GlobalLogger.LogDebug(\"[ModuleInitializer] Source generation mode enabled\");");
+ sourceBuilder.AppendLine();
// Reference types from assemblies to trigger their module constructors
+ if (model.TypesToReference.Length > 0)
+ {
+ sourceBuilder.AppendLine($"global::TUnit.Core.GlobalContext.Current.GlobalLogger.LogDebug(\"[ModuleInitializer] Loading {model.TypesToReference.Length} assembly reference(s)...\");");
+ }
+
foreach (var typeName in model.TypesToReference)
{
sourceBuilder.AppendLine("try");
sourceBuilder.AppendLine("{");
sourceBuilder.Indent();
+ sourceBuilder.AppendLine($"global::TUnit.Core.GlobalContext.Current.GlobalLogger.LogDebug(\"[ModuleInitializer] Loading assembly containing: {typeName.Replace("\"", "\\\"")}\");");
sourceBuilder.AppendLine($"_ = typeof({typeName});");
sourceBuilder.Unindent();
sourceBuilder.AppendLine("}");
- sourceBuilder.AppendLine("catch (global::System.Exception)");
+ sourceBuilder.AppendLine("catch (global::System.Exception ex)");
sourceBuilder.AppendLine("{");
sourceBuilder.Indent();
- sourceBuilder.AppendLine("// Type reference failed - continue");
+ sourceBuilder.AppendLine($"global::TUnit.Core.GlobalContext.Current.GlobalLogger.LogDebug(\"[ModuleInitializer] Failed to load {typeName.Replace("\"", "\\\"")}: \" + ex.Message);");
sourceBuilder.Unindent();
sourceBuilder.AppendLine("}");
}
+
+ sourceBuilder.AppendLine();
+ sourceBuilder.AppendLine("global::TUnit.Core.GlobalContext.Current.GlobalLogger.LogDebug(\"[ModuleInitializer] TUnit infrastructure initialized\");");
}
}
diff --git a/TUnit.Core/Logging/EarlyBufferLogger.cs b/TUnit.Core/Logging/EarlyBufferLogger.cs
new file mode 100644
index 0000000000..bf1a0c31ce
--- /dev/null
+++ b/TUnit.Core/Logging/EarlyBufferLogger.cs
@@ -0,0 +1,59 @@
+using System.Collections.Concurrent;
+
+namespace TUnit.Core.Logging;
+
+///
+/// Logger that buffers messages until the real logger is configured.
+/// Used as the initial GlobalLogger before TUnit infrastructure is set up.
+///
+internal sealed class EarlyBufferLogger : ILogger
+{
+ private readonly ConcurrentQueue<(LogLevel level, string message)> _buffer = new();
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public ValueTask LogAsync(LogLevel logLevel, TState state, Exception? exception, Func formatter)
+ {
+ var message = formatter(state, exception);
+ _buffer.Enqueue((logLevel, message));
+ return ValueTask.CompletedTask;
+ }
+
+ public void Log(LogLevel logLevel, TState state, Exception? exception, Func formatter)
+ {
+ var message = formatter(state, exception);
+ _buffer.Enqueue((logLevel, message));
+ }
+
+ ///
+ /// Flushes all buffered messages to the provided logger.
+ ///
+ internal void FlushTo(ILogger logger)
+ {
+ while (_buffer.TryDequeue(out var entry))
+ {
+ var (level, message) = entry;
+ switch (level)
+ {
+ case LogLevel.Trace:
+ logger.LogTrace(message);
+ break;
+ case LogLevel.Debug:
+ logger.LogDebug(message);
+ break;
+ case LogLevel.Information:
+ logger.LogInformation(message);
+ break;
+ case LogLevel.Warning:
+ logger.LogWarning(message);
+ break;
+ case LogLevel.Error:
+ logger.LogError(message);
+ break;
+ case LogLevel.Critical:
+ logger.LogCritical(message);
+ break;
+ }
+ }
+ }
+}
diff --git a/TUnit.Core/Models/GlobalContext.cs b/TUnit.Core/Models/GlobalContext.cs
index 57834c4ae2..fa88dd432f 100644
--- a/TUnit.Core/Models/GlobalContext.cs
+++ b/TUnit.Core/Models/GlobalContext.cs
@@ -20,7 +20,22 @@ internal GlobalContext() : base(null)
{
}
- internal ILogger GlobalLogger { get; set; } = new NullLogger();
+ private ILogger _globalLogger = new Logging.EarlyBufferLogger();
+
+ public ILogger GlobalLogger
+ {
+ get => _globalLogger;
+ internal set
+ {
+ // Flush buffered logs to the new logger
+ if (_globalLogger is Logging.EarlyBufferLogger bufferLogger)
+ {
+ bufferLogger.FlushTo(value);
+ }
+
+ _globalLogger = value;
+ }
+ }
public string? TestFilter { get; internal set; }
public TextWriter OriginalConsoleOut { get; set; } = Console.Out;
diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs
index 341439352d..3d3c1ad23c 100644
--- a/TUnit.Engine/Building/TestBuilder.cs
+++ b/TUnit.Engine/Building/TestBuilder.cs
@@ -22,7 +22,7 @@ internal sealed class TestBuilder : ITestBuilder
private readonly EventReceiverOrchestrator _eventReceiverOrchestrator;
private readonly IContextProvider _contextProvider;
private readonly ObjectLifecycleService _objectLifecycleService;
- private readonly Discovery.IHookDiscoveryService _hookDiscoveryService;
+ private readonly Discovery.IHookRegistrar _hookDiscoveryService;
private readonly TestArgumentRegistrationService _testArgumentRegistrationService;
private readonly IMetadataFilterMatcher _filterMatcher;
@@ -31,7 +31,7 @@ public TestBuilder(
EventReceiverOrchestrator eventReceiverOrchestrator,
IContextProvider contextProvider,
ObjectLifecycleService objectLifecycleService,
- Discovery.IHookDiscoveryService hookDiscoveryService,
+ Discovery.IHookRegistrar hookDiscoveryService,
TestArgumentRegistrationService testArgumentRegistrationService,
IMetadataFilterMatcher filterMatcher)
{
diff --git a/TUnit.Engine/Discovery/IHookDiscoveryService.cs b/TUnit.Engine/Discovery/IHookDiscoveryService.cs
deleted file mode 100644
index ac392694a3..0000000000
--- a/TUnit.Engine/Discovery/IHookDiscoveryService.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-namespace TUnit.Engine.Discovery;
-
-///
-/// Discovers hooks based on the execution mode (source generation or reflection).
-///
-internal interface IHookDiscoveryService
-{
- ///
- /// Discovers and registers all hooks for the test session.
- ///
- void DiscoverHooks();
-
- ///
- /// Discovers instance hooks for a specific type (used for closed generic types).
- ///
- void DiscoverInstanceHooksForType(Type type);
-}
diff --git a/TUnit.Engine/Discovery/IHookRegistrar.cs b/TUnit.Engine/Discovery/IHookRegistrar.cs
new file mode 100644
index 0000000000..6ab34c2efb
--- /dev/null
+++ b/TUnit.Engine/Discovery/IHookRegistrar.cs
@@ -0,0 +1,17 @@
+namespace TUnit.Engine.Discovery;
+
+///
+/// Registers hooks into Sources collections based on the execution mode (source generation or reflection).
+///
+internal interface IHookRegistrar
+{
+ ///
+ /// Discovers and registers all hooks for the test session into Sources collections.
+ ///
+ void DiscoverHooks();
+
+ ///
+ /// Discovers and registers instance hooks for a specific type (used for closed generic types).
+ ///
+ void DiscoverInstanceHooksForType(Type type);
+}
diff --git a/TUnit.Engine/Discovery/ReflectionBasedHookDiscoveryService.cs b/TUnit.Engine/Discovery/ReflectionHookRegistrar.cs
similarity index 70%
rename from TUnit.Engine/Discovery/ReflectionBasedHookDiscoveryService.cs
rename to TUnit.Engine/Discovery/ReflectionHookRegistrar.cs
index 2adba1e3d9..38ba2f8ebf 100644
--- a/TUnit.Engine/Discovery/ReflectionBasedHookDiscoveryService.cs
+++ b/TUnit.Engine/Discovery/ReflectionHookRegistrar.cs
@@ -3,14 +3,14 @@
namespace TUnit.Engine.Discovery;
///
-/// Hook discovery service for reflection mode.
-/// Uses reflection to scan assemblies and discover hooks at runtime.
+/// Hook registrar for reflection mode.
+/// Uses reflection to scan assemblies and register hooks into Sources at runtime.
/// This implementation requires reflection and is NOT AOT-compatible.
///
#if NET6_0_OR_GREATER
-[RequiresUnreferencedCode("Hook discovery uses reflection to scan assemblies and types")]
+[RequiresUnreferencedCode("Hook registration uses reflection to scan assemblies and types")]
#endif
-internal sealed class ReflectionBasedHookDiscoveryService : IHookDiscoveryService
+internal sealed class ReflectionHookRegistrar : IHookRegistrar
{
///
/// Discovers hooks using reflection by scanning all loaded assemblies.
diff --git a/TUnit.Engine/Discovery/SourceGenHookDiscoveryService.cs b/TUnit.Engine/Discovery/SourceGenHookRegistrar.cs
similarity index 76%
rename from TUnit.Engine/Discovery/SourceGenHookDiscoveryService.cs
rename to TUnit.Engine/Discovery/SourceGenHookRegistrar.cs
index e237c8a5bf..26e37fc0d3 100644
--- a/TUnit.Engine/Discovery/SourceGenHookDiscoveryService.cs
+++ b/TUnit.Engine/Discovery/SourceGenHookRegistrar.cs
@@ -1,11 +1,11 @@
namespace TUnit.Engine.Discovery;
///
-/// Hook discovery service for source generation mode.
-/// In this mode, hooks are discovered at compile time via source generators, so no runtime discovery is needed.
+/// Hook registrar for source generation mode.
+/// In this mode, hooks are registered at compile time via source generators, so no runtime registration is needed.
/// This implementation is AOT-compatible and does not use reflection.
///
-internal sealed class SourceGenHookDiscoveryService : IHookDiscoveryService
+internal sealed class SourceGenHookRegistrar : IHookRegistrar
{
///
/// No-op implementation. Hooks are already registered via source generation.
diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs
index 6e26194ba1..7eb019614d 100644
--- a/TUnit.Engine/Framework/TUnitServiceProvider.cs
+++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs
@@ -48,7 +48,7 @@ public ITestExecutionFilter? Filter
public TUnitMessageBus MessageBus { get; }
public EngineCancellationToken CancellationToken { get; }
public TestFilterService TestFilterService { get; }
- public IHookCollectionService HookCollectionService { get; }
+ public IHookDelegateBuilder HookDelegateBuilder { get; }
public TestExecutor TestExecutor { get; }
public EventReceiverOrchestrator EventReceiverOrchestrator { get; }
public ITestFinder TestFinder { get; }
@@ -87,15 +87,15 @@ public TUnitServiceProvider(IExtension extension,
// Determine execution mode early to create appropriate services
var useSourceGeneration = SourceRegistrar.IsEnabled = ExecutionModeHelper.IsSourceGenerationMode(CommandLineOptions);
- // Create and register mode-specific hook discovery service
- IHookDiscoveryService hookDiscoveryService;
+ // Create and register mode-specific hook registrar service
+ IHookRegistrar hookDiscoveryService;
if (useSourceGeneration)
{
- hookDiscoveryService = Register(new SourceGenHookDiscoveryService());
+ hookDiscoveryService = Register(new SourceGenHookRegistrar());
}
else
{
- hookDiscoveryService = Register(new ReflectionBasedHookDiscoveryService());
+ hookDiscoveryService = Register(new ReflectionHookRegistrar());
}
Initializer = new TUnitInitializer(CommandLineOptions, hookDiscoveryService);
@@ -161,13 +161,13 @@ public TUnitServiceProvider(IExtension extension,
CancellationToken = Register(new EngineCancellationToken());
EventReceiverOrchestrator = Register(new EventReceiverOrchestrator(Logger));
- HookCollectionService = Register(new HookCollectionService(EventReceiverOrchestrator));
+ HookDelegateBuilder = Register(new HookDelegateBuilder(EventReceiverOrchestrator, Logger));
ParallelLimitLockProvider = Register(new ParallelLimitLockProvider());
ContextProvider = Register(new ContextProvider(this, TestSessionId, Filter?.ToString()));
- var hookExecutor = Register(new HookExecutor(HookCollectionService, ContextProvider, EventReceiverOrchestrator));
+ var hookExecutor = Register(new HookExecutor(HookDelegateBuilder, ContextProvider, EventReceiverOrchestrator));
var lifecycleCoordinator = Register(new TestLifecycleCoordinator());
var beforeHookTaskCache = Register(new BeforeHookTaskCache());
var afterHookPairTracker = Register(new AfterHookPairTracker());
diff --git a/TUnit.Engine/Framework/TUnitTestFramework.cs b/TUnit.Engine/Framework/TUnitTestFramework.cs
index 9c68fcc49c..f2a334056a 100644
--- a/TUnit.Engine/Framework/TUnitTestFramework.cs
+++ b/TUnit.Engine/Framework/TUnitTestFramework.cs
@@ -55,11 +55,7 @@ public async Task ExecuteRequestAsync(ExecuteRequestContext context)
serviceProvider.Initializer.Initialize(context);
- // Initialize hook collection service to pre-compute global hooks
- if (serviceProvider.HookCollectionService is HookCollectionService hookCollectionService)
- {
- await hookCollectionService.InitializeAsync();
- }
+ await serviceProvider.HookDelegateBuilder.InitializeAsync();
GlobalContext.Current = serviceProvider.ContextProvider.GlobalContext;
GlobalContext.Current.GlobalLogger = serviceProvider.Logger;
diff --git a/TUnit.Engine/Helpers/HookTimeoutHelper.cs b/TUnit.Engine/Helpers/HookTimeoutHelper.cs
index d252c37f2e..aa9b7ec074 100644
--- a/TUnit.Engine/Helpers/HookTimeoutHelper.cs
+++ b/TUnit.Engine/Helpers/HookTimeoutHelper.cs
@@ -131,7 +131,7 @@ public static Func CreateTimeoutHookAction(
///
/// Creates a timeout-aware action wrapper for a hook delegate that returns ValueTask
/// This overload is used for instance hooks (InstanceHookMethod)
- /// Custom executor handling for instance hooks is done in HookCollectionService.CreateInstanceHookDelegateAsync
+ /// Custom executor handling for instance hooks is done in HookDelegateBuilder.CreateInstanceHookDelegateAsync
///
public static Func CreateTimeoutHookAction(
Func hookDelegate,
diff --git a/TUnit.Engine/Interfaces/IHookCollectionService.cs b/TUnit.Engine/Interfaces/IHookDelegateBuilder.cs
similarity index 85%
rename from TUnit.Engine/Interfaces/IHookCollectionService.cs
rename to TUnit.Engine/Interfaces/IHookDelegateBuilder.cs
index 3b9d4a96f8..13354c314f 100644
--- a/TUnit.Engine/Interfaces/IHookCollectionService.cs
+++ b/TUnit.Engine/Interfaces/IHookDelegateBuilder.cs
@@ -3,8 +3,17 @@
namespace TUnit.Engine.Interfaces;
-internal interface IHookCollectionService
+///
+/// Builds executable hook delegates from Sources collections.
+/// Responsible for converting hook metadata into Func delegates ready for execution.
+///
+internal interface IHookDelegateBuilder
{
+ ///
+ /// Eagerly initializes all global hook delegates at startup.
+ ///
+ ValueTask InitializeAsync();
+
ValueTask>> CollectBeforeTestHooksAsync(Type testClassType);
ValueTask>> CollectAfterTestHooksAsync(Type testClassType);
ValueTask>> CollectBeforeEveryTestHooksAsync(Type testClassType);
diff --git a/TUnit.Engine/Services/HookCollectionService.cs b/TUnit.Engine/Services/HookDelegateBuilder.cs
similarity index 79%
rename from TUnit.Engine/Services/HookCollectionService.cs
rename to TUnit.Engine/Services/HookDelegateBuilder.cs
index 0b9dd8b820..ab5eeb4439 100644
--- a/TUnit.Engine/Services/HookCollectionService.cs
+++ b/TUnit.Engine/Services/HookDelegateBuilder.cs
@@ -2,14 +2,21 @@
using System.Reflection;
using TUnit.Core;
using TUnit.Core.Hooks;
+using TUnit.Core.Logging;
using TUnit.Engine.Helpers;
using TUnit.Engine.Interfaces;
+using TUnit.Engine.Logging;
namespace TUnit.Engine.Services;
-internal sealed class HookCollectionService : IHookCollectionService
+///
+/// Builds executable hook delegates from Sources collections with caching.
+/// Reads hook metadata from Sources and compiles them into ready-to-execute Func delegates.
+///
+internal sealed class HookDelegateBuilder : IHookDelegateBuilder
{
private readonly EventReceiverOrchestrator _eventReceiverOrchestrator;
+ private readonly TUnitFrameworkLogger _logger;
private readonly ConcurrentDictionary>> _beforeTestHooksCache = new();
private readonly ConcurrentDictionary>> _afterTestHooksCache = new();
private readonly ConcurrentDictionary>> _beforeClassHooksCache = new();
@@ -35,9 +42,10 @@ internal sealed class HookCollectionService : IHookCollectionService
// Cache for processed hooks to avoid re-processing event receivers
private readonly ConcurrentDictionary
internal sealed class HookExecutor
{
- private readonly IHookCollectionService _hookCollectionService;
+ private readonly IHookDelegateBuilder _hookCollectionService;
private readonly IContextProvider _contextProvider;
private readonly EventReceiverOrchestrator _eventReceiverOrchestrator;
public HookExecutor(
- IHookCollectionService hookCollectionService,
+ IHookDelegateBuilder hookCollectionService,
IContextProvider contextProvider,
EventReceiverOrchestrator eventReceiverOrchestrator)
{
diff --git a/TUnit.Engine/TUnitInitializer.cs b/TUnit.Engine/TUnitInitializer.cs
index 3eb47fb89b..0e42f58d4a 100644
--- a/TUnit.Engine/TUnitInitializer.cs
+++ b/TUnit.Engine/TUnitInitializer.cs
@@ -8,7 +8,7 @@
namespace TUnit.Engine;
-internal class TUnitInitializer(ICommandLineOptions commandLineOptions, IHookDiscoveryService hookDiscoveryService)
+internal class TUnitInitializer(ICommandLineOptions commandLineOptions, IHookRegistrar hookDiscoveryService)
{
public void Initialize(ExecuteRequestContext context)
{
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 207727b38c..0cc53f2a25 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
@@ -813,6 +813,7 @@ namespace
}
public class GlobalContext : .Context
{
+ public . GlobalLogger { get; }
public .TextWriter OriginalConsoleError { get; set; }
public .TextWriter OriginalConsoleOut { get; set; }
public string? TestFilter { get; }
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 ce82e785d5..1c555a47d5 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
@@ -813,6 +813,7 @@ namespace
}
public class GlobalContext : .Context
{
+ public . GlobalLogger { get; }
public .TextWriter OriginalConsoleError { get; set; }
public .TextWriter OriginalConsoleOut { get; set; }
public string? TestFilter { get; }
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 4e79f131df..de1cb1bc62 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
@@ -813,6 +813,7 @@ namespace
}
public class GlobalContext : .Context
{
+ public . GlobalLogger { get; }
public .TextWriter OriginalConsoleError { get; set; }
public .TextWriter OriginalConsoleOut { get; set; }
public string? TestFilter { get; }
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 a90159d043..319cb11fed 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
@@ -789,6 +789,7 @@ namespace
}
public class GlobalContext : .Context
{
+ public . GlobalLogger { get; }
public .TextWriter OriginalConsoleError { get; set; }
public .TextWriter OriginalConsoleOut { get; set; }
public string? TestFilter { get; }