-
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.
Event-driven architectures? If your SUT is triggered entirely by consuming messages (no direct HTTP calls from the test), see Event-Driven Architecture Testing for the complete end-to-end guide. This page covers the lower-level mechanisms used under the hood.
Parallel test execution? If you need parallel-safe background correlation (v2.36.0+), see Parallel-Safe Background Correlation for
TestCorrelationStore— a thread-safe alternative toGlobalFallbackthat requires no production code changes.
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(...) or set via SetFromMessage() — propagates via AsyncLocal
|
| 4 | Message headers (kronikol-test-name / kronikol-test-id) |
Consumer/subscriber receives a message produced during a tracked test (v2.34.0+) |
| 5 | TestIdentityScope.GlobalFallback |
Pre-existing background threads (Change Feed Processor, Hangfire, hosted services) |
| 6 | 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–6. 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 |
| Kafka/ServiceBus/EventHubs/PubSub consumer processing a tracked message | Priority 4 | Message headers carry test identity from producer to consumer |
Change Feed Processor / hosted service with SetGlobalFallback
|
Priority 5 | Pre-existing thread reads the static fallback |
| Background thread with nothing configured | Priority 6 | Returns null — operation not tracked |
See Tracking Custom Dependencies#Tracking Background Processing with TestIdentityScope (v2.28.5+) for full details and examples.
For event-driven architectures where an ASP.NET application listens to messages (Kafka, Service Bus, Event Hubs, Google Pub/Sub, MassTransit), test identity is automatically propagated through message headers — no manual TestIdentityScope.Begin() or GlobalFallback needed.
When a tracked producer sends a message during a test, the kronikol-test-name and kronikol-test-id headers are injected into the message metadata. When a tracked consumer receives that message, the headers are extracted and established as the ambient TestIdentityScope via SetFromMessage().
Test Thread Producer Consumer (Background Thread)
│ │ │
│── Produce("order-created") ──│ │
│ [headers injected: │ │
│ kronikol-test-name + id] │ │
│ │── Message on topic ───────────>│
│ │ │── Headers extracted
│ │ │── TestIdentityScope.SetFromMessage()
│ │ │── All tracking resolves to test
| Extension | Producer Injects | Consumer Extracts | Header Location |
|---|---|---|---|
| Kafka | TrackingKafkaProducer |
TrackingKafkaConsumer |
message.Headers (byte[]) |
| Service Bus | TrackingServiceBusSender |
TrackingServiceBusReceiver |
message.ApplicationProperties |
| Event Hubs | TrackingEventHubProducerClient |
TrackingEventHubConsumerClient |
eventData.Properties |
| Google Pub/Sub | TrackingPublisherClient |
TrackingSubscriberClient |
message.Attributes |
| MassTransit |
TrackingSendObserver / TrackingPublishObserver
|
TrackingConsumeObserver |
Transport headers |
Propagation is enabled by default. To disable it (e.g. for performance-sensitive benchmarks):
var options = new KafkaTrackingOptions
{
PropagateTestIdentity = false, // Disable header injection/extraction
// ...
};All messaging extension option classes expose the same PropagateTestIdentity property.
| Scenario | Recommended Solution |
|---|---|
| Event-driven app consuming Kafka/ServiceBus/EventHubs/PubSub | Automatic propagation (v2.34.0+) — zero config needed |
| Background processing triggered by HTTP request |
HTTP headers (Priority 1) — already works via WebApplicationFactory
|
Task.Run / manual thread dispatch |
TestIdentityScope.Begin() — wraps the dispatch point |
| Pre-existing hosted service threads |
GlobalFallback — for threads started before the test |
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