Every test makes a promise in its name. This is the story of one that made the promise, passed every run, and was quietly watching the wrong thing the whole time. We caught it not by reading the test harder, but by asking it a single blunt question and letting it answer in public.
Here is the shape of it in EkoLite, our small real time backend. On the server we keep one wrapper around the WebSocket layer. The app hands it a message and it sends it to a client, or broadcasts it to all of them, and when a browser drops, the wrapper notices and tells the app the client has gone. Connect, send, broadcast, disconnect. Four small promises.
We had a coverage problem on that file. It read 39 percent, the lowest of any wrapper in the tree, and the reason was not laziness. It was duplication.
Three copies of one idea
The wrapper came in three classes, all implementing the same interface. One stood up a standalone WebSocket server. One registered a route on Fastify, which is the path that actually ships. One was the in memory stub the tests built. Each carried its own map of clients, its own id counter, its own send, its own broadcast, its own disconnect handling, its own tracking. Three copies of one idea.
The unit suite only ever built the stub. So the two real classes, the ones that run in production, only ran under integration tests, and our coverage pass does not count those. Roughly two thirds of the file never ran while coverage was measured. The damage showed in the shape of the code too: trackConnections and trackMessages literally threw only available on null instances on both real classes. Tracking that works only on the stub is the tell. It means the stub is a separate object pretending to be the real one, rather than the real one with the lights switched off.
That distinction is the whole job, so it is worth being precise about it.
What James Shore actually told us to do
The pattern we were reaching for is James Shore's Nullable Infrastructure, and his own worked example is, by a happy accident, almost exactly our problem. He builds a server side socket wrapper with simulateConnection, simulateMessage and simulateDisconnection, the same simulation seam we needed.
The important part of his example is not the simulation methods. It is one line of discipline underneath them. His real socket handler and his simulation methods both funnel into the same private handlers, handleConnection, handleMessage, handleDisconnection. One body of logic, reached through two doors. The stub is not a second class holding a copy of the behaviour. It is the same behaviour, reached through a different entrance. His own rule, in his own words: share as much code as possible with the code that handles real external events.
So the shape we wanted was clear. A boundary that abstracts only the genuinely different bit, which is how a connection arrives, and a single wrapper that owns everything after it. We called the boundary ServerSocketLike and ConnectionSource. Real WebSocket, real Fastify and the in memory null each accept a connection their own way and then hand the same wrapper a connected socket. The wrapper does the clients map, the sending, the broadcasting, the disconnect, the tracking, once, and never asks which kind of socket it is holding.
What we built on the first pass
The first pass folded the three classes into one. That part was right, and it is real progress. The throws were gone, the tracking was uniform, the boundary was sitting where it should.
But it kept two doors into the behaviour and wired them to two different rooms. send and broadcast branched on whether the client was a stub: push into the stub's array on one side, call the real socket on the other. Disconnect existed twice over. Once in the closure the real socket fires on close, which deletes the client and emits the event. Once in a separate private method the stub calls, which deletes the client and emits the event. The same three lines, written twice, living a few methods apart.
So the structure was one class. The behaviour was still two copies. We had moved the seam, not removed it. And a seam you cannot see is the most expensive kind.
A question worth sitting with
We did not open the review by saying this is wrong, change it. We asked the test a question it had to answer out loud.
The headline test for this work was the one written to prove the core promise: removes a client and notifies onDisconnect exactly once. It was green. So we asked it the only question that ever matters about a green test. What would have to break for you to go red?
The honest way to ask is to break the thing the test is named after and see if it notices. So we emptied the body of the disconnect that ships, the closure the real socket fires on close, and ran everything.
The receipt
npm run test, the unit suite: 168 green, including the exactly once test. Not a flicker.
npm run test:integration, the real suite: two red, both landing on expect(ws.clientCount).toBe(0), both reporting expected 1 to be 0.
Read that back slowly. We deleted the only code that removes a client when a real socket closes, and the test named removes a client did not move. It had been watching the stub's private copy of disconnect the whole time, through the door only the stub uses. The behaviour it claimed to guard could be lifted clean out of production and not one unit test would notice. The integration suite caught it, but the integration suite is precisely the slow broad test the pattern is meant to free us from, and our coverage pass does not even run it. That is also why the first pass only lifted the file from 39 percent to 55, well short of where it should land. The production paths were still hiding in the suite that coverage ignores.
A test that stays green when you delete the behaviour it is named after is not testing that behaviour. It is testing something that resembles it. It is the same shape as any fallback that only your own tests keep alive, and we keep meeting it.
The fix is one door
The repair is the drawing Shore handed us. Push the null down into the boundary. The null ServerSocketLike captures what the wrapper sends, so send and broadcast call socket.send with no branch at all. The null socket's close fires the same onClose the wrapper registered for every real socket, so the disconnect lives once, in one closure, and the stub reaches it through the same door a real connection does.
We have already done this once, a layer down, on the client. There the null socket is a genuine member of the boundary, the simulation seam hangs off the null and not off the shared interface, and the wrapper never once asks whether it is talking to a real socket or a null. The pattern was already in the house. This work is mirroring it onto the server.
When there is one door, the exactly once test runs the production disconnect. And the coverage climbs without anyone chasing it, because the only thing left for integration to cover is the genuinely different work of accepting a connection in each adapter, which is exactly where integration belongs.
The move you can reproduce
If you would rather feel this than read it, the move is short, and on today's code it lands the other way up, which is the proof the fix took.
In server/infrastructure/websocket.ts, empty the body of the onClose closure the wrapper registers, so a real disconnect does nothing. Run npm run test and predict the result before you look. It goes red now, on the exactly once test, because the wrapper has one door and the unit suite finally stands where the disconnect lives. Back when the behaviour had two copies, this same mutation left the unit suite green and only the slow integration suite noticed. The colour of that result is the whole lesson. Then put the closure back.
The mutation is not a fix. It is a question you ask the suite, and the suite cannot lie when you ask it that way.
Why we work this way
We treat a passing test as a claim, not a proof, and we read the claim by asking what would falsify it. Shore's pattern language gives everyone the same words for that reading, so the conversation is about overlapping sociable tests and embedded stubs and where the seam belongs, not about taste or seniority.
We pose the problem instead of handing down the verdict. The mutation was not us marking work. It was a question dropped where it could be answered from the code, in five minutes, by whoever wrote the line, who then owns the conclusion because they watched it happen rather than being told.
And we stay suspicious of any code, and any test, that only our own suite seems to need. That suspicion is most of the craft.
A test should fail when the thing it names breaks. Anything else is a green light wired to nothing.


