Skip to content

Multi Host Test Architectures

aryehcitron@gmail.com edited this page May 17, 2026 · 4 revisions

Many real-world microservices have a dual-host architecture:

  1. API hostWebApplicationFactory<T> serving HTTP endpoints
  2. Function host — Azure Functions FunctionTestServer<T> processing async triggers (Service Bus, Change Feed, etc.)

Both hosts share the same databases, messaging infrastructure, and caches. This page covers the patterns for making them work together with Kronikol.

Single-host event-driven? If your SUT is a single application that both consumes messages and calls dependencies (no separate Function host), see Event-Driven Architecture Testing for a simpler setup pattern.


Shared Messaging Across Hosts

When using in-memory messaging (e.g. AddInMemoryMessaging()), both hosts must share the same InMemoryMessaging and IServiceBusMessageSender instances. Otherwise, messages published by the API host are invisible to the Function host's trigger framework.

The DI Ordering Gotcha

If the messaging framework uses AddSingleton (not TryAddSingleton), it overwrites previously registered instances. Register your shared instances after the framework's registration:

// ❌ WRONG — framework's AddInMemoryMessaging overwrites these
serviceCollection.AddSingleton(sharedInMemoryMessaging);
serviceCollection.AddSingleton<IServiceBusMessageSender>(sharedSender);
serviceCollection.AddInMemoryMessaging(options);  // overwrites above

// ✅ CORRECT — register after to overwrite the framework's registrations
serviceCollection.AddInMemoryMessaging(options);
serviceCollection.AddSingleton(sharedInMemoryMessaging);            // overwrites
serviceCollection.AddSingleton<IServiceBusMessageSender>(sharedSender); // overwrites

Verifying Shared Instances

If messages aren't flowing between hosts, verify the instances are shared:

var webMessaging = WebFactory.Services.GetRequiredService<InMemoryMessaging>();
var funcMessaging = Functions.ServiceProvider.GetRequiredService<InMemoryMessaging>();
Assert.True(ReferenceEquals(webMessaging, funcMessaging), "Hosts must share the same InMemoryMessaging");

Cross-Container Tracker Bridging

TrackMessagesForDiagrams() registers a MessageTracker in one DI container. To track messages sent from the other host, bridge the tracker manually:

public async ValueTask InitializeAsync()
{
    // 1. Create the API host (registers MessageTracker in its DI)
    WebFactory = new CustomWebApplicationFactory<Startup>(...);

    // 2. Create the Function host (separate DI, no MessageTracker)
    Functions = new FunctionFixture(...);
    await Functions.InitializeAsync();

    // 3. Bridge the tracker to the Function's sender
    var messageTracker = WebFactory.Services.GetService<MessageTracker>();
    if (messageTracker is not null)
    {
        sender.AfterPublish += (_, args) =>
        {
            var topicOrQueue = args.QueueOrTopic ?? "unknown";
            messageTracker.TrackSendEvent(
                protocol: "Publish (Service Bus)",
                destinationName: "Service Bus",
                destinationUri: new Uri($"servicebus://service-bus/{topicOrQueue}"),
                payload: args.Message);
        };
    }
}

Shared Singletons Pattern

Track once, share everywhere:

Test Process
├── API Host (WebApplicationFactory)
│   ├── DI Container 1
│   │   ├── MessageTracker ← registered here
│   │   ├── TrackingDistributedCache ← created here
│   │   ├── CosmosTrackingMessageHandler ← via InMemoryCosmos
│   │   └── TestTrackingMessageHandler ← for outbound HTTP
│
├── Function Host (FunctionTestServer)
│   ├── DI Container 2
│   │   ├── ServiceBusMessageSender ← needs tracker from Container 1
│   │   ├── IDistributedCache ← needs shared instance from Container 1
│   │   └── CosmosDB ← shared CosmosDbFixture
│
└── Shared Fixtures (xUnit Collection)
    ├── CosmosDbFixture (singleton across both hosts)
    └── InMemoryMessaging (bridges message flow)

Key rules:

  1. Create the API host first — its tracked instances are passed to the Function host
  2. The CosmosDbFixture is created before both hosts and shared via xUnit collection fixtures
  3. Use TestIdentityScope.Begin() to propagate test identity into the Function host's background processing

FunctionTestServer HttpClient Tracking

If your FunctionTestServer exposes only a plain HttpClient (without access to the underlying TestServer), requests to the Function host bypass Kronikol. Options:

  1. Request the FunctionTestServer package to expose its TestServer or HttpMessageHandler — this is the proper fix
  2. Use RequestResponseLogger.LogPair() to manually log Function interactions — see Tracking Custom Dependencies
  3. Wrap with reflection (fragile, last resort):
var hostField = typeof(FunctionTestServer<Startup>)
    .GetField("_host", BindingFlags.NonPublic | BindingFlags.Instance);
var host = (IHost)hostField!.GetValue(functionServer)!;
var testServer = host.Services.GetRequiredService<TestServer>();
var trackedClient = testServer.CreateTestTrackingClient(options);

Consistent Test Identity Across Hosts

All tracking (Cosmos, HTTP, Service Bus) must use the same fetcher delegate so they all resolve the same test identity:

public class TestFixture : IAsyncLifetime
{
    public ActiveTestTracker TestTracker { get; } = new();

    public async ValueTask InitializeAsync()
    {
        var fetcher = TestTracker.Fetcher;

        // All tracking uses the same fetcher
        CosmosDbFixture = new CosmosDbFixture(fetcher);
        WebFactory = new CustomWebApplicationFactory(fetcher);
        FunctionFixture = new FunctionFixture(fetcher);
    }
}

For background thread correlation (change feed processors, message handlers), see Background Thread Correlation.


HttpContextAccessor Wiring Order

When using CosmosTrackingMessageHandler with a multi-host setup, IHttpContextAccessor is typically not available at Cosmos client construction time. The handler captures the accessor reference from options at construction time.

Use the LazyHttpContextAccessor pattern — see Background Thread Correlation#LazyHttpContextAccessor Pattern — to provide a non-null wrapper at construction time that delegates to the real accessor once WebApplicationFactory is available:

// 1. Create lazy accessor before building Cosmos client
var lazyAccessor = new LazyHttpContextAccessor();
cosmosTrackingOptions.HttpContextAccessor = lazyAccessor;

// 2. Build Cosmos client with tracking handler
var cosmos = InMemoryCosmos.Builder()
    .WrapHandler(h => new CosmosTrackingMessageHandler(cosmosTrackingOptions, h))
    .Build();

// 3. After WebApplicationFactory.CreateClient(), wire the real accessor
lazyAccessor.SetInner(factory.Services.GetRequiredService<IHttpContextAccessor>());

Initialization Order Summary

1. Create CosmosDbFixture (shared singleton, tracking handler wraps InMemory handler)
2. Create API host (WebApplicationFactory) — owns MessageTracker, IHttpContextAccessor
3. Create tracked test client (CreateTestTrackingClient)
4. Wire CosmosTrackingMessageHandler.HttpContextAccessor (LazyHttpContextAccessor.SetInner)
5. Create Function host — inject shared Cosmos, shared messaging, shared sender
6. Initialize Function host
7. Bridge MessageTracker to Function host's message sender (AfterPublish events)
8. Wire Service Bus tracking handler

Home


Demo


Getting Started

Common Tasks

Integration Guides

Extensions

Configuration

Features

Reference

Clone this wiki locally