Testing a Service Built on a Chassis
Twenty-two parts of source reading and not one about tests. How Convey's design choices land on your test suite: handlers that fake beautifully, an exposed host builder for integration tests, config flags as test infrastructure, and conventions that deserve a pin.
Here is a gap I am slightly embarrassed by: across twenty-two parts of the parent series, the word “test” appears roughly twice, in passing. I read Convey's dispatchers, decorators, brokers and outboxes without once asking the question every team asks in week one: how do we test a service built on this thing? The toolkit itself is quiet on the subject — the packages ship no testing helpers, and the samples ship no test projects. But a chassis makes dozens of decisions that land squarely on your test suite, and having shipped Convey services with real suites around them, I can walk the whole ladder: unit, integration, contract. The chassis is more testable than it looks — and the reasons are specific.
Unit tests: the markers pay their rent
Start where part 3 started: ICommand, IQuery<T>, IEvent are empty interfaces, and handlers are plain classes with constructor injection. No base class, no framework context object, no static service locator. That means the flagship handler from chapter one — CreateOrderHandler, with its five dependencies — unit-tests like any other class:
var repository = Substitute.For<IMongoRepository<Order, Guid>>();
var pricingClient = Substitute.For<IPricingServiceClient>();
var outbox = Substitute.For<IMessageOutbox>();
var publisher = Substitute.For<IBusPublisher>();
repository.ExistsAsync(Arg.Any<Expression<Func<Order, bool>>>()).Returns(false);
pricingClient.GetAsync(orderId).Returns(new PricingDto { OrderId = orderId, TotalAmount = 20.50m });
outbox.Enabled.Returns(true);
var handler = new CreateOrderHandler(repository, publisher, outbox, pricingClient, tracer, logger);
await handler.HandleAsync(new CreateOrder(orderId, customerId));
await outbox.Received(1).SendAsync(Arg.Is<OrderCreated>(e => e.OrderId == orderId), spanContext: Arg.Any<string>());
await publisher.DidNotReceiveWithAnyArgs().PublishAsync<OrderCreated>(default);
Every seam the parent series admired for composition reasons turns out to be a faking seam: IMongoRepository means no in-memory Mongo, IBusPublisher means no broker, IMessageOutbox.Enabled means the outbox-or-publish fork — the one that is real application logic in the sample — is directly steerable. The last two asserts are the test that fork deserves: outbox on, event goes to the outbox and not to the bus. Flip Enabled and assert the mirror image, and you have pinned the sample's most subtle behavior in two tests.
Two cautions from production suites. First, the tracer: handlers take OpenTracing's ITracer, and GlobalTracer is process-level static — inject NoopTracerFactory.Create() in tests and never touch the global, or test ordering will haunt you. Second, expression-matching against repository fakes (ExistsAsync(o => o.Id == id)) is brittle if you assert on the expression; assert on behavior (what was added) rather than trying to prove which lambda was passed.
The decorator seam adds one non-obvious rule: decorators like the CQRS logging wrapper are applied by Scrutor at container build. Unit tests that new a handler bypass them — which is what you want; test the decorator once, separately, not re-proven under every handler test.
Integration tests: the samples accidentally show the way
Look back at any sample's Program.cs and notice a shape .NET veterans will recognize:
public static IHostBuilder CreateHostBuilder(string[] args) => ...
Public and static — the exact hook the classic WebApplicationFactory/HostBuilder test pattern needs. Whether or not the authors intended it as a test seam, it is one: your test project calls CreateHostBuilder, layers ConfigureAppConfiguration on top, and boots the entire chassis — builder, registry, dispatchers, endpoint DSL — inside the test process.
And here the enabled: false fleet from chapter two reveals itself as accidental test infrastructure. The same flags that let the sample run without Consul let a test host run with no infrastructure at all:
config.AddInMemoryCollection(new Dictionary<string, string>
{
["consul:enabled"] = "false",
["fabio:enabled"] = "false",
["jaeger:enabled"] = "false",
["vault:enabled"] = "false",
["outbox:enabled"] = "false",
["logger:seq:enabled"] = "false",
});
What you cannot flag away: Mongo and RabbitMQ. AddMongo() registers a real client and AddRabbitMq() opens real connections eagerly — there is no enabled switch on either, a genuine asymmetry in the toolkit's own convention and worth knowing before you discover it as a 30-second connection-timeout hang in CI. Your choices are the standard three: Testcontainers (my default — the compose chapter in miniature, per test run), replacing registrations after the chassis adds them (ConfigureServices runs in order, so a later services.Replace(...) wins — the same last-write-wins behavior the builder's TryRegister was built to avoid), or restructuring Program.cs so the broker chain is a composition parameter. I have used all three; Testcontainers ages best, because it tests the conventions too — which brings us to the layer that actually breaks in production.
Contract tests: the conventions are the contract
The most Convey-specific testing insight is one the parent series set up without cashing: in this toolkit, your class names are your wire protocol. Part 8 showed the conventions turning OrderCreated into an exchange, routing key and queue; part 9 showed snake_casing and the {{assembly}}/{{exchange}}.{{message}} queue template. Rename a C# event class and you have changed a RabbitMQ binding. No compiler, no test, nothing will tell you — the messages just stop arriving, which is the worst failure mode distributed systems offer.
So pin the conventions. Resolve IConventions from a built test host and snapshot what it produces for every public message type:
var conventions = host.Services.GetRequiredService<IConventionsProvider>().Get(typeof(OrderCreated));
conventions.Exchange.ShouldBe("orders");
conventions.RoutingKey.ShouldBe("order_created");
Ten lines, one test per message type, and class renames become visible diffs in a failing test instead of silence in production. This is the cheapest high-value test a Convey service can own.
The HTTP flavor of the same idea comes free: part 6's /_contracts endpoint serves the service's command and event shapes as JSON. Snapshot it in CI and any accidental change to a public contract fails the build; hand the same URL to consuming teams and you have poor-man's consumer-driven contracts. For the rich version, the DevMentors ecosystem itself has an answer — Pactify, their lightweight contract-testing library, pairs naturally with services whose contracts are already this explicit. That ecosystem gets its own treatment in the final chapter.
What the chassis owes you, and what it doesn't
The scorecard, honestly stated. The chassis helps testing everywhere its abstractions are interfaces and its behaviors are config: handlers fake cleanly, hosts boot in-process, capabilities switch off. It hurts in exactly the places the parent series flagged as gotchas for other reasons: eager broker connections, assembly-scanning that will happily pick up handlers from your test assembly too (scope your fixtures), statics like GlobalTracer, and reflection-driven behaviors (route binding, conventions) that only integration tests exercise. Nothing here is unusual for the era — but on a chassis the ratio shifts: less of your risk is in your code and more is in the wiring, so the integration and contract rungs of the ladder matter more than they would on a framework-free service. Budget accordingly.
Next chapter: you've read the source, run the compose stack, written the tests — how does a team actually adopt this thing? The onboarding path, from clone to first service, including the tooling that isn't there.