Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d748e46
feat: add pluggable ITestContextResolver for custom console output co…
thomhurst Apr 10, 2026
9d79232
fix: address PR review - resolver ordering, exception safety, duplica…
thomhurst Apr 10, 2026
5753b8d
fix: make resolution order consistent and remove unnecessary NotInPar…
thomhurst Apr 10, 2026
83c28ea
test: exercise both AsyncLocal and resolver paths in integration tests
thomhurst Apr 10, 2026
3eb3242
fix: address follow-up PR review — docs, diagnostics, and cleanup
thomhurst Apr 10, 2026
1302d5d
feat: add TestContext.MakeCurrent() as primary correlation API
thomhurst Apr 10, 2026
59464bb
test: update public API snapshots for MakeCurrent and resolver APIs
thomhurst Apr 10, 2026
59b2309
docs: add double-dispose warning to ContextScope
thomhurst Apr 10, 2026
29b8e6e
refactor: remove ITestContextResolver — MakeCurrent() is sufficient
thomhurst Apr 10, 2026
3674122
docs: add cross-thread output correlation guide
thomhurst Apr 10, 2026
80a224b
cleanup: inline ResolveTestContext and remove dead guard
thomhurst Apr 10, 2026
75eaa02
refactor: replace CorrelatedTUnitLogger with internal SynchronousTUni…
thomhurst Apr 10, 2026
847c1b4
ci: add pipeline module for TUnit.AspNetCore.Tests
thomhurst Apr 10, 2026
e9c6294
rename: SynchronousTUnitLogger back to CorrelatedTUnitLogger
thomhurst Apr 10, 2026
cf46201
fix(tests): guard ExecutionContext.SuppressFlow with try/finally
thomhurst Apr 10, 2026
34509ee
refactor: restore full CorrelatedTUnitLogger, remove IHttpContextAcce…
thomhurst Apr 10, 2026
1ef4559
perf: use indexed access instead of FirstOrDefault() in middleware
thomhurst Apr 10, 2026
5c377b0
fix(ci): add AspNetCore test projects to TUnit.CI.slnx
thomhurst Apr 10, 2026
ae2ec33
fix(tests): call Undo() before await to stay on the same thread
thomhurst Apr 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public static class WebApplicationFactoryExtensions
/// Creates an <see cref="HttpClient"/> with a <see cref="TUnitTestIdHandler"/> that automatically
/// propagates the current test context ID to the server via HTTP headers.
/// Use with <see cref="Logging.CorrelatedTUnitLoggingExtensions.AddCorrelatedTUnitLogging"/>
/// on the server side to correlate logs with tests.
/// on the server side to enable the test context middleware.
/// </summary>
/// <typeparam name="TEntryPoint">The entry point class of the web application.</typeparam>
/// <param name="factory">The web application factory.</param>
Expand Down
39 changes: 10 additions & 29 deletions TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLogger.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using TUnit.Core;
using TUnit.Logging.Microsoft;
Expand All @@ -7,25 +6,23 @@ namespace TUnit.AspNetCore.Logging;

/// <summary>
/// A logger that resolves the current test context per log call, supporting shared web application scenarios.
/// Sets <see cref="TestContext.Current"/> and writes via <see cref="Console"/> so the console interceptor
/// and all registered log sinks naturally route the output to the correct test.
/// The resolution chain is:
/// <list type="number">
/// <item>Test context from <see cref="HttpContext.Items"/> (set by <see cref="TUnitTestContextMiddleware"/>)</item>
/// <item><see cref="TestContext.Current"/> (AsyncLocal fallback)</item>
/// <item>No-op if no test context is available</item>
/// </list>
/// Writes via <see cref="Console"/> synchronously on the calling thread so TUnit's console interceptor
/// can route the output to the correct test via <see cref="TestContext.Current"/> (set by
/// <see cref="TUnitTestContextMiddleware"/> calling <see cref="TestContext.MakeCurrent"/>).
/// </summary>
/// <remarks>
/// This logger is necessary because ASP.NET Core's built-in <c>ConsoleLogger</c> writes from a background
/// queue thread that does not inherit the <c>AsyncLocal</c> set by <see cref="TestContext.MakeCurrent"/>.
/// By writing synchronously on the request thread, the output is attributed to the correct test.
/// </remarks>
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;
}

Expand All @@ -48,7 +45,7 @@ public void Log<TState>(
return;
}

var testContext = ResolveTestContext();
var testContext = TestContext.Current;

if (testContext is null)
{
Expand All @@ -62,10 +59,6 @@ public void Log<TState>(
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)
Expand All @@ -84,16 +77,4 @@ public void Log<TState>(
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;
}
}
12 changes: 4 additions & 8 deletions TUnit.AspNetCore.Core/Logging/CorrelatedTUnitLoggerProvider.cs
Original file line number Diff line number Diff line change
@@ -1,29 +1,25 @@
using System.Collections.Concurrent;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;

namespace TUnit.AspNetCore.Logging;

/// <summary>
/// A logger provider that creates <see cref="CorrelatedTUnitLogger"/> 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 <see cref="TUnit.Core.TestContext.Current"/>,
/// supporting shared web application scenarios where a single host serves multiple tests.
/// </summary>
public sealed class CorrelatedTUnitLoggerProvider : ILoggerProvider
{
private readonly ConcurrentDictionary<string, CorrelatedTUnitLogger> _loggers = new();
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly LogLevel _minLogLevel;
private bool _disposed;

/// <summary>
/// Creates a new <see cref="CorrelatedTUnitLoggerProvider"/>.
/// </summary>
/// <param name="httpContextAccessor">The HTTP context accessor for resolving test context from requests.</param>
/// <param name="minLogLevel">The minimum log level to capture. Defaults to Information.</param>
public CorrelatedTUnitLoggerProvider(IHttpContextAccessor httpContextAccessor, LogLevel minLogLevel = LogLevel.Information)
public CorrelatedTUnitLoggerProvider(LogLevel minLogLevel = LogLevel.Information)
{
_httpContextAccessor = httpContextAccessor;
_minLogLevel = minLogLevel;
}

Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

Expand All @@ -14,8 +13,10 @@ public static class CorrelatedTUnitLoggingExtensions
/// <summary>
/// Adds correlated TUnit logging to the service collection.
/// This registers the <see cref="TUnitTestContextMiddleware"/> via an <see cref="IStartupFilter"/>
/// and a <see cref="CorrelatedTUnitLoggerProvider"/> that resolves the test context per log call.
/// Use with <see cref="TUnitTestIdHandler"/> on the client side to propagate test context.
/// and a <see cref="CorrelatedTUnitLoggerProvider"/> that writes <c>ILogger</c> output to
/// <see cref="System.Console"/> on the calling thread so TUnit's console interceptor can route
/// it to the correct test.
/// Use with <see cref="TUnitTestIdHandler"/> on the client side to propagate the test context ID.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="minLogLevel">The minimum log level to capture. Defaults to Information.</param>
Expand All @@ -24,12 +25,8 @@ public static IServiceCollection AddCorrelatedTUnitLogging(
this IServiceCollection services,
LogLevel minLogLevel = LogLevel.Information)
{
services.AddHttpContextAccessor();
services.AddSingleton<IStartupFilter>(new TUnitTestContextStartupFilter());
services.AddSingleton<ILoggerProvider>(sp =>
new CorrelatedTUnitLoggerProvider(
sp.GetRequiredService<IHttpContextAccessor>(),
minLogLevel));
services.AddSingleton<ILoggerProvider>(new CorrelatedTUnitLoggerProvider(minLogLevel));

return services;
}
Expand Down
20 changes: 10 additions & 10 deletions TUnit.AspNetCore.Core/Logging/TUnitTestContextMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,11 @@ namespace TUnit.AspNetCore.Logging;

/// <summary>
/// Middleware that extracts the TUnit test context ID from incoming HTTP request headers
/// and stores the associated <see cref="TestContext"/> in <see cref="HttpContext.Items"/>
/// for correlated logging.
/// and calls <see cref="TestContext.MakeCurrent"/> so that console output and log routing
/// within the request are attributed to the correct test.
/// </summary>
public sealed class TUnitTestContextMiddleware
{
/// <summary>
/// The key used to store the <see cref="TestContext"/> in <see cref="HttpContext.Items"/>.
/// </summary>
public const string HttpContextKey = "TUnit.TestContext";

private readonly RequestDelegate _next;

/// <summary>
Expand All @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions TUnit.AspNetCore.Tests.WebApp/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

var logger = app.Services.GetRequiredService<ILoggerFactory>().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;
10 changes: 10 additions & 0 deletions TUnit.AspNetCore.Tests.WebApp/TUnit.AspNetCore.Tests.WebApp.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>

</Project>
111 changes: 111 additions & 0 deletions TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using System.Net;
using TUnit.AspNetCore;
using TUnit.Core;

namespace TUnit.AspNetCore.Tests;

/// <summary>
/// Tests correlated logging via both resolution paths:
/// AsyncLocal inherited from test thread (TestServer), and
/// MakeCurrent() set by middleware when AsyncLocal is absent (simulated Kestrel).
/// </summary>
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");
}

/// <summary>
/// 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 <see cref="TestContext.MakeCurrent"/> call is the only way
/// the test context reaches server-side code.
/// </summary>
private static async Task<HttpResponseMessage> 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<HttpResponseMessage> task;
try
{
task = Task.Run(() => client.SendAsync(request));
}
finally
{
flowControl.Undo();
}

return await task;
}
}
2 changes: 2 additions & 0 deletions TUnit.AspNetCore.Tests/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
global using Assert = TUnit.Assertions.Assert;
global using TestAttribute = TUnit.Core.TestAttribute;
31 changes: 31 additions & 0 deletions TUnit.AspNetCore.Tests/TUnit.AspNetCore.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">

<Import Project="..\TestProject.props" />

<PropertyGroup>
<!-- ASP.NET Core testing only supports .NET 8.0+ -->
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\TUnit.AspNetCore\TUnit.AspNetCore.csproj" />
<ProjectReference Include="..\TUnit.AspNetCore.Tests.WebApp\TUnit.AspNetCore.Tests.WebApp.csproj" />
<ProjectReference Include="..\TUnit\TUnit.csproj" />
</ItemGroup>

<!-- Version attr is required by CPM (Directory.Packages.props); VersionOverride selects the TFM-compatible version -->
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" VersionOverride="8.0.0" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" VersionOverride="9.0.0" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
</ItemGroup>

<Import Project="..\TestProject.targets" />

</Project>
Loading
Loading