Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,11 @@ public static class WebApplicationFactoryExtensions
/// <summary>
/// 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 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>
/// <returns>An <see cref="HttpClient"/> configured with test context propagation.</returns>
[Obsolete("TestWebApplicationFactory now injects ActivityPropagationHandler and TUnitTestIdHandler automatically. Use CreateClient() or CreateDefaultClient() instead.")]
public static HttpClient CreateClientWithTestContext<TEntryPoint>(
this WebApplicationFactory<TEntryPoint> factory)
where TEntryPoint : class
Expand Down
4 changes: 3 additions & 1 deletion TUnit.AspNetCore.Core/Http/TUnitTestIdHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ public class TUnitTestIdHandler : DelegatingHandler
/// </summary>
public const string HeaderName = "X-TUnit-TestId";

private readonly TestContext? _testContext = TestContext.Current;

/// <summary>
/// Creates a new <see cref="TUnitTestIdHandler"/>.
/// When used with <see cref="Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory{TEntryPoint}.CreateDefaultClient(DelegatingHandler[])"/>,
Expand All @@ -36,7 +38,7 @@ public TUnitTestIdHandler(HttpMessageHandler innerHandler) : base(innerHandler)
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
if (TestContext.Current is { } ctx)
if ((_testContext ?? TestContext.Current) is { } ctx)
{
request.Headers.TryAddWithoutValidation(HeaderName, ctx.Id);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using TUnit.Logging.Microsoft;

namespace TUnit.AspNetCore.Logging;

/// <summary>
/// Extension methods for adding correlated TUnit logging to a shared web application.
/// Extension methods for adding correlated TUnit logging to a shared ASP.NET Core web application.
/// Registers both the <see cref="CorrelatedTUnitLoggerProvider"/> (from <c>TUnit.Logging.Microsoft</c>)
/// and the <see cref="TUnitTestContextMiddleware"/> for robust test context resolution via
/// both Activity baggage and the <c>X-TUnit-TestId</c> HTTP header.
/// </summary>
public static class CorrelatedTUnitLoggingExtensions
{
Expand Down
37 changes: 37 additions & 0 deletions TUnit.AspNetCore.Core/TestWebApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,41 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
});
}

/// <summary>
/// Creates an <see cref="HttpClient"/> with <see cref="ActivityPropagationHandler"/> and
/// <see cref="TUnitTestIdHandler"/> automatically prepended to the handler chain.
/// This ensures all HTTP requests made through clients created by this factory:
/// <list type="bullet">
/// <item><description>Propagate W3C <c>traceparent</c> and <c>baggage</c> headers for Activity-based correlation</description></item>
/// <item><description>Propagate the current test's context ID via the <c>X-TUnit-TestId</c> header</description></item>
/// </list>
/// </summary>
public new HttpClient CreateDefaultClient(params DelegatingHandler[] handlers)
{
var all = new DelegatingHandler[handlers.Length + 2];
all[0] = new ActivityPropagationHandler();
all[1] = new TUnitTestIdHandler();
Array.Copy(handlers, 0, all, 2, handlers.Length);
return base.CreateDefaultClient(all);
}

/// <inheritdoc cref="CreateDefaultClient(DelegatingHandler[])"/>
public new HttpClient CreateDefaultClient(Uri baseAddress, params DelegatingHandler[] handlers)
{
var client = CreateDefaultClient(handlers);
client.BaseAddress = baseAddress;
return client;
}

/// <summary>
/// Creates an <see cref="HttpClient"/> with automatic Activity tracing and test context propagation.
/// Equivalent to calling <see cref="CreateDefaultClient(DelegatingHandler[])"/> with no additional handlers.
/// </summary>
public new HttpClient CreateClient()
{
var client = CreateDefaultClient();
ConfigureClient(client);
return client;
}

}
8 changes: 3 additions & 5 deletions TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,9 @@ public HttpClient CreateDefaultClient(params DelegatingHandler[] handlers)
/// </summary>
public HttpClient CreateDefaultClient(Uri baseAddress, params DelegatingHandler[] handlers)
{
var all = new DelegatingHandler[handlers.Length + 2];
all[0] = new ActivityPropagationHandler();
all[1] = new TUnitTestIdHandler();
Array.Copy(handlers, 0, all, 2, handlers.Length);
return _inner.CreateDefaultClient(baseAddress, all);
var client = CreateDefaultClient(handlers);
client.BaseAddress = baseAddress;
return client;
}

/// <summary>
Expand Down
198 changes: 198 additions & 0 deletions TUnit.AspNetCore.Tests/ActivityBaggageCorrelationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using TUnit.Core;
using TUnit.Logging.Microsoft;

namespace TUnit.AspNetCore.Tests;

/// <summary>
/// Tests that Activity baggage propagation is sufficient for correlated logging,
/// validating the <c>TestContext.ResolveFromActivityBaggage()</c> fallback path.
/// </summary>
public class ActivityBaggageCorrelationTests
{
/// <summary>
/// Verifies that <see cref="TestContext.Current"/> resolves via Activity baggage
/// on a thread where AsyncLocal is null, and that Console output is attributed
/// to the correct test.
/// </summary>
[Test]
public async Task ActivityBaggage_ResolvesTestContext_WhenAsyncLocalIsNull()
{
var testContext = TestContext.Current!;
var marker = Guid.NewGuid().ToString("N");

await RunWithSuppressedFlow(() =>
{
using var activity = new Activity("test-server-request")
.SetBaggage(TUnitActivitySource.TagTestId, testContext.Id)
.Start();

Console.WriteLine($"ACTIVITY_RESOLVED:{marker}");
});

var output = testContext.GetStandardOutput();
await Assert.That(output).Contains($"ACTIVITY_RESOLVED:{marker}");
}

/// <summary>
/// Verifies that <see cref="CorrelatedTUnitLogger"/> writes to the correct test output
/// when the only correlation mechanism is Activity baggage.
/// </summary>
[Test]
public async Task CorrelatedLogger_WritesToCorrectTest_ViaActivityBaggage()
{
var testContext = TestContext.Current!;
var marker = Guid.NewGuid().ToString("N");

await RunWithSuppressedFlow(() =>
{
using var activity = new Activity("test-server-request")
.SetBaggage(TUnitActivitySource.TagTestId, testContext.Id)
.Start();

using var provider = new CorrelatedTUnitLoggerProvider();
var logger = provider.CreateLogger("TestCategory");
logger.LogInformation("CORRELATED_BAGGAGE:{Marker}", marker);
});

var output = testContext.GetStandardOutput();
await Assert.That(output).Contains($"CORRELATED_BAGGAGE:{marker}");
}

/// <summary>
/// Verifies that nested Activities don't break baggage resolution —
/// <see cref="Activity.GetBaggageItem"/> traverses the parent chain.
/// </summary>
[Test]
public async Task NestedActivities_BaggageTraversesParentChain()
{
var testContext = TestContext.Current!;
var marker = Guid.NewGuid().ToString("N");

await RunWithSuppressedFlow(() =>
{
// Parent Activity with baggage
using var parent = new Activity("parent-request")
.SetBaggage(TUnitActivitySource.TagTestId, testContext.Id)
.Start();

// Child Activity (e.g., middleware creating its own span) — no baggage set directly
using var child = new Activity("child-middleware").Start();

// Activity.Current is now the child, but GetBaggageItem traverses parents
using var provider = new CorrelatedTUnitLoggerProvider();
var logger = provider.CreateLogger("NestedTest");
logger.LogInformation("NESTED_ACTIVITY:{Marker}", marker);
});

var output = testContext.GetStandardOutput();
await Assert.That(output).Contains($"NESTED_ACTIVITY:{marker}");
}

/// <summary>
/// Verifies that <see cref="CorrelatedTUnitLogger"/> produces no output when
/// neither AsyncLocal nor Activity baggage provides a test context.
/// </summary>
[Test]
public async Task CorrelatedLogger_IsNoOp_WhenNoContextAvailable()
{
var testContext = TestContext.Current!;
var marker = Guid.NewGuid().ToString("N");

await RunWithSuppressedFlow(() =>
{
// No AsyncLocal, no Activity — TestContext.Current should be null
using var provider = new CorrelatedTUnitLoggerProvider();
var logger = provider.CreateLogger("NoContext");
logger.LogInformation("SHOULD_NOT_APPEAR:{Marker}", marker);
});

var output = testContext.GetStandardOutput();
await Assert.That(output).DoesNotContain($"SHOULD_NOT_APPEAR:{marker}");
}

/// <summary>
/// Verifies that <see cref="CorrelatedTUnitLogger"/> skips output when a per-test
/// <see cref="TUnitLoggerProvider"/> is active for the same test context (duplicate suppression).
/// </summary>
[Test]
public async Task CorrelatedLogger_SkipsOutput_WhenPerTestLoggerActive()
{
var testContext = TestContext.Current!;
var marker = Guid.NewGuid().ToString("N");

// Register a per-test logger provider to activate duplicate suppression
using var perTestProvider = new TUnitLoggerProvider(testContext);

using var correlatedProvider = new CorrelatedTUnitLoggerProvider();
var logger = correlatedProvider.CreateLogger("DuplicateTest");
logger.LogInformation("SHOULD_BE_SUPPRESSED:{Marker}", marker);

var output = testContext.GetStandardOutput();
await Assert.That(output).DoesNotContain($"SHOULD_BE_SUPPRESSED:{marker}");
}

/// <summary>
/// Verifies that <see cref="CorrelatedTUnitLogger"/> respects the minimum log level.
/// </summary>
[Test]
public async Task CorrelatedLogger_RespectsMinLogLevel()
{
var testContext = TestContext.Current!;
var marker = Guid.NewGuid().ToString("N");

using var provider = new CorrelatedTUnitLoggerProvider(LogLevel.Warning);
var logger = provider.CreateLogger("LogLevelTest");

// Info is below Warning — should not appear
logger.LogInformation("BELOW_LEVEL:{Marker}", marker);

// Warning meets the threshold — should appear
logger.LogWarning("AT_LEVEL:{Marker}", marker);

var output = testContext.GetStandardOutput();
await Assert.That(output).DoesNotContain($"BELOW_LEVEL:{marker}");
await Assert.That(output).Contains($"AT_LEVEL:{marker}");
}

/// <summary>
/// Verifies that Error+ log messages are routed to <see cref="Console.Error"/>.
/// </summary>
[Test]
public async Task CorrelatedLogger_RoutesErrorToStdErr()
{
var testContext = TestContext.Current!;
var marker = Guid.NewGuid().ToString("N");

using var provider = new CorrelatedTUnitLoggerProvider();
var logger = provider.CreateLogger("ErrorRouting");

logger.LogError("ERROR_MSG:{Marker}", marker);

var errorOutput = testContext.GetErrorOutput();
await Assert.That(errorOutput).Contains($"ERROR_MSG:{marker}");
}

/// <summary>
/// Runs an action on a thread pool thread with suppressed execution context flow.
/// The worker thread will have no AsyncLocal values from the test thread.
/// </summary>
private static async Task RunWithSuppressedFlow(Action action)
{
// SuppressFlow + Undo must run on the same thread.
// Capture the task without awaiting, Undo immediately, then await.
var flowControl = ExecutionContext.SuppressFlow();
Task task;
try
{
task = Task.Run(action);
}
finally
{
flowControl.Undo();
}

await task;
}
}
4 changes: 2 additions & 2 deletions TUnit.AspNetCore.Tests/CorrelatedLoggingResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public class CorrelatedLoggingResolverTests
public async Task InheritedAsyncLocal_ServerLog_CorrelatedToCorrectTest()
{
var marker = Guid.NewGuid().ToString("N");
using var client = Factory.CreateClientWithTestContext();
using var client = Factory.CreateClient();

var response = await client.GetAsync($"/log/{marker}");

Expand All @@ -33,7 +33,7 @@ public async Task InheritedAsyncLocal_MultipleRequests_EachCorrelatedToSameTest(
{
var marker1 = $"first_{Guid.NewGuid():N}";
var marker2 = $"second_{Guid.NewGuid():N}";
using var client = Factory.CreateClientWithTestContext();
using var client = Factory.CreateClient();

await client.GetAsync($"/log/{marker1}");
await client.GetAsync($"/log/{marker2}");
Expand Down
2 changes: 1 addition & 1 deletion TUnit.AspNetCore.Tests/MinimalApiAutoRegistrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public class MinimalApiAutoRegistrationTests
public async Task ServerLog_AutoCorrelated_OnMinimalApiHost_WithoutSubclassConfig()
{
var marker = Guid.NewGuid().ToString("N");
using var client = Factory.CreateClientWithTestContext();
using var client = Factory.CreateClient();

var response = await client.GetAsync($"/log/{marker}");

Expand Down
43 changes: 43 additions & 0 deletions TUnit.Logging.Microsoft/Extensions/CorrelatedLoggingExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace TUnit.Logging.Microsoft;

/// <summary>
/// Extension methods for adding correlated TUnit logging.
/// The correlated logger dynamically resolves the current test context per log call
/// via <see cref="TUnit.Core.TestContext.Current"/> (AsyncLocal or Activity baggage fallback),
/// enabling shared service scenarios where a single host serves multiple tests.
/// </summary>
public static class CorrelatedLoggingExtensions
{
/// <summary>
/// Adds a <see cref="CorrelatedTUnitLoggerProvider"/> that dynamically resolves the current
/// test context for each log call. Use this for shared service hosts (ASP.NET Core, gRPC, etc.)
/// where a single host serves multiple tests running in parallel.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="minLogLevel">The minimum log level to capture. Defaults to Information.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddCorrelatedTUnitLogging(
this IServiceCollection services,
LogLevel minLogLevel = LogLevel.Information)
{
services.AddSingleton<ILoggerProvider>(new CorrelatedTUnitLoggerProvider(minLogLevel));
return services;
}

/// <summary>
/// Adds a <see cref="CorrelatedTUnitLoggerProvider"/> to the logging builder.
/// </summary>
/// <param name="builder">The logging builder.</param>
/// <param name="minLogLevel">The minimum log level to capture. Defaults to Information.</param>
/// <returns>The logging builder for chaining.</returns>
public static ILoggingBuilder AddCorrelatedTUnit(
this ILoggingBuilder builder,
LogLevel minLogLevel = LogLevel.Information)
{
builder.AddProvider(new CorrelatedTUnitLoggerProvider(minLogLevel));
return builder;
}
}
Loading
Loading