Testing a Message You Can't Await
How do you assert that an endpoint published an event when publishing is a side effect with no return channel? FeedR's end-to-end test answers with WebApplicationFactory, a real Redis round-trip, and a TaskCompletionSource - plus the timeout and determinism traps the sample leaves open. Part 9 of the FeedR deep dive.
Testing request/response code is a solved problem: call the endpoint, assert on the response. Event-driven code breaks the mold, because the interesting outcome isn't in the response — it's a side effect that departed sideways, onto a bus, with no return channel. POST /news returns 202 Accepted in two milliseconds. The thing you actually need to verify — that a NewsPublished event went out over the wire, correctly shaped — happened (or didn't) somewhere else, at some point near but not exactly at the time of the response.
Most teams answer this badly in one of two directions: mock the publisher and assert PublishAsync was called (proving only that you wrote the line of code you wrote), or sprinkle Task.Delay(500) before asserting (buying flakiness by the pound). FeedR — the open-source DevMentors sample this series reads — ships exactly one test project, six files small, and it threads this needle with the pattern I now consider the house answer: subscribe first, set a completion trap, act, await the trap. Episode 11 of their series is titled “asynchronous testing” for a reason. Let's read it.
The harness: a real app in-process
The test project (Feedr.Feeds.News.Tests.EndToEnd) bootstraps the actual news service — not a mock shell of it — with ASP.NET Core's WebApplicationFactory:
internal sealed class NewsTestApp : WebApplicationFactory<Program>
{
}
That empty class is deceptively rich. WebApplicationFactory<Program> boots the real Program.cs — real DI registrations, real configuration, real middleware — in-process, over a test server with no sockets. CreateClient() hands back an HttpClient whose requests flow through the genuine pipeline.
Two mechanical details are worth pinning, because they trip everyone the first time. First, minimal APIs have no Program class — the compiler generates one, internal. That's why the news csproj carries:
<ItemGroup>
<InternalsVisibleTo Include="Feedr.Feeds.News.Tests.EndToEnd" />
</ItemGroup>
InternalsVisibleTo from the product project grants the test project sight of the generated Program type — the modern replacement for the old public partial class Program { } trick, declared in the csproj rather than in code. Second, note the harness class is itself internal sealed — the sample is tidy about not exporting its test plumbing.
The smoke test earns its keep in six lines:
[Fact]
public async Task get_base_endpoint_should_return_ok_status_code_and_service_name()
{
var response = await _client.GetAsync("/");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
content.ShouldBe("FeedR News feed");
}
Trivial-looking, but it proves the whole composition root builds and boots — every DI registration resolves, configuration binds, middleware constructs. In my experience this “does it even start” test catches more regressions per line than any other test you'll write. (Snake_cased test names and Shouldly assertions are house style; content.ShouldBe(...) reads aloud better than Assert.Equal(...) and I've stopped arguing about it.)
The main event: trap, act, await
Here's the test this article exists for, quoted in full from ApiTests.cs:
[Fact]
public async Task post_news_should_return_accepted_status_code_and_publish_news_published_event()
{
var tcs = new TaskCompletionSource<NewsPublished>();
var subscriber = _app.Services.GetRequiredService<IStreamSubscriber>();
await subscriber.SubscribeAsync<NewsPublished>("news", message =>
{
tcs.SetResult(message);
});
var request = new PublishNews("test news", "test category");
var response = await _client.PostAsJsonAsync("news", request);
response.StatusCode.ShouldBe(HttpStatusCode.Accepted);
var @event = await tcs.Task;
@event.Title.ShouldBe(request.Title);
@event.Category.ShouldBe(request.Category);
}
The choreography, in order:
- Build the trap.
TaskCompletionSource<NewsPublished>is a manually-triggered task — a promise you complete yourself. ItsTaskwon't finish until someone callsSetResult. - Arm it before acting. The test resolves
IStreamSubscriberfrom the running app's own DI container (_app.Services) and subscribes to thenewstopic, wiring the handler to spring the trap. Subscribing before posting is load-bearing — pub/sub has no memory (part 4!), so a subscription created after the publish would await forever. - Act through the front door. A real HTTP POST, JSON-serialized by the same conventions the service uses.
- Assert the sync part synchronously —
202 Accepted(the honest “queued, not done” status part 3 wished the quotes endpoints used). - Await the async part asynchronously.
await tcs.Taskparks the test — no polling, no sleep — until the event arrives, then asserts on its content, not merely its existence.
This is the pattern to steal: turn “eventually” into "awaitable." Every flaky Task.Delay in an event-driven test suite is a TaskCompletionSource that hasn't been written yet. The test takes exactly as long as the system takes — a fast publish means a fast test, and a hang means something is genuinely broken rather than a sleep being too short.
The twist: this test rides real Redis
Now the part that changes what kind of test this is. Where does that IStreamSubscriber come from? The news service's Program.cs registers AddStreaming() (no-op) then AddRedisStreaming() (Redis) — and by DI last-wins (part 4), Redis wins, in the test too. WebApplicationFactory overrides nothing here. The event travels from the endpoint's publisher to the test's trap through an actual Redis server on localhost.
The project is named Tests.EndToEnd, so this is stated intent, not an accident — but it's worth spelling out what was traded, because the trade is available to you in both directions:
- What it buys: the test exercises serialization (camelCase JSON through
ISerializer), the multiplexer, the real subscribe mechanics, and the topic name — the exact convention-based contract surface part 4 worried about. A property rename that would silently break the aggregator fails this test. A mocked publisher catches none of that. - What it costs: the suite now has an infrastructure dependency. No Redis, no green build — locally that's a
docker compose upaway (part 10), but your CI needs to know. And the test shares a Redis with anything else usinglocalhost:6379; a parallel test run publishing tonewscould spring someone else's trap. Isolation by topic-name-per-test or a Testcontainers-provisioned Redis per suite is the grown-up version.
The pattern's flexibility is the takeaway: the same test code becomes a fast, infrastructure-free integration test if you swap the registration (WithWebHostBuilder(b => b.ConfigureServices(s => s.AddSingleton<IStreamPublisher, InMemoryPublisher>())), plus an in-memory pub/sub of about fifteen lines). FeedR's seams make both variants one registration apart. Choose end-to-end for the contract surface, in-memory for the logic — ideally keep one of each.
The traps the sample leaves armed
Three critiques, each one incident-shaped, because this test is almost right in ways that teach:
No timeout on the trap. await tcs.Task with no deadline means a lost message hangs the test until the runner's global timeout kills it — minutes of silence instead of a crisp failure. The fix is now built into the platform: .NET 6 added WaitAsync:
var @event = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
Five seconds is generous for a localhost round-trip and turns “mystery hang” into “TimeoutException at the assertion that names the missing event”. Every completion-source trap should carry one.
SetResult instead of TrySetResult. If the handler fires twice — a retry, a second stray test publishing to news, a duplicated delivery — SetResult throws InvalidOperationException inside the subscription callback, on Redis's dispatch path, where it vanishes (or worse, disturbs the multiplexer). TrySetResult is the idempotent version and costs nothing. In callbacks you don't own the invocation count of, “try” variants are the default.
Determinism by luck. The news endpoint publishes before returning 202, so by the time the POST completes, the event is already in flight — the test's await is short and reliable. But the endpoint's source contains a commented-out variant that publishes from a fire-and-forget Task.Run after responding — left there, I suspect, precisely as the episode's demonstration that the trap pattern still works when the effect trails the response. It would: the TaskCompletionSource doesn't care when the message arrives, only that it does. That's the resilience Task.Delay-based tests never have — the sleep that was long enough for the inline version is too short for the deferred one, and nobody finds out until CI has a slow day.
The missing suite, honestly
One test project, two tests, covering one endpoint of one service — let's not grade on a curve: the quotes channel dispatcher, the generator's stop semantics, the gRPC stream, the aggregator's every-tenth-tick rule, and the Pulsar round-trip all ship untested. The pattern generalizes to each of them (the aggregator test would trap on a Pulsar subscription; the gRPC test would read one message from a streaming call against the factory's test server), and writing those is a genuinely good exercise for internalizing this series. But the sample's choice of which test to write first is telling: the hardest-to-test seam — an async side effect across a real wire — is the one with coverage. Teams usually do the opposite, unit-testing the trivial and declaring the bus untestable. FeedR's two tests demonstrate the bus is the easy part once you know the trap pattern.
Next, part 10 leaves the code for the terrain it runs on: six services, two infrastructure containers, and three different ways to start them all — pm2, Project Tye, and a deliberately split pair of compose files — plus the configuration layering that lets one build run on localhost and in Docker without changing a line.