Conversation
…uto-inject handlers Move CorrelatedTUnitLogger and CorrelatedTUnitLoggerProvider from TUnit.AspNetCore.Core to TUnit.Logging.Microsoft, removing the ASP.NET dependency so correlated logging works purely via Activity baggage and TestContext.ResolveFromActivityBaggage(). - Add CreateDefaultClient/CreateClient overrides on TestWebApplicationFactory that auto-inject ActivityPropagationHandler and TUnitTestIdHandler - Eagerly capture TestContext in TUnitTestIdHandler at construction time - Obsolete CreateClientWithTestContext (handlers now auto-injected) - Deduplicate TracedWebApplicationFactory.CreateDefaultClient(Uri,...) - Remove InternalsVisibleTo from TUnit.Logging.Microsoft (no longer needed) - Add comprehensive Activity baggage correlation tests
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 19 |
TIP This summary will be updated as you push new changes. Give us feedback
There was a problem hiding this comment.
Code Review
This is a well-motivated refactor with a clear goal: remove the ASP.NET dependency from CorrelatedTUnitLogger* and make handler injection automatic. The Activity baggage fallback in TestContext.Current (line 129 of TestContext.cs) makes the approach sound. Good test coverage and the TUnitTestIdHandler eager-capture change are both solid improvements.
That said, there are a few issues that need attention before merge.
Critical: new vs override breaks polymorphism
TestWebApplicationFactory.cs lines 93–118
All three CreateClient/CreateDefaultClient methods use the new (hiding) keyword instead of override. But these are declared virtual in WebApplicationFactory<TEntryPoint> in the ASP.NET Core source. This means the auto-injection is silently bypassed whenever the factory is accessed through a WebApplicationFactory<TEntryPoint> reference — which happens commonly:
// WithWebHostBuilder returns WebApplicationFactory<T>, not TestWebApplicationFactory<T>
var isolatedFactory = factory.WithWebHostBuilder(b => { ... });
using var client = isolatedFactory.CreateClient(); // ❌ handlers NOT injectedThe fix is to use override instead of new:
public override HttpClient CreateDefaultClient(params DelegatingHandler[] handlers)
public override HttpClient CreateClient()The TracedWebApplicationFactory wrapper pattern avoids this problem entirely (because it composes rather than inherits), which is why it was written as a wrapper in the first place. The method-hiding approach in TestWebApplicationFactory has the same fragility that TracedWebApplicationFactory was designed to avoid.
CreateClient() silently ignores ClientOptions
TestWebApplicationFactory.cs line 114
The new CreateClient() calls CreateDefaultClient() + ConfigureClient() directly. But the base WebApplicationFactory.CreateClient() routes through CreateClient(ClientOptions), which respects the factory's configured options (AllowAutoRedirect, HandleCookies, MaxAutomaticRedirections, etc.).
By bypassing ClientOptions, any user who has overridden or configured these options will find them silently ignored when calling CreateClient(). The correct implementation should delegate to the base method after ensuring the handler override works:
public override HttpClient CreateClient(WebApplicationFactoryClientOptions options)
{
// handlers are now prepended via the overridden CreateDefaultClient
return base.CreateClient(options);
}Missing CreateClient(WebApplicationFactoryClientOptions) shadow
TestWebApplicationFactory.cs
Since CreateClient() is shadowed/overridden, CreateClient(WebApplicationFactoryClientOptions) must also be shadowed/overridden. Otherwise:
var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
// ❌ handlers NOT injected (calls base overload directly)TracedWebApplicationFactory still duplicates handler injection logic
TracedWebApplicationFactory.cs lines 44, 53–54
The PR aimed to consolidate handler injection into TestWebApplicationFactory, but TracedWebApplicationFactory still manually prepends ActivityPropagationHandler and TUnitTestIdHandler. This is two separate sources of truth for the same logic.
TracedWebApplicationFactory wraps a generic WebApplicationFactory<TEntryPoint> (not necessarily a TestWebApplicationFactory<TEntryPoint>), so deduplication is non-trivial. But at minimum the duplication is worth calling out. Consider extracting the handler-prepend logic to a shared helper:
internal static DelegatingHandler[] PrependTUnitHandlers(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 all;
}Minor: CorrelatedLogger_SkipsOutput_WhenPerTestLoggerActive test correctness
ActivityBaggageCorrelationTests.cs line 119
The test creates TUnitLoggerProvider(testContext) which registers in TUnitLoggingRegistry.PerTestLoggingActive, then checks that CorrelatedTUnitLogger skips output. This looks correct — the PerTestLoggingActive check in CorrelatedTUnitLogger.Log uses TestContext.Current at call time, which is still in scope here (no flow suppression).
However, the test doesn't verify the CorrelatedTUnitLogger would have written output absent the suppression — it's testing the negative case only. A more rigorous test would also assert the positive case (logger writes when TUnitLoggerProvider is absent) in the same test.
What's good
- Moving
CorrelatedTUnitLogger*toTUnit.Logging.Microsoftis a good decoupling decision — it removes an ASP.NET Core dependency from the shared path. - The
_testContext = TestContext.Currenteager capture inTUnitTestIdHandlercorrectly handles theasync/awaitcase whereAsyncLocalvalues don't flow across thread transitions. - The Activity baggage fallback in
TestContext.Current(line 129,TestContext.cs) is properly plumbed, making the Activity-based tests inActivityBaggageCorrelationTestsvalid. - The 7 new tests are well-structured with good use of
RunWithSuppressedFlowto isolate the baggage path from the AsyncLocal path. - Deduplicating the
TracedWebApplicationFactoryCreateDefaultClient(Uri, ...)body (now just two lines instead of the array-copy block) is a clean improvement.
Summary
The new vs override issue is the most important: it means users accessing the factory via WebApplicationFactory<T> (e.g., after WithWebHostBuilder) will silently not get the auto-injected handlers, which defeats the feature's purpose. The ClientOptions bypass is a secondary correctness issue. Addressing both with override + adding a CreateClient(WebApplicationFactoryClientOptions) override should resolve the polymorphism problems cleanly.
thomhurst
left a comment
There was a problem hiding this comment.
Thanks for the detailed review. A few responses:
new vs override — not applicable
CreateClient(), CreateClient(WebApplicationFactoryClientOptions), CreateDefaultClient(params DelegatingHandler[]), and CreateDefaultClient(Uri, params DelegatingHandler[]) are all non-virtual on WebApplicationFactory<TEntryPoint> (verified against Microsoft.AspNetCore.Mvc.Testing v10.0.5 in the NuGet cache). Using override produces CS0506. Method hiding with new is the only option.
This is also why TracedWebApplicationFactory was designed as a composition wrapper rather than inheriting from WebApplicationFactory — it avoids the non-virtual method limitation entirely.
In TUnit's test patterns, factories are always typed as the concrete TestWebApplicationFactory<T> subclass (via [ClassDataSource]), so the new methods are always resolved correctly.
CreateClient() and ClientOptions
Since CreateClient(WebApplicationFactoryClientOptions) is also non-virtual, there's no way to intercept it. Our CreateClient() calls CreateDefaultClient() + ConfigureClient(client) which handles BaseAddress. The default ClientOptions behavior (auto-redirect, cookies) is handled by the base HttpClientHandler defaults, so the common case works. Users needing custom options can call CreateDefaultClient(customHandlers) directly.
Handler deduplication
TracedWebApplicationFactory wraps a generic WebApplicationFactory<T> (not a TestWebApplicationFactory<T>), so it can't delegate to the base class's shadow methods. The 4-line array-prepend pattern is simple enough that a shared helper would add indirection without meaningful benefit.
Test positive case
The positive case (correlated logger writes output when no per-test logger is active) is already covered by CorrelatedLogger_WritesToCorrectTest_ViaActivityBaggage and CorrelatedLogger_RoutesErrorToStdErr in the same test class, plus the HTTP integration tests in CorrelatedLoggingResolverTests.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.33.0 to 1.35.2. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.35.2 <!-- Release notes generated using configuration in .github/release.yml at v1.35.2 --> ## What's Changed ### Other Changes * fix: restore SourceLink and deterministic builds in published packages by @thomhurst in thomhurst/TUnit#5579 ### Dependencies * chore(deps): update tunit to 1.35.0 by @thomhurst in thomhurst/TUnit#5578 **Full Changelog**: thomhurst/TUnit@v1.35.0...v1.35.2 ## 1.35.0 <!-- Release notes generated using configuration in .github/release.yml at v1.35.0 --> ## What's Changed ### Other Changes * fix: support open generic transitive auto-mocks by @thomhurst in thomhurst/TUnit#5568 * refactor: separate test and lifecycle tracing by @thomhurst in thomhurst/TUnit#5572 * fix: expand nested And/Or expectations in failure messages (#5573) by @thomhurst in thomhurst/TUnit#5577 ### Dependencies * chore(deps): update tunit to 1.34.5 by @thomhurst in thomhurst/TUnit#5566 * chore(deps): bump follow-redirects from 1.15.11 to 1.16.0 in /docs by @dependabot[bot] in thomhurst/TUnit#5538 * chore(deps): update verify to 31.16.0 by @thomhurst in thomhurst/TUnit#5570 * chore(deps): update verify to 31.16.1 by @thomhurst in thomhurst/TUnit#5574 * chore(deps): update gittools/actions action to v4 by @thomhurst in thomhurst/TUnit#5575 **Full Changelog**: thomhurst/TUnit@v1.34.5...v1.35.0 ## 1.34.5 <!-- Release notes generated using configuration in .github/release.yml at v1.34.5 --> ## What's Changed ### Other Changes * fix: cap test output at 1M chars to prevent OOM by @thomhurst in thomhurst/TUnit#5561 * fix: handle explicit interface impl with different return types in mock generator by @thomhurst in thomhurst/TUnit#5564 * fix: include XML documentation files in NuGet packages by @thomhurst in thomhurst/TUnit#5565 ### Dependencies * chore(deps): update tunit to 1.34.0 by @thomhurst in thomhurst/TUnit#5562 **Full Changelog**: thomhurst/TUnit@v1.34.0...v1.34.5 ## 1.34.0 <!-- Release notes generated using configuration in .github/release.yml at v1.34.0 --> ## What's Changed ### Other Changes * refactor: move CorrelatedTUnitLogger to TUnit.Logging.Microsoft and auto-inject handlers by @thomhurst in thomhurst/TUnit#5532 * feat: add Dev Drive setup for Windows in CI workflow by @thomhurst in thomhurst/TUnit#5544 * fix: start session activity before discovery so discovery spans parent correctly by @thomhurst in thomhurst/TUnit#5534 * feat: cross-process test log correlation via OTLP receiver by @thomhurst in thomhurst/TUnit#5533 * refactor: use natural OTEL trace propagation instead of synthetic TraceIds by @thomhurst in thomhurst/TUnit#5557 * fix: route ITestOutput writes through synchronized ConcurrentStringWriter by @thomhurst in thomhurst/TUnit#5558 ### Dependencies * chore(deps): update tunit to 1.33.0 by @thomhurst in thomhurst/TUnit#5527 * chore(deps): update dependency dompurify to v3.4.0 by @thomhurst in thomhurst/TUnit#5537 * chore(deps): update dependency docusaurus-plugin-llms to ^0.3.1 by @thomhurst in thomhurst/TUnit#5541 * chore(deps): update dependency microsoft.sourcelink.github to 10.0.202 by @thomhurst in thomhurst/TUnit#5543 * chore(deps): update dependency microsoft.entityframeworkcore to 10.0.6 by @thomhurst in thomhurst/TUnit#5542 * chore(deps): update dependency microsoft.templateengine.authoring.templateverifier to 10.0.202 by @thomhurst in thomhurst/TUnit#5546 * chore(deps): update dependency microsoft.templateengine.authoring.cli to v10.0.202 by @thomhurst in thomhurst/TUnit#5545 * chore(deps): update dependency system.commandline to 2.0.6 by @thomhurst in thomhurst/TUnit#5547 * chore(deps): update microsoft.aspnetcore to 10.0.6 by @thomhurst in thomhurst/TUnit#5548 * chore(deps): update dependency nuget.protocol to 7.3.1 by @thomhurst in thomhurst/TUnit#5549 * chore(deps): update microsoft.extensions to 10.0.6 by @thomhurst in thomhurst/TUnit#5550 * chore(deps): update dependency dotnet-sdk to v10.0.202 by @thomhurst in thomhurst/TUnit#5551 * chore(deps): update opentelemetry by @thomhurst in thomhurst/TUnit#5552 * chore(deps): update microsoft.extensions to 10.5.0 by @thomhurst in thomhurst/TUnit#5554 **Full Changelog**: thomhurst/TUnit@v1.33.0...v1.34.0 Commits viewable in [compare view](thomhurst/TUnit@v1.33.0...v1.35.2). </details> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Summary
CorrelatedTUnitLoggerandCorrelatedTUnitLoggerProviderfromTUnit.AspNetCore.CoretoTUnit.Logging.Microsoft, removing the ASP.NET dependency so correlated logging works purely via Activity baggage andTestContext.ResolveFromActivityBaggage()ActivityPropagationHandlerandTUnitTestIdHandlerinto allHttpClientinstances created byTestWebApplicationFactoryvianewmethod shadows onCreateDefaultClient/CreateClient, obsoletingCreateClientWithTestContextTestContextinTUnitTestIdHandlerat construction time (with dynamic fallback), and deduplicate handler-prepend logic inTracedWebApplicationFactoryTest plan
dotnet run --framework net10.0inTUnit.AspNetCore.Tests)TUnit.AspNetCore.CoreandTUnit.Logging.Microsoftbuild with 0 errors, 0 warnings