Skip to content

refactor: move CorrelatedTUnitLogger to TUnit.Logging.Microsoft and auto-inject handlers#5532

Merged
thomhurst merged 1 commit intomainfrom
refactor/move-correlated-logger-to-logging-microsoft
Apr 13, 2026
Merged

refactor: move CorrelatedTUnitLogger to TUnit.Logging.Microsoft and auto-inject handlers#5532
thomhurst merged 1 commit intomainfrom
refactor/move-correlated-logger-to-logging-microsoft

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

Summary

  • 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()
  • Auto-inject ActivityPropagationHandler and TUnitTestIdHandler into all HttpClient instances created by TestWebApplicationFactory via new method shadows on CreateDefaultClient/CreateClient, obsoleting CreateClientWithTestContext
  • Eagerly capture TestContext in TUnitTestIdHandler at construction time (with dynamic fallback), and deduplicate handler-prepend logic in TracedWebApplicationFactory
  • Add 7 comprehensive tests covering Activity baggage resolution, correlated logger behavior, duplicate suppression, log level filtering, and error routing

Test plan

  • All 13 ASP.NET Core tests pass (dotnet run --framework net10.0 in TUnit.AspNetCore.Tests)
  • TUnit.AspNetCore.Core and TUnit.Logging.Microsoft build with 0 errors, 0 warnings
  • CI passes across net8.0/net9.0/net10.0

…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
@codacy-production
Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 19 complexity

Metric Results
Complexity 19

View in Codacy

TIP This summary will be updated as you push new changes. Give us feedback

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 injected

The 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* to TUnit.Logging.Microsoft is a good decoupling decision — it removes an ASP.NET Core dependency from the shared path.
  • The _testContext = TestContext.Current eager capture in TUnitTestIdHandler correctly handles the async/await case where AsyncLocal values 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 in ActivityBaggageCorrelationTests valid.
  • The 7 new tests are well-structured with good use of RunWithSuppressedFlow to isolate the baggage path from the AsyncLocal path.
  • Deduplicating the TracedWebApplicationFactory CreateDefaultClient(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.

Copy link
Copy Markdown
Owner Author

@thomhurst thomhurst left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@thomhurst thomhurst merged commit 300ffc2 into main Apr 13, 2026
16 checks passed
@thomhurst thomhurst deleted the refactor/move-correlated-logger-to-logging-microsoft branch April 13, 2026 18:29
@claude claude bot mentioned this pull request Apr 15, 2026
1 task
intellitect-bot pushed a commit to IntelliTect/EssentialCSharp.Web that referenced this pull request Apr 17, 2026
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>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit&package-manager=nuget&previous-version=1.33.0&new-version=1.35.2)](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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant