DEV Community

M TOQEER ZIA
M TOQEER ZIA

Posted on

Testing Microservices: Scope, Strategy, and the Auth Service Test Setup

A practical breakdown of how to think about and implement automated testing in a microservices architecture — using an Auth service (Express + TypeScript + MongoDB) as the working example.


1. Why This Matters (The Problem)

Up to this point, the auth service (and the broader app) had no automated tests — only manual testing done through Postman. That's fine for quick checks, but it doesn't scale, doesn't run in CI, and doesn't protect you when you refactor.

So the goal here is to build a real automated testing setup — but before writing a single line of test code, you need to answer a more fundamental question:

"What exactly am I testing, and how much of the system am I trying to cover in one test?"

This is the scope problem, and it's the single biggest decision in microservices testing.


2. The Four Levels of Test Scope

In any microservices system, tests can span a spectrum from very narrow to very broad. This list isn't exhaustive, but it captures the main levels you'll encounter:

# Scope What it tests Example
1 Unit test A single piece of code in isolation Testing one middleware function on its own
2 Integration within a service Multiple pieces of code working together A request flowing through requireAuth middleware → into a route handler
3 Component interaction How a service talks to an external system ("component" here means a program, not a UI component) Testing how the service interacts with MongoDB, or with an event bus
4 Cross-service test How two or more independent services work together Launching the Orders service and the Ticketing service together, having one emit an event, and checking the other processes it correctly

Why not just always test at the biggest scope (#4)?

It's tempting to think: "Let's just test how all the services work together — that's closest to real life!"

But in practice, this is extremely complex and expensive to set up. Think about what it would actually require:

  • Spinning up a temporary Kubernetes cluster
  • Deploying multiple services into it just for the test
  • Figuring out how to send requests into that environment
  • Figuring out how to assert on results coming out of it

That's a lot of infrastructure just to run a test suite. It's slow, costly, and fragile.

The Decision

Because of that complexity, the strategy here is:

Test each service in isolation. Don't try to launch multiple services together to test their interaction directly.

Instead, cross-service behavior will be tested indirectly — through event emitting and receiving (more on this below). This gets you most of the confidence of a full integration test without the operational overhead.


3. The Three Kinds of Tests You'll Actually Write (Per Service)

For each individual service (starting with Auth), there are three categories of tests to focus on:

Test Goal #1 — Basic Request Handling

Send a request into the service and assert on the outcome. For example:

  • Hit the sign-up endpoint → expect a response with a cookie containing a JWT
  • Assert that the expected data was written into MongoDB

This is the first and primary focus for the Auth service right now.

Test Goal #2 — Model-Level Unit Tests

Test the behavior of a specific data model in isolation (e.g., a method on the User model).

Not very relevant for Auth right now, since the User model is simple. But later services will have more complex models that need this kind of testing.

Test Goal #3 — Event Emitting & Receiving

Test that a service:

  • Correctly receives an incoming event and processes it properly
  • Correctly emits an event to the outside world

Not applicable yet — the app doesn't send or receive events at this stage, and event infrastructure hasn't been built.

Why Test Goal #3 matters long-term

This is the key insight: testing events is how cross-service behavior gets tested, without needing to launch multiple services together.

Instead of running Orders + Ticketing simultaneously (scope level #4 — expensive and complex), you can:

  • Test that the Orders service emits the correct event
  • Test that the Ticketing service receives and processes that event correctly

Each of these can be tested within a single service in isolation, but together they give you confidence that the services will work correctly when connected in the real world.


4. How Tests Will Actually Be Run

The practical execution model is intentionally simple:

  • Tests run directly in the terminal on your local machine
  • No Docker, no Kubernetes involved in running the tests themselves
  • Command: npm run test
  • This spins up the service locally and runs tests against it

The underlying assumption

This approach assumes your local machine can fully run the service — meaning all its dependencies (Node.js, MongoDB, etc.) are available locally without a complex setup.

For the Auth service right now, that's true — you only need Node.js and MongoDB.

A note on future complexity

Not every future service will be this simple. Some may need a specific OS, or a complex, hard-to-install database. When that happens, this simple "just run it locally" approach won't hold up — a more advanced testing setup will be needed later. For now, though, local execution is the right call.


5. Setting Up the Test Pipeline

The plan for how a single test run works, using Jest as the test runner:

  1. Spin up an in-memory copy of MongoDB (no need to install MongoDB locally)
  2. Start the Express app
  3. Use SuperTest to send fake HTTP requests to the Express app
  4. Run assertions — check the response and/or check what got written to MongoDB

Step 3 (SuperTest) is where things get interesting, because using it properly requires a refactor of the project structure.


6. Why a Refactor Is Needed: The app.ts / index.ts Split

The problem

SuperTest needs direct access to the Express app object to send fake requests into it. But currently, the Express app is created and started (app.listen(3000)) inside index.ts, along with:

  • Mongoose connecting to a hardcoded MongoDB URL
  • The app listening on a hardcoded port (3000)

If a test file imports the app from index.ts, it inherits all of that startup logic — including binding to port 3000.

Why hardcoded port 3000 is a real problem

You'll eventually want to run tests for multiple services at the same time on the same machine (e.g., Auth service tests running alongside Orders service tests). If both services are hardcoded to listen on port 3000, running their tests concurrently will cause a port conflict and tests will fail.

The fix: SuperTest's ephemeral port behavior

SuperTest has a built-in behavior: if the server passed to it isn't already listening on a port, SuperTest will automatically start it on a random available ("ephemeral") port.

This solves the port collision problem — but only if the Express app you hand to SuperTest isn't already bound to a fixed port.

The Solution: Split into two files

File Responsibility
app.ts Creates the Express app, wires up all middleware and route handlers, and exports the app. Does not call .listen() anywhere.
index.ts Imports the app from app.ts, connects Mongoose to MongoDB, and calls app.listen(3000) to actually start the server for real (dev/production use).

This way:

  • Production/dev: you still run index.ts, which starts everything normally, including listening on port 3000.
  • Testing: your test files import the app directly from app.ts — no port binding, no Mongoose connection baked in — so SuperTest can safely assign it a random ephemeral port and multiple services' tests can run concurrently without colliding.

Implementation Notes (from the refactor walkthrough)

  1. Create app.ts inside the service's source directory.
  2. Cut all the middleware/route-wiring code out of index.ts (everything above the start function) and paste it into app.ts.
  3. Watch out — the mongoose import might get pulled over by mistake. It needs to stay in index.ts, since that's where the DB connection actually happens.
  4. At the bottom of app.ts, add a named export:
   export { app };
Enter fullscreen mode Exit fullscreen mode

Note: curly braces are required — this is a named export, not a default export.

  1. In index.ts, import the app at the top:
   import { app } from './app';
Enter fullscreen mode Exit fullscreen mode
  1. Save both files and verify the app still boots correctly (e.g., via Skaffold).

End result: app.ts only configures the Express app. index.ts is responsible for actually starting it (listening on a port + connecting to Mongo).


7. Installing the Test Dependencies

Three packages get installed, all as dev dependencies (--save-dev / -D flag):

npm install --save-dev @types/supertest jest supertest mongodb-memory-server
Enter fullscreen mode Exit fullscreen mode
Package Purpose
jest The test runner — executes your test suite
supertest Lets you make fake/simulated HTTP requests against your Express app in tests
@types/supertest TypeScript type definitions for SuperTest
mongodb-memory-server Spins up a temporary, in-memory MongoDB instance for tests

Why mongodb-memory-server instead of a real/shared MongoDB?

  • It lets each service's test suite run its own isolated in-memory database, rather than all services fighting over one shared test MongoDB instance.
  • This means multiple services' test suites can run concurrently on the same machine without interfering with each other's data.
  • It downloads a real copy of MongoDB (roughly ~80MB) the first time it's used, and runs it purely in memory — no local MongoDB installation required.

Why "dev dependency" matters here

The production Docker image for the service should only ever run the actual Express app — never the test suite. So none of these testing libraries need to exist inside the built Docker image.

By installing them as dev dependencies:

  • They get skipped when the image is built using npm install --only=production (or --omit=dev)
  • This avoids re-downloading the ~80MB MongoDB memory server binary every time the Docker image is rebuilt

Dockerfile change

Inside the Auth service's Dockerfile, the npm install command gets updated to only install production dependencies:

RUN npm install --only=production
Enter fullscreen mode Exit fullscreen mode

This keeps rebuilds fast and avoids unnecessary bloat/downloads in the image build process — which matters a lot once you're rebuilding the image repeatedly during active development.


8. Summary — The Mental Model

  1. Decide your test's scope first — unit → integration-within-service → component interaction → cross-service. Don't default to the broadest scope; it's expensive and complex in a microservices setup.
  2. Test each service in isolation. Simulate cross-service behavior through event emit/receive tests rather than spinning up multiple real services together.
  3. Focus each service's test suite on three goals: (1) request handling, (2) model behavior, (3) event emit/receive.
  4. Run tests locally via npm run test — no Docker/Kubernetes needed for the test run itself.
  5. Separate app creation (app.ts) from app startup (index.ts) so SuperTest can bind the app to a random port and multiple services can be tested concurrently without port conflicts.
  6. Use mongodb-memory-server for fast, isolated, in-memory database testing — installed as a dev dependency so it never bloats the production Docker image.

Top comments (0)