Mocking and Stubbing: Patterns and Anti-Patterns
A practical guide to test doubles in Node.js covering mocks, stubs, spies, and fakes with Jest and Sinon, including common anti-patterns and when integration tests are better than mocks.
Mocking and Stubbing: Patterns and Anti-Patterns
Mocking and stubbing are the most misused tools in a test engineer's toolkit. Used well, they isolate units of code and make tests fast, deterministic, and focused. Used poorly, they create a parallel universe where every test passes but production is on fire.
This article covers the taxonomy of test doubles, practical patterns with Jest and Sinon, HTTP and database mocking strategies, and the anti-patterns I have seen torpedo entire test suites across a decade of Node.js projects.
Prerequisites
- Working knowledge of Node.js and Express.js
- Basic understanding of unit testing concepts
- Familiarity with npm and project setup
- Node.js v18+ installed locally
Install the libraries we will use throughout:
npm install --save-dev jest sinon nock supertest
Test Double Terminology
The term "test double" comes from Gerard Meszaros's xUnit Test Patterns. Most engineers use "mock" as a catch-all, but there are five distinct types. Getting the terminology right matters because each one solves a different problem.
Dummy
A dummy is an object passed around but never actually used. It fills a parameter list.
// A dummy logger that satisfies the function signature
var dummyLogger = { info: function() {}, error: function() {}, warn: function() {} };
function createUserService(db, logger) {
return {
getUser: function(id) {
logger.info("Fetching user " + id);
return db.query("SELECT * FROM users WHERE id = $1", [id]);
}
};
}
// We don't care about logging in this test
var service = createUserService(mockDb, dummyLogger);
Stub
A stub provides canned answers to calls made during the test. It does not record how it was called.
var userStub = {
findById: function(id) {
return Promise.resolve({ id: id, name: "Shane", email: "[email protected]" });
}
};
Spy
A spy records information about how it was called: arguments, call count, return values. It may or may not delegate to the real implementation.
var sinon = require("sinon");
var calculator = {
add: function(a, b) { return a + b; }
};
var spy = sinon.spy(calculator, "add");
calculator.add(2, 3);
console.log(spy.calledOnce); // true
console.log(spy.calledWith(2, 3)); // true
console.log(spy.returnValues[0]); // 5 (real implementation ran)
Mock
A mock is a pre-programmed object with expectations. It verifies that specific interactions happened. Mocks are strict: if the expected call does not happen, the test fails.
var sinon = require("sinon");
var api = { sendEmail: function(to, subject, body) {} };
var mock = sinon.mock(api);
mock.expects("sendEmail")
.once()
.withArgs("[email protected]", "Welcome", sinon.match.string);
// Code under test
api.sendEmail("[email protected]", "Welcome", "Hello there");
mock.verify(); // Passes. Would throw if sendEmail was not called correctly.
mock.restore();
Fake
A fake is a working implementation that takes a shortcut. An in-memory database is the classic example.
function createFakeUserRepository() {
var users = {};
var nextId = 1;
return {
create: function(userData) {
var user = Object.assign({}, userData, { id: nextId++ });
users[user.id] = user;
return Promise.resolve(user);
},
findById: function(id) {
return Promise.resolve(users[id] || null);
},
findByEmail: function(email) {
var found = Object.values(users).find(function(u) {
return u.email === email;
});
return Promise.resolve(found || null);
},
deleteById: function(id) {
var existed = !!users[id];
delete users[id];
return Promise.resolve(existed);
}
};
}
Fakes are the most underrated test double. They test behavior rather than implementation, and they survive refactors far better than mocks.
When to Mock vs When Not to Mock
This is where most teams go wrong. Here is my decision framework after years of cleaning up brittle test suites.
Mock these things:
- Network calls (HTTP APIs, DNS, SMTP)
- File system operations in unit tests
- Timers and dates
- Random number generators
- Third-party SDKs you do not control (Stripe, AWS, Twilio)
Do not mock these things:
- Your own pure functions
- Data transformation logic
- Simple utility modules
- The module you are testing (yes, people do this)
- Everything two layers deep from the unit under test
The rule of thumb: mock at the boundary between your code and the outside world. If you are mocking something you wrote and it has no side effects, you are testing the wrong thing.
Mocking with Jest
Jest has mocking built in. No extra libraries needed. Three primary mechanisms: jest.fn(), jest.mock(), and jest.spyOn().
jest.fn() - Manual Mock Functions
// userService.js
var userService = {
createUser: function(repo, userData) {
if (!userData.email) {
throw new Error("Email is required");
}
return repo.create(userData);
}
};
module.exports = userService;
// userService.test.js
var userService = require("./userService");
describe("userService.createUser", function() {
it("should call repo.create with user data", function() {
var mockRepo = {
create: jest.fn().mockResolvedValue({ id: 1, email: "[email protected]" })
};
return userService.createUser(mockRepo, { email: "[email protected]" })
.then(function(result) {
expect(mockRepo.create).toHaveBeenCalledTimes(1);
expect(mockRepo.create).toHaveBeenCalledWith({ email: "[email protected]" });
expect(result.id).toBe(1);
});
});
it("should throw if email is missing", function() {
var mockRepo = { create: jest.fn() };
expect(function() {
userService.createUser(mockRepo, { name: "Shane" });
}).toThrow("Email is required");
expect(mockRepo.create).not.toHaveBeenCalled();
});
});
jest.mock() - Module-Level Mocking
This replaces an entire module. Every exported function becomes a jest.fn().
// emailService.js
var nodemailer = require("nodemailer");
var transporter = nodemailer.createTransport({
host: "smtp.example.com",
port: 587,
auth: { user: "api", pass: process.env.SMTP_PASSWORD }
});
function sendWelcomeEmail(userEmail, userName) {
return transporter.sendMail({
from: "[email protected]",
to: userEmail,
subject: "Welcome, " + userName,
html: "<h1>Welcome aboard, " + userName + "!</h1>"
});
}
module.exports = { sendWelcomeEmail: sendWelcomeEmail };
// emailService.test.js
jest.mock("nodemailer");
var nodemailer = require("nodemailer");
var emailService = require("./emailService");
describe("sendWelcomeEmail", function() {
it("should send email with correct parameters", function() {
var mockSendMail = jest.fn().mockResolvedValue({ messageId: "abc123" });
nodemailer.createTransport.mockReturnValue({ sendMail: mockSendMail });
// Re-require to pick up the mock
jest.resetModules();
jest.mock("nodemailer");
nodemailer.createTransport.mockReturnValue({ sendMail: mockSendMail });
var freshEmailService = require("./emailService");
return freshEmailService.sendWelcomeEmail("[email protected]", "Shane")
.then(function() {
expect(mockSendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: "[email protected]",
subject: "Welcome, Shane"
})
);
});
});
});
jest.spyOn() - Partial Mocking
Spies let you observe calls without replacing the implementation (unless you want to).
var utils = require("./utils");
describe("spyOn example", function() {
it("should track calls to slugify", function() {
var spy = jest.spyOn(utils, "slugify");
var result = utils.slugify("Hello World");
expect(spy).toHaveBeenCalledWith("Hello World");
expect(result).toBe("hello-world"); // Real implementation runs
spy.mockRestore();
});
it("should replace implementation when needed", function() {
var spy = jest.spyOn(utils, "slugify").mockReturnValue("mocked-slug");
var result = utils.slugify("Anything");
expect(result).toBe("mocked-slug");
spy.mockRestore();
});
});
Mocking with Sinon
Sinon is framework-agnostic and pairs well with Mocha, Chai, or any runner. It gives you finer control over test doubles.
sinon.stub()
var sinon = require("sinon");
var assert = require("assert");
var db = require("./db");
var userService = require("./userService");
describe("userService", function() {
afterEach(function() {
sinon.restore(); // Critical: always restore stubs
});
it("should return user from database", function() {
var stub = sinon.stub(db, "query").resolves([{ id: 1, name: "Shane" }]);
return userService.getUserById(1).then(function(user) {
assert.strictEqual(user.name, "Shane");
assert.ok(stub.calledOnce);
assert.ok(stub.calledWith("SELECT * FROM users WHERE id = $1", [1]));
});
});
it("should handle sequential calls differently", function() {
var stub = sinon.stub(db, "query");
stub.onFirstCall().resolves([{ id: 1, name: "Shane" }]);
stub.onSecondCall().resolves([]);
return Promise.all([
userService.getUserById(1),
userService.getUserById(999)
]).then(function(results) {
assert.strictEqual(results[0].name, "Shane");
assert.strictEqual(results[1], null);
});
});
});
sinon.fake()
Fakes combine the best parts of stubs and spies. They record calls and return configured values.
var sinon = require("sinon");
var fake = sinon.fake.resolves({ id: 1, name: "Shane" });
// Use it as a callback
fake(1).then(function(result) {
console.log(result); // { id: 1, name: "Shane" }
console.log(fake.callCount); // 1
console.log(fake.firstArg); // 1
});
Mocking HTTP Requests with Nock
Nock intercepts outgoing HTTP requests at the http/https module level. No server required.
var nock = require("nock");
var assert = require("assert");
// paymentService.js
var https = require("https");
function chargeCustomer(customerId, amount) {
var axios = require("axios");
return axios.post("https://api.stripe.com/v1/charges", {
customer: customerId,
amount: amount,
currency: "usd"
}, {
headers: { Authorization: "Bearer sk_test_abc123" }
}).then(function(response) {
return response.data;
});
}
// paymentService.test.js
describe("chargeCustomer", function() {
afterEach(function() {
nock.cleanAll();
});
it("should create a charge via Stripe API", function() {
var scope = nock("https://api.stripe.com")
.post("/v1/charges", {
customer: "cus_123",
amount: 5000,
currency: "usd"
})
.reply(200, {
id: "ch_abc",
amount: 5000,
status: "succeeded"
});
return chargeCustomer("cus_123", 5000).then(function(result) {
assert.strictEqual(result.status, "succeeded");
assert.strictEqual(result.amount, 5000);
assert.ok(scope.isDone()); // Verify the request was made
});
});
it("should handle Stripe API errors", function() {
nock("https://api.stripe.com")
.post("/v1/charges")
.reply(402, {
error: {
type: "card_error",
message: "Your card was declined"
}
});
return chargeCustomer("cus_123", 5000)
.then(function() {
assert.fail("Should have thrown");
})
.catch(function(err) {
assert.strictEqual(err.response.status, 402);
assert.strictEqual(err.response.data.error.message, "Your card was declined");
});
});
it("should handle network timeouts", function() {
nock("https://api.stripe.com")
.post("/v1/charges")
.delay(5000)
.reply(200, {});
// Assuming axios timeout is set to 3000ms
return chargeCustomer("cus_123", 5000)
.then(function() {
assert.fail("Should have timed out");
})
.catch(function(err) {
assert.ok(err.code === "ECONNABORTED" || err.message.includes("timeout"));
});
});
});
Mocking Databases
Database mocking is where teams diverge the most. Here are three approaches, from worst to best.
Approach 1: Stub the Query Method (Fragile)
// Tightly coupled to SQL strings. Any query change breaks the test.
sinon.stub(db, "query")
.withArgs("SELECT * FROM users WHERE id = $1", [1])
.resolves({ rows: [{ id: 1, name: "Shane" }] });
This breaks the moment you add a column to the SELECT or change whitespace. Do not do this for anything beyond the simplest cases.
Approach 2: Stub the Repository Layer (Better)
// userRepository.js
function UserRepository(pool) {
this.pool = pool;
}
UserRepository.prototype.findById = function(id) {
return this.pool.query("SELECT * FROM users WHERE id = $1", [id])
.then(function(result) { return result.rows[0] || null; });
};
UserRepository.prototype.create = function(userData) {
return this.pool.query(
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *",
[userData.name, userData.email]
).then(function(result) { return result.rows[0]; });
};
module.exports = UserRepository;
// In tests, stub the repository, not the raw pool
var sinon = require("sinon");
var UserRepository = require("./userRepository");
var repo = new UserRepository(null); // Pool not needed
sinon.stub(repo, "findById").resolves({ id: 1, name: "Shane", email: "[email protected]" });
sinon.stub(repo, "create").resolves({ id: 2, name: "New User", email: "[email protected]" });
Approach 3: In-Memory Fake (Best for Unit Tests)
function FakeUserRepository() {
this.users = new Map();
this.nextId = 1;
}
FakeUserRepository.prototype.findById = function(id) {
return Promise.resolve(this.users.get(id) || null);
};
FakeUserRepository.prototype.create = function(userData) {
var user = Object.assign({}, userData, { id: this.nextId++ });
this.users.set(user.id, user);
return Promise.resolve(user);
};
FakeUserRepository.prototype.findByEmail = function(email) {
var found = null;
this.users.forEach(function(user) {
if (user.email === email) found = user;
});
return Promise.resolve(found);
};
// Tests use the fake
var repo = new FakeUserRepository();
repo.create({ name: "Shane", email: "[email protected]" }).then(function(user) {
return repo.findById(user.id);
}).then(function(found) {
console.log(found); // { id: 1, name: "Shane", email: "[email protected]" }
});
Fakes survive refactors. They test behavior ("can I store and retrieve a user?") rather than implementation ("did you call query with this exact SQL string?").
Mocking Timers and Dates
Time-dependent code is notoriously flaky without proper mocking.
Jest Timer Mocks
// scheduler.js
function scheduleRetry(fn, delayMs, maxRetries) {
var attempts = 0;
function attempt() {
attempts++;
try {
return fn();
} catch (err) {
if (attempts >= maxRetries) throw err;
setTimeout(attempt, delayMs);
}
}
return attempt();
}
module.exports = { scheduleRetry: scheduleRetry };
// scheduler.test.js
var scheduler = require("./scheduler");
describe("scheduleRetry", function() {
beforeEach(function() {
jest.useFakeTimers();
});
afterEach(function() {
jest.useRealTimers();
});
it("should retry after delay on failure", function() {
var callCount = 0;
var fn = jest.fn(function() {
callCount++;
if (callCount < 3) throw new Error("Not ready");
return "success";
});
// First call throws
expect(function() { scheduler.scheduleRetry(fn, 1000, 5); }).toThrow();
// Advance time to trigger retry
jest.advanceTimersByTime(1000);
expect(fn).toHaveBeenCalledTimes(2);
jest.advanceTimersByTime(1000);
expect(fn).toHaveBeenCalledTimes(3);
});
});
Sinon Fake Timers
var sinon = require("sinon");
var assert = require("assert");
describe("cache expiry", function() {
var clock;
beforeEach(function() {
clock = sinon.useFakeTimers(new Date("2026-01-15T10:00:00Z"));
});
afterEach(function() {
clock.restore();
});
it("should expire entries after TTL", function() {
var cache = require("./cache");
cache.set("key", "value", 60000); // 60 second TTL
assert.strictEqual(cache.get("key"), "value");
clock.tick(59000);
assert.strictEqual(cache.get("key"), "value"); // Still valid
clock.tick(2000); // Now at 61 seconds
assert.strictEqual(cache.get("key"), null); // Expired
});
});
Mocking Date.now()
// tokenService.js
function isTokenExpired(token) {
return Date.now() > token.expiresAt;
}
// tokenService.test.js
describe("isTokenExpired", function() {
it("should return true for expired tokens", function() {
var now = new Date("2026-02-01T12:00:00Z").getTime();
jest.spyOn(Date, "now").mockReturnValue(now);
var token = { expiresAt: now - 1000 };
expect(isTokenExpired(token)).toBe(true);
Date.now.mockRestore();
});
it("should return false for valid tokens", function() {
var now = new Date("2026-02-01T12:00:00Z").getTime();
jest.spyOn(Date, "now").mockReturnValue(now);
var token = { expiresAt: now + 3600000 };
expect(isTokenExpired(token)).toBe(false);
Date.now.mockRestore();
});
});
Module Mocking Patterns
There are two main philosophies for making code testable: dependency injection and monkey-patching. Each has trade-offs.
Dependency Injection
Pass dependencies into functions or constructors. The cleanest approach.
// orderService.js
function OrderService(deps) {
this.orderRepo = deps.orderRepo;
this.paymentGateway = deps.paymentGateway;
this.emailService = deps.emailService;
this.logger = deps.logger;
}
OrderService.prototype.placeOrder = function(userId, items) {
var self = this;
var total = items.reduce(function(sum, item) { return sum + item.price * item.qty; }, 0);
return self.orderRepo.create({ userId: userId, items: items, total: total })
.then(function(order) {
return self.paymentGateway.charge(userId, total)
.then(function(charge) {
return self.orderRepo.updateStatus(order.id, "paid")
.then(function() {
self.emailService.sendOrderConfirmation(userId, order.id);
self.logger.info("Order placed", { orderId: order.id });
return order;
});
});
});
};
module.exports = OrderService;
// orderService.test.js
var OrderService = require("./orderService");
describe("OrderService.placeOrder", function() {
var service;
var deps;
beforeEach(function() {
deps = {
orderRepo: {
create: jest.fn().mockResolvedValue({ id: "ord_1", total: 50 }),
updateStatus: jest.fn().mockResolvedValue(true)
},
paymentGateway: {
charge: jest.fn().mockResolvedValue({ id: "ch_1", status: "succeeded" })
},
emailService: {
sendOrderConfirmation: jest.fn()
},
logger: {
info: jest.fn(),
error: jest.fn()
}
};
service = new OrderService(deps);
});
it("should create order, charge payment, and send email", function() {
var items = [{ name: "Book", price: 25, qty: 2 }];
return service.placeOrder("user_1", items).then(function(order) {
expect(deps.orderRepo.create).toHaveBeenCalledWith({
userId: "user_1",
items: items,
total: 50
});
expect(deps.paymentGateway.charge).toHaveBeenCalledWith("user_1", 50);
expect(deps.orderRepo.updateStatus).toHaveBeenCalledWith("ord_1", "paid");
expect(deps.emailService.sendOrderConfirmation).toHaveBeenCalledWith("user_1", "ord_1");
});
});
it("should not send email if payment fails", function() {
deps.paymentGateway.charge.mockRejectedValue(new Error("Card declined"));
return service.placeOrder("user_1", [{ name: "Book", price: 25, qty: 1 }])
.then(function() { throw new Error("Should have rejected"); })
.catch(function(err) {
expect(err.message).toBe("Card declined");
expect(deps.emailService.sendOrderConfirmation).not.toHaveBeenCalled();
expect(deps.orderRepo.updateStatus).not.toHaveBeenCalled();
});
});
});
Monkey-Patching with jest.mock()
When you cannot inject dependencies (legacy code, third-party modules), module-level mocking is the escape hatch.
// legacyService.js
var redis = require("redis");
var client = redis.createClient({ url: process.env.REDIS_URL });
function getCachedUser(userId) {
return new Promise(function(resolve, reject) {
client.get("user:" + userId, function(err, data) {
if (err) return reject(err);
resolve(data ? JSON.parse(data) : null);
});
});
}
module.exports = { getCachedUser: getCachedUser };
// legacyService.test.js
jest.mock("redis", function() {
var mockGet = jest.fn();
return {
createClient: jest.fn().mockReturnValue({
get: mockGet,
on: jest.fn(),
connect: jest.fn()
}),
__mockGet: mockGet // Expose for test configuration
};
});
var redis = require("redis");
var legacyService = require("./legacyService");
describe("getCachedUser", function() {
it("should return parsed user from Redis", function() {
var userData = { id: 1, name: "Shane" };
redis.__mockGet.mockImplementation(function(key, cb) {
cb(null, JSON.stringify(userData));
});
return legacyService.getCachedUser(1).then(function(user) {
expect(user).toEqual(userData);
expect(redis.__mockGet).toHaveBeenCalledWith("user:1", expect.any(Function));
});
});
});
Dependency injection is always preferable. Monkey-patching is for code you cannot refactor yet.
Common Anti-Patterns
These are the patterns I see repeatedly in codebases where "all tests pass" but bugs still ship.
Anti-Pattern 1: Over-Mocking (The Tautology Test)
// BAD: This test proves nothing. You mocked the thing you're testing.
describe("calculateDiscount", function() {
it("should return 10% discount", function() {
var calculateDiscount = jest.fn().mockReturnValue(10);
expect(calculateDiscount(100)).toBe(10);
});
});
This test will pass forever, even if calculateDiscount is completely broken. You are testing your mock, not your code.
Anti-Pattern 2: Testing Implementation Details
// BAD: This test breaks when you refactor, even if behavior is the same.
describe("getUserProfile", function() {
it("should call database in correct order", function() {
var mockDb = {
query: jest.fn()
.mockResolvedValueOnce({ rows: [{ id: 1 }] })
.mockResolvedValueOnce({ rows: [{ user_id: 1, role: "admin" }] })
};
return getUserProfile(mockDb, 1).then(function() {
// Testing call ORDER is an implementation detail
expect(mockDb.query.mock.calls[0][0]).toBe("SELECT * FROM users WHERE id = $1");
expect(mockDb.query.mock.calls[1][0]).toBe("SELECT * FROM roles WHERE user_id = $1");
});
});
});
// GOOD: Test the output, not the internal call sequence
describe("getUserProfile", function() {
it("should return user with role", function() {
var fakeRepo = new FakeUserRepository();
return fakeRepo.create({ name: "Shane", email: "[email protected]", role: "admin" })
.then(function(user) {
return getUserProfile(fakeRepo, user.id);
})
.then(function(profile) {
expect(profile.name).toBe("Shane");
expect(profile.role).toBe("admin");
});
});
});
Anti-Pattern 3: Mock-Heavy Tests That Pass But Production Breaks
// BAD: Every dependency is mocked. No integration point is actually tested.
describe("processPayment", function() {
it("should process payment", function() {
var mockDb = { query: jest.fn().mockResolvedValue({}) };
var mockStripe = { charges: { create: jest.fn().mockResolvedValue({ id: "ch_1" }) } };
var mockRedis = { set: jest.fn().mockResolvedValue("OK") };
var mockEmail = { send: jest.fn().mockResolvedValue(true) };
var mockLogger = { info: jest.fn(), error: jest.fn() };
var mockQueue = { push: jest.fn().mockResolvedValue(true) };
return processPayment(mockDb, mockStripe, mockRedis, mockEmail, mockLogger, mockQueue, {
userId: 1,
amount: 50
}).then(function(result) {
expect(result).toBeTruthy(); // This tells you nothing useful
});
});
});
When everything is mocked, you are testing whether your code calls functions in a particular sequence. That is a screenplay, not a test. The actual Stripe API could return a completely different shape and this test would still pass.
Anti-Pattern 4: Not Cleaning Up Mocks
// BAD: Stubs leak between tests, causing mysterious failures.
describe("user tests", function() {
it("test one", function() {
sinon.stub(db, "query").resolves([]);
// ... test runs, but stub is never restored
});
it("test two", function() {
// db.query is STILL stubbed from test one!
// This test is using stale mock state.
});
});
// GOOD: Always clean up.
describe("user tests", function() {
afterEach(function() {
sinon.restore();
});
// Or in Jest:
afterEach(function() {
jest.restoreAllMocks();
});
});
Anti-Pattern 5: Mocking What You Do Not Own Without a Wrapper
// BAD: Direct mock of third-party API shape. If Stripe changes their SDK, tests still pass.
jest.mock("stripe", function() {
return function() {
return {
charges: { create: jest.fn().mockResolvedValue({ id: "ch_1" }) }
};
};
});
// GOOD: Wrap the third party, mock the wrapper, integration-test the wrapper.
// paymentGateway.js
var Stripe = require("stripe");
var stripe = Stripe(process.env.STRIPE_SECRET_KEY);
function createCharge(amount, currency, customerId) {
return stripe.charges.create({
amount: amount,
currency: currency,
customer: customerId
}).then(function(charge) {
return { chargeId: charge.id, status: charge.status, amount: charge.amount };
});
}
module.exports = { createCharge: createCharge };
// Now mock paymentGateway, not stripe directly.
// Integration-test paymentGateway against Stripe's test mode.
Integration Testing as an Alternative
When you find yourself writing more mock setup code than actual test logic, it is time for an integration test.
// integration/userFlow.test.js
var supertest = require("supertest");
var app = require("../app");
var db = require("../db");
describe("User Registration Flow", function() {
var request;
beforeAll(function() {
return db.migrate().then(function() {
request = supertest(app);
});
});
afterAll(function() {
return db.close();
});
beforeEach(function() {
return db.query("DELETE FROM users");
});
it("should register user and return profile", function() {
return request
.post("/api/users")
.send({ name: "Shane", email: "[email protected]", password: "secure123" })
.expect(201)
.then(function(response) {
expect(response.body.name).toBe("Shane");
expect(response.body.email).toBe("[email protected]");
expect(response.body.password).toBeUndefined(); // Should not leak
// Verify we can fetch the user
return request
.get("/api/users/" + response.body.id)
.expect(200);
})
.then(function(response) {
expect(response.body.name).toBe("Shane");
});
});
it("should reject duplicate email", function() {
return request
.post("/api/users")
.send({ name: "Shane", email: "[email protected]", password: "pass1" })
.expect(201)
.then(function() {
return request
.post("/api/users")
.send({ name: "Other", email: "[email protected]", password: "pass2" })
.expect(409);
})
.then(function(response) {
expect(response.body.error).toContain("already exists");
});
});
});
Run integration tests separately from unit tests:
{
"scripts": {
"test": "jest --testPathPattern='\\.test\\.js$' --testPathIgnorePatterns='integration'",
"test:integration": "jest --testPathPattern='integration/.*\\.test\\.js$' --runInBand",
"test:all": "npm test && npm run test:integration"
}
}
# Unit tests: fast, no external deps, run in parallel
$ npm test
# Test Suites: 47 passed, 47 total
# Tests: 312 passed, 312 total
# Time: 4.2 s
# Integration tests: slower, need database, run sequentially
$ npm run test:integration
# Test Suites: 8 passed, 8 total
# Tests: 34 passed, 34 total
# Time: 12.8 s
Complete Working Example
Here is a realistic Express service with database, external API, and cache layers, tested with proper mocking boundaries.
The Service
// services/productService.js
function ProductService(deps) {
this.productRepo = deps.productRepo;
this.pricingApi = deps.pricingApi;
this.cache = deps.cache;
this.logger = deps.logger;
}
ProductService.prototype.getProductWithPricing = function(productId) {
var self = this;
// Check cache first
return self.cache.get("product:" + productId)
.then(function(cached) {
if (cached) {
self.logger.info("Cache hit", { productId: productId });
return JSON.parse(cached);
}
// Fetch from database
return self.productRepo.findById(productId)
.then(function(product) {
if (!product) {
var err = new Error("Product not found");
err.statusCode = 404;
throw err;
}
// Enrich with pricing from external API
return self.pricingApi.getPrice(product.sku)
.then(function(pricing) {
var enriched = Object.assign({}, product, {
price: pricing.price,
currency: pricing.currency,
discount: pricing.discount || null
});
// Cache for 5 minutes
return self.cache.set(
"product:" + productId,
JSON.stringify(enriched),
300
).then(function() {
return enriched;
});
})
.catch(function(pricingErr) {
// Degrade gracefully: return product without pricing
self.logger.error("Pricing API failed", {
productId: productId,
error: pricingErr.message
});
return Object.assign({}, product, { price: null, currency: null, discount: null });
});
});
});
};
module.exports = ProductService;
The Tests (Good Approach)
// services/productService.test.js
var ProductService = require("./productService");
function createMockDeps(overrides) {
var defaults = {
productRepo: {
findById: jest.fn().mockResolvedValue(null)
},
pricingApi: {
getPrice: jest.fn().mockResolvedValue({ price: 29.99, currency: "USD", discount: null })
},
cache: {
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue("OK")
},
logger: {
info: jest.fn(),
error: jest.fn()
}
};
return Object.assign(defaults, overrides);
}
describe("ProductService.getProductWithPricing", function() {
it("should return cached product if available", function() {
var cachedProduct = { id: 1, name: "Widget", sku: "WDG-001", price: 29.99 };
var deps = createMockDeps({
cache: {
get: jest.fn().mockResolvedValue(JSON.stringify(cachedProduct)),
set: jest.fn()
}
});
var service = new ProductService(deps);
return service.getProductWithPricing(1).then(function(result) {
expect(result).toEqual(cachedProduct);
expect(deps.productRepo.findById).not.toHaveBeenCalled();
expect(deps.pricingApi.getPrice).not.toHaveBeenCalled();
expect(deps.logger.info).toHaveBeenCalledWith("Cache hit", { productId: 1 });
});
});
it("should fetch from DB and pricing API on cache miss", function() {
var product = { id: 1, name: "Widget", sku: "WDG-001" };
var deps = createMockDeps({
productRepo: { findById: jest.fn().mockResolvedValue(product) }
});
var service = new ProductService(deps);
return service.getProductWithPricing(1).then(function(result) {
expect(result.name).toBe("Widget");
expect(result.price).toBe(29.99);
expect(result.currency).toBe("USD");
expect(deps.cache.set).toHaveBeenCalledWith(
"product:1",
expect.any(String),
300
);
});
});
it("should throw 404 if product not found in DB", function() {
var deps = createMockDeps();
var service = new ProductService(deps);
return service.getProductWithPricing(999)
.then(function() { throw new Error("Should have thrown"); })
.catch(function(err) {
expect(err.message).toBe("Product not found");
expect(err.statusCode).toBe(404);
});
});
it("should degrade gracefully when pricing API fails", function() {
var product = { id: 1, name: "Widget", sku: "WDG-001" };
var deps = createMockDeps({
productRepo: { findById: jest.fn().mockResolvedValue(product) },
pricingApi: { getPrice: jest.fn().mockRejectedValue(new Error("Service unavailable")) }
});
var service = new ProductService(deps);
return service.getProductWithPricing(1).then(function(result) {
expect(result.name).toBe("Widget");
expect(result.price).toBeNull();
expect(deps.logger.error).toHaveBeenCalledWith(
"Pricing API failed",
expect.objectContaining({ error: "Service unavailable" })
);
});
});
});
The Tests (Bad Approach for Comparison)
// BAD: What not to do. Included for educational purposes.
// Anti-pattern: Mocking internals of ProductService itself
describe("BAD productService tests", function() {
it("tests nothing useful", function() {
// Mocking the method you're supposed to be testing
var service = new ProductService(createMockDeps());
service.getProductWithPricing = jest.fn().mockResolvedValue({
id: 1, name: "Widget", price: 29.99
});
return service.getProductWithPricing(1).then(function(result) {
expect(result.price).toBe(29.99); // Of course it does, you mocked it
});
});
it("over-specifies internal behavior", function() {
var deps = createMockDeps({
productRepo: { findById: jest.fn().mockResolvedValue({ id: 1, sku: "WDG-001" }) }
});
var service = new ProductService(deps);
return service.getProductWithPricing(1).then(function() {
// Testing exact call count on logger is testing implementation details
expect(deps.logger.info).not.toHaveBeenCalled(); // Fragile: breaks if you add logging
expect(deps.cache.get).toHaveBeenCalledTimes(1); // Fragile: breaks if caching strategy changes
expect(deps.cache.set).toHaveBeenCalledBefore(deps.logger.info); // Fragile: order doesn't matter
});
});
});
Common Issues and Troubleshooting
Issue 1: "Cannot spy the property because it is not a function"
TypeError: Cannot spy the xxx property because it is not a function; undefined given instead
This happens when jest.spyOn targets a property that does not exist on the object yet.
// BAD
var obj = {};
jest.spyOn(obj, "doSomething"); // TypeError: not a function
// GOOD
var obj = { doSomething: function() {} };
jest.spyOn(obj, "doSomething");
If the method is added dynamically, assign a no-op first:
obj.doSomething = obj.doSomething || function() {};
jest.spyOn(obj, "doSomething");
Issue 2: "Nock: No match for request" in Tests
Error: Nock: No match for request {
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
headers: { 'content-type': 'application/x-www-form-urlencoded' }
}
The request does not match your nock interceptor. Common causes:
- Body encoding mismatch: Nock matches the exact body. If the library sends
application/x-www-form-urlencodedbut you configured JSON, it will not match. - Missing headers: Some nock configurations require matching headers.
- URL path mismatch: Trailing slash differences.
// Fix: Use a body matcher function instead of exact matching
nock("https://api.stripe.com")
.post("/v1/charges", function(body) {
return body.amount === 5000; // Match on relevant fields only
})
.reply(200, { id: "ch_1" });
Issue 3: Stubs Not Restoring Between Tests (Sinon)
Error: Attempted to wrap query which is already wrapped
You are calling sinon.stub() on a method that is already stubbed from a previous test.
// BAD: Stub not restored
sinon.stub(db, "query");
// Test runs... next test tries to stub again: BOOM
// GOOD: Always restore in afterEach
afterEach(function() {
sinon.restore(); // Restores ALL stubs, spies, and mocks
});
Issue 4: Jest Mock Hoisting Confusion
// This looks correct but fails because jest.mock is hoisted above the require
var config = require("./config");
jest.mock("./config"); // Hoisted to top of file by Jest
// config is already the mock when it's required
// But if you try to set return values before jest.mock, it won't work
// FIX: Use jest.mock with a factory function, or configure mocks after require
jest.mock("./config", function() {
return {
getDbUrl: jest.fn().mockReturnValue("postgres://localhost/testdb"),
getPort: jest.fn().mockReturnValue(3000)
};
});
var config = require("./config");
// config.getDbUrl() now returns "postgres://localhost/testdb"
Issue 5: Async Mock Not Awaited Properly
UnhandledPromiseRejectionWarning: Error: expect(received).toBe(expected)
Tests pass but you see warnings because assertions run before async mocks resolve.
// BAD: Missing return/await
it("should fetch user", function() {
var repo = { findById: jest.fn().mockResolvedValue({ id: 1 }) };
service.getUser(repo, 1).then(function(user) {
expect(user.id).toBe(1); // This may not execute before test ends
});
});
// GOOD: Return the promise
it("should fetch user", function() {
var repo = { findById: jest.fn().mockResolvedValue({ id: 1 }) };
return service.getUser(repo, 1).then(function(user) {
expect(user.id).toBe(1);
});
});
// ALSO GOOD: Use done callback
it("should fetch user", function(done) {
var repo = { findById: jest.fn().mockResolvedValue({ id: 1 }) };
service.getUser(repo, 1).then(function(user) {
expect(user.id).toBe(1);
done();
}).catch(done);
});
Best Practices
Mock at the boundary, not in the middle. External APIs, databases, file systems, and clocks are boundaries. Your own utility functions are not. If you are mocking a function you wrote that has no side effects, you are probably testing the wrong thing.
Prefer fakes over mocks for repositories. An in-memory implementation of your data access layer tests behavior ("does this flow correctly?") rather than implementation ("did you call
querywith this SQL string?"). Fakes survive refactors that mocks do not.Always restore mocks in afterEach. Leaked stubs cause cascading test failures that are incredibly hard to debug. Use
sinon.restore()orjest.restoreAllMocks()in every test suite. No exceptions.Wrap third-party libraries before mocking them. Never mock
stripeoraws-sdkdirectly in business logic tests. Create a thin wrapper (paymentGateway.js,storageService.js), mock that wrapper in unit tests, and integration-test the wrapper against the real service in test mode.Use a factory function for mock dependencies. A
createMockDeps()helper with sensible defaults reduces boilerplate and makes each test's setup express only what is different about that test case.Do not test implementation details. If your test breaks because you changed the order of two independent database calls, or added a log statement, or renamed an internal variable, the test is too tightly coupled. Test inputs and outputs, not internal wiring.
Let integration tests cover the seams. Unit tests with mocks verify that individual components behave correctly in isolation. Integration tests verify that the components actually work together. You need both. A test suite with 100% unit coverage and zero integration tests is a liability.
Keep mock setup shorter than the test itself. If your mock configuration is 30 lines and your assertion is 2 lines, the test is telling you the code has too many dependencies or the unit is too large. Refactor the code before adding more mocks.
Name your test doubles clearly. Use
fakeUserRepo,mockPaymentGateway,stubLoggerrather than generic names likemock1,m, ordeps. When a test fails six months later, the person debugging it needs to understand what each double represents immediately.
References
- Meszaros, G. xUnit Test Patterns: Refactoring Test Code. Addison-Wesley, 2007.
- Jest Documentation: Mock Functions - https://jestjs.io/docs/mock-functions
- Sinon.JS Documentation - https://sinonjs.org/releases/latest/
- Nock: HTTP Server Mocking - https://github.com/nock/nock
- Fowler, M. "Mocks Aren't Stubs" - https://martinfowler.com/articles/mocksArentStubs.html
- Node.js Testing Best Practices - https://github.com/goldbergyoni/nodebestpractices#4-testing-and-overall-quality-practices
- Supertest: HTTP Assertions - https://github.com/ladjs/supertest
