-
Notifications
You must be signed in to change notification settings - Fork 1
Background Thread Correlation
Background processing — change feed processors, message handlers, hosted services, timer triggers — runs on threads where the test framework's execution context is unavailable. This page explains how to correlate those operations back to the originating test.
CurrentTestInfo.Fetcher (xUnit3, and equivalents in other frameworks) uses TestContext.Current.Test to determine the active test. This only works on the test runner thread — any background thread, task continuation, or callback will see TestContext.Current.Test as null, and the fetcher will return ("Unknown", "unknown").
Common scenarios where this occurs:
| Scenario | Why TestContext is null |
|---|---|
| Cosmos DB Change Feed Processor | Polls on a dedicated background thread |
| Azure Functions timer triggers | Executes on a thread pool thread |
Hosted services (IHostedService) |
Runs on a separate thread |
Event handlers (e.g. AfterPublish) |
May fire on thread pool threads |
| Read-through cache refresh | HTTP calls from a caching layer's background refresh |
Task.Run() / thread pool work |
Any work dispatched to the thread pool |
- Diagnostic report shows a large number of "Unknown" entries
- Sequence diagrams for tests that trigger background processing are missing operations
- Service Bus publishes triggered by change feed events don't appear in diagrams
TestIdentityScope is an AsyncLocal-based ambient scope that propagates test identity across async boundaries. It is the recommended approach when you control the code that dispatches background work.
// Before dispatching background work, push the test identity into scope:
using (TestIdentityScope.Begin(testName, testId))
{
await TriggerBackgroundProcessing();
// All tracking within this scope (and any async continuations) will use testName/testId
}Resolution order for all trackers (v2.28.16+):
| Priority | Source | When Available |
|---|---|---|
| 1 | HTTP request headers (IHttpContextAccessor) |
Code running inside the SUT's request pipeline (e.g. WebApplicationFactory scenarios) |
| 2 |
CurrentTestInfoFetcher delegate |
Code running on the test thread where the framework's TestContext is accessible |
| 3 | TestIdentityScope.Current |
Code wrapped in TestIdentityScope.Begin(...) — propagates via AsyncLocal
|
| 4 | TestIdentityScope.GlobalFallback |
Pre-existing background threads (Change Feed Processor, Hangfire, hosted services) |
| 5 | Returns null
|
Operation is silently not logged |
v2.28.22+: Priority 2 (
CurrentTestInfoFetcher) now throwsInvalidOperationExceptionwhen invoked outside a test context. The resolver catches this and falls through to priorities 3–4. See Tracking Custom Dependencies#Test Context Availability for details.
| Scenario | Resolves At | Why |
|---|---|---|
HTTP request inside WebApplicationFactory
|
Priority 1 |
TestTrackingMessageHandler propagates test identity as HTTP headers |
| Test method body (direct call) | Priority 2 | Framework TestContext is available on the test thread |
Task.Run / background thread wrapped in TestIdentityScope.Begin
|
Priority 3 |
AsyncLocal propagates to async continuations |
Change Feed Processor / hosted service with SetGlobalFallback
|
Priority 4 | Pre-existing thread reads the static fallback |
| Background thread with nothing configured | Priority 5 | Returns null — operation not tracked |
See Tracking Custom Dependencies#Tracking Background Processing with TestIdentityScope (v2.28.5+) for full details and examples.
When you cannot use TestIdentityScope (e.g. you don't control the dispatch point), use an instance-scoped tracker that explicitly sets the active test identity on the fixture:
public class ActiveTestTracker
{
private readonly object _syncLock = new();
private (string Name, string Id)? _activeTest;
public void Set(string name, string id)
{
lock (_syncLock) { _activeTest = (name, id); }
}
public void Clear()
{
lock (_syncLock) { _activeTest = null; }
}
/// <summary>
/// Tries TestContext.Current first (works on the test thread),
/// falls back to the explicitly set active test (works on background threads).
/// </summary>
public (string Name, string Id) Fetcher()
{
var (name, id) = CurrentTestInfo.Fetcher();
if (name != "Unknown")
return (name, id);
lock (_syncLock)
{
return _activeTest ?? ("Unknown", "unknown");
}
}
}public class MyCollectionFixture : IAsyncLifetime
{
public ActiveTestTracker TestTracker { get; } = new();
public async ValueTask InitializeAsync()
{
// Pass TestTracker.Fetcher to ALL tracking configuration:
// Cosmos tracking
var cosmosOptions = new CosmosTrackingMessageHandlerOptions
{
ServiceName = "CosmosDB",
CallerName = "My Service",
CurrentTestInfoFetcher = TestTracker.Fetcher,
};
// HTTP tracking
services.TrackDependenciesForDiagrams(new TestTrackingMessageHandlerOptions
{
CallerName = "My Service",
CurrentTestInfoFetcher = TestTracker.Fetcher,
});
// Message tracking
services.TrackMessagesForDiagrams(new MessageTrackerOptions
{
CallerName = "My Service",
ServiceName = "Service Bus",
CurrentTestInfoFetcher = TestTracker.Fetcher,
});
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}public class MyTestBase : IAsyncLifetime
{
protected MyCollectionFixture Fixture { get; }
public ValueTask InitializeAsync()
{
// Set BEFORE test runs — background threads from here on will resolve correctly
// ⚠️ Safe here because InitializeAsync runs on the test thread.
// v2.28.22+: Fetcher() throws InvalidOperationException if called
// outside a test context (e.g. background thread or fixture constructor).
var (name, id) = CurrentTestInfo.Fetcher();
Fixture.TestTracker.Set(name, id);
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync()
{
// Clear BEFORE logging — prevents stale attribution
Fixture.TestTracker.Clear();
DiagrammedTestRun.TestContexts.Enqueue(TestContext.Current);
return ValueTask.CompletedTask;
}
}A static tracker breaks under parallel execution. Each fixture instance must own its tracker:
- Per-test fixtures: New fixture per test → new tracker → no contention
- Per-class fixtures: Tests within a class run sequentially (xUnit guarantee) → safe
- Per-collection fixtures: Tests within a collection run sequentially → safe
- Parallel collections: Each collection has its own fixture → own tracker → no cross-contamination
When pre-existing background threads (Change Feed Processor polling threads, Hangfire workers, hosted service loops) were started before TestIdentityScope.Begin(), the AsyncLocal value never propagates to them. GlobalFallback is a process-wide static fallback that these threads can read.
public class MyTestBase : IAsyncLifetime
{
protected MyCollectionFixture Fixture { get; }
public ValueTask InitializeAsync()
{
var (name, id) = CurrentTestInfo.Fetcher();
// AsyncLocal scope for new async continuations:
_scope = TestIdentityScope.Begin(name, id);
// Static fallback for pre-existing threads:
TestIdentityScope.SetGlobalFallback(name, id);
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync()
{
_scope?.Dispose();
TestIdentityScope.ClearGlobalFallback();
DiagrammedTestRun.TestContexts.Enqueue(TestContext.Current);
return ValueTask.CompletedTask;
}
private IDisposable? _scope;
}Resolution order (v2.28.16+): Same as the #scenario-resolution-reference — HTTP headers → delegate → scope → GlobalFallback → null.
⚠ Parallel execution warning:
GlobalFallbackis a single process-wide value. It only works correctly when tests sharing the same background infrastructure run serially (e.g. within an xUnit collection fixture). This matches the existingActiveTestTrackerpattern's semantics — if your tests already run in parallel with shared infrastructure, use Solution 1 (TestIdentityScope.Begin) instead.
| Scenario | Recommended |
|---|---|
| Pre-existing Change Feed Processor threads | GlobalFallback — simplest, no custom tracker class needed |
| Hosted services started before test |
GlobalFallback — eliminates CurrentTestInfoFetcher boilerplate |
| Multiple independent message trackers | ActiveTestTracker — each tracker can have its own fetcher |
| Need to distinguish between "no test" and "test active" |
ActiveTestTracker — Fetcher() can conditionally return Unknown |
When a tracking handler captures an operation but cannot determine which test it belongs to, the operation is counted as Unknown in the diagnostic report.
Some Unknown entries are normal and expected:
-
Background processing: Change feed processors, message handlers, hosted services — they have no
HttpContextor test scope -
Application startup/teardown: Operations during
WebApplicationFactoryinitialisation or disposal happen outside test scope - Health checks and middleware: Periodic background operations from infrastructure
If all entries for a component are Unknown, investigate:
| Symptom | Likely Cause | Fix |
|---|---|---|
| All Cosmos operations are Unknown |
IHttpContextAccessor not wired to the tracking handler |
See Integration CosmosDB Extension#Using with CosmosDB.InMemoryEmulator |
| All outgoing HTTP calls are Unknown |
IHttpContextAccessor not passed to TestTrackingMessageHandler
|
See HTTP Tracking Setup#Dual-Resolution Test Identity (v2.23.0+) |
| Unknown count matches total |
IHttpContextAccessor was null at handler construction time |
Use the LazyHttpContextAccessor pattern (see below) |
A typical diagnostic report shows:
- 0% Unknown for inbound HTTP handlers (API controller calls)
- 0% Unknown for synchronous outbound HTTP calls (typed HttpClients)
- 30-80% Unknown for Cosmos/Service Bus when async processing is involved
- 100% Unknown for components used only in background processing
When CosmosTrackingMessageHandler (or any extension handler) is constructed before IHttpContextAccessor is available (common with WebApplicationFactory where the Cosmos client is a singleton built during DI), use a lazy wrapper:
internal sealed class LazyHttpContextAccessor : IHttpContextAccessor
{
private IHttpContextAccessor? _inner;
public HttpContext? HttpContext
{
get => _inner?.HttpContext;
set { if (_inner is not null) _inner.HttpContext = value; }
}
public void SetInner(IHttpContextAccessor inner) => _inner = inner;
}Usage:
// 1. Create wrapper and assign to options BEFORE building the Cosmos client
var lazyAccessor = new LazyHttpContextAccessor();
cosmosTrackingOptions.HttpContextAccessor = lazyAccessor;
// 2. Build client — handler captures the lazyAccessor reference
var cosmos = InMemoryCosmos.Builder()
.WrapHandler(h => new CosmosTrackingMessageHandler(cosmosTrackingOptions, h))
.Build();
// 3. Wire the real accessor after WebApplicationFactory is available
lazyAccessor.SetInner(factory.Services.GetRequiredService<IHttpContextAccessor>());Important: The wrapper must be assigned to options before calling
WrapHandler. Settingoptions.HttpContextAccessorafterwards has no effect — the handler has already captured the value at construction time.
After implementing either solution, check the Diagnostic Report with DiagnosticMode = true. The "Unknown" count should either disappear or be reduced to expected background operations (e.g. initial container setup before any test starts).
Getting Started
Common Tasks
Integration Guides
- Integration xUnit3
- Integration xUnit2
- Integration NUnit
- Integration MSTest
- Integration TUnit
- Integration BDDfy xUnit3
- Integration LightBDD xUnit2
- Integration LightBDD xUnit3
- Integration LightBDD TUnit
- Integration ReqNRoll xUnit2
- Integration ReqNRoll xUnit3
- Integration ReqNRoll TUnit
Extensions
- Integration AtlasDataApi Extension
- Integration BigQuery Extension
- Integration Bigtable Extension
- Integration BlobStorage Extension
- Integration ClickHouse Extension
- Integration CloudStorage Extension
- Integration CosmosDB Extension
- Integration Dapper Extension
- Integration DynamoDB Extension
- Integration EF Core Relational Extension
- Integration Elasticsearch Extension
- Integration EventBridge Extension
- Integration EventHubs Extension
- Integration Grpc Extension
- Integration Kafka Extension
- Integration MassTransit Extension
- Integration MongoDB Extension
- Integration MySqlConnector Extension
- Integration Npgsql Extension
- Integration Oracle Extension
- Integration PubSub Extension
- Integration Redis Extension
- Integration S3 Extension
- Integration ServiceBus Extension
- Integration SNS Extension
- Integration Spanner Extension
- Integration SqlClient Extension
- Integration Sqlite Extension
- Integration SQS Extension
- Integration StorageQueues Extension
- Integration OpenTelemetry Extension
- Integration DispatchProxy Extension
- Integration MediatR Extension
- Integration PlantUML IKVM
Configuration
- Tracking Dependencies
- Tracking Custom Dependencies
- HTTP Tracking Setup
- Report Configuration
- Diagram Customisation
- Phase-Aware Tracking
- Content Formatting
- PlantUML Server Configuration
Features
- Generated Reports
- Search Syntax
- Component Diagrams
- PlantUML Browser Rendering
- Inline SVG Rendering
- Internal Flow Tracking
- Tags and Attributes
- Excluding Requests
- Excluded Headers
- Multi-Host Test Architectures
- Event-Driven Architecture Testing
- Service Bus Tracking Patterns
- Background Thread Correlation
- Parallel-Safe Background Correlation
- Event & Message Tracking
- Assertion Tracking
- Step Tracking
- Tabular Attributes
- Large Response and Diagram Handling
- Diagnostics and Debugging
- CI Summary Integration
- CI Artifact Upload
- Merging Parallel Reports
Reference