From d748e466c08998c26ae2e70697326f1deca43c4d Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:41:08 +0100 Subject: [PATCH 01/19] feat: add pluggable ITestContextResolver for custom console output correlation Adds an extensibility point that allows users to register custom logic for resolving which TestContext should receive console output, beyond the built-in AsyncLocal-based mechanism. This enables correct log routing when shared services (MCP servers, gRPC handlers, message queue consumers, etc.) process work on their own thread pool threads. Closes #5500 --- .../Logging/CorrelatedTUnitLogger.cs | 25 +-- .../Logging/CorrelatedTUnitLoggerProvider.cs | 15 +- .../CorrelatedTUnitLoggingExtensions.cs | 5 +- .../Logging/HttpContextTestContextResolver.cs | 47 +++++ TUnit.AspNetCore.Tests.WebApp/Program.cs | 20 ++ .../TUnit.AspNetCore.Tests.WebApp.csproj | 10 + .../CorrelatedLoggingResolverTests.cs | 78 +++++++ TUnit.AspNetCore.Tests/GlobalUsings.cs | 2 + .../TUnit.AspNetCore.Tests.csproj | 31 +++ TUnit.AspNetCore.Tests/TestWebAppFactory.cs | 33 +++ TUnit.Core/Context.cs | 3 +- TUnit.Core/ITestContextResolver.cs | 46 +++++ TUnit.Core/TestContextResolverRegistry.cs | 119 +++++++++++ .../TestContextResolverRegistryTests.cs | 193 ++++++++++++++++++ TUnit.slnx | 2 + 15 files changed, 603 insertions(+), 26 deletions(-) create mode 100644 TUnit.AspNetCore.Core/Logging/HttpContextTestContextResolver.cs create mode 100644 TUnit.AspNetCore.Tests.WebApp/Program.cs create mode 100644 TUnit.AspNetCore.Tests.WebApp/TUnit.AspNetCore.Tests.WebApp.csproj create mode 100644 TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs create mode 100644 TUnit.AspNetCore.Tests/GlobalUsings.cs create mode 100644 TUnit.AspNetCore.Tests/TUnit.AspNetCore.Tests.csproj create mode 100644 TUnit.AspNetCore.Tests/TestWebAppFactory.cs create mode 100644 TUnit.Core/ITestContextResolver.cs create mode 100644 TUnit.Core/TestContextResolverRegistry.cs create mode 100644 TUnit.UnitTests/TestContextResolverRegistryTests.cs diff --git a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs index fea4cd1be4..246b8a4a11 100644 --- a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs +++ b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs @@ -1,4 +1,3 @@ -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using TUnit.Core; using TUnit.Logging.Microsoft; @@ -9,23 +8,18 @@ namespace TUnit.AspNetCore.Logging; /// A logger that resolves the current test context per log call, supporting shared web application scenarios. /// Sets and writes via so the console interceptor /// and all registered log sinks naturally route the output to the correct test. -/// The resolution chain is: -/// -/// Test context from (set by ) -/// (AsyncLocal fallback) -/// No-op if no test context is available -/// +/// Resolution is delegated to the (which includes the +/// registered by ), +/// then falls back to (AsyncLocal). /// public sealed class CorrelatedTUnitLogger : ILogger { private readonly string _categoryName; - private readonly IHttpContextAccessor _httpContextAccessor; private readonly LogLevel _minLogLevel; - internal CorrelatedTUnitLogger(string categoryName, IHttpContextAccessor httpContextAccessor, LogLevel minLogLevel) + internal CorrelatedTUnitLogger(string categoryName, LogLevel minLogLevel) { _categoryName = categoryName; - _httpContextAccessor = httpContextAccessor; _minLogLevel = minLogLevel; } @@ -85,15 +79,10 @@ public void Log( } } - private TestContext? ResolveTestContext() + private static TestContext? ResolveTestContext() { - // 1. Try to get from HttpContext.Items (set by TUnitTestContextMiddleware) - if (_httpContextAccessor.HttpContext?.Items[TUnitTestContextMiddleware.HttpContextKey] is TestContext httpTestContext) - { - return httpTestContext; - } - + // 1. Consult custom resolvers (includes HttpContextTestContextResolver for ASP.NET Core) // 2. Fall back to AsyncLocal - return TestContext.Current; + return TestContextResolverRegistry.Resolve() ?? TestContext.Current; } } diff --git a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs index 0958419073..e79adf319d 100644 --- a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs +++ b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs @@ -1,30 +1,32 @@ using System.Collections.Concurrent; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; +using TUnit.Core; namespace TUnit.AspNetCore.Logging; /// /// A logger provider that creates instances. -/// Each log call resolves the current test context dynamically, supporting -/// shared web application scenarios where a single host serves multiple tests. +/// Each log call resolves the current test context dynamically via , +/// supporting shared web application scenarios where a single host serves multiple tests. /// public sealed class CorrelatedTUnitLoggerProvider : ILoggerProvider { private readonly ConcurrentDictionary _loggers = new(); - private readonly IHttpContextAccessor _httpContextAccessor; + private readonly HttpContextTestContextResolver _resolver; private readonly LogLevel _minLogLevel; private bool _disposed; /// /// Creates a new . /// - /// The HTTP context accessor for resolving test context from requests. + /// The HTTP context accessor used to resolve test context from incoming requests. /// The minimum log level to capture. Defaults to Information. public CorrelatedTUnitLoggerProvider(IHttpContextAccessor httpContextAccessor, LogLevel minLogLevel = LogLevel.Information) { - _httpContextAccessor = httpContextAccessor; _minLogLevel = minLogLevel; + _resolver = new HttpContextTestContextResolver(httpContextAccessor); + TestContextResolverRegistry.Register(_resolver); } public ILogger CreateLogger(string categoryName) @@ -32,7 +34,7 @@ public ILogger CreateLogger(string categoryName) ObjectDisposedException.ThrowIf(_disposed, this); return _loggers.GetOrAdd(categoryName, - name => new CorrelatedTUnitLogger(name, _httpContextAccessor, _minLogLevel)); + name => new CorrelatedTUnitLogger(name, _minLogLevel)); } public void Dispose() @@ -43,6 +45,7 @@ public void Dispose() } _disposed = true; + TestContextResolverRegistry.Unregister(_resolver); _loggers.Clear(); } } diff --git a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs index 4661271475..58910d016a 100644 --- a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs +++ b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs @@ -14,7 +14,10 @@ public static class CorrelatedTUnitLoggingExtensions /// /// Adds correlated TUnit logging to the service collection. /// This registers the via an - /// and a that resolves the test context per log call. + /// and a that resolves the test context per log call + /// and registers an with + /// for automatic context resolution + /// on ASP.NET Core request-processing threads. /// Use with on the client side to propagate test context. /// /// The service collection. diff --git a/TUnit.AspNetCore.Core/Logging/HttpContextTestContextResolver.cs b/TUnit.AspNetCore.Core/Logging/HttpContextTestContextResolver.cs new file mode 100644 index 0000000000..89864a8c3a --- /dev/null +++ b/TUnit.AspNetCore.Core/Logging/HttpContextTestContextResolver.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Http; +using TUnit.Core; + +namespace TUnit.AspNetCore.Logging; + +/// +/// An that resolves the current test context from +/// , where it was stored by . +/// +/// +/// +/// This resolver is automatically registered when +/// is called. It enables (and therefore the console interceptor) +/// to route output to the correct test even on ASP.NET Core request-processing threads +/// that don't inherit the test's AsyncLocal context. +/// +/// +/// Resolution cost: one property access +/// plus one dictionary lookup. Both are very cheap. +/// +/// +public sealed class HttpContextTestContextResolver : ITestContextResolver +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + /// + /// Creates a new . + /// + /// The HTTP context accessor. + public HttpContextTestContextResolver(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + /// + public TestContext? ResolveCurrentTestContext() + { + if (_httpContextAccessor.HttpContext?.Items is { } items + && items.TryGetValue(TUnitTestContextMiddleware.HttpContextKey, out var value) + && value is TestContext testContext) + { + return testContext; + } + + return null; + } +} diff --git a/TUnit.AspNetCore.Tests.WebApp/Program.cs b/TUnit.AspNetCore.Tests.WebApp/Program.cs new file mode 100644 index 0000000000..03abeda9f9 --- /dev/null +++ b/TUnit.AspNetCore.Tests.WebApp/Program.cs @@ -0,0 +1,20 @@ +var builder = WebApplication.CreateBuilder(args); + +var app = builder.Build(); + +var logger = app.Services.GetRequiredService().CreateLogger("Endpoints"); + +// Endpoint that logs a caller-provided marker so we can verify log routing. +// The marker appears in the server-side ILogger output; the test verifies +// that it ends up in the correct TestContext's captured output. +app.MapGet("/log/{marker}", (string marker) => +{ + logger.LogInformation("SERVER_LOG:{Marker}", marker); + return Results.Ok(new { Marker = marker }); +}); + +app.MapGet("/ping", () => "pong"); + +app.Run(); + +public partial class Program; diff --git a/TUnit.AspNetCore.Tests.WebApp/TUnit.AspNetCore.Tests.WebApp.csproj b/TUnit.AspNetCore.Tests.WebApp/TUnit.AspNetCore.Tests.WebApp.csproj new file mode 100644 index 0000000000..ff70c90a2d --- /dev/null +++ b/TUnit.AspNetCore.Tests.WebApp/TUnit.AspNetCore.Tests.WebApp.csproj @@ -0,0 +1,10 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + false + + + diff --git a/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs b/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs new file mode 100644 index 0000000000..4888992923 --- /dev/null +++ b/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs @@ -0,0 +1,78 @@ +using System.Net; +using TUnit.Core; + +namespace TUnit.AspNetCore.Tests; + +/// +/// Integration tests that verify correctly routes server-side +/// log output to the originating test when the factory is shared (PerTestSession) and the +/// server processes requests on its own thread pool threads (no AsyncLocal inheritance). +/// +/// The flow being tested: +/// 1. Test sends HTTP request → TUnitTestIdHandler adds X-TUnit-TestId header +/// 2. Server receives request → TUnitTestContextMiddleware extracts test ID, stores in HttpContext.Items +/// 3. Endpoint calls ILogger.LogInformation(...) +/// 4. CorrelatedTUnitLogger resolves test context via TestContextResolverRegistry +/// → HttpContextTestContextResolver finds it from HttpContext.Items +/// 5. Console interceptor routes output to the correct test's output buffer +/// 6. Test verifies its own GetStandardOutput() contains the expected marker +/// +[NotInParallel("CorrelatedLogging")] +public class CorrelatedLoggingResolverTests +{ + /// + /// Shared factory (PerTestSession) — no per-test TestContext is injected into the DI container. + /// The resolver mechanism is the only way server-side logs get correlated. + /// + [ClassDataSource(Shared = [SharedType.PerTestSession])] + public TestWebAppFactory Factory { get; set; } = null!; + + [Test] + public async Task ServerLog_CorrelatedToCorrectTest_ViaResolver() + { + // Arrange - unique marker for this test instance + var marker = Guid.NewGuid().ToString("N"); + + // Create client with TUnitTestIdHandler to propagate test context + var client = Factory.CreateDefaultClient(new TUnitTestIdHandler()); + + // Act - hit the logging endpoint; the server logs "SERVER_LOG:{marker}" + var response = await client.GetAsync($"/log/{marker}"); + + // Assert - request succeeded + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // Assert - the server-side log message was routed to THIS test's output + var output = TestContext.Current!.GetStandardOutput(); + await Assert.That(output).Contains($"SERVER_LOG:{marker}"); + } + + [Test] + public async Task PingEndpoint_Works_WithSharedFactory() + { + // Verify the shared factory serves requests correctly + var client = Factory.CreateDefaultClient(new TUnitTestIdHandler()); + + var response = await client.GetStringAsync("/ping"); + + await Assert.That(response).IsEqualTo("pong"); + } + + [Test] + public async Task MultipleRequests_EachCorrelatedToSameTest() + { + var marker1 = $"first_{Guid.NewGuid():N}"; + var marker2 = $"second_{Guid.NewGuid():N}"; + + var client = Factory.CreateDefaultClient(new TUnitTestIdHandler()); + + // Send two requests from the same test + await client.GetAsync($"/log/{marker1}"); + await client.GetAsync($"/log/{marker2}"); + + // Both markers should appear in this test's output + var output = TestContext.Current!.GetStandardOutput(); + await Assert.That(output).Contains($"SERVER_LOG:{marker1}"); + await Assert.That(output).Contains($"SERVER_LOG:{marker2}"); + } +} diff --git a/TUnit.AspNetCore.Tests/GlobalUsings.cs b/TUnit.AspNetCore.Tests/GlobalUsings.cs new file mode 100644 index 0000000000..605ed73015 --- /dev/null +++ b/TUnit.AspNetCore.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Assert = TUnit.Assertions.Assert; +global using TestAttribute = TUnit.Core.TestAttribute; diff --git a/TUnit.AspNetCore.Tests/TUnit.AspNetCore.Tests.csproj b/TUnit.AspNetCore.Tests/TUnit.AspNetCore.Tests.csproj new file mode 100644 index 0000000000..e318fa61a0 --- /dev/null +++ b/TUnit.AspNetCore.Tests/TUnit.AspNetCore.Tests.csproj @@ -0,0 +1,31 @@ + + + + + + + net8.0;net9.0;net10.0 + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TUnit.AspNetCore.Tests/TestWebAppFactory.cs b/TUnit.AspNetCore.Tests/TestWebAppFactory.cs new file mode 100644 index 0000000000..e762e106cc --- /dev/null +++ b/TUnit.AspNetCore.Tests/TestWebAppFactory.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Hosting; +using TUnit.AspNetCore; +using TUnit.AspNetCore.Logging; +using TUnit.Core.Interfaces; + +namespace TUnit.AspNetCore.Tests; + +/// +/// Shared web application factory for integration tests. +/// Extends TestWebApplicationFactory which automatically registers AddCorrelatedTUnitLogging() +/// (and therefore the HttpContextTestContextResolver) so that server-side logs are correlated +/// to the correct test context via the resolver mechanism. +/// +public class TestWebAppFactory : TestWebApplicationFactory, IAsyncInitializer +{ + public Task InitializeAsync() + { + // Eagerly start the server to catch configuration errors early + _ = Server; + return Task.CompletedTask; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // For minimal API apps, CreateHostBuilder() returns null so the base class's + // AddCorrelatedTUnitLogging() in CreateHostBuilder is never called. + // Register it here via ConfigureWebHost which IS called for minimal API apps. + builder.ConfigureServices(services => + { + services.AddCorrelatedTUnitLogging(); + }); + } +} diff --git a/TUnit.Core/Context.cs b/TUnit.Core/Context.cs index dd5b916d00..21c90d2d62 100644 --- a/TUnit.Core/Context.cs +++ b/TUnit.Core/Context.cs @@ -13,7 +13,8 @@ protected Context? Parent } public static Context Current => - TestContext.Current as Context + TestContextResolverRegistry.Resolve() as Context + ?? TestContext.Current as Context ?? TestBuildContext.Current as Context ?? ClassHookContext.Current as Context ?? AssemblyHookContext.Current as Context diff --git a/TUnit.Core/ITestContextResolver.cs b/TUnit.Core/ITestContextResolver.cs new file mode 100644 index 0000000000..f2277d7a8f --- /dev/null +++ b/TUnit.Core/ITestContextResolver.cs @@ -0,0 +1,46 @@ +namespace TUnit.Core; + +/// +/// Allows custom logic for resolving which should receive console output, +/// beyond the built-in -based mechanism. +/// +/// +/// +/// The built-in context resolution uses AsyncLocal<T> which works when code runs on the same +/// async execution flow as the test. However, it breaks when shared services (e.g., hosted services, +/// gRPC handlers, message queue consumers) process work on their own thread pool threads. +/// +/// +/// Implement this interface and register it via +/// to provide custom resolution logic for these scenarios. +/// +/// +/// Performance and thread safety: This method is called on every +/// Console.Write / Console.WriteLine from arbitrary threads, +/// so implementations must be thread-safe and very cheap. Avoid allocations, locks, and I/O in the hot path. +/// +/// +/// +/// +/// public class McpTestContextResolver : ITestContextResolver +/// { +/// public TestContext? ResolveCurrentTestContext() +/// { +/// var testId = McpRequestContext.Current?.TestId; +/// return testId is not null ? TestContext.GetById(testId) : null; +/// } +/// } +/// +/// // Register in a [Before(Assembly)] hook: +/// TestContextResolverRegistry.Register(new McpTestContextResolver()); +/// +/// +public interface ITestContextResolver +{ + /// + /// Attempts to resolve the current test context. + /// Return null to fall through to the next resolver or the built-in AsyncLocal chain. + /// + /// The resolved , or null if this resolver cannot determine the context. + TestContext? ResolveCurrentTestContext(); +} diff --git a/TUnit.Core/TestContextResolverRegistry.cs b/TUnit.Core/TestContextResolverRegistry.cs new file mode 100644 index 0000000000..243855cf75 --- /dev/null +++ b/TUnit.Core/TestContextResolverRegistry.cs @@ -0,0 +1,119 @@ +namespace TUnit.Core; + +/// +/// Registry for custom instances. +/// Registered resolvers are consulted (in registration order) before the built-in +/// AsyncLocal chain when determining the current . +/// +/// +/// +/// When no resolvers are registered, the overhead on every Console.Write call +/// is a single volatile array-length check. +/// +/// +/// Register resolvers in a [Before(Assembly)] hook so they are active before any tests run. +/// +/// +/// +/// +/// [Before(Assembly)] +/// public static void SetupResolvers() +/// { +/// TestContextResolverRegistry.Register(new MyCustomResolver()); +/// } +/// +/// +public static class TestContextResolverRegistry +{ + // Volatile array for lock-free reads on the hot path. + // Writes are guarded by _lock to ensure consistency. + private static volatile ITestContextResolver[] _resolvers = []; + private static readonly Lock _lock = new(); + + /// + /// Registers a custom resolver. Resolvers are consulted in registration order. + /// + /// The resolver to register. + public static void Register(ITestContextResolver resolver) + { + if (resolver is null) + { + throw new ArgumentNullException(nameof(resolver)); + } + + lock (_lock) + { + var current = _resolvers; + var newArray = new ITestContextResolver[current.Length + 1]; + current.CopyTo(newArray, 0); + newArray[current.Length] = resolver; + _resolvers = newArray; + } + } + + /// + /// Removes a previously registered resolver. + /// + /// The resolver to remove. + /// true if the resolver was found and removed; otherwise false. + public static bool Unregister(ITestContextResolver resolver) + { + if (resolver is null) + { + throw new ArgumentNullException(nameof(resolver)); + } + + lock (_lock) + { + var current = _resolvers; + var index = Array.IndexOf(current, resolver); + if (index < 0) + { + return false; + } + + var newArray = new ITestContextResolver[current.Length - 1]; + Array.Copy(current, 0, newArray, 0, index); + Array.Copy(current, index + 1, newArray, index, current.Length - index - 1); + _resolvers = newArray; + return true; + } + } + + /// + /// Consults all registered resolvers in order, returning the first non-null result. + /// Returns null when no resolver can determine the context (or none are registered). + /// + internal static TestContext? Resolve() + { + // Hot-path: volatile read of the array reference. + // When no resolvers are registered this is just an array-length check. + var resolvers = _resolvers; + if (resolvers.Length == 0) + { + return null; + } + + foreach (var resolver in resolvers) + { + var context = resolver.ResolveCurrentTestContext(); + if (context is not null) + { + return context; + } + } + + return null; + } + + /// + /// Removes all registered resolvers. For internal/testing use. + /// + internal static void Clear() + { + lock (_lock) + { + _resolvers = []; + } + } +} diff --git a/TUnit.UnitTests/TestContextResolverRegistryTests.cs b/TUnit.UnitTests/TestContextResolverRegistryTests.cs new file mode 100644 index 0000000000..8d4b335e7c --- /dev/null +++ b/TUnit.UnitTests/TestContextResolverRegistryTests.cs @@ -0,0 +1,193 @@ +using TUnit.Core; + +namespace TUnit.UnitTests; + +/// +/// Tests for and . +/// Verifies that custom resolvers are consulted before the AsyncLocal chain when resolving Context.Current. +/// +[NotInParallel] +public class TestContextResolverRegistryTests +{ + [Before(Test)] + public void SetUp() + { + TestContextResolverRegistry.Clear(); + } + + [After(Test)] + public void TearDown() + { + TestContextResolverRegistry.Clear(); + } + + [Test] + public async Task Resolve_NoResolversRegistered_ReturnsNull() + { + // Act + var result = TestContextResolverRegistry.Resolve(); + + // Assert + await Assert.That(result).IsNull(); + } + + [Test] + public async Task Resolve_ResolverReturnsNull_ReturnsNull() + { + // Arrange + TestContextResolverRegistry.Register(new NullResolver()); + + // Act + var result = TestContextResolverRegistry.Resolve(); + + // Assert + await Assert.That(result).IsNull(); + } + + [Test] + public async Task Resolve_ResolverReturnsContext_ReturnsIt() + { + // Arrange + var expectedContext = TestContext.Current!; + TestContextResolverRegistry.Register(new FixedResolver(expectedContext)); + + // Act + var result = TestContextResolverRegistry.Resolve(); + + // Assert + await Assert.That(result).IsSameReferenceAs(expectedContext); + } + + [Test] + public async Task Resolve_MultipleResolvers_ReturnsFirstNonNull() + { + // Arrange + var expectedContext = TestContext.Current!; + TestContextResolverRegistry.Register(new NullResolver()); + TestContextResolverRegistry.Register(new FixedResolver(expectedContext)); + TestContextResolverRegistry.Register(new NullResolver()); // should not be reached + + // Act + var result = TestContextResolverRegistry.Resolve(); + + // Assert + await Assert.That(result).IsSameReferenceAs(expectedContext); + } + + [Test] + public async Task Unregister_RemovesResolver() + { + // Arrange + var expectedContext = TestContext.Current!; + var resolver = new FixedResolver(expectedContext); + TestContextResolverRegistry.Register(resolver); + + // Act + var removed = TestContextResolverRegistry.Unregister(resolver); + + // Assert + await Assert.That(removed).IsTrue(); + await Assert.That(TestContextResolverRegistry.Resolve()).IsNull(); + } + + [Test] + public async Task Unregister_UnknownResolver_ReturnsFalse() + { + // Act + var removed = TestContextResolverRegistry.Unregister(new NullResolver()); + + // Assert + await Assert.That(removed).IsFalse(); + } + + [Test] + public async Task ContextCurrent_ConsultsResolversBeforeAsyncLocal() + { + // Arrange - register a resolver that returns the current test context + // even when AsyncLocal is cleared + var testContext = TestContext.Current!; + TestContextResolverRegistry.Register(new FixedResolver(testContext)); + + // Act - Context.Current should use our resolver + var result = Context.Current; + + // Assert - should be our test context (from resolver), not some other context + await Assert.That(result).IsSameReferenceAs(testContext); + } + + [Test] + public async Task ContextCurrent_CrossThread_ResolverProvidesContext() + { + // This test demonstrates the core use case: code running on a thread pool thread + // (which does NOT inherit AsyncLocal) can still resolve the correct test context + // via a custom resolver. + + var testContext = TestContext.Current!; + + // Store the test context ID in a thread-static or similar mechanism + // that a resolver can read (simulating e.g. an MCP request context) + var threadLocalResolver = new ThreadStaticResolver(); + TestContextResolverRegistry.Register(threadLocalResolver); + + Context? resolvedOnBackgroundThread = null; + + // Run on a fresh thread pool thread that does NOT inherit AsyncLocal + var thread = new Thread(() => + { + // Set the correlation data that the resolver will use + ThreadStaticResolver.CurrentTestContext = testContext; + + // This would normally return GlobalContext since AsyncLocal is empty + // but our resolver kicks in first + resolvedOnBackgroundThread = Context.Current; + + ThreadStaticResolver.CurrentTestContext = null; + }); + + thread.Start(); + thread.Join(); + + // Assert - the background thread resolved the correct context via our custom resolver + await Assert.That(resolvedOnBackgroundThread).IsSameReferenceAs(testContext); + } + + [Test] + public async Task ContextCurrent_FallsBackToAsyncLocal_WhenResolverReturnsNull() + { + // Arrange - resolver returns null, so should fall back to AsyncLocal + TestContextResolverRegistry.Register(new NullResolver()); + var asyncLocalContext = TestContext.Current; + + // Act + var result = Context.Current; + + // Assert - should still resolve via AsyncLocal + await Assert.That(result).IsSameReferenceAs(asyncLocalContext); + } + + private class NullResolver : ITestContextResolver + { + public TestContext? ResolveCurrentTestContext() => null; + } + + private class FixedResolver : ITestContextResolver + { + private readonly TestContext _context; + + public FixedResolver(TestContext context) => _context = context; + + public TestContext? ResolveCurrentTestContext() => _context; + } + + /// + /// A resolver that uses thread-static storage, simulating how a custom protocol + /// (MCP, gRPC, etc.) might store the test context ID and resolve it. + /// + private class ThreadStaticResolver : ITestContextResolver + { + [ThreadStatic] + public static TestContext? CurrentTestContext; + + public TestContext? ResolveCurrentTestContext() => CurrentTestContext; + } +} diff --git a/TUnit.slnx b/TUnit.slnx index 10f42a6a3f..99533b2fbb 100644 --- a/TUnit.slnx +++ b/TUnit.slnx @@ -80,6 +80,8 @@ + + From 9d79232d90d50d2cd5e4a5bef025528c4acc6686 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:51:25 +0100 Subject: [PATCH 02/19] fix: address PR review - resolver ordering, exception safety, duplicate guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reorder Context.Current so AsyncLocal (TestContext.Current) is checked before custom resolvers — resolvers are a fallback, not an override - Add try/catch in Resolve() to swallow exceptions from faulty resolvers on the Console.Write hot path - Add duplicate-registration guard in Register() using Array.IndexOf - Update docs to reflect resolvers as fallback mechanism - Add tests for duplicate registration idempotency and exception swallowing --- TUnit.Core/Context.cs | 4 +- TUnit.Core/ITestContextResolver.cs | 7 +-- TUnit.Core/TestContextResolverRegistry.cs | 24 ++++++++-- .../TestContextResolverRegistryTests.cs | 47 +++++++++++++++++-- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/TUnit.Core/Context.cs b/TUnit.Core/Context.cs index 21c90d2d62..b277a68464 100644 --- a/TUnit.Core/Context.cs +++ b/TUnit.Core/Context.cs @@ -13,8 +13,8 @@ protected Context? Parent } public static Context Current => - TestContextResolverRegistry.Resolve() as Context - ?? TestContext.Current as Context + TestContext.Current as Context + ?? TestContextResolverRegistry.Resolve() as Context ?? TestBuildContext.Current as Context ?? ClassHookContext.Current as Context ?? AssemblyHookContext.Current as Context diff --git a/TUnit.Core/ITestContextResolver.cs b/TUnit.Core/ITestContextResolver.cs index f2277d7a8f..bc9644fe99 100644 --- a/TUnit.Core/ITestContextResolver.cs +++ b/TUnit.Core/ITestContextResolver.cs @@ -1,14 +1,15 @@ namespace TUnit.Core; /// -/// Allows custom logic for resolving which should receive console output, -/// beyond the built-in -based mechanism. +/// Allows custom logic for resolving which should receive console output +/// when the built-in -based mechanism cannot determine the context. /// /// /// /// The built-in context resolution uses AsyncLocal<T> which works when code runs on the same -/// async execution flow as the test. However, it breaks when shared services (e.g., hosted services, +/// async execution flow as the test. However, it returns null when shared services (e.g., hosted services, /// gRPC handlers, message queue consumers) process work on their own thread pool threads. +/// Registered resolvers act as a fallback, consulted only when the AsyncLocal chain yields no result. /// /// /// Implement this interface and register it via diff --git a/TUnit.Core/TestContextResolverRegistry.cs b/TUnit.Core/TestContextResolverRegistry.cs index 243855cf75..d9524ee862 100644 --- a/TUnit.Core/TestContextResolverRegistry.cs +++ b/TUnit.Core/TestContextResolverRegistry.cs @@ -2,8 +2,8 @@ namespace TUnit.Core; /// /// Registry for custom instances. -/// Registered resolvers are consulted (in registration order) before the built-in -/// AsyncLocal chain when determining the current . +/// Registered resolvers are consulted (in registration order) as a fallback when the built-in +/// AsyncLocal chain cannot determine the current . /// /// /// @@ -44,6 +44,12 @@ public static void Register(ITestContextResolver resolver) lock (_lock) { var current = _resolvers; + + if (Array.IndexOf(current, resolver) >= 0) + { + return; + } + var newArray = new ITestContextResolver[current.Length + 1]; current.CopyTo(newArray, 0); newArray[current.Length] = resolver; @@ -96,10 +102,18 @@ public static bool Unregister(ITestContextResolver resolver) foreach (var resolver in resolvers) { - var context = resolver.ResolveCurrentTestContext(); - if (context is not null) + try + { + var context = resolver.ResolveCurrentTestContext(); + if (context is not null) + { + return context; + } + } + catch { - return context; + // Swallow exceptions from user-provided resolvers on the hot path. + // A faulty resolver must not crash Console.Write/WriteLine. } } diff --git a/TUnit.UnitTests/TestContextResolverRegistryTests.cs b/TUnit.UnitTests/TestContextResolverRegistryTests.cs index 8d4b335e7c..43880b13a6 100644 --- a/TUnit.UnitTests/TestContextResolverRegistryTests.cs +++ b/TUnit.UnitTests/TestContextResolverRegistryTests.cs @@ -101,17 +101,16 @@ public async Task Unregister_UnknownResolver_ReturnsFalse() } [Test] - public async Task ContextCurrent_ConsultsResolversBeforeAsyncLocal() + public async Task ContextCurrent_AsyncLocalTakesPrecedenceOverResolvers() { - // Arrange - register a resolver that returns the current test context - // even when AsyncLocal is cleared + // Arrange - register a resolver, but AsyncLocal should win var testContext = TestContext.Current!; TestContextResolverRegistry.Register(new FixedResolver(testContext)); - // Act - Context.Current should use our resolver + // Act - Context.Current should use AsyncLocal first (since we're on the test thread) var result = Context.Current; - // Assert - should be our test context (from resolver), not some other context + // Assert - resolved via AsyncLocal, same reference since both point to current test await Assert.That(result).IsSameReferenceAs(testContext); } @@ -151,6 +150,39 @@ public async Task ContextCurrent_CrossThread_ResolverProvidesContext() await Assert.That(resolvedOnBackgroundThread).IsSameReferenceAs(testContext); } + [Test] + public async Task Register_DuplicateResolver_IsIdempotent() + { + // Arrange + var expectedContext = TestContext.Current!; + var resolver = new FixedResolver(expectedContext); + + // Act - register the same instance twice + TestContextResolverRegistry.Register(resolver); + TestContextResolverRegistry.Register(resolver); + + // Unregister once should remove it completely + TestContextResolverRegistry.Unregister(resolver); + + // Assert - no resolvers left, so Resolve returns null + await Assert.That(TestContextResolverRegistry.Resolve()).IsNull(); + } + + [Test] + public async Task Resolve_SwallowsResolverExceptions() + { + // Arrange - a faulty resolver followed by a working one + var expectedContext = TestContext.Current!; + TestContextResolverRegistry.Register(new ThrowingResolver()); + TestContextResolverRegistry.Register(new FixedResolver(expectedContext)); + + // Act - should skip the throwing resolver and return the second one + var result = TestContextResolverRegistry.Resolve(); + + // Assert + await Assert.That(result).IsSameReferenceAs(expectedContext); + } + [Test] public async Task ContextCurrent_FallsBackToAsyncLocal_WhenResolverReturnsNull() { @@ -170,6 +202,11 @@ private class NullResolver : ITestContextResolver public TestContext? ResolveCurrentTestContext() => null; } + private class ThrowingResolver : ITestContextResolver + { + public TestContext? ResolveCurrentTestContext() => throw new InvalidOperationException("Faulty resolver"); + } + private class FixedResolver : ITestContextResolver { private readonly TestContext _context; From 5753b8d6a75651e963cb1bfffaaa7fef06e67e7e Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:59:03 +0100 Subject: [PATCH 03/19] fix: make resolution order consistent and remove unnecessary NotInParallel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Flip CorrelatedTUnitLogger.ResolveTestContext() to check AsyncLocal first, matching Context.Current's resolution order - Guard TestContext.Current assignment to skip no-op AsyncLocal writes - Remove [NotInParallel] from integration tests — the entire chain is execution-context-scoped via AsyncLocal, so concurrent tests are safe - Update stale XML docs --- .../Logging/CorrelatedTUnitLogger.cs | 15 ++++++++------- .../CorrelatedLoggingResolverTests.cs | 1 - .../TestContextResolverRegistryTests.cs | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs index 246b8a4a11..f14da75d7e 100644 --- a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs +++ b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs @@ -8,9 +8,9 @@ namespace TUnit.AspNetCore.Logging; /// A logger that resolves the current test context per log call, supporting shared web application scenarios. /// Sets and writes via so the console interceptor /// and all registered log sinks naturally route the output to the correct test. -/// Resolution is delegated to the (which includes the -/// registered by ), -/// then falls back to (AsyncLocal). +/// Resolution checks (AsyncLocal) first, then falls back to the +/// (which includes the +/// registered by ). /// public sealed class CorrelatedTUnitLogger : ILogger { @@ -58,7 +58,10 @@ public void Log( // Set the current test context so the console interceptor routes output // to the correct test's sinks (test output, IDE real-time, console) - TestContext.Current = testContext; + if (TestContext.Current != testContext) + { + TestContext.Current = testContext; + } var message = formatter(state, exception); @@ -81,8 +84,6 @@ public void Log( private static TestContext? ResolveTestContext() { - // 1. Consult custom resolvers (includes HttpContextTestContextResolver for ASP.NET Core) - // 2. Fall back to AsyncLocal - return TestContextResolverRegistry.Resolve() ?? TestContext.Current; + return TestContext.Current ?? TestContextResolverRegistry.Resolve(); } } diff --git a/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs b/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs index 4888992923..2cd2fc1aa6 100644 --- a/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs +++ b/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs @@ -17,7 +17,6 @@ namespace TUnit.AspNetCore.Tests; /// 5. Console interceptor routes output to the correct test's output buffer /// 6. Test verifies its own GetStandardOutput() contains the expected marker /// -[NotInParallel("CorrelatedLogging")] public class CorrelatedLoggingResolverTests { /// diff --git a/TUnit.UnitTests/TestContextResolverRegistryTests.cs b/TUnit.UnitTests/TestContextResolverRegistryTests.cs index 43880b13a6..b43e7d7a1f 100644 --- a/TUnit.UnitTests/TestContextResolverRegistryTests.cs +++ b/TUnit.UnitTests/TestContextResolverRegistryTests.cs @@ -4,7 +4,7 @@ namespace TUnit.UnitTests; /// /// Tests for and . -/// Verifies that custom resolvers are consulted before the AsyncLocal chain when resolving Context.Current. +/// Verifies that custom resolvers act as a fallback when AsyncLocal cannot resolve the context. /// [NotInParallel] public class TestContextResolverRegistryTests From 83c28eac17dca55b30b3b37ba57110f2c59cdf5f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:08:20 +0100 Subject: [PATCH 04/19] test: exercise both AsyncLocal and resolver paths in integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original tests used TestServer which processes requests inline on the caller's async flow — TestContext.Current (AsyncLocal) was always inherited, so the resolver fallback was never exercised. Split tests into two groups: - AsyncLocal path: TestServer inherits execution context (fast path) - Resolver path: ExecutionContext.SuppressFlow() breaks AsyncLocal inheritance to simulate real Kestrel behavior, forcing fallback to HttpContextTestContextResolver via HttpContext.Items This proves the resolver mechanism actually works when AsyncLocal is absent, which is the real-world scenario with Kestrel. --- .../CorrelatedLoggingResolverTests.cs | 110 ++++++++++++------ 1 file changed, 75 insertions(+), 35 deletions(-) diff --git a/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs b/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs index 2cd2fc1aa6..ed364a8692 100644 --- a/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs +++ b/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs @@ -1,77 +1,117 @@ using System.Net; +using TUnit.AspNetCore; using TUnit.Core; namespace TUnit.AspNetCore.Tests; /// -/// Integration tests that verify correctly routes server-side -/// log output to the originating test when the factory is shared (PerTestSession) and the -/// server processes requests on its own thread pool threads (no AsyncLocal inheritance). -/// -/// The flow being tested: -/// 1. Test sends HTTP request → TUnitTestIdHandler adds X-TUnit-TestId header -/// 2. Server receives request → TUnitTestContextMiddleware extracts test ID, stores in HttpContext.Items -/// 3. Endpoint calls ILogger.LogInformation(...) -/// 4. CorrelatedTUnitLogger resolves test context via TestContextResolverRegistry -/// → HttpContextTestContextResolver finds it from HttpContext.Items -/// 5. Console interceptor routes output to the correct test's output buffer -/// 6. Test verifies its own GetStandardOutput() contains the expected marker +/// Integration tests verifying correlated logging works via both resolution paths: +/// +/// AsyncLocal path — TestServer inherits the test's execution context, +/// so is available server-side (fast path). +/// Resolver path breaks AsyncLocal +/// inheritance (simulating real Kestrel), forcing fallback to +/// HttpContextTestContextResolver +/// → . +/// /// public class CorrelatedLoggingResolverTests { - /// - /// Shared factory (PerTestSession) — no per-test TestContext is injected into the DI container. - /// The resolver mechanism is the only way server-side logs get correlated. - /// [ClassDataSource(Shared = [SharedType.PerTestSession])] public TestWebAppFactory Factory { get; set; } = null!; + // ── AsyncLocal path (TestServer inherits execution context) ── + [Test] - public async Task ServerLog_CorrelatedToCorrectTest_ViaResolver() + public async Task AsyncLocalPath_ServerLog_CorrelatedToCorrectTest() { - // Arrange - unique marker for this test instance var marker = Guid.NewGuid().ToString("N"); - - // Create client with TUnitTestIdHandler to propagate test context var client = Factory.CreateDefaultClient(new TUnitTestIdHandler()); - // Act - hit the logging endpoint; the server logs "SERVER_LOG:{marker}" var response = await client.GetAsync($"/log/{marker}"); - // Assert - request succeeded await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); - // Assert - the server-side log message was routed to THIS test's output var output = TestContext.Current!.GetStandardOutput(); await Assert.That(output).Contains($"SERVER_LOG:{marker}"); } [Test] - public async Task PingEndpoint_Works_WithSharedFactory() + public async Task AsyncLocalPath_MultipleRequests_EachCorrelatedToSameTest() { - // Verify the shared factory serves requests correctly + var marker1 = $"first_{Guid.NewGuid():N}"; + var marker2 = $"second_{Guid.NewGuid():N}"; var client = Factory.CreateDefaultClient(new TUnitTestIdHandler()); - var response = await client.GetStringAsync("/ping"); + await client.GetAsync($"/log/{marker1}"); + await client.GetAsync($"/log/{marker2}"); - await Assert.That(response).IsEqualTo("pong"); + var output = TestContext.Current!.GetStandardOutput(); + await Assert.That(output).Contains($"SERVER_LOG:{marker1}"); + await Assert.That(output).Contains($"SERVER_LOG:{marker2}"); } + // ── Resolver path (AsyncLocal suppressed to simulate real Kestrel) ── + [Test] - public async Task MultipleRequests_EachCorrelatedToSameTest() + public async Task ResolverPath_ServerLog_CorrelatedToCorrectTest() { + var testContext = TestContext.Current!; + var marker = Guid.NewGuid().ToString("N"); + + var response = await SendWithSuppressedFlow( + Factory, $"/log/{marker}", testContext.Id); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var output = testContext.GetStandardOutput(); + await Assert.That(output).Contains($"SERVER_LOG:{marker}"); + } + + [Test] + public async Task ResolverPath_MultipleRequests_EachCorrelatedToSameTest() + { + var testContext = TestContext.Current!; var marker1 = $"first_{Guid.NewGuid():N}"; var marker2 = $"second_{Guid.NewGuid():N}"; - var client = Factory.CreateDefaultClient(new TUnitTestIdHandler()); - - // Send two requests from the same test - await client.GetAsync($"/log/{marker1}"); - await client.GetAsync($"/log/{marker2}"); + await SendWithSuppressedFlow(Factory, $"/log/{marker1}", testContext.Id); + await SendWithSuppressedFlow(Factory, $"/log/{marker2}", testContext.Id); - // Both markers should appear in this test's output - var output = TestContext.Current!.GetStandardOutput(); + var output = testContext.GetStandardOutput(); await Assert.That(output).Contains($"SERVER_LOG:{marker1}"); await Assert.That(output).Contains($"SERVER_LOG:{marker2}"); } + + // ── Basic sanity ── + + [Test] + public async Task PingEndpoint_Works_WithSharedFactory() + { + var client = Factory.CreateDefaultClient(); + var response = await client.GetStringAsync("/ping"); + await Assert.That(response).IsEqualTo("pong"); + } + + /// + /// Sends an HTTP request with the test ID header on a thread pool thread + /// whose execution context does NOT inherit the test's AsyncLocal values. + /// This simulates real Kestrel behavior where server threads are independent + /// of the test's async flow, forcing the resolver fallback path. + /// + private static async Task SendWithSuppressedFlow( + TestWebAppFactory factory, string path, string testId) + { + var client = factory.CreateDefaultClient(); + var request = new HttpRequestMessage(HttpMethod.Get, path); + request.Headers.TryAddWithoutValidation(TUnitTestIdHandler.HeaderName, testId); + + // Suppress flow, start the task (queued WITHOUT execution context), then + // restore flow immediately on the same thread to avoid cross-thread Undo(). + var flowControl = ExecutionContext.SuppressFlow(); + var task = Task.Run(() => client.SendAsync(request)); + flowControl.Undo(); + + return await task; + } } From 3eb324275fddbdcc5cf98270dee094b0326d6d62 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:15:22 +0100 Subject: [PATCH 05/19] =?UTF-8?q?fix:=20address=20follow-up=20PR=20review?= =?UTF-8?q?=20=E2=80=94=20docs,=20diagnostics,=20and=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Document resolver ordering in ITestContextResolver: resolvers sit between AsyncLocal and hook contexts, must return null when no protocol request is in-flight to avoid shadowing hook contexts - Improve catch comment in Resolve() to explain re-entrancy constraint (logging from inside Console.Write would recurse) - Document one-resolver-per-provider behavior on CorrelatedTUnitLoggerProvider - Document minimal-API CreateHostBuilder() gap in TestWebAppFactory - Add CPM VersionOverride explanation comment in csproj - Use CreateClientWithTestContext() extension, add using on disposables --- .../Logging/CorrelatedTUnitLoggerProvider.cs | 6 +++ .../CorrelatedLoggingResolverTests.cs | 46 ++++++------------- .../TUnit.AspNetCore.Tests.csproj | 2 +- TUnit.AspNetCore.Tests/TestWebAppFactory.cs | 7 +-- TUnit.Core/ITestContextResolver.cs | 8 ++++ TUnit.Core/TestContextResolverRegistry.cs | 5 +- 6 files changed, 36 insertions(+), 38 deletions(-) diff --git a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs index e79adf319d..2a097a5dc6 100644 --- a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs +++ b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs @@ -10,6 +10,12 @@ namespace TUnit.AspNetCore.Logging; /// Each log call resolves the current test context dynamically via , /// supporting shared web application scenarios where a single host serves multiple tests. /// +/// +/// Each provider instance registers its own in the global +/// and unregisters it on . In multi-factory +/// scenarios, each factory's provider adds one resolver — this is correct because each resolver queries +/// its own factory's . +/// public sealed class CorrelatedTUnitLoggerProvider : ILoggerProvider { private readonly ConcurrentDictionary _loggers = new(); diff --git a/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs b/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs index ed364a8692..5225cf50ef 100644 --- a/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs +++ b/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs @@ -5,28 +5,18 @@ namespace TUnit.AspNetCore.Tests; /// -/// Integration tests verifying correlated logging works via both resolution paths: -/// -/// AsyncLocal path — TestServer inherits the test's execution context, -/// so is available server-side (fast path). -/// Resolver path breaks AsyncLocal -/// inheritance (simulating real Kestrel), forcing fallback to -/// HttpContextTestContextResolver -/// → . -/// +/// Tests both AsyncLocal and resolver-based test context correlation paths. /// public class CorrelatedLoggingResolverTests { [ClassDataSource(Shared = [SharedType.PerTestSession])] public TestWebAppFactory Factory { get; set; } = null!; - // ── AsyncLocal path (TestServer inherits execution context) ── - [Test] public async Task AsyncLocalPath_ServerLog_CorrelatedToCorrectTest() { var marker = Guid.NewGuid().ToString("N"); - var client = Factory.CreateDefaultClient(new TUnitTestIdHandler()); + using var client = Factory.CreateClientWithTestContext(); var response = await client.GetAsync($"/log/{marker}"); @@ -41,7 +31,7 @@ public async Task AsyncLocalPath_MultipleRequests_EachCorrelatedToSameTest() { var marker1 = $"first_{Guid.NewGuid():N}"; var marker2 = $"second_{Guid.NewGuid():N}"; - var client = Factory.CreateDefaultClient(new TUnitTestIdHandler()); + using var client = Factory.CreateClientWithTestContext(); await client.GetAsync($"/log/{marker1}"); await client.GetAsync($"/log/{marker2}"); @@ -51,16 +41,13 @@ public async Task AsyncLocalPath_MultipleRequests_EachCorrelatedToSameTest() await Assert.That(output).Contains($"SERVER_LOG:{marker2}"); } - // ── Resolver path (AsyncLocal suppressed to simulate real Kestrel) ── - [Test] public async Task ResolverPath_ServerLog_CorrelatedToCorrectTest() { var testContext = TestContext.Current!; var marker = Guid.NewGuid().ToString("N"); - var response = await SendWithSuppressedFlow( - Factory, $"/log/{marker}", testContext.Id); + var response = await SendWithSuppressedFlow(Factory, $"/log/{marker}", testContext); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); @@ -75,39 +62,34 @@ public async Task ResolverPath_MultipleRequests_EachCorrelatedToSameTest() var marker1 = $"first_{Guid.NewGuid():N}"; var marker2 = $"second_{Guid.NewGuid():N}"; - await SendWithSuppressedFlow(Factory, $"/log/{marker1}", testContext.Id); - await SendWithSuppressedFlow(Factory, $"/log/{marker2}", testContext.Id); + await SendWithSuppressedFlow(Factory, $"/log/{marker1}", testContext); + await SendWithSuppressedFlow(Factory, $"/log/{marker2}", testContext); var output = testContext.GetStandardOutput(); await Assert.That(output).Contains($"SERVER_LOG:{marker1}"); await Assert.That(output).Contains($"SERVER_LOG:{marker2}"); } - // ── Basic sanity ── - [Test] public async Task PingEndpoint_Works_WithSharedFactory() { - var client = Factory.CreateDefaultClient(); + using var client = Factory.CreateDefaultClient(); var response = await client.GetStringAsync("/ping"); await Assert.That(response).IsEqualTo("pong"); } /// - /// Sends an HTTP request with the test ID header on a thread pool thread - /// whose execution context does NOT inherit the test's AsyncLocal values. - /// This simulates real Kestrel behavior where server threads are independent - /// of the test's async flow, forcing the resolver fallback path. + /// Sends an HTTP request on a thread pool thread whose execution context does NOT + /// inherit the test's AsyncLocal values, simulating real Kestrel behavior and forcing + /// the resolver fallback path. /// private static async Task SendWithSuppressedFlow( - TestWebAppFactory factory, string path, string testId) + TestWebAppFactory factory, string path, TestContext testContext) { - var client = factory.CreateDefaultClient(); - var request = new HttpRequestMessage(HttpMethod.Get, path); - request.Headers.TryAddWithoutValidation(TUnitTestIdHandler.HeaderName, testId); + using var client = factory.CreateDefaultClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, path); + request.Headers.TryAddWithoutValidation(TUnitTestIdHandler.HeaderName, testContext.Id); - // Suppress flow, start the task (queued WITHOUT execution context), then - // restore flow immediately on the same thread to avoid cross-thread Undo(). var flowControl = ExecutionContext.SuppressFlow(); var task = Task.Run(() => client.SendAsync(request)); flowControl.Undo(); diff --git a/TUnit.AspNetCore.Tests/TUnit.AspNetCore.Tests.csproj b/TUnit.AspNetCore.Tests/TUnit.AspNetCore.Tests.csproj index e318fa61a0..9f26d4cb3a 100644 --- a/TUnit.AspNetCore.Tests/TUnit.AspNetCore.Tests.csproj +++ b/TUnit.AspNetCore.Tests/TUnit.AspNetCore.Tests.csproj @@ -13,7 +13,7 @@ - + diff --git a/TUnit.AspNetCore.Tests/TestWebAppFactory.cs b/TUnit.AspNetCore.Tests/TestWebAppFactory.cs index e762e106cc..db656439d7 100644 --- a/TUnit.AspNetCore.Tests/TestWebAppFactory.cs +++ b/TUnit.AspNetCore.Tests/TestWebAppFactory.cs @@ -7,9 +7,10 @@ namespace TUnit.AspNetCore.Tests; /// /// Shared web application factory for integration tests. -/// Extends TestWebApplicationFactory which automatically registers AddCorrelatedTUnitLogging() -/// (and therefore the HttpContextTestContextResolver) so that server-side logs are correlated -/// to the correct test context via the resolver mechanism. +/// Overrides to register +/// because the base class registers it in CreateHostBuilder(), which returns null for +/// minimal API apps (top-level statements). This is a known gap in TestWebApplicationFactory +/// — any minimal API host must register correlated logging via ConfigureWebHost instead. /// public class TestWebAppFactory : TestWebApplicationFactory, IAsyncInitializer { diff --git a/TUnit.Core/ITestContextResolver.cs b/TUnit.Core/ITestContextResolver.cs index bc9644fe99..ce27e79227 100644 --- a/TUnit.Core/ITestContextResolver.cs +++ b/TUnit.Core/ITestContextResolver.cs @@ -20,6 +20,14 @@ namespace TUnit.Core; /// Console.Write / Console.WriteLine from arbitrary threads, /// so implementations must be thread-safe and very cheap. Avoid allocations, locks, and I/O in the hot path. /// +/// +/// Ordering: In , registered resolvers are consulted +/// after (AsyncLocal) but before hook contexts +/// (TestBuildContext, ClassHookContext, AssemblyHookContext). +/// If your resolver returns a non-null value on threads that execute class- or assembly-level hooks, +/// it will shadow those hook contexts. Ensure your resolver returns null when no +/// protocol request (HTTP, gRPC, etc.) is in-flight. +/// /// /// /// diff --git a/TUnit.Core/TestContextResolverRegistry.cs b/TUnit.Core/TestContextResolverRegistry.cs index d9524ee862..b544c140d3 100644 --- a/TUnit.Core/TestContextResolverRegistry.cs +++ b/TUnit.Core/TestContextResolverRegistry.cs @@ -112,8 +112,9 @@ public static bool Unregister(ITestContextResolver resolver) } catch { - // Swallow exceptions from user-provided resolvers on the hot path. - // A faulty resolver must not crash Console.Write/WriteLine. + // Must swallow: this runs inside Console.Write, so logging the error + // via Console/stderr would re-enter this method. A broken resolver + // manifests as uncorrelated test output, which is visible in results. } } From 1302d5dd83af4394bd7de9ec522b730022b4e8f1 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:30:44 +0100 Subject: [PATCH 06/19] feat: add TestContext.MakeCurrent() as primary correlation API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MakeCurrent() sets the AsyncLocal directly for the calling scope, making it safe for concurrent tests — unlike per-test resolvers which can't determine "which resolver belongs to which test" when 50 tests register simultaneously. Resolvers are demoted to true last-resort fallback (just before GlobalContext) for protocol-level integration. - Add TestContext.MakeCurrent() returning disposable ContextScope - Move TestContextResolverRegistry.Resolve() to end of Context.Current chain - Update TUnitTestContextMiddleware to use MakeCurrent() with scoped cleanup - Rewrite ITestContextResolver docs to guide toward MakeCurrent() - Rename integration tests to reflect actual path being tested --- .../Logging/TUnitTestContextMiddleware.cs | 7 +++ .../CorrelatedLoggingResolverTests.cs | 17 ++++--- TUnit.Core/Context.cs | 2 +- TUnit.Core/ITestContextResolver.cs | 48 +++++++++++------- TUnit.Core/TestContext.cs | 49 +++++++++++++++++++ 5 files changed, 97 insertions(+), 26 deletions(-) diff --git a/TUnit.AspNetCore.Core/Logging/TUnitTestContextMiddleware.cs b/TUnit.AspNetCore.Core/Logging/TUnitTestContextMiddleware.cs index 6f711b4d3a..42a2315a08 100644 --- a/TUnit.AspNetCore.Core/Logging/TUnitTestContextMiddleware.cs +++ b/TUnit.AspNetCore.Core/Logging/TUnitTestContextMiddleware.cs @@ -34,6 +34,13 @@ public async Task InvokeAsync(HttpContext httpContext) && TestContext.GetById(testId) is { } testContext) { httpContext.Items[HttpContextKey] = testContext; + + using (testContext.MakeCurrent()) + { + await _next(httpContext); + } + + return; } await _next(httpContext); diff --git a/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs b/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs index 5225cf50ef..e919ddae06 100644 --- a/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs +++ b/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs @@ -5,7 +5,9 @@ namespace TUnit.AspNetCore.Tests; /// -/// Tests both AsyncLocal and resolver-based test context correlation paths. +/// Tests correlated logging via both resolution paths: +/// AsyncLocal inherited from test thread (TestServer), and +/// MakeCurrent() set by middleware when AsyncLocal is absent (simulated Kestrel). /// public class CorrelatedLoggingResolverTests { @@ -13,7 +15,7 @@ public class CorrelatedLoggingResolverTests public TestWebAppFactory Factory { get; set; } = null!; [Test] - public async Task AsyncLocalPath_ServerLog_CorrelatedToCorrectTest() + public async Task InheritedAsyncLocal_ServerLog_CorrelatedToCorrectTest() { var marker = Guid.NewGuid().ToString("N"); using var client = Factory.CreateClientWithTestContext(); @@ -27,7 +29,7 @@ public async Task AsyncLocalPath_ServerLog_CorrelatedToCorrectTest() } [Test] - public async Task AsyncLocalPath_MultipleRequests_EachCorrelatedToSameTest() + public async Task InheritedAsyncLocal_MultipleRequests_EachCorrelatedToSameTest() { var marker1 = $"first_{Guid.NewGuid():N}"; var marker2 = $"second_{Guid.NewGuid():N}"; @@ -42,7 +44,7 @@ public async Task AsyncLocalPath_MultipleRequests_EachCorrelatedToSameTest() } [Test] - public async Task ResolverPath_ServerLog_CorrelatedToCorrectTest() + public async Task MiddlewareMakeCurrent_ServerLog_CorrelatedToCorrectTest() { var testContext = TestContext.Current!; var marker = Guid.NewGuid().ToString("N"); @@ -56,7 +58,7 @@ public async Task ResolverPath_ServerLog_CorrelatedToCorrectTest() } [Test] - public async Task ResolverPath_MultipleRequests_EachCorrelatedToSameTest() + public async Task MiddlewareMakeCurrent_MultipleRequests_EachCorrelatedToSameTest() { var testContext = TestContext.Current!; var marker1 = $"first_{Guid.NewGuid():N}"; @@ -80,8 +82,9 @@ public async Task PingEndpoint_Works_WithSharedFactory() /// /// Sends an HTTP request on a thread pool thread whose execution context does NOT - /// inherit the test's AsyncLocal values, simulating real Kestrel behavior and forcing - /// the resolver fallback path. + /// inherit the test's AsyncLocal values. This simulates real Kestrel behavior where + /// the middleware's call is the only way + /// the test context reaches server-side code. /// private static async Task SendWithSuppressedFlow( TestWebAppFactory factory, string path, TestContext testContext) diff --git a/TUnit.Core/Context.cs b/TUnit.Core/Context.cs index b277a68464..99fba5d987 100644 --- a/TUnit.Core/Context.cs +++ b/TUnit.Core/Context.cs @@ -14,12 +14,12 @@ protected Context? Parent public static Context Current => TestContext.Current as Context - ?? TestContextResolverRegistry.Resolve() as Context ?? TestBuildContext.Current as Context ?? ClassHookContext.Current as Context ?? AssemblyHookContext.Current as Context ?? TestSessionContext.Current as Context ?? BeforeTestDiscoveryContext.Current as Context + ?? TestContextResolverRegistry.Resolve() as Context ?? GlobalContext.Current; private readonly StringBuilder _outputBuilder = new(); diff --git a/TUnit.Core/ITestContextResolver.cs b/TUnit.Core/ITestContextResolver.cs index ce27e79227..e0e6821d0f 100644 --- a/TUnit.Core/ITestContextResolver.cs +++ b/TUnit.Core/ITestContextResolver.cs @@ -1,36 +1,51 @@ namespace TUnit.Core; /// -/// Allows custom logic for resolving which should receive console output -/// when the built-in -based mechanism cannot determine the context. +/// Advanced extension point for resolving which should receive console output +/// when the built-in AsyncLocal mechanism cannot determine the context. /// /// /// -/// The built-in context resolution uses AsyncLocal<T> which works when code runs on the same -/// async execution flow as the test. However, it returns null when shared services (e.g., hosted services, -/// gRPC handlers, message queue consumers) process work on their own thread pool threads. -/// Registered resolvers act as a fallback, consulted only when the AsyncLocal chain yields no result. +/// Prefer . For most scenarios (gRPC handlers, +/// message queue consumers, MCP servers, etc.), call in your +/// handler after extracting the test ID. This sets the AsyncLocal directly and is simpler, +/// safer, and more efficient than implementing a resolver. /// /// -/// Implement this interface and register it via -/// to provide custom resolution logic for these scenarios. +/// Implement this interface only when you need automatic protocol-level correlation +/// without requiring each handler to call explicitly +/// (for example, the built-in ASP.NET Core middleware). +/// +/// +/// Do not register a resolver per test. Resolvers must use ambient state +/// (e.g., HttpContext.Items, Activity.Current) to determine which test +/// is active. A resolver that always returns a fixed will produce +/// incorrect results when multiple tests run concurrently. /// /// /// Performance and thread safety: This method is called on every -/// Console.Write / Console.WriteLine from arbitrary threads, -/// so implementations must be thread-safe and very cheap. Avoid allocations, locks, and I/O in the hot path. +/// Console.Write / Console.WriteLine when no AsyncLocal context is available, +/// so implementations must be thread-safe and very cheap. /// /// /// Ordering: In , registered resolvers are consulted -/// after (AsyncLocal) but before hook contexts -/// (TestBuildContext, ClassHookContext, AssemblyHookContext). -/// If your resolver returns a non-null value on threads that execute class- or assembly-level hooks, -/// it will shadow those hook contexts. Ensure your resolver returns null when no -/// protocol request (HTTP, gRPC, etc.) is in-flight. +/// only after all AsyncLocal-based contexts (test, build, class hook, assembly hook, session, +/// and discovery contexts) return null. Resolvers are a true last resort before +/// . /// /// /// /// +/// // Prefer MakeCurrent() for simple cases: +/// if (TestContext.GetById(testId) is { } ctx) +/// { +/// using (ctx.MakeCurrent()) +/// { +/// await ProcessRequest(); +/// } +/// } +/// +/// // Use ITestContextResolver only for automatic protocol-level correlation: /// public class McpTestContextResolver : ITestContextResolver /// { /// public TestContext? ResolveCurrentTestContext() @@ -39,9 +54,6 @@ namespace TUnit.Core; /// return testId is not null ? TestContext.GetById(testId) : null; /// } /// } -/// -/// // Register in a [Before(Assembly)] hook: -/// TestContextResolverRegistry.Register(new McpTestContextResolver()); /// /// public interface ITestContextResolver diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs index fafe5c0ea0..2b73faf7e0 100644 --- a/TUnit.Core/TestContext.cs +++ b/TUnit.Core/TestContext.cs @@ -132,6 +132,55 @@ internal set } } + /// + /// Associates the current async scope with this test context so that console output + /// and log routing within the scope are attributed to this test. + /// Dispose the returned to restore the previous context. + /// + /// + /// Use this in protocol handlers (gRPC, MCP, message queue consumers, etc.) after + /// extracting the test ID from the incoming request. This is the primary public API + /// for correlating background-thread output to a test — prefer this over implementing + /// a custom . + /// + /// + /// + /// // In a gRPC handler: + /// var testId = context.RequestHeaders.GetValue("x-tunit-test-id"); + /// if (TestContext.GetById(testId) is { } testContext) + /// { + /// using (testContext.MakeCurrent()) + /// { + /// // All Console.Write / ILogger calls here route to the test + /// await ProcessRequest(); + /// } + /// } + /// + /// + /// A disposable scope that restores the previous context on disposal. + public ContextScope MakeCurrent() + { + var previous = Current; + Current = this; + return new ContextScope(previous); + } + + /// + /// A disposable scope returned by that restores the previous + /// value when disposed. + /// + public readonly struct ContextScope : IDisposable + { + private readonly TestContext? _previous; + + internal ContextScope(TestContext? previous) => _previous = previous; + + /// + /// Restores the previous test context. + /// + public void Dispose() => Current = _previous; + } + /// /// Gets a by its unique identifier, or null if not found. /// From 59464bb1be85b897eeccda8e47aaaca904eaca05 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:38:04 +0100 Subject: [PATCH 07/19] test: update public API snapshots for MakeCurrent and resolver APIs Adds ITestContextResolver, TestContextResolverRegistry, TestContext.MakeCurrent(), and TestContext.ContextScope to the verified public API surface for all four target frameworks. --- ...rary_Has_No_API_Changes.DotNet10_0.verified.txt | 14 ++++++++++++++ ...brary_Has_No_API_Changes.DotNet8_0.verified.txt | 14 ++++++++++++++ ...brary_Has_No_API_Changes.DotNet9_0.verified.txt | 14 ++++++++++++++ ..._Library_Has_No_API_Changes.Net4_7.verified.txt | 14 ++++++++++++++ 4 files changed, 56 insertions(+) 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 602c5d9d6e..7acb063371 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 @@ -902,6 +902,10 @@ namespace { ScopeType { get; } } + public interface ITestContextResolver + { + .TestContext? ResolveCurrentTestContext(); + } public interface ITestDefinition { } public interface ITestEntrySource { @@ -1387,8 +1391,13 @@ namespace public static string WorkingDirectory { get; set; } public override string GetErrorOutput() { } public override string GetStandardOutput() { } + public . MakeCurrent() { } public void RegisterTrace(.ActivityTraceId traceId) { } public static .TestContext? GetById(string id) { } + public readonly struct ContextScope : + { + public void Dispose() { } + } } public class TestContextEvents : . { @@ -1404,6 +1413,11 @@ namespace public .AsyncEvent<.TestContext>? OnTestSkipped { get; set; } public .AsyncEvent<.TestContext>? OnTestStart { get; set; } } + public static class TestContextResolverRegistry + { + public static void Register(.ITestContextResolver resolver) { } + public static bool Unregister(.ITestContextResolver resolver) { } + } public class TestDataCombination { public TestDataCombination() { } 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 a8c3c14902..3d9dc93176 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 @@ -902,6 +902,10 @@ namespace { ScopeType { get; } } + public interface ITestContextResolver + { + .TestContext? ResolveCurrentTestContext(); + } public interface ITestDefinition { } public interface ITestEntrySource { @@ -1387,8 +1391,13 @@ namespace public static string WorkingDirectory { get; set; } public override string GetErrorOutput() { } public override string GetStandardOutput() { } + public . MakeCurrent() { } public void RegisterTrace(.ActivityTraceId traceId) { } public static .TestContext? GetById(string id) { } + public readonly struct ContextScope : + { + public void Dispose() { } + } } public class TestContextEvents : . { @@ -1404,6 +1413,11 @@ namespace public .AsyncEvent<.TestContext>? OnTestSkipped { get; set; } public .AsyncEvent<.TestContext>? OnTestStart { get; set; } } + public static class TestContextResolverRegistry + { + public static void Register(.ITestContextResolver resolver) { } + public static bool Unregister(.ITestContextResolver resolver) { } + } public class TestDataCombination { public TestDataCombination() { } 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 600c6be393..22470bc572 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 @@ -902,6 +902,10 @@ namespace { ScopeType { get; } } + public interface ITestContextResolver + { + .TestContext? ResolveCurrentTestContext(); + } public interface ITestDefinition { } public interface ITestEntrySource { @@ -1387,8 +1391,13 @@ namespace public static string WorkingDirectory { get; set; } public override string GetErrorOutput() { } public override string GetStandardOutput() { } + public . MakeCurrent() { } public void RegisterTrace(.ActivityTraceId traceId) { } public static .TestContext? GetById(string id) { } + public readonly struct ContextScope : + { + public void Dispose() { } + } } public class TestContextEvents : . { @@ -1404,6 +1413,11 @@ namespace public .AsyncEvent<.TestContext>? OnTestSkipped { get; set; } public .AsyncEvent<.TestContext>? OnTestStart { get; set; } } + public static class TestContextResolverRegistry + { + public static void Register(.ITestContextResolver resolver) { } + public static bool Unregister(.ITestContextResolver resolver) { } + } public class TestDataCombination { public TestDataCombination() { } 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 846e8c1bcb..225cf98021 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 @@ -878,6 +878,10 @@ namespace { ScopeType { get; } } + public interface ITestContextResolver + { + .TestContext? ResolveCurrentTestContext(); + } public interface ITestDefinition { } public interface ITestEntrySource { @@ -1338,7 +1342,12 @@ namespace public static string WorkingDirectory { get; set; } public override string GetErrorOutput() { } public override string GetStandardOutput() { } + public . MakeCurrent() { } public static .TestContext? GetById(string id) { } + public readonly struct ContextScope : + { + public void Dispose() { } + } } public class TestContextEvents : . { @@ -1354,6 +1363,11 @@ namespace public .AsyncEvent<.TestContext>? OnTestSkipped { get; set; } public .AsyncEvent<.TestContext>? OnTestStart { get; set; } } + public static class TestContextResolverRegistry + { + public static void Register(.ITestContextResolver resolver) { } + public static bool Unregister(.ITestContextResolver resolver) { } + } public class TestDataCombination { public TestDataCombination() { } From 59b230919edbafc76805bf394531f20c548b7a5f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:45:32 +0100 Subject: [PATCH 08/19] docs: add double-dispose warning to ContextScope Address review feedback: since ContextScope is a readonly struct, disposing it twice would silently restore a stale context. Add a doc note so users who manage the lifetime manually know the constraint. Also filed #5503 to track the minimal-API TestWebApplicationFactory correlated logging registration gap. --- TUnit.Core/TestContext.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs index 2b73faf7e0..6d2a54ae72 100644 --- a/TUnit.Core/TestContext.cs +++ b/TUnit.Core/TestContext.cs @@ -168,6 +168,7 @@ public ContextScope MakeCurrent() /// /// A disposable scope returned by that restores the previous /// value when disposed. + /// This is a value type — do not dispose more than once. /// public readonly struct ContextScope : IDisposable { From 29b8e6e74ea2452353576c13b86b9c2b9c9322fd Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:58:26 +0100 Subject: [PATCH 09/19] =?UTF-8?q?refactor:=20remove=20ITestContextResolver?= =?UTF-8?q?=20=E2=80=94=20MakeCurrent()=20is=20sufficient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolver mechanism is fundamentally unsafe under concurrent execution: with N tests registering resolvers simultaneously, first-non-null-wins cannot determine which resolver belongs to which test. MakeCurrent() sets the AsyncLocal directly and is safe for any number of concurrent tests. The resolver was also unreachable in practice — the middleware calls MakeCurrent(), which sets TestContext.Current, so the AsyncLocal is always found before the resolver chain is consulted. Removed: ITestContextResolver, TestContextResolverRegistry, HttpContextTestContextResolver, and all associated tests/docs. Simplified: CorrelatedTUnitLoggerProvider (no longer needs IHttpContextAccessor), middleware (no longer writes HttpContext.Items), CorrelatedTUnitLogger (resolves via TestContext.Current only). --- .../Logging/CorrelatedTUnitLogger.cs | 7 +- .../Logging/CorrelatedTUnitLoggerProvider.cs | 17 +- .../CorrelatedTUnitLoggingExtensions.cs | 11 +- .../Logging/HttpContextTestContextResolver.cs | 47 ---- .../Logging/TUnitTestContextMiddleware.cs | 11 +- TUnit.Core/Context.cs | 1 - TUnit.Core/ITestContextResolver.cs | 67 ----- TUnit.Core/TestContext.cs | 4 +- TUnit.Core/TestContextResolverRegistry.cs | 134 ---------- ...Has_No_API_Changes.DotNet10_0.verified.txt | 9 - ..._Has_No_API_Changes.DotNet8_0.verified.txt | 9 - ..._Has_No_API_Changes.DotNet9_0.verified.txt | 9 - ...ary_Has_No_API_Changes.Net4_7.verified.txt | 9 - .../TestContextResolverRegistryTests.cs | 230 ------------------ 14 files changed, 10 insertions(+), 555 deletions(-) delete mode 100644 TUnit.AspNetCore.Core/Logging/HttpContextTestContextResolver.cs delete mode 100644 TUnit.Core/ITestContextResolver.cs delete mode 100644 TUnit.Core/TestContextResolverRegistry.cs delete mode 100644 TUnit.UnitTests/TestContextResolverRegistryTests.cs diff --git a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs index f14da75d7e..dead3e1e16 100644 --- a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs +++ b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs @@ -8,9 +8,8 @@ namespace TUnit.AspNetCore.Logging; /// A logger that resolves the current test context per log call, supporting shared web application scenarios. /// Sets and writes via so the console interceptor /// and all registered log sinks naturally route the output to the correct test. -/// Resolution checks (AsyncLocal) first, then falls back to the -/// (which includes the -/// registered by ). +/// Resolution uses (AsyncLocal), which is set by the middleware via +/// . /// public sealed class CorrelatedTUnitLogger : ILogger { @@ -84,6 +83,6 @@ public void Log( private static TestContext? ResolveTestContext() { - return TestContext.Current ?? TestContextResolverRegistry.Resolve(); + return TestContext.Current; } } diff --git a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs index 2a097a5dc6..2117251219 100644 --- a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs +++ b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs @@ -1,38 +1,26 @@ using System.Collections.Concurrent; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -using TUnit.Core; namespace TUnit.AspNetCore.Logging; /// /// A logger provider that creates instances. -/// Each log call resolves the current test context dynamically via , +/// Each log call resolves the current test context dynamically via , /// supporting shared web application scenarios where a single host serves multiple tests. /// -/// -/// Each provider instance registers its own in the global -/// and unregisters it on . In multi-factory -/// scenarios, each factory's provider adds one resolver — this is correct because each resolver queries -/// its own factory's . -/// public sealed class CorrelatedTUnitLoggerProvider : ILoggerProvider { private readonly ConcurrentDictionary _loggers = new(); - private readonly HttpContextTestContextResolver _resolver; private readonly LogLevel _minLogLevel; private bool _disposed; /// /// Creates a new . /// - /// The HTTP context accessor used to resolve test context from incoming requests. /// The minimum log level to capture. Defaults to Information. - public CorrelatedTUnitLoggerProvider(IHttpContextAccessor httpContextAccessor, LogLevel minLogLevel = LogLevel.Information) + public CorrelatedTUnitLoggerProvider(LogLevel minLogLevel = LogLevel.Information) { _minLogLevel = minLogLevel; - _resolver = new HttpContextTestContextResolver(httpContextAccessor); - TestContextResolverRegistry.Register(_resolver); } public ILogger CreateLogger(string categoryName) @@ -51,7 +39,6 @@ public void Dispose() } _disposed = true; - TestContextResolverRegistry.Unregister(_resolver); _loggers.Clear(); } } diff --git a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs index 58910d016a..499673ee7b 100644 --- a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs +++ b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -15,9 +14,7 @@ public static class CorrelatedTUnitLoggingExtensions /// Adds correlated TUnit logging to the service collection. /// This registers the via an /// and a that resolves the test context per log call - /// and registers an with - /// for automatic context resolution - /// on ASP.NET Core request-processing threads. + /// via . /// Use with on the client side to propagate test context. /// /// The service collection. @@ -27,12 +24,8 @@ public static IServiceCollection AddCorrelatedTUnitLogging( this IServiceCollection services, LogLevel minLogLevel = LogLevel.Information) { - services.AddHttpContextAccessor(); services.AddSingleton(new TUnitTestContextStartupFilter()); - services.AddSingleton(sp => - new CorrelatedTUnitLoggerProvider( - sp.GetRequiredService(), - minLogLevel)); + services.AddSingleton(new CorrelatedTUnitLoggerProvider(minLogLevel)); return services; } diff --git a/TUnit.AspNetCore.Core/Logging/HttpContextTestContextResolver.cs b/TUnit.AspNetCore.Core/Logging/HttpContextTestContextResolver.cs deleted file mode 100644 index 89864a8c3a..0000000000 --- a/TUnit.AspNetCore.Core/Logging/HttpContextTestContextResolver.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Microsoft.AspNetCore.Http; -using TUnit.Core; - -namespace TUnit.AspNetCore.Logging; - -/// -/// An that resolves the current test context from -/// , where it was stored by . -/// -/// -/// -/// This resolver is automatically registered when -/// is called. It enables (and therefore the console interceptor) -/// to route output to the correct test even on ASP.NET Core request-processing threads -/// that don't inherit the test's AsyncLocal context. -/// -/// -/// Resolution cost: one property access -/// plus one dictionary lookup. Both are very cheap. -/// -/// -public sealed class HttpContextTestContextResolver : ITestContextResolver -{ - private readonly IHttpContextAccessor _httpContextAccessor; - - /// - /// Creates a new . - /// - /// The HTTP context accessor. - public HttpContextTestContextResolver(IHttpContextAccessor httpContextAccessor) - { - _httpContextAccessor = httpContextAccessor; - } - - /// - public TestContext? ResolveCurrentTestContext() - { - if (_httpContextAccessor.HttpContext?.Items is { } items - && items.TryGetValue(TUnitTestContextMiddleware.HttpContextKey, out var value) - && value is TestContext testContext) - { - return testContext; - } - - return null; - } -} diff --git a/TUnit.AspNetCore.Core/Logging/TUnitTestContextMiddleware.cs b/TUnit.AspNetCore.Core/Logging/TUnitTestContextMiddleware.cs index 42a2315a08..b6d08c667c 100644 --- a/TUnit.AspNetCore.Core/Logging/TUnitTestContextMiddleware.cs +++ b/TUnit.AspNetCore.Core/Logging/TUnitTestContextMiddleware.cs @@ -5,16 +5,11 @@ namespace TUnit.AspNetCore.Logging; /// /// Middleware that extracts the TUnit test context ID from incoming HTTP request headers -/// and stores the associated in -/// for correlated logging. +/// and calls so that console output and log routing +/// within the request are attributed to the correct test. /// public sealed class TUnitTestContextMiddleware { - /// - /// The key used to store the in . - /// - public const string HttpContextKey = "TUnit.TestContext"; - private readonly RequestDelegate _next; /// @@ -33,8 +28,6 @@ public async Task InvokeAsync(HttpContext httpContext) && values.FirstOrDefault() is { } testId && TestContext.GetById(testId) is { } testContext) { - httpContext.Items[HttpContextKey] = testContext; - using (testContext.MakeCurrent()) { await _next(httpContext); diff --git a/TUnit.Core/Context.cs b/TUnit.Core/Context.cs index 99fba5d987..dd5b916d00 100644 --- a/TUnit.Core/Context.cs +++ b/TUnit.Core/Context.cs @@ -19,7 +19,6 @@ TestContext.Current as Context ?? AssemblyHookContext.Current as Context ?? TestSessionContext.Current as Context ?? BeforeTestDiscoveryContext.Current as Context - ?? TestContextResolverRegistry.Resolve() as Context ?? GlobalContext.Current; private readonly StringBuilder _outputBuilder = new(); diff --git a/TUnit.Core/ITestContextResolver.cs b/TUnit.Core/ITestContextResolver.cs deleted file mode 100644 index e0e6821d0f..0000000000 --- a/TUnit.Core/ITestContextResolver.cs +++ /dev/null @@ -1,67 +0,0 @@ -namespace TUnit.Core; - -/// -/// Advanced extension point for resolving which should receive console output -/// when the built-in AsyncLocal mechanism cannot determine the context. -/// -/// -/// -/// Prefer . For most scenarios (gRPC handlers, -/// message queue consumers, MCP servers, etc.), call in your -/// handler after extracting the test ID. This sets the AsyncLocal directly and is simpler, -/// safer, and more efficient than implementing a resolver. -/// -/// -/// Implement this interface only when you need automatic protocol-level correlation -/// without requiring each handler to call explicitly -/// (for example, the built-in ASP.NET Core middleware). -/// -/// -/// Do not register a resolver per test. Resolvers must use ambient state -/// (e.g., HttpContext.Items, Activity.Current) to determine which test -/// is active. A resolver that always returns a fixed will produce -/// incorrect results when multiple tests run concurrently. -/// -/// -/// Performance and thread safety: This method is called on every -/// Console.Write / Console.WriteLine when no AsyncLocal context is available, -/// so implementations must be thread-safe and very cheap. -/// -/// -/// Ordering: In , registered resolvers are consulted -/// only after all AsyncLocal-based contexts (test, build, class hook, assembly hook, session, -/// and discovery contexts) return null. Resolvers are a true last resort before -/// . -/// -/// -/// -/// -/// // Prefer MakeCurrent() for simple cases: -/// if (TestContext.GetById(testId) is { } ctx) -/// { -/// using (ctx.MakeCurrent()) -/// { -/// await ProcessRequest(); -/// } -/// } -/// -/// // Use ITestContextResolver only for automatic protocol-level correlation: -/// public class McpTestContextResolver : ITestContextResolver -/// { -/// public TestContext? ResolveCurrentTestContext() -/// { -/// var testId = McpRequestContext.Current?.TestId; -/// return testId is not null ? TestContext.GetById(testId) : null; -/// } -/// } -/// -/// -public interface ITestContextResolver -{ - /// - /// Attempts to resolve the current test context. - /// Return null to fall through to the next resolver or the built-in AsyncLocal chain. - /// - /// The resolved , or null if this resolver cannot determine the context. - TestContext? ResolveCurrentTestContext(); -} diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs index 6d2a54ae72..96c8fc8cb4 100644 --- a/TUnit.Core/TestContext.cs +++ b/TUnit.Core/TestContext.cs @@ -139,9 +139,7 @@ internal set /// /// /// Use this in protocol handlers (gRPC, MCP, message queue consumers, etc.) after - /// extracting the test ID from the incoming request. This is the primary public API - /// for correlating background-thread output to a test — prefer this over implementing - /// a custom . + /// extracting the test ID from the incoming request. /// /// /// diff --git a/TUnit.Core/TestContextResolverRegistry.cs b/TUnit.Core/TestContextResolverRegistry.cs deleted file mode 100644 index b544c140d3..0000000000 --- a/TUnit.Core/TestContextResolverRegistry.cs +++ /dev/null @@ -1,134 +0,0 @@ -namespace TUnit.Core; - -/// -/// Registry for custom instances. -/// Registered resolvers are consulted (in registration order) as a fallback when the built-in -/// AsyncLocal chain cannot determine the current . -/// -/// -/// -/// When no resolvers are registered, the overhead on every Console.Write call -/// is a single volatile array-length check. -/// -/// -/// Register resolvers in a [Before(Assembly)] hook so they are active before any tests run. -/// -/// -/// -/// -/// [Before(Assembly)] -/// public static void SetupResolvers() -/// { -/// TestContextResolverRegistry.Register(new MyCustomResolver()); -/// } -/// -/// -public static class TestContextResolverRegistry -{ - // Volatile array for lock-free reads on the hot path. - // Writes are guarded by _lock to ensure consistency. - private static volatile ITestContextResolver[] _resolvers = []; - private static readonly Lock _lock = new(); - - /// - /// Registers a custom resolver. Resolvers are consulted in registration order. - /// - /// The resolver to register. - public static void Register(ITestContextResolver resolver) - { - if (resolver is null) - { - throw new ArgumentNullException(nameof(resolver)); - } - - lock (_lock) - { - var current = _resolvers; - - if (Array.IndexOf(current, resolver) >= 0) - { - return; - } - - var newArray = new ITestContextResolver[current.Length + 1]; - current.CopyTo(newArray, 0); - newArray[current.Length] = resolver; - _resolvers = newArray; - } - } - - /// - /// Removes a previously registered resolver. - /// - /// The resolver to remove. - /// true if the resolver was found and removed; otherwise false. - public static bool Unregister(ITestContextResolver resolver) - { - if (resolver is null) - { - throw new ArgumentNullException(nameof(resolver)); - } - - lock (_lock) - { - var current = _resolvers; - var index = Array.IndexOf(current, resolver); - if (index < 0) - { - return false; - } - - var newArray = new ITestContextResolver[current.Length - 1]; - Array.Copy(current, 0, newArray, 0, index); - Array.Copy(current, index + 1, newArray, index, current.Length - index - 1); - _resolvers = newArray; - return true; - } - } - - /// - /// Consults all registered resolvers in order, returning the first non-null result. - /// Returns null when no resolver can determine the context (or none are registered). - /// - internal static TestContext? Resolve() - { - // Hot-path: volatile read of the array reference. - // When no resolvers are registered this is just an array-length check. - var resolvers = _resolvers; - if (resolvers.Length == 0) - { - return null; - } - - foreach (var resolver in resolvers) - { - try - { - var context = resolver.ResolveCurrentTestContext(); - if (context is not null) - { - return context; - } - } - catch - { - // Must swallow: this runs inside Console.Write, so logging the error - // via Console/stderr would re-enter this method. A broken resolver - // manifests as uncorrelated test output, which is visible in results. - } - } - - return null; - } - - /// - /// Removes all registered resolvers. For internal/testing use. - /// - internal static void Clear() - { - lock (_lock) - { - _resolvers = []; - } - } -} 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 7acb063371..1600d3729a 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 @@ -902,10 +902,6 @@ namespace { ScopeType { get; } } - public interface ITestContextResolver - { - .TestContext? ResolveCurrentTestContext(); - } public interface ITestDefinition { } public interface ITestEntrySource { @@ -1413,11 +1409,6 @@ namespace public .AsyncEvent<.TestContext>? OnTestSkipped { get; set; } public .AsyncEvent<.TestContext>? OnTestStart { get; set; } } - public static class TestContextResolverRegistry - { - public static void Register(.ITestContextResolver resolver) { } - public static bool Unregister(.ITestContextResolver resolver) { } - } public class TestDataCombination { public TestDataCombination() { } 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 3d9dc93176..9c639e8df0 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 @@ -902,10 +902,6 @@ namespace { ScopeType { get; } } - public interface ITestContextResolver - { - .TestContext? ResolveCurrentTestContext(); - } public interface ITestDefinition { } public interface ITestEntrySource { @@ -1413,11 +1409,6 @@ namespace public .AsyncEvent<.TestContext>? OnTestSkipped { get; set; } public .AsyncEvent<.TestContext>? OnTestStart { get; set; } } - public static class TestContextResolverRegistry - { - public static void Register(.ITestContextResolver resolver) { } - public static bool Unregister(.ITestContextResolver resolver) { } - } public class TestDataCombination { public TestDataCombination() { } 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 22470bc572..1cb8e8b263 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 @@ -902,10 +902,6 @@ namespace { ScopeType { get; } } - public interface ITestContextResolver - { - .TestContext? ResolveCurrentTestContext(); - } public interface ITestDefinition { } public interface ITestEntrySource { @@ -1413,11 +1409,6 @@ namespace public .AsyncEvent<.TestContext>? OnTestSkipped { get; set; } public .AsyncEvent<.TestContext>? OnTestStart { get; set; } } - public static class TestContextResolverRegistry - { - public static void Register(.ITestContextResolver resolver) { } - public static bool Unregister(.ITestContextResolver resolver) { } - } public class TestDataCombination { public TestDataCombination() { } 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 225cf98021..6efe709542 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 @@ -878,10 +878,6 @@ namespace { ScopeType { get; } } - public interface ITestContextResolver - { - .TestContext? ResolveCurrentTestContext(); - } public interface ITestDefinition { } public interface ITestEntrySource { @@ -1363,11 +1359,6 @@ namespace public .AsyncEvent<.TestContext>? OnTestSkipped { get; set; } public .AsyncEvent<.TestContext>? OnTestStart { get; set; } } - public static class TestContextResolverRegistry - { - public static void Register(.ITestContextResolver resolver) { } - public static bool Unregister(.ITestContextResolver resolver) { } - } public class TestDataCombination { public TestDataCombination() { } diff --git a/TUnit.UnitTests/TestContextResolverRegistryTests.cs b/TUnit.UnitTests/TestContextResolverRegistryTests.cs deleted file mode 100644 index b43e7d7a1f..0000000000 --- a/TUnit.UnitTests/TestContextResolverRegistryTests.cs +++ /dev/null @@ -1,230 +0,0 @@ -using TUnit.Core; - -namespace TUnit.UnitTests; - -/// -/// Tests for and . -/// Verifies that custom resolvers act as a fallback when AsyncLocal cannot resolve the context. -/// -[NotInParallel] -public class TestContextResolverRegistryTests -{ - [Before(Test)] - public void SetUp() - { - TestContextResolverRegistry.Clear(); - } - - [After(Test)] - public void TearDown() - { - TestContextResolverRegistry.Clear(); - } - - [Test] - public async Task Resolve_NoResolversRegistered_ReturnsNull() - { - // Act - var result = TestContextResolverRegistry.Resolve(); - - // Assert - await Assert.That(result).IsNull(); - } - - [Test] - public async Task Resolve_ResolverReturnsNull_ReturnsNull() - { - // Arrange - TestContextResolverRegistry.Register(new NullResolver()); - - // Act - var result = TestContextResolverRegistry.Resolve(); - - // Assert - await Assert.That(result).IsNull(); - } - - [Test] - public async Task Resolve_ResolverReturnsContext_ReturnsIt() - { - // Arrange - var expectedContext = TestContext.Current!; - TestContextResolverRegistry.Register(new FixedResolver(expectedContext)); - - // Act - var result = TestContextResolverRegistry.Resolve(); - - // Assert - await Assert.That(result).IsSameReferenceAs(expectedContext); - } - - [Test] - public async Task Resolve_MultipleResolvers_ReturnsFirstNonNull() - { - // Arrange - var expectedContext = TestContext.Current!; - TestContextResolverRegistry.Register(new NullResolver()); - TestContextResolverRegistry.Register(new FixedResolver(expectedContext)); - TestContextResolverRegistry.Register(new NullResolver()); // should not be reached - - // Act - var result = TestContextResolverRegistry.Resolve(); - - // Assert - await Assert.That(result).IsSameReferenceAs(expectedContext); - } - - [Test] - public async Task Unregister_RemovesResolver() - { - // Arrange - var expectedContext = TestContext.Current!; - var resolver = new FixedResolver(expectedContext); - TestContextResolverRegistry.Register(resolver); - - // Act - var removed = TestContextResolverRegistry.Unregister(resolver); - - // Assert - await Assert.That(removed).IsTrue(); - await Assert.That(TestContextResolverRegistry.Resolve()).IsNull(); - } - - [Test] - public async Task Unregister_UnknownResolver_ReturnsFalse() - { - // Act - var removed = TestContextResolverRegistry.Unregister(new NullResolver()); - - // Assert - await Assert.That(removed).IsFalse(); - } - - [Test] - public async Task ContextCurrent_AsyncLocalTakesPrecedenceOverResolvers() - { - // Arrange - register a resolver, but AsyncLocal should win - var testContext = TestContext.Current!; - TestContextResolverRegistry.Register(new FixedResolver(testContext)); - - // Act - Context.Current should use AsyncLocal first (since we're on the test thread) - var result = Context.Current; - - // Assert - resolved via AsyncLocal, same reference since both point to current test - await Assert.That(result).IsSameReferenceAs(testContext); - } - - [Test] - public async Task ContextCurrent_CrossThread_ResolverProvidesContext() - { - // This test demonstrates the core use case: code running on a thread pool thread - // (which does NOT inherit AsyncLocal) can still resolve the correct test context - // via a custom resolver. - - var testContext = TestContext.Current!; - - // Store the test context ID in a thread-static or similar mechanism - // that a resolver can read (simulating e.g. an MCP request context) - var threadLocalResolver = new ThreadStaticResolver(); - TestContextResolverRegistry.Register(threadLocalResolver); - - Context? resolvedOnBackgroundThread = null; - - // Run on a fresh thread pool thread that does NOT inherit AsyncLocal - var thread = new Thread(() => - { - // Set the correlation data that the resolver will use - ThreadStaticResolver.CurrentTestContext = testContext; - - // This would normally return GlobalContext since AsyncLocal is empty - // but our resolver kicks in first - resolvedOnBackgroundThread = Context.Current; - - ThreadStaticResolver.CurrentTestContext = null; - }); - - thread.Start(); - thread.Join(); - - // Assert - the background thread resolved the correct context via our custom resolver - await Assert.That(resolvedOnBackgroundThread).IsSameReferenceAs(testContext); - } - - [Test] - public async Task Register_DuplicateResolver_IsIdempotent() - { - // Arrange - var expectedContext = TestContext.Current!; - var resolver = new FixedResolver(expectedContext); - - // Act - register the same instance twice - TestContextResolverRegistry.Register(resolver); - TestContextResolverRegistry.Register(resolver); - - // Unregister once should remove it completely - TestContextResolverRegistry.Unregister(resolver); - - // Assert - no resolvers left, so Resolve returns null - await Assert.That(TestContextResolverRegistry.Resolve()).IsNull(); - } - - [Test] - public async Task Resolve_SwallowsResolverExceptions() - { - // Arrange - a faulty resolver followed by a working one - var expectedContext = TestContext.Current!; - TestContextResolverRegistry.Register(new ThrowingResolver()); - TestContextResolverRegistry.Register(new FixedResolver(expectedContext)); - - // Act - should skip the throwing resolver and return the second one - var result = TestContextResolverRegistry.Resolve(); - - // Assert - await Assert.That(result).IsSameReferenceAs(expectedContext); - } - - [Test] - public async Task ContextCurrent_FallsBackToAsyncLocal_WhenResolverReturnsNull() - { - // Arrange - resolver returns null, so should fall back to AsyncLocal - TestContextResolverRegistry.Register(new NullResolver()); - var asyncLocalContext = TestContext.Current; - - // Act - var result = Context.Current; - - // Assert - should still resolve via AsyncLocal - await Assert.That(result).IsSameReferenceAs(asyncLocalContext); - } - - private class NullResolver : ITestContextResolver - { - public TestContext? ResolveCurrentTestContext() => null; - } - - private class ThrowingResolver : ITestContextResolver - { - public TestContext? ResolveCurrentTestContext() => throw new InvalidOperationException("Faulty resolver"); - } - - private class FixedResolver : ITestContextResolver - { - private readonly TestContext _context; - - public FixedResolver(TestContext context) => _context = context; - - public TestContext? ResolveCurrentTestContext() => _context; - } - - /// - /// A resolver that uses thread-static storage, simulating how a custom protocol - /// (MCP, gRPC, etc.) might store the test context ID and resolve it. - /// - private class ThreadStaticResolver : ITestContextResolver - { - [ThreadStatic] - public static TestContext? CurrentTestContext; - - public TestContext? ResolveCurrentTestContext() => CurrentTestContext; - } -} From 3674122cdf4bcfcbe209bf59313cf551aa1fbe2c Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:01:31 +0100 Subject: [PATCH 10/19] docs: add cross-thread output correlation guide Documents TestContext.MakeCurrent() and TestContext.GetById() for correlating console/ILogger output from background threads back to the originating test. Includes protocol-specific examples for gRPC, message queues, and a note that ASP.NET Core handles this automatically. --- docs/docs/extending/logging.md | 140 +++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/docs/docs/extending/logging.md b/docs/docs/extending/logging.md index 3fe3a2f38e..91ac994b72 100644 --- a/docs/docs/extending/logging.md +++ b/docs/docs/extending/logging.md @@ -31,6 +31,146 @@ public async Task MyTest() This logger can integrate with other logging frameworks like Microsoft.Extensions.Logging for ASP.NET applications. +## Cross-Thread Output Correlation + +By default, TUnit uses `AsyncLocal` to track which test is running on the current async flow. This works automatically when your code runs on the same async context as the test — for example, calling `Console.WriteLine()` from test code, or from services invoked directly by the test. + +However, when work runs on a **different thread or async context** — such as inside a gRPC handler, message queue consumer, MCP server, or background service — the `AsyncLocal` context is not inherited, and TUnit cannot automatically determine which test the output belongs to. + +### The Problem + +```csharp +[Test] +public async Task MyTest() +{ + // ✅ This works — same async context as the test + Console.WriteLine("This is captured by the test"); + + // Start some background work that processes on its own thread + await myService.ProcessAsync(requestId); + + // ❌ Inside ProcessAsync, if it runs on a different thread, + // Console.WriteLine output won't be associated with this test +} +``` + +### The Solution: `TestContext.MakeCurrent()` + +Use `TestContext.MakeCurrent()` to associate a scope with a specific test. All `Console.Write`, `Console.WriteLine`, and `ILogger` calls within that scope are routed to the correct test's output. + +```csharp +using (testContext.MakeCurrent()) +{ + // All output here is attributed to the test + Console.WriteLine("This goes to the right test"); + await ProcessRequest(); +} +// Previous context is automatically restored +``` + +### How to Get the Test Context + +Your background service needs a way to know **which test** to correlate to. The typical pattern is to propagate the test's unique ID through your protocol (HTTP header, gRPC metadata, message property, etc.), then look it up on the receiving side with `TestContext.GetById()`. + +#### Step 1: Send the Test ID + +From your test, include `TestContext.Current!.Id` in the request: + +```csharp +[Test] +public async Task MyTest() +{ + var request = new MyRequest + { + Payload = "test data", + TestId = TestContext.Current!.Id // Propagate the test ID + }; + + await myService.SendAsync(request); + + var output = TestContext.Current!.GetStandardOutput(); + await Assert.That(output).Contains("processed"); +} +``` + +#### Step 2: Resolve and Activate on the Receiving Side + +In your handler, extract the test ID and call `MakeCurrent()`: + +```csharp +public async Task HandleAsync(MyRequest request) +{ + // Look up the test context by the propagated ID + if (TestContext.GetById(request.TestId) is { } testContext) + { + using (testContext.MakeCurrent()) + { + // All output here is attributed to the originating test + Console.WriteLine("processed"); + await DoWork(request); + } + } +} +``` + +### Protocol-Specific Examples + +#### gRPC Server Interceptor + +```csharp +public class TUnitGrpcInterceptor : Interceptor +{ + public override async Task UnaryServerHandler( + TRequest request, + ServerCallContext context, + UnaryServerMethod continuation) + { + var testId = context.RequestHeaders.GetValue("x-tunit-test-id"); + + if (testId is not null && TestContext.GetById(testId) is { } testContext) + { + using (testContext.MakeCurrent()) + { + return await continuation(request, context); + } + } + + return await continuation(request, context); + } +} +``` + +#### Message Queue Consumer + +```csharp +public class OrderConsumer : IConsumer +{ + public async Task Consume(ConsumeContext context) + { + var testId = context.Headers.Get("x-tunit-test-id"); + + if (testId is not null && TestContext.GetById(testId) is { } testContext) + { + using (testContext.MakeCurrent()) + { + await ProcessOrder(context.Message); + } + } + } +} +``` + +#### ASP.NET Core (Built-In) + +For ASP.NET Core, `TUnit.AspNetCore` handles this automatically. The `TUnitTestIdHandler` propagates the test ID via an HTTP header, and the `TUnitTestContextMiddleware` calls `MakeCurrent()` on the server side. See [ASP.NET Core Integration Testing](/docs/examples/aspnet#tunit-logging-integration) for details. + +### Key Points + +- `MakeCurrent()` returns a disposable scope — always use it with `using` to ensure the previous context is restored +- `TestContext.GetById()` returns `null` if the ID is not found (e.g., if the test has already completed), so always null-check +- `MakeCurrent()` is safe for concurrent tests — each call sets its own `AsyncLocal` scope independently +- The scope only affects the current async flow — other threads/tasks are not affected unless they inherit the `ExecutionContext` + ## Log Sinks TUnit uses a sink-based architecture where all output is routed through registered log sinks. Each sink decides how to handle the messages - write to files, stream to IDEs, send to external services, etc. From 80a224b2ec588d4415eff1919dcded9902de3e32 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:04:22 +0100 Subject: [PATCH 11/19] cleanup: inline ResolveTestContext and remove dead guard ResolveTestContext() was a trivial pass-through to TestContext.Current. The guard `if (TestContext.Current != testContext)` was always false since testContext IS TestContext.Current. Both removed. --- .../Logging/CorrelatedTUnitLogger.cs | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs index dead3e1e16..00b0366596 100644 --- a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs +++ b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs @@ -5,11 +5,10 @@ namespace TUnit.AspNetCore.Logging; /// -/// A logger that resolves the current test context per log call, supporting shared web application scenarios. -/// Sets and writes via so the console interceptor -/// and all registered log sinks naturally route the output to the correct test. -/// Resolution uses (AsyncLocal), which is set by the middleware via -/// . +/// A logger that routes output to the current test via (AsyncLocal), +/// which is set by the middleware via . +/// Writes via so the console interceptor and all registered log sinks +/// naturally route the output to the correct test. /// public sealed class CorrelatedTUnitLogger : ILogger { @@ -41,7 +40,7 @@ public void Log( return; } - var testContext = ResolveTestContext(); + var testContext = TestContext.Current; if (testContext is null) { @@ -55,13 +54,6 @@ public void Log( return; } - // Set the current test context so the console interceptor routes output - // to the correct test's sinks (test output, IDE real-time, console) - if (TestContext.Current != testContext) - { - TestContext.Current = testContext; - } - var message = formatter(state, exception); if (exception is not null) @@ -80,9 +72,4 @@ public void Log( Console.WriteLine(formattedMessage); } } - - private static TestContext? ResolveTestContext() - { - return TestContext.Current; - } } From 75eaa02f0df46f4ba17d64b45fd2e504d4e61895 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:10:35 +0100 Subject: [PATCH 12/19] refactor: replace CorrelatedTUnitLogger with internal SynchronousTUnitLogger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ASP.NET Core's built-in ConsoleLogger writes from a background queue thread that doesn't inherit the AsyncLocal set by MakeCurrent(). We still need a logger that writes to Console synchronously on the request thread so TUnit's interceptor routes output to the correct test. Replaced the public CorrelatedTUnitLogger/Provider with a minimal internal SynchronousTUnitLogger — no dedup check, no caching, no IHttpContextAccessor dependency. Just writes to Console if TestContext.Current is set. --- .../WebApplicationFactoryExtensions.cs | 2 +- .../Logging/CorrelatedTUnitLogger.cs | 75 ------------------- .../Logging/CorrelatedTUnitLoggerProvider.cs | 44 ----------- .../CorrelatedTUnitLoggingExtensions.cs | 17 ++--- .../Logging/SynchronousTUnitLoggerProvider.cs | 59 +++++++++++++++ 5 files changed, 67 insertions(+), 130 deletions(-) delete mode 100644 TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs delete mode 100644 TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs create mode 100644 TUnit.AspNetCore.Core/Logging/SynchronousTUnitLoggerProvider.cs diff --git a/TUnit.AspNetCore.Core/Extensions/WebApplicationFactoryExtensions.cs b/TUnit.AspNetCore.Core/Extensions/WebApplicationFactoryExtensions.cs index bf50eb065c..9973714fa7 100644 --- a/TUnit.AspNetCore.Core/Extensions/WebApplicationFactoryExtensions.cs +++ b/TUnit.AspNetCore.Core/Extensions/WebApplicationFactoryExtensions.cs @@ -12,7 +12,7 @@ public static class WebApplicationFactoryExtensions /// Creates an with a that automatically /// propagates the current test context ID to the server via HTTP headers. /// Use with - /// on the server side to correlate logs with tests. + /// on the server side to enable the test context middleware. /// /// The entry point class of the web application. /// The web application factory. diff --git a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs deleted file mode 100644 index 00b0366596..0000000000 --- a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs +++ /dev/null @@ -1,75 +0,0 @@ -using Microsoft.Extensions.Logging; -using TUnit.Core; -using TUnit.Logging.Microsoft; - -namespace TUnit.AspNetCore.Logging; - -/// -/// A logger that routes output to the current test via (AsyncLocal), -/// which is set by the middleware via . -/// Writes via so the console interceptor and all registered log sinks -/// naturally route the output to the correct test. -/// -public sealed class CorrelatedTUnitLogger : ILogger -{ - private readonly string _categoryName; - private readonly LogLevel _minLogLevel; - - internal CorrelatedTUnitLogger(string categoryName, LogLevel minLogLevel) - { - _categoryName = categoryName; - _minLogLevel = minLogLevel; - } - - public IDisposable? BeginScope(TState state) where TState : notnull - { - return TUnitLoggerScope.Push(state); - } - - public bool IsEnabled(LogLevel logLevel) => logLevel >= _minLogLevel; - - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - { - if (!IsEnabled(logLevel)) - { - return; - } - - var testContext = TestContext.Current; - - if (testContext is null) - { - return; - } - - // Skip if a per-test logger is active for this test context - // (avoids duplicate output when isolated factories inherit correlated logging) - if (TUnitLoggingRegistry.PerTestLoggingActive.ContainsKey(testContext.Id)) - { - return; - } - - var message = formatter(state, exception); - - if (exception is not null) - { - message = $"{message}{Environment.NewLine}{exception}"; - } - - var formattedMessage = $"[{logLevel}] {_categoryName}: {message}"; - - if (logLevel >= LogLevel.Error) - { - Console.Error.WriteLine(formattedMessage); - } - else - { - Console.WriteLine(formattedMessage); - } - } -} diff --git a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs deleted file mode 100644 index 2117251219..0000000000 --- a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Collections.Concurrent; -using Microsoft.Extensions.Logging; - -namespace TUnit.AspNetCore.Logging; - -/// -/// A logger provider that creates instances. -/// Each log call resolves the current test context dynamically via , -/// supporting shared web application scenarios where a single host serves multiple tests. -/// -public sealed class CorrelatedTUnitLoggerProvider : ILoggerProvider -{ - private readonly ConcurrentDictionary _loggers = new(); - private readonly LogLevel _minLogLevel; - private bool _disposed; - - /// - /// Creates a new . - /// - /// The minimum log level to capture. Defaults to Information. - public CorrelatedTUnitLoggerProvider(LogLevel minLogLevel = LogLevel.Information) - { - _minLogLevel = minLogLevel; - } - - public ILogger CreateLogger(string categoryName) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - return _loggers.GetOrAdd(categoryName, - name => new CorrelatedTUnitLogger(name, _minLogLevel)); - } - - public void Dispose() - { - if (_disposed) - { - return; - } - - _disposed = true; - _loggers.Clear(); - } -} diff --git a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs index 499673ee7b..e8e37c6aaf 100644 --- a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs +++ b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs @@ -6,26 +6,23 @@ namespace TUnit.AspNetCore.Logging; /// -/// Extension methods for adding correlated TUnit logging to a shared web application. +/// Extension methods for adding TUnit test context middleware to a shared web application. /// public static class CorrelatedTUnitLoggingExtensions { /// - /// Adds correlated TUnit logging to the service collection. - /// This registers the via an - /// and a that resolves the test context per log call - /// via . - /// Use with on the client side to propagate test context. + /// Adds the to the pipeline via an + /// and a synchronous logger provider that writes ILogger output to + /// on the calling thread so TUnit's console interceptor can route it to the correct test. + /// Use with on the client side to propagate the test context ID. /// /// The service collection. - /// The minimum log level to capture. Defaults to Information. /// The service collection for chaining. public static IServiceCollection AddCorrelatedTUnitLogging( - this IServiceCollection services, - LogLevel minLogLevel = LogLevel.Information) + this IServiceCollection services) { services.AddSingleton(new TUnitTestContextStartupFilter()); - services.AddSingleton(new CorrelatedTUnitLoggerProvider(minLogLevel)); + services.AddSingleton(new SynchronousTUnitLoggerProvider()); return services; } diff --git a/TUnit.AspNetCore.Core/Logging/SynchronousTUnitLoggerProvider.cs b/TUnit.AspNetCore.Core/Logging/SynchronousTUnitLoggerProvider.cs new file mode 100644 index 0000000000..e10c4b41f5 --- /dev/null +++ b/TUnit.AspNetCore.Core/Logging/SynchronousTUnitLoggerProvider.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Logging; +using TUnit.Core; + +namespace TUnit.AspNetCore.Logging; + +/// +/// A logger provider that writes ILogger output to synchronously on the calling thread. +/// This is necessary because ASP.NET Core's built-in ConsoleLogger writes from a background queue thread +/// that does not inherit the AsyncLocal set by . +/// By writing on the request thread, TUnit's console interceptor can route the output to the correct test. +/// +internal sealed class SynchronousTUnitLoggerProvider : ILoggerProvider +{ + public ILogger CreateLogger(string categoryName) => new SynchronousTUnitLogger(categoryName); + + public void Dispose() { } +} + +internal sealed class SynchronousTUnitLogger : ILogger +{ + private readonly string _categoryName; + + internal SynchronousTUnitLogger(string categoryName) => _categoryName = categoryName; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information && TestContext.Current is not null; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + var message = formatter(state, exception); + + if (exception is not null) + { + message = $"{message}{Environment.NewLine}{exception}"; + } + + var formattedMessage = $"[{logLevel}] {_categoryName}: {message}"; + + if (logLevel >= LogLevel.Error) + { + Console.Error.WriteLine(formattedMessage); + } + else + { + Console.WriteLine(formattedMessage); + } + } +} From 847c1b4e6766e9806fa23127b232103216e1a2bf Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:12:04 +0100 Subject: [PATCH 13/19] ci: add pipeline module for TUnit.AspNetCore.Tests Runs the ASP.NET Core integration tests on net10.0 and net8.0 (no net472 since ASP.NET Core doesn't support .NET Framework). --- .../Modules/RunAspNetCoreTestsModule.cs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 TUnit.Pipeline/Modules/RunAspNetCoreTestsModule.cs diff --git a/TUnit.Pipeline/Modules/RunAspNetCoreTestsModule.cs b/TUnit.Pipeline/Modules/RunAspNetCoreTestsModule.cs new file mode 100644 index 0000000000..f4c3187bcf --- /dev/null +++ b/TUnit.Pipeline/Modules/RunAspNetCoreTestsModule.cs @@ -0,0 +1,42 @@ +using ModularPipelines.Context; +using ModularPipelines.DotNet.Options; +using ModularPipelines.Extensions; +using ModularPipelines.Git.Extensions; +using ModularPipelines.Options; +using TUnit.Pipeline.Modules.Abstract; + +namespace TUnit.Pipeline.Modules; + +public class RunAspNetCoreTestsModule : TestBaseModule +{ + protected override IEnumerable TestableFrameworks + { + get + { + yield return "net10.0"; + yield return "net8.0"; + } + } + + protected override Task<(DotNetRunOptions Options, CommandExecutionOptions? ExecutionOptions)> GetTestOptions(IModuleContext context, string framework, CancellationToken cancellationToken) + { + var project = context.Git().RootDirectory.FindFile(x => x.Name == "TUnit.AspNetCore.Tests.csproj").AssertExists(); + + return Task.FromResult<(DotNetRunOptions, CommandExecutionOptions?)>(( + new DotNetRunOptions + { + NoBuild = true, + Configuration = "Release", + Framework = framework, + }, + new CommandExecutionOptions + { + WorkingDirectory = project.Folder!.Path, + EnvironmentVariables = new Dictionary + { + ["TUNIT_DISABLE_GITHUB_REPORTER"] = "true", + } + } + )); + } +} From e9c6294952a9b0b577e91fe123f8c12b6ffd82ea Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:13:35 +0100 Subject: [PATCH 14/19] rename: SynchronousTUnitLogger back to CorrelatedTUnitLogger The name describes the purpose (correlating output to the correct test), not the implementation detail (synchronous writes). --- ...LoggerProvider.cs => CorrelatedTUnitLoggerProvider.cs} | 8 ++++---- .../Logging/CorrelatedTUnitLoggingExtensions.cs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) rename TUnit.AspNetCore.Core/Logging/{SynchronousTUnitLoggerProvider.cs => CorrelatedTUnitLoggerProvider.cs} (83%) diff --git a/TUnit.AspNetCore.Core/Logging/SynchronousTUnitLoggerProvider.cs b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs similarity index 83% rename from TUnit.AspNetCore.Core/Logging/SynchronousTUnitLoggerProvider.cs rename to TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs index e10c4b41f5..0e546224f3 100644 --- a/TUnit.AspNetCore.Core/Logging/SynchronousTUnitLoggerProvider.cs +++ b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs @@ -9,18 +9,18 @@ namespace TUnit.AspNetCore.Logging; /// that does not inherit the AsyncLocal set by . /// By writing on the request thread, TUnit's console interceptor can route the output to the correct test. /// -internal sealed class SynchronousTUnitLoggerProvider : ILoggerProvider +internal sealed class CorrelatedTUnitLoggerProvider : ILoggerProvider { - public ILogger CreateLogger(string categoryName) => new SynchronousTUnitLogger(categoryName); + public ILogger CreateLogger(string categoryName) => new CorrelatedTUnitLogger(categoryName); public void Dispose() { } } -internal sealed class SynchronousTUnitLogger : ILogger +internal sealed class CorrelatedTUnitLogger : ILogger { private readonly string _categoryName; - internal SynchronousTUnitLogger(string categoryName) => _categoryName = categoryName; + internal CorrelatedTUnitLogger(string categoryName) => _categoryName = categoryName; public IDisposable? BeginScope(TState state) where TState : notnull => null; diff --git a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs index e8e37c6aaf..f5be6865db 100644 --- a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs +++ b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs @@ -22,7 +22,7 @@ public static IServiceCollection AddCorrelatedTUnitLogging( this IServiceCollection services) { services.AddSingleton(new TUnitTestContextStartupFilter()); - services.AddSingleton(new SynchronousTUnitLoggerProvider()); + services.AddSingleton(new CorrelatedTUnitLoggerProvider()); return services; } From cf4620104e922d873481a202ad028fd4bc4944b0 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:20:42 +0100 Subject: [PATCH 15/19] fix(tests): guard ExecutionContext.SuppressFlow with try/finally Ensures flowControl.Undo() runs even if Task.Run throws synchronously, preventing the execution context flow from staying suppressed. --- .../CorrelatedLoggingResolverTests.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs b/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs index e919ddae06..b60c67a2f5 100644 --- a/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs +++ b/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs @@ -94,9 +94,13 @@ private static async Task SendWithSuppressedFlow( request.Headers.TryAddWithoutValidation(TUnitTestIdHandler.HeaderName, testContext.Id); var flowControl = ExecutionContext.SuppressFlow(); - var task = Task.Run(() => client.SendAsync(request)); - flowControl.Undo(); - - return await task; + try + { + return await Task.Run(() => client.SendAsync(request)); + } + finally + { + flowControl.Undo(); + } } } From 34509ee9c7797d8024bf14eb6be053d0e8f911fc Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:29:42 +0100 Subject: [PATCH 16/19] refactor: restore full CorrelatedTUnitLogger, remove IHttpContextAccessor Restore the original fleshed-out logger (scope support via TUnitLoggerScope, configurable minLogLevel, PerTestLoggingActive dedup guard) but remove the IHttpContextAccessor/HttpContext.Items resolution path since MakeCurrent() sets AsyncLocal in the middleware and it flows naturally to the logger. --- .../Logging/CorrelatedTUnitLogger.cs | 80 +++++++++++++++++++ .../Logging/CorrelatedTUnitLoggerProvider.cs | 69 +++++++--------- .../CorrelatedTUnitLoggingExtensions.cs | 16 ++-- 3 files changed, 117 insertions(+), 48 deletions(-) create mode 100644 TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs diff --git a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs new file mode 100644 index 0000000000..5004dc96b6 --- /dev/null +++ b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs @@ -0,0 +1,80 @@ +using Microsoft.Extensions.Logging; +using TUnit.Core; +using TUnit.Logging.Microsoft; + +namespace TUnit.AspNetCore.Logging; + +/// +/// A logger that resolves the current test context per log call, supporting shared web application scenarios. +/// Writes via synchronously on the calling thread so TUnit's console interceptor +/// can route the output to the correct test via (set by +/// calling ). +/// +/// +/// This logger is necessary because ASP.NET Core's built-in ConsoleLogger writes from a background +/// queue thread that does not inherit the AsyncLocal set by . +/// By writing synchronously on the request thread, the output is attributed to the correct test. +/// +public sealed class CorrelatedTUnitLogger : ILogger +{ + private readonly string _categoryName; + private readonly LogLevel _minLogLevel; + + internal CorrelatedTUnitLogger(string categoryName, LogLevel minLogLevel) + { + _categoryName = categoryName; + _minLogLevel = minLogLevel; + } + + public IDisposable? BeginScope(TState state) where TState : notnull + { + return TUnitLoggerScope.Push(state); + } + + public bool IsEnabled(LogLevel logLevel) => logLevel >= _minLogLevel; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + var testContext = TestContext.Current; + + if (testContext is null) + { + return; + } + + // Skip if a per-test logger is active for this test context + // (avoids duplicate output when isolated factories inherit correlated logging) + if (TUnitLoggingRegistry.PerTestLoggingActive.ContainsKey(testContext.Id)) + { + return; + } + + var message = formatter(state, exception); + + if (exception is not null) + { + message = $"{message}{Environment.NewLine}{exception}"; + } + + var formattedMessage = $"[{logLevel}] {_categoryName}: {message}"; + + if (logLevel >= LogLevel.Error) + { + Console.Error.WriteLine(formattedMessage); + } + else + { + Console.WriteLine(formattedMessage); + } + } +} diff --git a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs index 0e546224f3..2117251219 100644 --- a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs +++ b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs @@ -1,59 +1,44 @@ +using System.Collections.Concurrent; using Microsoft.Extensions.Logging; -using TUnit.Core; namespace TUnit.AspNetCore.Logging; /// -/// A logger provider that writes ILogger output to synchronously on the calling thread. -/// This is necessary because ASP.NET Core's built-in ConsoleLogger writes from a background queue thread -/// that does not inherit the AsyncLocal set by . -/// By writing on the request thread, TUnit's console interceptor can route the output to the correct test. +/// A logger provider that creates instances. +/// Each log call resolves the current test context dynamically via , +/// supporting shared web application scenarios where a single host serves multiple tests. /// -internal sealed class CorrelatedTUnitLoggerProvider : ILoggerProvider +public sealed class CorrelatedTUnitLoggerProvider : ILoggerProvider { - public ILogger CreateLogger(string categoryName) => new CorrelatedTUnitLogger(categoryName); - - public void Dispose() { } -} - -internal sealed class CorrelatedTUnitLogger : ILogger -{ - private readonly string _categoryName; - - internal CorrelatedTUnitLogger(string categoryName) => _categoryName = categoryName; + private readonly ConcurrentDictionary _loggers = new(); + private readonly LogLevel _minLogLevel; + private bool _disposed; + + /// + /// Creates a new . + /// + /// The minimum log level to capture. Defaults to Information. + public CorrelatedTUnitLoggerProvider(LogLevel minLogLevel = LogLevel.Information) + { + _minLogLevel = minLogLevel; + } - public IDisposable? BeginScope(TState state) where TState : notnull => null; + public ILogger CreateLogger(string categoryName) + { + ObjectDisposedException.ThrowIf(_disposed, this); - public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information && TestContext.Current is not null; + return _loggers.GetOrAdd(categoryName, + name => new CorrelatedTUnitLogger(name, _minLogLevel)); + } - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) + public void Dispose() { - if (!IsEnabled(logLevel)) + if (_disposed) { return; } - var message = formatter(state, exception); - - if (exception is not null) - { - message = $"{message}{Environment.NewLine}{exception}"; - } - - var formattedMessage = $"[{logLevel}] {_categoryName}: {message}"; - - if (logLevel >= LogLevel.Error) - { - Console.Error.WriteLine(formattedMessage); - } - else - { - Console.WriteLine(formattedMessage); - } + _disposed = true; + _loggers.Clear(); } } diff --git a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs index f5be6865db..d6135e2f4d 100644 --- a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs +++ b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs @@ -6,23 +6,27 @@ namespace TUnit.AspNetCore.Logging; /// -/// Extension methods for adding TUnit test context middleware to a shared web application. +/// Extension methods for adding correlated TUnit logging to a shared web application. /// public static class CorrelatedTUnitLoggingExtensions { /// - /// Adds the to the pipeline via an - /// and a synchronous logger provider that writes ILogger output to - /// on the calling thread so TUnit's console interceptor can route it to the correct test. + /// Adds correlated TUnit logging to the service collection. + /// This registers the via an + /// and a that writes ILogger output to + /// on the calling thread so TUnit's console interceptor can route + /// it to the correct test. /// Use with on the client side to propagate the test context ID. /// /// The service collection. + /// The minimum log level to capture. Defaults to Information. /// The service collection for chaining. public static IServiceCollection AddCorrelatedTUnitLogging( - this IServiceCollection services) + this IServiceCollection services, + LogLevel minLogLevel = LogLevel.Information) { services.AddSingleton(new TUnitTestContextStartupFilter()); - services.AddSingleton(new CorrelatedTUnitLoggerProvider()); + services.AddSingleton(new CorrelatedTUnitLoggerProvider(minLogLevel)); return services; } From 1ef4559b83a0b4b13f60aa3572eba2c6ff2696b8 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:34:22 +0100 Subject: [PATCH 17/19] perf: use indexed access instead of FirstOrDefault() in middleware StringValues[0] avoids the LINQ enumeration overhead of FirstOrDefault() on every HTTP request through the middleware pipeline. --- TUnit.AspNetCore.Core/Logging/TUnitTestContextMiddleware.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TUnit.AspNetCore.Core/Logging/TUnitTestContextMiddleware.cs b/TUnit.AspNetCore.Core/Logging/TUnitTestContextMiddleware.cs index b6d08c667c..c2cbbee41e 100644 --- a/TUnit.AspNetCore.Core/Logging/TUnitTestContextMiddleware.cs +++ b/TUnit.AspNetCore.Core/Logging/TUnitTestContextMiddleware.cs @@ -25,8 +25,8 @@ public sealed class TUnitTestContextMiddleware public async Task InvokeAsync(HttpContext httpContext) { if (httpContext.Request.Headers.TryGetValue(TUnitTestIdHandler.HeaderName, out var values) - && values.FirstOrDefault() is { } testId - && TestContext.GetById(testId) is { } testContext) + && values.Count > 0 + && TestContext.GetById(values[0]!) is { } testContext) { using (testContext.MakeCurrent()) { From 5c377b0f6c436ca9a07380b2d284db9a03a83030 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:35:23 +0100 Subject: [PATCH 18/19] fix(ci): add AspNetCore test projects to TUnit.CI.slnx Without these entries the CI build doesn't compile the test projects, causing RunAspNetCoreTestsModule to fail with NoBuild = true. --- TUnit.CI.slnx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TUnit.CI.slnx b/TUnit.CI.slnx index 68be89d4fe..9ed2dc92b3 100644 --- a/TUnit.CI.slnx +++ b/TUnit.CI.slnx @@ -78,6 +78,8 @@ + + From ae2ec333479fb28eb3c780fcc7e24395b05d891d Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:58:02 +0100 Subject: [PATCH 19/19] fix(tests): call Undo() before await to stay on the same thread AsyncFlowControl.Undo() must run on the same thread as SuppressFlow(). The previous try/finally wrapped the await, which can resume on a different thread. Fix by capturing the Task first, undoing flow on the original thread, then awaiting. --- TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs b/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs index b60c67a2f5..afaa91192c 100644 --- a/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs +++ b/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs @@ -93,14 +93,19 @@ private static async Task SendWithSuppressedFlow( using var request = new HttpRequestMessage(HttpMethod.Get, path); request.Headers.TryAddWithoutValidation(TUnitTestIdHandler.HeaderName, testContext.Id); + // SuppressFlow + Undo must run on the same thread (before the await), + // so we capture the task first, then undo, then await. var flowControl = ExecutionContext.SuppressFlow(); + Task task; try { - return await Task.Run(() => client.SendAsync(request)); + task = Task.Run(() => client.SendAsync(request)); } finally { flowControl.Undo(); } + + return await task; } }