DEV Community

Cover image for Test Isolation vs. Mocking Mechanics in Express
Dhokia Rinidh
Dhokia Rinidh

Posted on

Test Isolation vs. Mocking Mechanics in Express

Hello everyone,

It’s been a while since I last shared something—back when I was deep into React Hook Form. After wrapping up my frontend, I shifted focus to gluing all pieces together and building full MERN stack applications (more on that another time...).

While writing unit tests for the backend of one of these apps, I ran into a subtle but important realization: mocking dependencies and clearing mock state alone is not always enough to guarantee proper test isolation—especially when testing Express routing logic.

The scenario

Consider the following Express router:

// product.route.ts
import express from "express";
import {
  createProduct,
  deleteProduct,
  getAllProducts,
  updateProduct,
} from "../controllers/product.controller.js";

const router = express.Router();

/**
 * Endpoint: /api/products
 */
router.get("/", getAllProducts);
router.post("/", createProduct);
router.put("/:id", updateProduct);
router.delete("/:id", deleteProduct);

export default router;
Enter fullscreen mode Exit fullscreen mode

Suppose our goal is simple: verify that each HTTP method is correctly wired to its corresponding controller. We are not interested in the controller logic itself, so mocking the controllers is a perfectly reasonable approach.

Since my project used Typescript and ESM, I preferred Vitest for testing. We can mock the controllers like this:

vi.mock("../controllers/product.controller.js", () => ({
  getAllProducts: vi.fn((req, res) =>
    res.status(200).json({ message: "getAllProducts called" }),
  ),
  createProduct: vi.fn((req, res) =>
    res.status(201).json({ message: "createProduct called" }),
  ),
  updateProduct: vi.fn((req, res) =>
    res.status(200).json({ message: "updateProduct called" }),
  ),
  deleteProduct: vi.fn((req, res) =>
    res.status(200).json({ message: "deleteProduct called" }),
  ),
}));
Enter fullscreen mode Exit fullscreen mode

And before each test, we typically clear mocks:

vi.clearAllMocks();
Enter fullscreen mode Exit fullscreen mode

This does reset:

  • call counts (.mock.calls)

  • recorded arguments

  • mock results (.mock.results)

However, this is where a common misconception appears.

What clearing mocks does not reset

Clearing mocks does not reset the runtime state of your application. Particularly, it doesn't:

  • Recreate the Express app or router instance

  • Reset Express’ internal routing stack

  • Undo mutations to req, res, or app objects made by previous tests

  • Reset middleware side effects

  • Reload cached modules from Node’s module system

Note that Express routers are stateful objects. Once a router is created and attached to an app, its middleware stack and handlers persist for the lifetime of that app instance.

Why this matters in tests

If you reuse the same Express app across tests, subtle state can leak:

  • A previous test may mutate req.headers, req.body, or res.locals

  • Middleware may attach data to req that subsequent tests unexpectedly inherit and misuse

  • Order-dependent behavior can emerge when tests rely on shared app state

This can lead to tests that:

  • Pass or fail depending on execution order

  • Fail intermittently

  • Give false confidence about route behavior

Mock isolation alone cannot protect you from this class of problems.

The correct approach: reset runtime state

For modules with stateful dependencies (such as Express apps) the solution is twofold:

  1. Reset mock state (call counts, return values, spies)

  2. Recreate the runtime environment (new app instance per test)

import express, { type Express } from "express";
import router from "./routes";

describe("Product routes", () => {
  let app: Express;

  const createTestApp = () => {
    const app = express();
    app.use(express.json());
    app.use("/api", router);
    return app;
  };

  beforeEach(() => {
    vi.clearAllMocks();
    vi.restoreAllMocks();
    app = createTestApp();
  });

  // tests go here
});
Enter fullscreen mode Exit fullscreen mode

By constructing a fresh Express app for each test, you guarantee:

  • No shared routing or middleware state

  • Predictable request/response objects

  • True test isolation

Takeaway

Mocking is about behavior substitution. Test isolation is about state control.

When testing Express routes, clearing mocks addresses only half the problem. To achieve reliable and deterministic tests, you must also reset—or recreate—the application runtime itself.

Feel free to drop your own experiences and lessons below.

Happy testing guys👋

Top comments (0)