Testing

Mocking and Stubbing: Patterns and Anti-Patterns

A practical guide to mocking and stubbing in Node.js tests covering dependency injection, module mocking, spy patterns, and common mistakes that make tests brittle.

Mocking and Stubbing: Patterns and Anti-Patterns

Mocking is the most powerful and most abused technique in testing. Done well, mocks isolate units and make tests fast and deterministic. Done poorly, mocks create tests that pass when the code is broken and fail when the code is correct.

The difference between good and bad mocking comes down to what you mock, how much you mock, and whether the mock reflects reality. This guide covers patterns that create reliable tests and anti-patterns that create maintenance nightmares.

Prerequisites

  • Node.js installed (v16+)
  • Familiarity with Jest or Mocha + Sinon
  • A project with tests to improve

Test Doubles: The Vocabulary

Before diving into patterns, the terminology matters. "Mock" gets used as a catch-all, but each test double has a specific purpose:

Double Purpose Example
Dummy Fills a parameter, never used An empty object passed to satisfy a type
Stub Returns predetermined data A database function that returns a fixed user
Spy Records calls without changing behavior Watching if a logger was called
Mock Pre-programmed with expectations Expects save() called once with specific args
Fake Working implementation, simplified An in-memory database instead of PostgreSQL
// Dummy — just fills a parameter slot
var dummyLogger = { log: function() {} };

// Stub — returns predetermined data
var userStub = {
  findById: function() {
    return { id: 1, name: "Shane", email: "[email protected]" };
  }
};

// Spy — records calls
var spy = jest.fn();
someFunction(spy);
expect(spy).toHaveBeenCalledWith("expected argument");

// Fake — simplified working implementation
var fakeDatabase = {
  _data: {},
  save: function(key, value) { this._data[key] = value; },
  find: function(key) { return this._data[key] || null; }
};

Pattern 1: Dependency Injection

The cleanest mocking pattern. Pass dependencies as parameters instead of importing them directly.

The Module Under Test

// userService.js
function createUserService(db, emailService, logger) {
  return {
    register: function(name, email, password) {
      logger.log("Registering user: " + email);

      var existing = db.findByEmail(email);
      if (existing) {
        throw new Error("Email already registered");
      }

      var hashedPassword = hashPassword(password);
      var user = db.insert("users", {
        name: name,
        email: email,
        password: hashedPassword,
        createdAt: new Date()
      });

      emailService.sendWelcome(user.email, user.name);
      logger.log("User registered: " + user.id);

      return user;
    },

    getProfile: function(userId) {
      var user = db.findById("users", userId);
      if (!user) return null;

      // Never return the password
      return {
        id: user.id,
        name: user.name,
        email: user.email,
        createdAt: user.createdAt
      };
    }
  };
}

function hashPassword(password) {
  var crypto = require("crypto");
  return crypto.createHash("sha256").update(password).digest("hex");
}

module.exports = createUserService;

The Tests

var createUserService = require("./userService");

describe("UserService", function() {
  var db, emailService, logger, service;

  beforeEach(function() {
    db = {
      findByEmail: jest.fn(),
      findById: jest.fn(),
      insert: jest.fn(function(table, data) {
        return Object.assign({ id: 1 }, data);
      })
    };

    emailService = {
      sendWelcome: jest.fn()
    };

    logger = {
      log: jest.fn()
    };

    service = createUserService(db, emailService, logger);
  });

  describe("register", function() {
    test("creates a new user and sends welcome email", function() {
      db.findByEmail.mockReturnValue(null);

      var user = service.register("Shane", "[email protected]", "password123");

      expect(user.id).toBe(1);
      expect(user.name).toBe("Shane");
      expect(db.insert).toHaveBeenCalledWith("users", expect.objectContaining({
        name: "Shane",
        email: "[email protected]"
      }));
      expect(emailService.sendWelcome).toHaveBeenCalledWith("[email protected]", "Shane");
    });

    test("throws when email is already registered", function() {
      db.findByEmail.mockReturnValue({ id: 2, email: "[email protected]" });

      expect(function() {
        service.register("Shane", "[email protected]", "password123");
      }).toThrow("Email already registered");

      expect(db.insert).not.toHaveBeenCalled();
      expect(emailService.sendWelcome).not.toHaveBeenCalled();
    });
  });

  describe("getProfile", function() {
    test("returns user without password", function() {
      db.findById.mockReturnValue({
        id: 1,
        name: "Shane",
        email: "[email protected]",
        password: "hashedpassword",
        createdAt: new Date("2026-01-01")
      });

      var profile = service.getProfile(1);

      expect(profile.name).toBe("Shane");
      expect(profile.password).toBeUndefined();
    });

    test("returns null for non-existent user", function() {
      db.findById.mockReturnValue(null);

      var profile = service.getProfile(999);

      expect(profile).toBeNull();
    });
  });
});

Why this works: Dependencies are explicit. Tests construct exactly the doubles they need. No module system trickery. Easy to understand what each test controls.

Pattern 2: Module Mocking with Jest

When you cannot change the module to accept injected dependencies, Jest can intercept require() calls.

// orderService.js
var db = require("./db");
var paymentGateway = require("./paymentGateway");
var emailService = require("./emailService");

function placeOrder(userId, items) {
  var user = db.findById("users", userId);
  if (!user) throw new Error("User not found");

  var total = items.reduce(function(sum, item) {
    return sum + (item.price * item.quantity);
  }, 0);

  var charge = paymentGateway.charge(user.paymentMethodId, total);

  var order = db.insert("orders", {
    userId: userId,
    items: items,
    total: total,
    chargeId: charge.id,
    status: "confirmed"
  });

  emailService.sendOrderConfirmation(user.email, order);

  return order;
}

module.exports = { placeOrder: placeOrder };
// orderService.test.js
jest.mock("./db");
jest.mock("./paymentGateway");
jest.mock("./emailService");

var db = require("./db");
var paymentGateway = require("./paymentGateway");
var emailService = require("./emailService");
var orderService = require("./orderService");

describe("placeOrder", function() {
  beforeEach(function() {
    jest.clearAllMocks();
  });

  test("processes a complete order", function() {
    db.findById.mockReturnValue({
      id: 1,
      email: "[email protected]",
      paymentMethodId: "pm_123"
    });

    paymentGateway.charge.mockReturnValue({ id: "ch_456", status: "succeeded" });

    db.insert.mockReturnValue({
      id: 100,
      userId: 1,
      items: [{ name: "Widget", price: 10, quantity: 2 }],
      total: 20,
      chargeId: "ch_456",
      status: "confirmed"
    });

    var items = [{ name: "Widget", price: 10, quantity: 2 }];
    var order = orderService.placeOrder(1, items);

    expect(order.status).toBe("confirmed");
    expect(paymentGateway.charge).toHaveBeenCalledWith("pm_123", 20);
    expect(emailService.sendOrderConfirmation).toHaveBeenCalled();
  });

  test("does not charge when user is not found", function() {
    db.findById.mockReturnValue(null);

    expect(function() {
      orderService.placeOrder(999, []);
    }).toThrow("User not found");

    expect(paymentGateway.charge).not.toHaveBeenCalled();
  });
});

Pattern 3: Sinon Stubs with Mocha

Sinon provides standalone test doubles that work with any framework.

var assert = require("assert");
var sinon = require("sinon");
var db = require("./db");
var orderService = require("./orderService");

describe("placeOrder", function() {
  var sandbox;

  beforeEach(function() {
    sandbox = sinon.createSandbox();
  });

  afterEach(function() {
    sandbox.restore();
  });

  it("calculates total from items", function() {
    sandbox.stub(db, "findById").returns({
      id: 1,
      email: "[email protected]",
      paymentMethodId: "pm_1"
    });

    var chargeStub = sandbox.stub(db, "insert").returns({ id: 1 });
    var paymentStub = sandbox.stub(require("./paymentGateway"), "charge")
      .returns({ id: "ch_1" });

    sandbox.stub(require("./emailService"), "sendOrderConfirmation");

    var items = [
      { name: "A", price: 10, quantity: 2 },
      { name: "B", price: 5, quantity: 3 }
    ];

    orderService.placeOrder(1, items);

    assert.ok(paymentStub.calledWith("pm_1", 35));
  });
});

Key difference from Jest: Sinon stubs must be explicitly restored. The sandbox pattern groups stubs and restores them together in afterEach. Forgetting to restore stubs is the most common Sinon mistake.

Pattern 4: Spying on Real Implementations

Sometimes you want the real function to run but also verify it was called correctly.

// With Jest
var analytics = require("./analytics");

test("logs page views", function() {
  var trackSpy = jest.spyOn(analytics, "track");

  // The real track function runs
  renderPage("/dashboard");

  expect(trackSpy).toHaveBeenCalledWith("page_view", { path: "/dashboard" });
  trackSpy.mockRestore();
});
// With Sinon
it("logs page views", function() {
  var trackSpy = sinon.spy(analytics, "track");

  renderPage("/dashboard");

  assert.ok(trackSpy.calledWith("page_view", { path: "/dashboard" }));
  trackSpy.restore();
});

When to spy instead of mock: When the real behavior matters but you also need to verify the call. Common for logging, analytics, and event emitters.

Pattern 5: Fakes for Complex Dependencies

When a stub is too simple and the real dependency is too heavy, build a fake.

// fakeDatabase.js — in-memory fake for testing
function createFakeDatabase() {
  var tables = {};
  var nextId = 1;

  return {
    insert: function(table, data) {
      if (!tables[table]) tables[table] = [];
      var record = Object.assign({ id: nextId++ }, data);
      tables[table].push(record);
      return record;
    },

    findById: function(table, id) {
      if (!tables[table]) return null;
      for (var i = 0; i < tables[table].length; i++) {
        if (tables[table][i].id === id) return tables[table][i];
      }
      return null;
    },

    findByEmail: function(email) {
      var users = tables["users"] || [];
      for (var i = 0; i < users.length; i++) {
        if (users[i].email === email) return users[i];
      }
      return null;
    },

    findAll: function(table, filter) {
      if (!tables[table]) return [];
      if (!filter) return tables[table].slice();
      return tables[table].filter(function(record) {
        var keys = Object.keys(filter);
        for (var i = 0; i < keys.length; i++) {
          if (record[keys[i]] !== filter[keys[i]]) return false;
        }
        return true;
      });
    },

    clear: function() {
      tables = {};
      nextId = 1;
    }
  };
}

module.exports = createFakeDatabase;
var createFakeDatabase = require("./fakeDatabase");
var createUserService = require("./userService");

describe("UserService with fake database", function() {
  var db, service;

  beforeEach(function() {
    db = createFakeDatabase();
    var emailService = { sendWelcome: jest.fn() };
    var logger = { log: jest.fn() };
    service = createUserService(db, emailService, logger);
  });

  test("register then getProfile returns the user", function() {
    var user = service.register("Shane", "[email protected]", "password123");
    var profile = service.getProfile(user.id);

    expect(profile.name).toBe("Shane");
    expect(profile.email).toBe("[email protected]");
  });

  test("register twice with same email throws", function() {
    service.register("Shane", "[email protected]", "password123");

    expect(function() {
      service.register("Other", "[email protected]", "different");
    }).toThrow("Email already registered");
  });
});

Why fakes are powerful: They test the interactions between your code and the dependency with realistic behavior. Stubs return hardcoded data — fakes actually store and retrieve data, catching bugs that stubs miss.

Pattern 6: Mocking Timers

Testing code that depends on setTimeout, setInterval, or Date.now().

// rateLimiter.js
function createRateLimiter(maxRequests, windowMs) {
  var requests = {};

  return {
    check: function(clientId) {
      var now = Date.now();
      var windowStart = now - windowMs;

      if (!requests[clientId]) {
        requests[clientId] = [];
      }

      // Remove expired entries
      requests[clientId] = requests[clientId].filter(function(timestamp) {
        return timestamp > windowStart;
      });

      if (requests[clientId].length >= maxRequests) {
        return { allowed: false, retryAfter: requests[clientId][0] + windowMs - now };
      }

      requests[clientId].push(now);
      return { allowed: true };
    }
  };
}

module.exports = createRateLimiter;
var createRateLimiter = require("./rateLimiter");

describe("RateLimiter", function() {
  beforeEach(function() {
    jest.useFakeTimers();
  });

  afterEach(function() {
    jest.useRealTimers();
  });

  test("allows requests within the limit", function() {
    var limiter = createRateLimiter(3, 60000);

    expect(limiter.check("client1").allowed).toBe(true);
    expect(limiter.check("client1").allowed).toBe(true);
    expect(limiter.check("client1").allowed).toBe(true);
  });

  test("blocks requests over the limit", function() {
    var limiter = createRateLimiter(3, 60000);

    limiter.check("client1");
    limiter.check("client1");
    limiter.check("client1");

    var result = limiter.check("client1");
    expect(result.allowed).toBe(false);
    expect(result.retryAfter).toBeGreaterThan(0);
  });

  test("allows requests after the window expires", function() {
    var limiter = createRateLimiter(3, 60000);

    limiter.check("client1");
    limiter.check("client1");
    limiter.check("client1");

    // Advance time past the window
    jest.advanceTimersByTime(61000);

    expect(limiter.check("client1").allowed).toBe(true);
  });
});

Pattern 7: Mocking HTTP Requests

Testing code that makes external API calls without hitting real servers.

// githubClient.js
var https = require("https");

function getRepository(owner, repo, callback) {
  var options = {
    hostname: "api.github.com",
    path: "/repos/" + owner + "/" + repo,
    headers: { "User-Agent": "node-app" }
  };

  https.get(options, function(res) {
    var data = "";
    res.on("data", function(chunk) { data += chunk; });
    res.on("end", function() {
      if (res.statusCode === 200) {
        callback(null, JSON.parse(data));
      } else {
        callback(new Error("GitHub API error: " + res.statusCode));
      }
    });
  }).on("error", callback);
}

module.exports = { getRepository: getRepository };

Using the nock library to intercept HTTP:

var nock = require("nock");
var github = require("./githubClient");

describe("GitHub Client", function() {
  afterEach(function() {
    nock.cleanAll();
  });

  test("fetches repository data", function(done) {
    nock("https://api.github.com")
      .get("/repos/expressjs/express")
      .reply(200, {
        id: 237159,
        name: "express",
        full_name: "expressjs/express",
        stargazers_count: 60000
      });

    github.getRepository("expressjs", "express", function(err, repo) {
      expect(err).toBeNull();
      expect(repo.name).toBe("express");
      expect(repo.stargazers_count).toBe(60000);
      done();
    });
  });

  test("handles API errors", function(done) {
    nock("https://api.github.com")
      .get("/repos/nonexistent/repo")
      .reply(404, { message: "Not Found" });

    github.getRepository("nonexistent", "repo", function(err) {
      expect(err.message).toContain("404");
      done();
    });
  });
});

Anti-Pattern 1: Mocking What You Do Not Own

// BAD — mocking Express internals
test("handles request", function() {
  var req = {
    params: { id: "1" },
    query: {},
    body: {},
    headers: { authorization: "Bearer token" },
    get: jest.fn(),
    accepts: jest.fn(),
    is: jest.fn()
  };

  var res = {
    status: jest.fn().mockReturnThis(),
    json: jest.fn().mockReturnThis(),
    send: jest.fn(),
    set: jest.fn()
  };

  // This mock is fragile — it breaks when Express changes
  // or when the handler uses a method you did not mock
  handler(req, res);
});
// GOOD — use supertest to test through the real Express stack
var request = require("supertest");
var app = require("./app");

test("GET /users/1 returns user", function() {
  return request(app)
    .get("/users/1")
    .expect(200)
    .expect(function(res) {
      expect(res.body.name).toBe("Shane");
    });
});

Rule: Do not mock libraries and frameworks you do not control. Their internal API can change. Use integration-level tools (supertest, nock) that work through the real interfaces.

Anti-Pattern 2: Testing Implementation Details

// BAD — tests break when implementation changes
test("register calls db.insert with correct table", function() {
  service.register("Shane", "[email protected]", "pass");

  // This test knows too much about how register works internally
  expect(db.insert).toHaveBeenCalledWith(
    "users",
    expect.objectContaining({
      name: "Shane",
      email: "[email protected]",
      password: expect.any(String),
      createdAt: expect.any(Date)
    })
  );
});
// GOOD — tests verify behavior, not implementation
test("registered user can be retrieved", function() {
  service.register("Shane", "[email protected]", "pass");

  var profile = service.getProfile(1);
  expect(profile.name).toBe("Shane");
  expect(profile.email).toBe("[email protected]");
});

test("registered user receives welcome email", function() {
  service.register("Shane", "[email protected]", "pass");

  expect(emailService.sendWelcome).toHaveBeenCalledWith(
    "[email protected]",
    "Shane"
  );
});

Rule: Test what the code does, not how it does it. If you refactor the internals without changing behavior, tests should still pass.

Anti-Pattern 3: Over-Mocking

// BAD — everything is mocked, the test proves nothing
test("processOrder works", function() {
  jest.mock("./validator");
  jest.mock("./calculator");
  jest.mock("./db");
  jest.mock("./email");
  jest.mock("./logger");
  jest.mock("./metrics");

  var validator = require("./validator");
  var calculator = require("./calculator");

  validator.validateOrder.mockReturnValue(true);
  calculator.calculateTotal.mockReturnValue(100);
  // ... more mocks

  var result = processOrder(orderData);

  // This test only proves that processOrder calls functions
  // in a specific order. It does not test any real logic.
  expect(result).toBeDefined();
});
// GOOD — mock boundaries, test logic
test("processOrder calculates correct total with tax", function() {
  // Only mock external boundaries (database, email)
  var db = { insert: jest.fn(function(t, d) { return Object.assign({ id: 1 }, d); }) };
  var email = { send: jest.fn() };

  // Let the real validator and calculator run
  var result = processOrder(db, email, {
    items: [
      { name: "Widget", price: 10, quantity: 2 },
      { name: "Gadget", price: 25, quantity: 1 }
    ],
    taxRate: 0.08
  });

  expect(result.subtotal).toBe(45);
  expect(result.tax).toBe(3.60);
  expect(result.total).toBe(48.60);
});

Rule: Mock at the boundaries (database, network, filesystem, email). Let internal logic run for real. If everything is mocked, the test is just verifying that functions are called in order — it catches almost no real bugs.

Anti-Pattern 4: Incomplete Cleanup

// BAD — stub leaks between tests
describe("UserService", function() {
  test("finds user", function() {
    sinon.stub(db, "findById").returns({ id: 1, name: "Shane" });
    var user = service.getUser(1);
    expect(user.name).toBe("Shane");
    // Forgot to restore!
  });

  test("handles missing user", function() {
    // db.findById is STILL STUBBED from previous test
    // This test may pass or fail depending on run order
    var user = service.getUser(999);
    expect(user).toBeNull(); // FAILS — still returns Shane
  });
});
// GOOD — sandbox handles cleanup automatically
describe("UserService", function() {
  var sandbox;

  beforeEach(function() {
    sandbox = sinon.createSandbox();
  });

  afterEach(function() {
    sandbox.restore();
  });

  test("finds user", function() {
    sandbox.stub(db, "findById").returns({ id: 1, name: "Shane" });
    var user = service.getUser(1);
    expect(user.name).toBe("Shane");
  });

  test("handles missing user", function() {
    sandbox.stub(db, "findById").returns(null);
    var user = service.getUser(999);
    expect(user).toBeNull(); // Works correctly
  });
});

Anti-Pattern 5: Mock Return Values That Cannot Happen

// BAD — mock returns data the real dependency never would
test("handles user", function() {
  db.findById.mockReturnValue({
    id: 1,
    name: "Shane",
    // The real database also returns email, createdAt, role
    // But this mock omits them
    // Code that accesses user.email will get undefined
    // Test passes, production crashes
  });

  var profile = service.getProfile(1);
  expect(profile.name).toBe("Shane");
  // Passes! But profile.email is undefined because mock was incomplete
});
// GOOD — mock returns realistic data
function createMockUser(overrides) {
  return Object.assign({
    id: 1,
    name: "Test User",
    email: "[email protected]",
    role: "user",
    createdAt: new Date("2026-01-01"),
    updatedAt: new Date("2026-01-01")
  }, overrides || {});
}

test("handles user", function() {
  db.findById.mockReturnValue(createMockUser({ name: "Shane" }));

  var profile = service.getProfile(1);
  expect(profile.name).toBe("Shane");
  expect(profile.email).toBe("[email protected]");
});

Rule: Use factory functions that create realistic test data. Every mock should return data shaped like what the real dependency returns.

Choosing the Right Test Double

Do you control the dependency?
├── No → Do not mock it. Use integration tests or tools like nock/supertest.
└── Yes → Does it have side effects (DB, network, email)?
    ├── No → Use the real implementation. No mock needed.
    └── Yes → Is the behavior complex?
        ├── No → Use a stub (fixed return value).
        └── Yes → Use a fake (simplified implementation).

Do you need to verify a call was made?
├── Yes → Add a spy (or check mock call records).
└── No → A stub is sufficient.

Testing Strategy by Layer

// Controller/Route layer → Integration tests with supertest
// Mock: nothing (use real Express, mock database)
request(app).get("/users/1").expect(200);

// Service layer → Unit tests with injected stubs
// Mock: database, external APIs, email
var service = createService(mockDb, mockEmail);
service.register("Shane", "[email protected]", "pass");

// Data access layer → Integration tests with real database
// Mock: nothing (use test database)
var user = db.insert("users", { name: "Shane" });
var found = db.findById("users", user.id);

// Utility functions → Unit tests with no mocks
// Mock: nothing
expect(slugify("Hello World")).toBe("hello-world");

Common Issues and Troubleshooting

Jest mock is not working — real module runs instead

The jest.mock() call must be at the top of the file. Jest hoists mock calls but they must appear before the require:

Fix: Place jest.mock("./module") before var module = require("./module"). Check that the path matches exactly — "./db" and "./db.js" might be treated differently.

Sinon stub does not restore — affects other tests

A stub was created without a sandbox, and restore() was not called:

Fix: Always use sinon.createSandbox() and call sandbox.restore() in afterEach. Never create stubs with sinon.stub() directly unless you are certain about cleanup.

Mock is called but test says it was not

The mock was cleared or recreated between the call and the assertion:

Fix: Check that jest.clearAllMocks() or jest.resetAllMocks() is not running between the action and the assertion. Verify the mock reference is the same object the code uses.

Mocked function returns undefined instead of the configured value

The mock was configured for one call pattern but called with different arguments:

Fix: Use mockReturnValue for any call, or mockReturnValueOnce for sequential calls. Check that mockImplementation logic handles the actual arguments. Log the mock's calls array to see what arguments it received.

Best Practices

  • Mock at boundaries, not between internal modules. Database, network, filesystem, and email are boundaries. Your own utility functions are not.
  • Use dependency injection when possible. It makes testing straightforward and avoids module mocking complexity.
  • Create test data factories. Functions like createMockUser() ensure consistent, realistic test data across all tests.
  • Use sandboxes with Sinon. Always group stubs in a sandbox and restore in afterEach. Leaked stubs cause mysterious test failures.
  • Prefer spies over mocks when you just need to verify calls. Spies let the real function run while recording calls. Mocks replace behavior entirely.
  • Keep mocks close to reality. If your mock returns data that the real dependency never produces, your tests prove nothing about production behavior.
  • Test behavior, not implementation. Good tests survive refactoring. If you rename an internal method and tests break, the tests were testing implementation details.
  • When in doubt, do not mock. A test with fewer mocks that tests more real code is almost always more valuable than a heavily mocked unit test.

References

Powered by Contentful