Testing · Reliability

An End-to-End Test
That Wasn't

Every NestJS end-to-end spec, for months, assembled its own hand-picked module instead of the real application — and every language's LLM-backed feature had only ever been tested through its own no-Ollama fallback path, never the request it was actually supposed to send. Both were called end-to-end. Neither one was, in the way that mattered.

Real HTTP requests, real Postgres and LocalStack in testcontainers, real assertions on real response bodies — by every surface measure, the NestJS suite was doing exactly what an end-to-end test is supposed to do. What it never did was boot the actual application. Each spec built its own Test.createTestingModule, with its own hand-picked entity list and its own synchronize: true to build the schema on the fly — a parallel approximation of the real AppModule, close enough to pass, never actually the thing that runs in production. Close enough turned out to matter: it's exactly the gap that let a real entity-registration omission ship while every test stayed green.

Booting the Real Thing, and the One Ordering Trick That Makes It Possible

The fix sounds simple and has one real subtlety in it. Each spec now starts its containers, sets every environment variable the real app needs, and only then does await import('@/app-module') — dynamically, not as a static import at the top of the file. Jest gives each spec file its own module registry, and the app's data-source module reads DATABASE_URL at import time, not at call time — so importing statically, before the containers exist, would freeze in the wrong values permanently for that file. The dynamic import, ordered after the environment is real, is what lets the actual composition root see the actual values. Schema creation moved from TypeORM's synchronize: true to migrationsRun: true — the real migrations build the real schema, the same ones a real deployment runs, not a live reflection of whatever the entity classes currently look like. And the bootstrap logic itself — pipes, filters, interceptors — moved out of main.ts into a shared configureApp() that both main.ts and every spec call, so there's exactly one bootstrap sequence in the codebase instead of one real one and seven approximations of it.

Two Libraries Fighting Over the Same Module

Mocking the one remaining external dependency — the LLM calls — introduced a genuinely strange failure. Importing nock patches Node's global http module the instant the import runs, and testcontainers drives the Docker daemon over that same module while probing which container runtime is actually available. On a warm local Docker daemon the race resolved fine every time. On CI's cold start, it didn't — containers failed with a plain EPIPE, no useful message pointing at why.

The fix is an explicit ordering discipline: leave nock deliberately inactive — nock.restore() — until the containers and the app are both fully up, activate it only then for the LLM stubs, and deactivate it again before teardown begins. A background consumer that fires a stray LLM call during shutdown just DNS-fails cleanly into its own fallback path instead of corrupting whatever the next spec file tries to do with the Docker socket.

The Second Half: Stop Faking the Part That Was Never Actually Tested

A separate but related gap ran across all five languages, not just NestJS: every LLM-backed feature — answering a question about transaction history, categorizing a transaction by merchant name, classifying a refund reason — had only ever been end-to-end tested through its own no-Ollama fallback. Nothing simulated the model, so the actual request-and-parse path had never run in any test, in any language, for any of these features.

The shape of the fix was identical everywhere, even though the tool differed by language: a fake server answering exactly one endpoint, POST /api/chat, routing purely by content — the system prompt in the request identifies which service is calling, and something recognizable in the user message picks a deterministic reply, a specific merchant name mapping to a specific category and so on. A marker string anywhere in the request forces a 500, for the handful of tests that specifically want to prove the fallback path still works when the model is unavailable. An unrecognized prompt gets a loud failure rather than a silent default, so a future LLM feature can't quietly coast on the fallback forever without anyone noticing its real path was never exercised. Go used a plain httptest.Server, FastAPI used respx, Kotlin and Java used the JDK's own built-in HTTP server rather than adding a dependency, and NestJS reused the same nock now wired correctly into the real-app suite.

Two separate investigations, the same session, converging on one realization: a test suite's coverage isn't measured by what it exercises when everything is stubbed to go right. It's measured by whether "end-to-end" means the end that actually matters — the real composition root an app boots from, the real request a feature actually sends — rather than a stand-in built, reasonably enough at the time, to make the suite pass a little faster.

Further reading in the repo

test/support/test-app.ts — the real-AppModule bootstrap every spec now shares · fake_ollama_test.go — one language's version of the fake model server