The Test Folder That Lies
Nine DShop services ship a tests folder and only one of them tests anything - a census where five test projects cannot even see the code they claim to cover and the lone unit test asserts a thing that never happens. Part 14 of Nine Services and a Message Bus.
A tests/ folder is a promise. It tells a new engineer “this code is exercised; change it and something will tell you if you broke it.” DShop makes that promise nine times and keeps it once. Eight of the nine services ship a test project, and eight of the nine test projects are theatre — empty, or unable to compile against the code they name, or asserting a thing the code never does. This part is the census, and I ran it against the actual .csproj and .cs files rather than trusting the folder names, because the folder names are the lie.
The census
Two questions decide whether a test project is real. Does its .csproj reference the service it claims to test — can it even see the code? And does it contain any test files? Here is every service, read from source:
| Service | Test project | References the service code? | Test files |
|---|---|---|---|
| Customers | yes | no | 0 |
| Notifications | yes | no | 0 |
| Signalr | yes | no | 0 |
| Operations | yes | no | 0 |
| Orders | yes | no | 0 |
| Identity | yes | yes | 0 |
| Products | yes | yes | 1 (broken) |
| Discounts | yes | yes | 5 (real) |
| Storage | none | — | — |
Read the two right-hand columns and the promise collapses. Five test projects do not reference the service they are named after — they physically cannot see the code, so no test they contained could compile against it. They are xUnit projects with the test-SDK packages wired up and no ProjectReference to the thing under test. One more (Identity) does reference its service and then contains zero test files. Storage does not ship a test project at all. That is seven services with no meaningful test surface, expressed three different ways: no reference, no tests, no project.
The tragedy of the empty-with-a-nameplate variety is that it is worse than having no folder. A missing tests/ folder is honest — it tells you the truth. A tests/ folder containing a project that can't see the code tells you a comforting lie, and lies about test coverage are the expensive kind, because they are believed exactly when you most need them not to be.
The one unit test, and why it errors
Products is the only service with a unit test, and reading it is the single best QA lesson in the estate, because it fails in a way that teaches. The test wants to assert that creating a product whose name already exists publishes a CreateProductRejected:
[Fact]
public async Task handle_async_published_create_product_rejecte_if_product_with_given_name_already_exists()
{
_productsRepository.ExistsAsync(_command.Name).Returns(true);
await Act(_command); // calls the handler
await _busPublisher.Received().PublishAsync(
Arg.Is<CreateProductRejected>(e => e.Id == _command.Id
&& e.Code == "product_already_exists" /* ... */), _context);
}
The intent is right. The mechanism is wrong, and it is wrong because of a framework fact this series established back in Part 2: rejected events are not built by the handler — they are built by the bus's onError factory when the handler throws. When a product already exists, CreateProductHandler throws a DShopException; it does not publish a CreateProductRejected. So await Act(_command) throws, the exception propagates out of the test, and the assertion below it is never reached. The test does not fail with “expected a publish, got none” — it errors, on the throw, before it can assert anything. It is testing for a behaviour that lives one layer up, in the subscription wiring, which a unit test of the handler cannot see.
Two more tells in the same file, both small, both diagnostic. The [Fact] method name misspells its own subject — create_product_rejecte, not rejected — the kind of typo that survives only in code nobody runs. And the constructor builds the handler twice:
_commandHandler = new CreateProductHandler(_productsRepository, _busPublisher);
_commandHandler = new CreateProductHandler(_productsRepository, _busPublisher);
The first assignment is dead. A test that had ever been executed and read would not carry a duplicated constructor line and a misspelled method name; both are fossils of code written and never run. The lone unit test in the estate is not just broken — it is unrun, and its brokenness is the proof.
The service that actually tests, and why it's different
Discounts is the exception, and every earlier part has been quietly foreshadowing it. It is the only service whose tests are integration tests: they stand the service up with a WebApplicationFactory, talk to a live Mongo, publish real messages through real RabbitMQ, and assert on the results. Five real test files, real fixtures, a docker-compose to bring up the dependencies. It is the only place in the estate where the tests exercise the code they cover.
And it explains a piece of drift from Part 2 that looked like sloppiness at the time. Recall that eight services scan Assembly.GetEntryAssembly() for their handlers, and Discounts alone scans typeof(Startup).Assembly. That is not a stray idiom — it is because Discounts is tested this way. Under a test runner, GetEntryAssembly() returns the test host, and the handler scan would find nothing; typeof(Startup).Assembly always resolves to the service. The one service whose assembly-scan idiom differs is the one service whose tests actually boot it — the drift and the coverage are the same fact seen twice. Testing pressure changed the production code, in exactly one place, and reading the tests is what tells you why.
To be fair, this is a teaching estate, and nobody ships DShop to production; empty test folders in a demo are a venial sin. But the estate is a faithful mirror of a real failure mode — the test suite that exists to be pointed at in a stand-up while covering nothing — and the mechanism is worth internalising precisely because production codebases wear the same disguise: green CI over projects that never referenced the code.
The rule of thumb
- A test project that doesn't reference the code under test covers nothing, and a folder can't tell you that. The real coverage question is not “is there a
tests/folder” but “does its.csprojreference the service, and does it contain files that run.” Check the.csproj, not the directory. - Unit-test the layer where the behaviour lives. Products' test failed because rejection is a subscription-wiring behaviour, not a handler behaviour — no unit test of the handler could ever see it. Match the test's altitude to the behaviour's.
- An unrun test rots into a lie. A misspelled method name and a duplicated constructor are not style nits; they are evidence the test has never executed, and a test that has never executed is documentation of an intention, not a check on the code.
One service tests. The rest trust the reader. Next, the retrospective: Rich Domain, Honest Stubs — what this estate genuinely gets right, sitting next to the discount that only prints and the emails that never send.