Sinon.js Spies, Stubs & Sandboxes Explained
Sinon.js predates Jest's built-in mocking by years, and it still shows up in large codebases — particularly those using Mocha, Jasmine, or any test runner without first-class mock support. Understanding Sinon is also useful as a conceptual foundation: the spy/stub/mock distinction it makes explicit is often blurred in Jest, which encourages collapsing all three into jest.fn().
The Three Concepts: Spy, Stub, Mock
Sinon is opinionated about terminology in a way Jest isn't:
Spy: wraps a real function. The original runs; Sinon records what happened.
Stub: replaces a function completely. The original never runs. You control behavior.
Mock: a stub with pre-declared expectations. You declare what should happen before the call, then verify afterward.
Using the wrong one leads to either tests that let bad behavior through (spy when you needed a stub) or false failures (mock expectations that don't match production flow).
sinon.spy() — Tracking Function Calls
const sinon = require('sinon');
const user = {
save: function (data) {
// real database write
return db.insert(data);
},
};
const spy = sinon.spy(user, 'save');
await user.save({ name: 'Alice' });
console.log(spy.callCount); // 1
console.log(spy.calledOnce); // true
console.log(spy.firstCall.args); // [{ name: 'Alice' }]
console.log(spy.calledWith({ name: 'Alice' })); // trueThe real user.save runs. The spy is invisible from the perspective of the code under test. Use spies when you want to confirm side effects — that a function was called, with what arguments, how many times — without interfering with behavior.
Spy Properties Reference
spy.callCount // number of times called
spy.calledOnce // boolean
spy.calledTwice
spy.calledThrice
spy.calledWith(...args) // true if any call matches
spy.alwaysCalledWith(...args)
spy.calledWithExactly(...args)
spy.firstCall // SinonSpyCall object
spy.lastCall
spy.getCall(n) // nth call (0-indexed)
spy.returnValues // array of return values
spy.exceptions // array of thrown errorsStandalone Spy Functions
You can create a spy without wrapping an existing function, similar to jest.fn():
const callback = sinon.spy();
button.on('click', callback);
triggerClick();
expect(callback.calledOnce).to.be.true;sinon.stub() — Controlling Behavior
Stubs replace a function entirely. The original never runs. This is what you want when:
- The real function has slow I/O (network, disk, database)
- The real function has side effects you need to suppress
- You need to simulate error conditions
const emailService = require('./emailService');
const stub = sinon.stub(emailService, 'send');
stub.returns({ messageId: 'mock-123' });
await processOrder(order); // emailService.send will NOT actually send
expect(stub.calledOnce).to.be.true;
expect(stub.calledWith(sinon.match({ to: order.email }))).to.be.true;Stub Behavior Methods
// Synchronous returns
stub.returns(value);
stub.returnsArg(0); // returns the first argument
stub.returnsThis(); // returns `this` (for chaining APIs)
// Promise-based
stub.resolves(value);
stub.rejects(new Error('DB down'));
stub.rejects('NetworkError'); // creates Error with that name
// Sequential behavior
stub.onFirstCall().returns('first');
stub.onSecondCall().returns('second');
stub.onCall(2).returns('third');
stub.onCall(3).resolves('async-fourth');stub.yields() and stub.callsArg()
For functions that accept callbacks:
// Call the first callback argument with these values
stub.yields(null, { id: 1 });
// Equivalent but async
stub.yieldsAsync(null, { id: 1 });
// Call the callback at index 1 (second argument)
stub.callsArg(1);
// Call it with specific args
stub.callsArgWith(1, null, 'result');const readFile = sinon.stub(fs, 'readFile');
readFile.yields(null, Buffer.from('file content'));
fs.readFile('/etc/hosts', (err, data) => {
console.log(data.toString()); // 'file content'
});Conditional Behavior with withArgs()
const stub = sinon.stub(db, 'query');
stub.withArgs('SELECT * FROM users').resolves([{ id: 1 }]);
stub.withArgs('SELECT * FROM orders').resolves([{ orderId: 99 }]);
stub.returns([]); // default for unmatched callsThis is more readable than a single implementation function with conditionals.
sinon.mock() — Strict Expectations
Mocks combine stubbing with upfront expectation declaration. You define what must happen before the test runs, then verify at the end. They're strict — any deviation causes a failure.
const emailService = require('./emailService');
const mock = sinon.mock(emailService);
// Declare expectations before the action
mock.expects('send')
.once()
.withArgs(sinon.match({ to: 'user@example.com' }))
.resolves({ messageId: 'abc' });
await signupUser('user@example.com');
mock.verify(); // throws if expectations weren't met
mock.restore();Mock Expectation Methods
mock.expects('methodName')
.once() // must be called exactly once
.twice()
.atLeast(2)
.atMost(3)
.never() // must NOT be called
.exactly(n)
.withArgs(...args)
.withExactArgs(...args)
.returns(value)
.resolves(value)
.rejects(error);When to use mocks vs stubs: Mocks are best when the interaction itself is the behavior under test — "this function must call the mailer exactly once with these arguments." For most cases, stubs with separate assertions (using Chai or Node's built-in assert) are more readable and less brittle.
Sandboxes for Automatic Cleanup
The most important Sinon pattern for production test suites is the sandbox. Without it, you're manually tracking every spy and stub created across a test, and if a test throws before the restore calls, you leak mocks into subsequent tests.
const sinon = require('sinon');
describe('OrderService', () => {
let sandbox;
beforeEach(() => {
sandbox = sinon.createSandbox();
});
afterEach(() => {
sandbox.restore(); // restores ALL spies, stubs, mocks created via sandbox
});
it('sends a confirmation email', async () => {
const emailStub = sandbox.stub(emailService, 'send').resolves(true);
const dbStub = sandbox.stub(db, 'save').resolves({ id: 1 });
await orderService.create({ userId: 1, items: [] });
expect(emailStub.calledOnce).to.be.true;
expect(dbStub.calledOnce).to.be.true;
});
});sandbox.restore() in afterEach guarantees cleanup regardless of whether the test passed, failed, or threw. No manual spy.restore() or stub.restore() calls needed.
Sandbox Shorthand with useFakeTimers
Sandboxes also manage fake timers:
beforeEach(() => {
sandbox = sinon.createSandbox({ useFakeTimers: true });
});
it('debounces calls', () => {
const handler = sandbox.spy();
const debounced = debounce(handler, 300);
debounced();
debounced();
debounced();
sandbox.clock.tick(299);
expect(handler.callCount).to.equal(0);
sandbox.clock.tick(1);
expect(handler.callCount).to.equal(1);
});Integrating with Mocha and Jest
Sinon is test-runner agnostic. It works with Mocha, Jest, Jasmine, or plain Node.js.
With Mocha (most common):
const sinon = require('sinon');
const { expect } = require('chai');
describe('UserService', () => {
let sandbox;
beforeEach(() => { sandbox = sinon.createSandbox(); });
afterEach(() => { sandbox.restore(); });
it('creates a user', async () => {
sandbox.stub(db, 'insert').resolves({ id: 42 });
const user = await userService.create({ name: 'Alice' });
expect(user.id).to.equal(42);
});
});With Jest (less common but valid):
const sinon = require('sinon');
describe('UserService', () => {
let sandbox;
beforeEach(() => { sandbox = sinon.createSandbox(); });
afterEach(() => { sandbox.restore(); });
test('creates a user', async () => {
sandbox.stub(db, 'insert').resolves({ id: 42 });
const user = await userService.create({ name: 'Alice' });
expect(user.id).toBe(42);
});
});You mix Sinon's spy/stub creation with whichever assertion library you prefer.
sinon-chai: Better Assertions
The sinon-chai package adds Chai-style matchers for Sinon:
const chai = require('chai');
const sinonChai = require('sinon-chai');
chai.use(sinonChai);
const { expect } = chai;Now instead of:
expect(stub.calledOnce).to.be.true;
expect(stub.calledWith('arg')).to.be.true;You write:
expect(stub).to.have.been.calledOnce;
expect(stub).to.have.been.calledWith('arg');
expect(stub).to.have.been.calledWithExactly('arg1', 'arg2');
expect(stub).to.have.been.calledBefore(otherStub);
expect(stub).to.have.been.calledAfter(otherStub);The error messages are also better. AssertionError: expected spy to have been called once, but was called twice is far more useful than AssertionError: expected false to equal true.
Available matchers include:
.calledOnce / .calledTwice / .calledThrice
.called
.callCount
.calledWith(...args)
.calledWithMatch(obj) // deep partial match
.calledWithExactly(...args)
.returned(value)
.threw()
.threw('ErrorName')
.calledBefore(other)
.calledAfter(other)
.calledImmediatelyBefore(other)
.calledImmediatelyAfter(other)sinon.match() — Flexible Argument Matching
When exact argument matching is too strict:
stub.calledWith(sinon.match({ userId: 1 })); // object containing userId: 1
stub.calledWith(sinon.match.string); // any string
stub.calledWith(sinon.match.number);
stub.calledWith(sinon.match.func);
stub.calledWith(sinon.match.truthy);
stub.calledWith(sinon.match(/pattern/)); // regex
stub.calledWith(sinon.match(v => v > 0, 'positive number')); // custom predicateMatchers compose:
sinon.match({ id: sinon.match.number, name: sinon.match.string })Common Pitfalls
Forgetting to restore: Always use a sandbox. Leaked stubs change module behavior for subsequent tests in the same process.
Stubbing imported references: Sinon stubs work on object properties. If your module caches a reference to a function at import time (e.g., const send = require('./email').send), stubbing emailModule.send won't affect the cached reference. Structure modules so they always call through the object, or restructure the code to be more testable.
Mocks with too many expectations: Mock objects with 10 chained expectations are hard to read and fragile. Prefer stubs + explicit assertions for complex scenarios.
Using returns with async code: If the real function is async, use resolves/rejects. Using returns(Promise.resolve(x)) works but is verbose. resolves(x) is the clean way.
Sinon rewards a disciplined approach: spies for observation, stubs for isolation, mocks only when the interaction is the contract. Add a sandbox to every test suite and you eliminate an entire class of test pollution bugs.