Sinon.js: Stubs, Spies, and Mocks for JavaScript Testing
Test isolation is one of the hardest problems in JavaScript testing. Your code calls third-party APIs, reads the filesystem, fires timers, and makes network requests — none of which you want running in a unit test. Sinon.js has been the go-to solution for this for over a decade, providing spies, stubs, mocks, fake timers, and fake XHR in a single library.
This guide covers the core Sinon.js primitives with real examples, shows how to avoid the most common pitfalls, and explains how to use Sinon alongside both Mocha and Jest.
Installation and Setup
npm install --save-dev sinon
# TypeScript types
npm install --save-dev @types/sinonSinon is framework-agnostic. It works with Mocha, Jest, Jasmine, or any other test runner. With Jest, you typically import it directly:
import sinon from 'sinon';With Mocha, the setup is identical — Sinon does not hook into your test runner automatically.
Spies: Observing Real Behavior
A spy wraps a function without changing its behavior. It records calls, arguments, return values, and exceptions so you can assert on them afterward.
import sinon from 'sinon';
const userService = {
sendWelcomeEmail(email: string): void {
console.log(`Sending to ${email}`);
},
};
const spy = sinon.spy(userService, 'sendWelcomeEmail');
userService.sendWelcomeEmail('alice@example.com');
console.log(spy.calledOnce); // true
console.log(spy.calledWith('alice@example.com')); // true
console.log(spy.callCount); // 1
spy.restore(); // always restore spiesSpies are useful when you want to verify a function was called with specific arguments but still want its original implementation to run — for example, testing that a logger is invoked without suppressing the actual logging.
Spy Assertions
spy.calledOnce // called exactly once
spy.calledTwice // called exactly twice
spy.calledWithExactly('a', 'b') // strict argument match
spy.returnedWith(42) // return value check
spy.threw() // whether it threw
spy.firstCall.args // arguments of the first invocationStubs: Replacing Functions Entirely
Stubs replace a function's implementation entirely. They do nothing by default, but you can configure them to return values, throw errors, or call callbacks.
import sinon from 'sinon';
import * as db from './database';
const stub = sinon.stub(db, 'getUserById');
// Return a fixed value
stub.returns({ id: 1, name: 'Alice' });
const user = db.getUserById(1);
console.log(user.name); // 'Alice'
// Async stubs
stub.resolves({ id: 1, name: 'Alice' });
stub.rejects(new Error('DB connection failed'));
stub.restore();Conditional Stub Behavior
Stubs can behave differently based on arguments:
const stub = sinon.stub(db, 'getUserById');
stub.withArgs(1).returns({ id: 1, name: 'Alice' });
stub.withArgs(2).returns({ id: 2, name: 'Bob' });
stub.throws(new Error('User not found')); // default for other argsThis is invaluable when the same function is called multiple times with different inputs in a single test.
Mocks: Expectations on Behavior
Mocks combine stubs with built-in expectations. Instead of asserting after the fact, you declare what you expect before the code runs, then verify:
import sinon from 'sinon';
import * as mailer from './mailer';
const mock = sinon.mock(mailer);
mock.expects('send').once().withArgs('alice@example.com', sinon.match.string);
// Run the code under test
await registerUser({ email: 'alice@example.com' });
mock.verify(); // throws if expectations weren't met
mock.restore();Mocks are best for testing that a collaborator is called in a specific way. However, many teams prefer stubs + explicit assertions because the failure messages are clearer. Use mocks sparingly.
Fake Timers
setTimeout, setInterval, Date, and process.hrtime are notoriously hard to test. Sinon's fake timers replace them with controllable versions:
import sinon from 'sinon';
let clock: sinon.SinonFakeTimers;
beforeEach(() => {
clock = sinon.useFakeTimers();
});
afterEach(() => {
clock.restore();
});
it('calls the callback after 1 second', () => {
const callback = sinon.spy();
setTimeout(callback, 1000);
clock.tick(999);
expect(callback.called).toBe(false);
clock.tick(1);
expect(callback.calledOnce).toBe(true);
});You can also fake Date.now():
const clock = sinon.useFakeTimers(new Date('2024-01-15').getTime());
console.log(new Date().toISOString()); // 2024-01-15T00:00:00.000ZThis eliminates flaky tests that depend on the current date.
Fake XHR and Fetch
For testing code that makes HTTP requests without a real network:
import sinon from 'sinon';
let server: sinon.SinonFakeServer;
beforeEach(() => {
server = sinon.fakeServer.create();
});
afterEach(() => {
server.restore();
});
it('parses user data from the API', async () => {
server.respondWith('GET', '/api/users/1', [
200,
{ 'Content-Type': 'application/json' },
JSON.stringify({ id: 1, name: 'Alice' }),
]);
const promise = fetchUser(1);
server.respond();
const user = await promise;
expect(user.name).toBe('Alice');
});For modern fetch-based code, use sinon.stub(global, 'fetch') instead, since fake XHR intercepts XHR only.
Using Sinon with Jest
Jest has its own mocking system, but Sinon's fake timers and fake server fill gaps that Jest doesn't cover as cleanly. A common pattern is to use Jest for assertions and module mocking, and Sinon for timers and XHR:
// jest.config.ts
// No special config needed — sinon works out of the box
import sinon from 'sinon';
describe('retry logic', () => {
let clock: sinon.SinonFakeTimers;
beforeEach(() => { clock = sinon.useFakeTimers(); });
afterEach(() => { clock.restore(); });
it('retries after 500ms', async () => {
const fetchStub = sinon.stub(global, 'fetch')
.onFirstCall().rejects(new Error('timeout'))
.onSecondCall().resolves(new Response('{"ok":true}'));
const promise = fetchWithRetry('/api/data');
await clock.tickAsync(500);
const result = await promise;
expect(result.ok).toBe(true);
fetchStub.restore();
});
});Avoiding the Most Common Pitfalls
Always restore stubs. If a stub or spy is not restored, it bleeds into other tests:
// Use a sandbox to auto-restore everything
const sandbox = sinon.createSandbox();
afterEach(() => {
sandbox.restore(); // restores all stubs/spies/mocks created in this sandbox
});
it('sends email', () => {
const stub = sandbox.stub(mailer, 'send').resolves();
// test...
});Don't over-stub. Stubs that mirror the real implementation just hide bugs. Stub at the boundary (HTTP, DB, filesystem), not inside your own code.
Prefer stubs over mocks for readability. Mock failure messages like "expected send to be called once but was called 0 times" are harder to debug than a plain expect(stub.calledOnce).toBe(true) with a clear test name.
Summary
Sinon.js gives you three levels of test isolation: spies for observation, stubs for replacement, and mocks for pre-declared expectations. Combined with fake timers and fake XHR, it handles virtually every external dependency you'll encounter. The sandbox pattern keeps cleanup automatic and prevents test pollution. Whether you're on Mocha, Jest, or anything else, Sinon integrates cleanly and remains one of the most battle-tested testing utilities in the JavaScript ecosystem.