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
1 change: 1 addition & 0 deletions .claude/docs/project-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
| `TUnit.Analyzers` | Roslyn analyzers & code fixes |
| `TUnit.AspNetCore` | ASP.NET Core integration |
| `TUnit.AspNetCore.Analyzers` | ASP.NET Core-specific analyzers |
| `TUnit.Logging.Microsoft` | Microsoft.Extensions.Logging integration (no ASP.NET Core dependency) |
| `TUnit.PropertyTesting` | Property-based testing |
| `TUnit.FsCheck` | F#-based property testing integration |
| `TUnit.Playwright` | Browser testing integration |
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
<PackageVersion Include="Microsoft.CSharp" Version="4.7.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.3" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.3" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="Microsoft.Playwright" Version="1.58.0" />
<PackageVersion Include="Microsoft.TemplateEngine.Authoring.TemplateVerifier" Version="10.0.103" />
Expand Down
5 changes: 2 additions & 3 deletions TUnit.AspNetCore/Extensions/LoggingExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using TUnit.AspNetCore.Logging;
using TUnit.Core;
using TUnit.Logging.Microsoft;

namespace TUnit.AspNetCore.Extensions;

/// <summary>
/// Extension methods for <see cref="IServiceCollection"/> to simplify service replacement in tests.
/// Extension methods for <see cref="ILoggingBuilder"/> to add TUnit logging.
/// </summary>
public static class LoggingExtensions
{
Expand Down
3 changes: 2 additions & 1 deletion TUnit.AspNetCore/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using TUnit.Core;
using TUnit.Logging.Microsoft;

namespace TUnit.AspNetCore.Extensions;

Expand Down Expand Up @@ -114,7 +115,7 @@ public static IServiceCollection AddTUnitLogging(
TestContext context,
LogLevel minLogLevel = LogLevel.Information)
{
services.AddLogging(builder => LoggingExtensions.AddTUnit(builder, context, minLogLevel));
services.AddLogging(builder => builder.AddProvider(new TUnitLoggerProvider(context, minLogLevel)));
return services;
}
}
26 changes: 26 additions & 0 deletions TUnit.AspNetCore/Extensions/WebApplicationFactoryExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Mvc.Testing;

namespace TUnit.AspNetCore;

/// <summary>
/// Extension methods for <see cref="WebApplicationFactory{TEntryPoint}"/> to simplify creating
/// HTTP clients that propagate TUnit test context.
/// </summary>
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 correlate logs with tests.
/// </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>
public static HttpClient CreateClientWithTestContext<TEntryPoint>(
this WebApplicationFactory<TEntryPoint> factory)
where TEntryPoint : class
{
return factory.CreateDefaultClient(new TUnitTestIdHandler());
}
}
46 changes: 46 additions & 0 deletions TUnit.AspNetCore/Http/TUnitTestIdHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using TUnit.Core;

namespace TUnit.AspNetCore;

/// <summary>
/// A delegating handler that propagates the current test context ID via an HTTP header.
/// Use this with <see cref="TUnit.AspNetCore.Logging.TUnitTestContextMiddleware"/> to correlate
/// server-side logs with the originating test when using a shared <c>WebApplicationFactory</c>.
/// </summary>
public class TUnitTestIdHandler : DelegatingHandler
{
/// <summary>
/// The HTTP header name used to propagate the test context ID.
/// </summary>
public const string HeaderName = "X-TUnit-TestId";

/// <summary>
/// Creates a new <see cref="TUnitTestIdHandler"/>.
/// When used with <see cref="Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory{TEntryPoint}.CreateDefaultClient(DelegatingHandler[])"/>,
/// the inner handler is set automatically by the factory pipeline.
/// </summary>
public TUnitTestIdHandler()
{
}

/// <summary>
/// Creates a new <see cref="TUnitTestIdHandler"/> with the specified inner handler.
/// Use this constructor for standalone <see cref="HttpClient"/> creation outside of a <c>WebApplicationFactory</c>.
/// </summary>
/// <param name="innerHandler">The inner handler to delegate to.</param>
public TUnitTestIdHandler(HttpMessageHandler innerHandler) : base(innerHandler)
{
}

/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
if (TestContext.Current is { } ctx)
{
request.Headers.TryAddWithoutValidation(HeaderName, ctx.Id);
}

return base.SendAsync(request, cancellationToken);
}
}
99 changes: 99 additions & 0 deletions TUnit.AspNetCore/Logging/CorrelatedTUnitLogger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using TUnit.Core;
using TUnit.Logging.Microsoft;

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>
/// </summary>
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)
{
_categoryName = categoryName;
_httpContextAccessor = httpContextAccessor;
_minLogLevel = minLogLevel;
}

public IDisposable? BeginScope<TState>(TState state) where TState : notnull
{
return TUnitLoggerScope.Push(state);
}

public bool IsEnabled(LogLevel logLevel) => logLevel >= _minLogLevel;

public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
{
return;
}

var testContext = ResolveTestContext();

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;
}

// 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)
{
message = $"{message}{Environment.NewLine}{exception}";
}

var formattedMessage = $"[{logLevel}] {_categoryName}: {message}";

if (logLevel >= LogLevel.Error)
{
Console.Error.WriteLine(formattedMessage);
}
else
{
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;
}
}
48 changes: 48 additions & 0 deletions TUnit.AspNetCore/Logging/CorrelatedTUnitLoggerProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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.
/// </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)
{
_httpContextAccessor = httpContextAccessor;
_minLogLevel = minLogLevel;
}

public ILogger CreateLogger(string categoryName)
{
ObjectDisposedException.ThrowIf(_disposed, this);

return _loggers.GetOrAdd(categoryName,
name => new CorrelatedTUnitLogger(name, _httpContextAccessor, _minLogLevel));
}

public void Dispose()
{
if (_disposed)
{
return;
}

_disposed = true;
_loggers.Clear();
}
}
51 changes: 51 additions & 0 deletions TUnit.AspNetCore/Logging/CorrelatedTUnitLoggingExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace TUnit.AspNetCore.Logging;

/// <summary>
/// Extension methods for adding correlated TUnit logging to a shared web application.
/// </summary>
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.
/// </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.AddHttpContextAccessor();
services.AddSingleton<IStartupFilter>(new TUnitTestContextStartupFilter());
services.AddSingleton<ILoggerProvider>(sp =>
new CorrelatedTUnitLoggerProvider(
sp.GetRequiredService<IHttpContextAccessor>(),
minLogLevel));

return services;
}
}

/// <summary>
/// Startup filter that adds <see cref="TUnitTestContextMiddleware"/> early in the pipeline.
/// </summary>
internal sealed class TUnitTestContextStartupFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
return app =>
{
app.UseMiddleware<TUnitTestContextMiddleware>();
next(app);
};
}
}
41 changes: 41 additions & 0 deletions TUnit.AspNetCore/Logging/TUnitTestContextMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using Microsoft.AspNetCore.Http;
using TUnit.Core;

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.
/// </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>
/// Creates a new <see cref="TUnitTestContextMiddleware"/>.
/// </summary>
/// <param name="next">The next middleware in the pipeline.</param>
public TUnitTestContextMiddleware(RequestDelegate next) => _next = next;

/// <summary>
/// Invokes the middleware, extracting the test context from the request header if present.
/// </summary>
/// <param name="httpContext">The HTTP context.</param>
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)
{
httpContext.Items[HttpContextKey] = testContext;
}

await _next(httpContext);
}
}
1 change: 1 addition & 0 deletions TUnit.AspNetCore/TUnit.AspNetCore.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

<ItemGroup>
<ProjectReference Include="..\TUnit\TUnit.csproj" />
<ProjectReference Include="..\TUnit.Logging.Microsoft\TUnit.Logging.Microsoft.csproj" />
</ItemGroup>

<!-- Framework-specific package versions for Microsoft.AspNetCore.Mvc.Testing -->
Expand Down
Loading
Loading