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
index fea4cd1be4..5004dc96b6 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;
@@ -7,25 +6,23 @@ 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
-///
+/// 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 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;
}
@@ -48,7 +45,7 @@ public void Log(
return;
}
- var testContext = ResolveTestContext();
+ var testContext = TestContext.Current;
if (testContext is null)
{
@@ -62,10 +59,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)
- TestContext.Current = testContext;
-
var message = formatter(state, exception);
if (exception is not null)
@@ -84,16 +77,4 @@ public void Log(
Console.WriteLine(formattedMessage);
}
}
-
- private TestContext? ResolveTestContext()
- {
- // 1. Try to get from HttpContext.Items (set by TUnitTestContextMiddleware)
- if (_httpContextAccessor.HttpContext?.Items[TUnitTestContextMiddleware.HttpContextKey] is TestContext httpTestContext)
- {
- return httpTestContext;
- }
-
- // 2. Fall back to AsyncLocal
- return TestContext.Current;
- }
}
diff --git a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs
index 0958419073..2117251219 100644
--- a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs
+++ b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs
@@ -1,29 +1,25 @@
using System.Collections.Concurrent;
-using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
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 LogLevel _minLogLevel;
private bool _disposed;
///
/// Creates a new .
///
- /// The HTTP context accessor for resolving test context from requests.
/// The minimum log level to capture. Defaults to Information.
- public CorrelatedTUnitLoggerProvider(IHttpContextAccessor httpContextAccessor, LogLevel minLogLevel = LogLevel.Information)
+ public CorrelatedTUnitLoggerProvider(LogLevel minLogLevel = LogLevel.Information)
{
- _httpContextAccessor = httpContextAccessor;
_minLogLevel = minLogLevel;
}
@@ -32,7 +28,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()
diff --git a/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs b/TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggingExtensions.cs
index 4661271475..d6135e2f4d 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;
@@ -14,8 +13,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.
- /// Use with on the client side to propagate test context.
+ /// 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.
@@ -24,12 +25,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/TUnitTestContextMiddleware.cs b/TUnit.AspNetCore.Core/Logging/TUnitTestContextMiddleware.cs
index 6f711b4d3a..c2cbbee41e 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;
///
@@ -30,10 +25,15 @@ 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)
{
- httpContext.Items[HttpContextKey] = testContext;
+ using (testContext.MakeCurrent())
+ {
+ await _next(httpContext);
+ }
+
+ return;
}
await _next(httpContext);
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..afaa91192c
--- /dev/null
+++ b/TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs
@@ -0,0 +1,111 @@
+using System.Net;
+using TUnit.AspNetCore;
+using TUnit.Core;
+
+namespace TUnit.AspNetCore.Tests;
+
+///
+/// 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
+{
+ [ClassDataSource(Shared = [SharedType.PerTestSession])]
+ public TestWebAppFactory Factory { get; set; } = null!;
+
+ [Test]
+ public async Task InheritedAsyncLocal_ServerLog_CorrelatedToCorrectTest()
+ {
+ var marker = Guid.NewGuid().ToString("N");
+ using var client = Factory.CreateClientWithTestContext();
+
+ var response = await client.GetAsync($"/log/{marker}");
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ var output = TestContext.Current!.GetStandardOutput();
+ await Assert.That(output).Contains($"SERVER_LOG:{marker}");
+ }
+
+ [Test]
+ public async Task InheritedAsyncLocal_MultipleRequests_EachCorrelatedToSameTest()
+ {
+ var marker1 = $"first_{Guid.NewGuid():N}";
+ var marker2 = $"second_{Guid.NewGuid():N}";
+ using var client = Factory.CreateClientWithTestContext();
+
+ await client.GetAsync($"/log/{marker1}");
+ await client.GetAsync($"/log/{marker2}");
+
+ var output = TestContext.Current!.GetStandardOutput();
+ await Assert.That(output).Contains($"SERVER_LOG:{marker1}");
+ await Assert.That(output).Contains($"SERVER_LOG:{marker2}");
+ }
+
+ [Test]
+ public async Task MiddlewareMakeCurrent_ServerLog_CorrelatedToCorrectTest()
+ {
+ var testContext = TestContext.Current!;
+ var marker = Guid.NewGuid().ToString("N");
+
+ var response = await SendWithSuppressedFlow(Factory, $"/log/{marker}", testContext);
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+
+ var output = testContext.GetStandardOutput();
+ await Assert.That(output).Contains($"SERVER_LOG:{marker}");
+ }
+
+ [Test]
+ public async Task MiddlewareMakeCurrent_MultipleRequests_EachCorrelatedToSameTest()
+ {
+ var testContext = TestContext.Current!;
+ var marker1 = $"first_{Guid.NewGuid():N}";
+ var marker2 = $"second_{Guid.NewGuid():N}";
+
+ 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}");
+ }
+
+ [Test]
+ public async Task PingEndpoint_Works_WithSharedFactory()
+ {
+ using var client = Factory.CreateDefaultClient();
+ var response = await client.GetStringAsync("/ping");
+ await Assert.That(response).IsEqualTo("pong");
+ }
+
+ ///
+ /// Sends an HTTP request on a thread pool thread whose execution context does NOT
+ /// 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)
+ {
+ using var client = factory.CreateDefaultClient();
+ 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
+ {
+ task = Task.Run(() => client.SendAsync(request));
+ }
+ finally
+ {
+ flowControl.Undo();
+ }
+
+ return await task;
+ }
+}
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..9f26d4cb3a
--- /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..db656439d7
--- /dev/null
+++ b/TUnit.AspNetCore.Tests/TestWebAppFactory.cs
@@ -0,0 +1,34 @@
+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.
+/// 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
+{
+ 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.CI.slnx b/TUnit.CI.slnx
index 68be89d4fe..9ed2dc92b3 100644
--- a/TUnit.CI.slnx
+++ b/TUnit.CI.slnx
@@ -78,6 +78,8 @@
+
+
diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs
index fafe5c0ea0..96c8fc8cb4 100644
--- a/TUnit.Core/TestContext.cs
+++ b/TUnit.Core/TestContext.cs
@@ -132,6 +132,54 @@ 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.
+ ///
+ ///
+ ///
+ /// // 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.
+ /// This is a value type — do not dispose more than once.
+ ///
+ 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.
///
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",
+ }
+ }
+ ));
+ }
+}
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..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
@@ -1387,8 +1387,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 : .
{
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..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
@@ -1387,8 +1387,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 : .
{
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..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
@@ -1387,8 +1387,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 : .
{
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..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
@@ -1338,7 +1338,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 : .
{
diff --git a/TUnit.slnx b/TUnit.slnx
index 10f42a6a3f..99533b2fbb 100644
--- a/TUnit.slnx
+++ b/TUnit.slnx
@@ -80,6 +80,8 @@
+
+
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.