Test without a Broker
Goal: fast, deterministic tests for everything that reads a cache, with Kafka nowhere in the picture.
Prerequisite: none beyond the generated caches. The code is in TestWithoutABroker.cs; every block runs as a test. All test projects in the repository use NUnit; the patterns are framework-neutral.
The code under test
A read model that takes two caches and knows nothing about where their data comes from. That is the seam: in production AddKafkaCaches fills them, in a test you do.
/// <summary>The code under test: a read model over two caches. It neither knows nor cares where the data came from.</summary>
public sealed class CustomerStatement(OrderCache orders, CustomerCache customers) {
public string? Describe(int customerId) {
if (!customers.Cache.TryGet(customerId, out var customer))
return null;
using var open = orders.Query()
.WithCustomerId(customerId)
.Or(b => b.WithStatus(OrderStatus.Pending), b => b.WithStatus(OrderStatus.Paid))
.ExecutePooled();
return $"{customer.Name}: {open.Count} open order(s), {open.Sum(o => o.Total):0.00} outstanding";
}
}
1. A stand-alone cache
new OrderCache() is a complete cache. AddOrUpdate is the same method the consumer calls per message; pass a timestamp when the last-update index matters.
var orders = new OrderCache(); // no registry, no Kafka, no DI
orders.AddOrUpdate(NewOrder("ORD-001", 42, OrderStatus.Pending, 120m));
orders.AddOrUpdate(NewOrder("ORD-002", 42, OrderStatus.Cancelled, 80m), timestamp: 1_700_000_000_000); // explicit timestamp
using var open = orders.Query().WithCustomerId(42).WithStatus(OrderStatus.Pending).ExecutePooled();
Assert.That(open.Count, Is.EqualTo(1));
Assert.That(open[0].OrderId, Is.EqualTo("ORD-001"));
Assert.That(open.Select(o => o.Total), Is.EqualTo(new[] { 120m })); // QueryResults<T> is an IReadOnlyList<T>
2. The registry, for joins and global indices
Joins and global last-update indices are wired when caches are built together. DataCacheRegistryBuilder.Register<OrderCache>() pulls in every cache the foreign keys reach.
var registry = new DataCacheRegistryBuilder().Register<OrderCache>().Build(); // wires FK joins + CustomerActivityIndex
var orders = registry.GetCache<OrderCache>();
var customers = registry.GetCache<CustomerCache>();
customers.AddOrUpdate(new Customer { Id = 42, Email = "ada@example.com", Country = "GB", Tier = CustomerTier.Gold, Name = "Ada" });
orders.AddOrUpdate(NewOrder("ORD-001", 42, OrderStatus.Paid, 120m));
var statement = new CustomerStatement(orders, customers);
Assert.That(statement.Describe(42), Is.EqualTo("Ada: 1 open order(s), 120.00 outstanding"));
Assert.That(statement.Describe(7), Is.Null);
3. After-handlers
An ICacheAfterHandler has one method; call it with the UpdateType sequence a topic would produce (see React to changes for the arguments per case).
var projector = new OrderProjector(); // the ICacheAfterHandler<string, Order> from "React to changes"
var order = NewOrder("ORD-001", 42, OrderStatus.Pending, 100m);
await projector.Handle(UpdateType.Add, order.OrderId, order, null);
await projector.Handle(UpdateType.Delete, order.OrderId, null, order);
Assert.That(projector.OpenTotalByCustomer[42], Is.Zero);
4. Asserting on pooled results
QueryResults<T> is an IReadOnlyList<T>, so NUnit's collection constraints work on it directly. Assert the set, not the order, unless the query sorts.
using var rows = orders.Query().WithCustomerId(42).ExecutePooled(); // `using`: the buffer goes back when the test ends
Assert.That(rows.Count, Is.EqualTo(2));
Assert.That(rows.Select(o => o.OrderId), Is.EquivalentTo(new[] { "ORD-001", "ORD-002" })); // order is not asserted
Assert.That(rows.TotalCount, Is.EqualTo(2));
var ids = rows.Map(o => o.OrderId); // consumes `rows`; the later `using` Dispose is a no-op
using (ids)
Assert.That(ids.Count, Is.EqualTo(2));
5. Frozen against eager
A frozen query is a plan; the eager query is its specification. Compare them over several argument values, as sets when unsorted.
var frozen = orders.Prepare<int>().WithCustomerId(static c => c).WithStatus(OrderStatus.Paid).BuildFrozen();
foreach (var customerId in new[] { 0, 1, 2, 99 }) {
using var expected = orders.Query().WithCustomerId(customerId).WithStatus(OrderStatus.Paid).ExecutePooled();
using var actual = frozen.ExecutePooled(customerId);
// Same rows, same counts; the unsorted order is not part of the frozen contract, so compare as sets.
Assert.That(actual.Select(o => o.OrderId), Is.EquivalentTo(expected.Select(o => o.OrderId)));
Assert.That(actual.TotalCount, Is.EqualTo(expected.TotalCount));
}
6. The real container, not started
AddKafkaCaches contacts no broker until the hosted service starts. Build the provider, resolve the cache singletons and seed them: your services then run against the same instances the app would inject.
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
services.AddLogging();
services.AddKafkaCaches(o => o.BootstrapServers = "unreachable:9092", // never contacted: no hosted service starts
b => b.AddCache<OrderCache, string, Order>().WithAfterHandler<OrderProjector>());
// CustomerCache rides in through the FK: it is in the registry but not a DI service until you expose it.
services.AddSingleton(sp => sp.GetRequiredService<IDataCacheRegistry>().GetCache<CustomerCache>());
services.AddSingleton<CustomerStatement>();
using var provider = services.BuildServiceProvider(); // do NOT resolve/start IHostedService in a unit test
var orders = provider.GetRequiredService<OrderCache>();
orders.AddOrUpdate(NewOrder("ORD-001", 42, OrderStatus.Paid, 10m)); // seed the real singleton the app would use
Assert.That(provider.GetRequiredService<ICacheAfterHandler<string, Order>>(), Is.TypeOf<OrderProjector>());
Assert.That(provider.GetRequiredService<CustomerStatement>().Describe(42), Is.Null); // no customer seeded
When you do want a broker
The repository's Prague.Kafka.IntegrationTests start Kafka with Testcontainers and exercise the consumer end to end: initial load, live updates, tombstones, filters, after-handlers, health. Without Docker they fail on the socket within seconds, which is the only reason they fail. Use that project as the template when a test has to cover deserialization or topic semantics; everything above it belongs in a unit test.
Pitfalls
- Asserting order on an unsorted frozen result. It carries no row-order guarantee. Use
Is.EquivalentTo, or add a total sort. - Forgetting
usingon pooled results in tests. The test passes and the buffer leaks until the pool refills; under a long test run that shows up as allocation noise. Theusingis free. - Resolving
IHostedServicein a unit test. It starts the consumer. Build the provider and stop there. - Caches pulled in by foreign keys are not DI services. Register a factory from the registry if the code under test injects them (step 6 does).