The core problem is always the same: you have multiple systems that need to work together, and bugs only appear at the boundaries. The solution is to build confidence layer by layer, starting from the outermost dependency inward.
The Mental Model
Before writing any backend code, prove the external system works and you understand it. Build the simplest possible script that calls it directly — no framework, no DI, no abstractions. This becomes your ground truth. If you can't call it from 20 lines of code, you can't call it from the backend either.
Only once you understand what the external system actually expects do you start wiring your services to it.
The Four Layers
1. Parity Tests — "Does my abstraction produce the same output as doing it manually?"
Before involving any app infrastructure, test that your service classes produce identical output to doing the thing by hand. If MyBuilder and raw manual construction diverge, you've found a bug in your abstraction. These run in milliseconds with no dependencies.
2. Fast Service Tests — "Do my services work with a real DB but no network?"
Spin up a real database (isolated port, in-memory/tmpfs, wiped and re-seeded each run). Instantiate services directly — no framework DI overhead. Run the full service orchestration pipeline. These should finish in a few seconds, which means you run them on every change. This layer catches config bugs, seed data problems, logic errors, and bad service wiring.
3. Controller/App Tests — "Does the full framework stack work?"
Boot the real app (NestJS, Express, whatever) via its test harness. Hit actual HTTP endpoints. This layer surfaces bugs that only appear with the full stack — things like env variables overriding test config, missing module registrations, middleware interference, or serialization mismatches. You can't find these in service tests.
4. Live Integration — "Does the external system accept what we produce?"
Call the real external service (API, queue, database, third-party). Read back the result — don't just check the status code. Verify that the system's state actually changed the way you expected. This is the final proof.
Why This Order Matters
Each layer has a narrow blast radius. When a parity test fails, you know the bug is in your abstraction, not your DB config or your HTTP routing. When a service test fails, you know it's a logic or config issue, not a framework problem. When a controller test fails, you know the services are fine and the issue is in the wiring.
Debugging a signing bug inside a full app boot cycle is painful. Debugging it in a 20-line script takes 30 seconds.
The Debugging Sequence
When something breaks, walk the layers top-down:
- Can you call the external system from a standalone script? If not — it's an API contract problem (wrong format, wrong auth, wrong endpoint).
- Does your abstraction produce the same output as doing it manually? If not — it's a bug in your builder/adapter/client.
- Do service tests pass with a real DB? If not — it's a config, seed, or orchestration bug.
- Does the controller test pass? If not — check env bleed, module registration, middleware, serialization.
- Does the external system reflect the expected state after the call? If not — re-read the external system's validation rules. The contract is always right.
The Principles That Don't Change
- Start from the external dependency. Understand what it actually expects before building anything around it.
- Test abstractions in isolation. Parity checks (your code vs manual) catch the largest class of subtle bugs fastest.
- Real dependencies, fast feedback. A real DB with in-memory storage and a seed script gives you production fidelity in seconds.
- Full stack tests find glue bugs. Env override, missing registrations, serialization — these only appear with everything wired together.
- Verification is not a status code. Read back the actual state from the external system. If the state didn't change correctly, the integration isn't working regardless of what the response said.
- Keep test environments isolated. Separate ports, wipe and re-seed between runs. Shared state between test runs is how you get flaky tests that are impossible to debug.
Top comments (0)