EkoLite, TDD Engineering Guide
This is the reference for how EkoLite is tested: the nullable pattern, the wrappers, the layers and the shape of the suite. Test-driven development is the tour with worked examples; start there if you want the loop rather than the catalogue. ekolite-overview.md is the big picture.
The pattern comes from James Shore's Testing Without Mocks.
Core Principles
- No mocks. Not for MongoDB, not for the WebSocket, not for the file system. There is no mocking library in the project.
- Nullables. Every infrastructure wrapper has
create()andcreateNull(). The nulled one behaves the same and touches nothing outside the process. - Sociable tests. Real dependency chains.
Publicationsis tested as the realPublications, holding a nulledMongoWrapper. - State-based assertions. Assert what came out, not which functions were called on the way.
- A-Frame architecture. Logic and infrastructure are peers, and the application layer wires them together.
A-Frame Architecture
Application layer
(server/app.ts)
/ | \
v v v
Logic Logic Logic
(publications) (methods) (files)
\ | /
v v v
Infrastructure wrappers
Mongo WebSocket FileStorage ScriptRunnerLogic never imports infrastructure. It receives wrappers through the constructor, so it cannot tell a real one from a nulled one, and the application layer decides which it gets.
Infrastructure Wrappers
Each wrapper owns one external system, and each has an embedded stub in the same file.
MongoWrapper
server/infrastructure/mongo.ts, over the mongodb driver.
static create(uri: string): MongoWrapper;
static createNull(options?: StubbedMongoOptions): MongoWrapper;
async find<T>(collection: string, query: object): Promise<T[]>;
async insert(collection: string, doc: object): Promise<void>;
async update(collection: string, query: object, changes: object): Promise<void>;
async remove(collection: string, query: object): Promise<void>;
async close(): Promise<void>;
watcherCount(collection: string): number;
async trackChanges(collection: string): Promise<OutputTracker>;
trackClose(): OutputTracker;The nulled Mongo is configured with responses, not with a seeded database. Each key takes a list, and each entry is the answer to the next call:
const mongo = MongoWrapper.createNull({
find: [[{ _id: '1', name: 'existing.bam' }]],
});Pass an Error instead of a value to make the next call fail, which is how failure paths are tested without breaking a real database to get there.
WebSocketWrapper
server/infrastructure/websocket.ts, over @fastify/websocket.
static create(): WebSocketWrapper;
static createRawWs(options: { port: number }): WebSocketWrapper;
static createNull(options?: { close?: unknown[] }): WebSocketWrapper;
async attach(fastify: FastifyInstance): Promise<void>;
send(clientId: string, message: unknown): void;
broadcast(message: unknown): void;
onMessage(cb: (clientId: string, message: unknown) => void): void;
simulateConnection(): StubbedClient; // null instance only
trackMessages(): OutputTracker;
trackConnections(): OutputTracker;
trackDisconnections(): OutputTracker;create() takes no Fastify instance. It is attached separately with attach(fastify), which is what lets the wrapper be constructed before the server exists.
simulateConnection() returns a StubbedClient that records everything sent to it. That recording is how the tests see the output:
const ws = WebSocketWrapper.createNull();
const client = ws.simulateConnection();
client.send({ type: 'subscribe', id: 'sub1', name: 'files.all' });
expect(client.messages).toContainEqual({ type: 'ready', id: 'sub1', collection: 'files' });FileStorageWrapper
server/infrastructure/fileStorage.ts, over node:fs.
static create(basePath: string): FileStorageWrapper;
static createNull(options?: StubbedFileSystemOptions): FileStorageWrapper;
async save(name: string, data: Buffer): Promise<void>;
async read(name: string): Promise<Buffer>;
async exists(name: string): Promise<boolean>;
async remove(name: string): Promise<void>;
resolve(name: string): string;
trackChanges(): OutputTracker;ScriptRunnerWrapper
server/infrastructure/scriptRunner.ts, over child_process.
static create(): ScriptRunnerWrapper;
static createNull(responses?: ScriptRunnerResponses): ScriptRunnerWrapper;
async exec(command: string, args: string[]): Promise<ScriptResult>;
trackChanges(): OutputTracker;The nulled runner is keyed by command, so a test can say what python3 returns without a Python interpreter anywhere near it.
Logic Layer
Logic classes take their infrastructure through the constructor and know nothing about where it came from.
new Publications(mongo, ws); // pub/sub over change streams
new Methods(); // the method registry, no infrastructure at all
new RpcHandler(methods, ws); // routes method messages, sends result or error
new Files(mongo, storage, options); // upload, read, locate, validate
new Shutdown(closable, proc, { graceMs }); // graceful stopApplication Layer
server/app.ts is where the wiring lives, and it is the only place that decides real or nulled.
const app = App.create({ mongoUri, fileDir, port });
const app = App.createNull({ findResponses, scriptResponses });Both return the same graph. App exposes publications, methods and files. The assembly that boots in production is the assembly the tests drive, so there is no second wiring to drift out of step.
Output Tracking Replaces Spies
An OutputTracker subscribes to an emitter and collects what passed through it. It exposes one thing, data:
const tracker = ws.trackMessages();
// ... exercise the code ...
expect(tracker.data).toContainEqual({ type: 'ready', id: 'sub1', collection: 'files' });A spy asks whether a function was called. A tracker asks what was produced. Rename a private method and the spy assertion breaks while the tracker assertion does not, because the tracker was never coupled to the implementation in the first place.
Contract Tests Keep the Null Honest
A nulled wrapper is only useful while it behaves like the real one. The guard against drift is a shared contract: one list of behaviours, run twice, once against create() and once against createNull().
tests/infrastructure/fileStorageContract.ts exports the list:
export function fileStorageContract(makeStorage: () => FileStorageWrapper): void {
it('reports a saved file as existing', async () => {
const storage = makeStorage();
await storage.save('report.bam', Buffer.from('content'));
expect(await storage.exists('report.bam')).toBe(true);
});
// ...
}The fast suite runs it against the null:
fileStorageContract(() => FileStorageWrapper.createNull());The integration suite runs the same list against the real file system:
fileStorageContract(() => FileStorageWrapper.create(TEST_DIR));If the two ever disagree, one of them goes red. That is the whole mechanism, and it is why a nulled wrapper can be trusted.
Test Stack
{ "devDependencies": { "vitest": "^4.1" } }Vitest, and nothing else. No supertest, no mocking library, no test containers.
The Shape of the Suite
tests/
├── infrastructure/ wrappers: nullable tests, contract lists, narrow integration
├── logic/ publications, methods, rpcHandler, files, shutdown, analysis
├── client/ clientSocket, connectionManager, reactiveStore, uploader
├── server/ createServer routing, app wiring, and the full pipeline
└── shared/ protocol types and helpersNullable tests and integration tests live in separate files, x.test.ts and x.integration.test.ts, and that split is what keeps the fast suite honest. It never touches the network or the disk, so it can run on every save.
npm test # the fast suite: nullable and sociable tests
npm run test:integration # narrow integration, needs a real local MongoDBWhat Replaces What
| Traditional | Testing Without Mocks |
|---|---|
vi.mock('mongodb') | MongoWrapper.createNull() |
vi.spyOn(ws, 'send') | ws.trackMessages(), or the stubbed client's messages |
| Asserting a call happened | Asserting what was produced |
| A mock that drifts | A contract test run against real and null |
