sinon
Sinon gives you test doubles that do not care which test runner you use: spies that record how a function was called, stubs that replace a method with programmable behavior, mocks that assert expectations up front, and fake timers that let you control Date, setTimeout, setInterval, and the microtask queue. It also ships matchers for loose argument checks and its own assertion helpers that print the calls that actually happened when something fails. It works in Node and in browsers, and it predates the mocking built into modern runners, which is both why it exists and why fewer new projects reach for it.
Still the best standalone mocking library in JavaScript and the right answer for Mocha, node:test, and browser suites. If your runner already has vi.fn or jest.fn, installing Sinon on top of it buys you argument-level stub behavior at the cost of a second API your team has to learn.
Use it if
- Your runner has no mocking of its own: Mocha, AVA, node:test, Karma, or a plain browser test page
- You need stub behavior that varies by argument or call index: withArgs, onCall, resolves, rejects, yields, and callsFake all compose on one stub
- You need deep control of time, including Date, timers, requestAnimationFrame, process.nextTick, and the Temporal API through fake-timers 15
- You want failure messages that show the recorded calls, which sinon.assert prints and a bare boolean assertion does not
- You already run Vitest or Jest: vi.fn, vi.spyOn, vi.useFakeTimers and their Jest equivalents cover the same ground, and adding Sinon means two mocking vocabularies in one suite
- Your code under test is native ESM: stubbing a module namespace throws "ES Modules cannot be stubbed" because namespace properties are non-writable by spec, and the documented workaround is running your tests through the third-party esm loader with mutableNamespace
- You need to swap out whole modules rather than object methods: Sinon replaces properties on objects you hand it, so module graph mocking needs proxyquire, esmock, or your runner
- You are upgrading from an old version: Sinon 19 made fake-timers fake process.nextTick and queueMicrotask by default, which silently stops awaited promises from resolving in tests that used to pass
- You want a slow-moving dependency: majors 17 through 22 shipped between October 2023 and May 2026, each dropping older Node versions
- You write TypeScript and dislike extra installs: the package ships no type definitions, so you also need @types/sinon from DefinitelyTyped
Setup reality
npm i -D sinon and there are no peer dependencies to satisfy. The package exports both builds, CommonJS at lib/sinon.js and ESM at pkg/sinon-esm.js, so a default import works either way; note that lib/ is generated and not committed, which matters only if you install from a git ref. The real setup cost is restore discipline. Every spy or stub you attach to a shared object stays attached until something restores it, so an afterEach calling sinon.restore(), or an explicit sinon.createSandbox() per file, is not optional; without it one test leaks into the next and the failure shows up somewhere unrelated. TypeScript users add @types/sinon separately. If you use fake timers, expect to convert tick() calls to tickAsync() or narrow the toFake list once you are on Sinon 19 or newer.
Patterns
Record calls without changing behaviorspy-on-method
import sinon from 'sinon'
const anonymous = sinon.spy()
const save = sinon.spy(repo, 'save')
await service.createUser({ name: 'ada' })
console.log(save.calledOnce, save.firstCall.args)
save.restore()A spy still runs the original method, so side effects such as real database writes still happen. Use a stub when you want the call blocked.
Replace a method with a fixed return valuestub-method
const findUser = sinon.stub(db, 'findUser').returns({ id: 1, name: 'ada' })
// restore a single stub
findUser.restore()Since Sinon 4, stubbing a property that does not exist throws instead of inventing one, so a renamed method fails the test loudly rather than quietly returning undefined.
Stub promise-returning functionsstub-async
sinon.stub(api, 'fetchUser').resolves({ id: 1 })
sinon.stub(api, 'deleteUser').rejects(new Error('gateway down'))
const stub = sinon.stub(api, 'listUsers')
stub.onFirstCall().rejects(new Error('flaky'))
stub.onSecondCall().resolves([])rejects() creates the rejected promise as soon as the stub is called, so a test that never awaits it can log an unhandled rejection warning even while passing.
Different behavior per argument or per callbehavior-per-argument
const stub = sinon.stub()
stub.returns('default')
stub.withArgs(42).returns('answer')
stub.withArgs(sinon.match.string).throws(new TypeError('numbers only'))
stub.onCall(2).returns('third time')
stub(1) // 'default'
stub(42) // 'answer'withArgs matches win over the default behavior, and onCall is zero indexed, so onCall(2) fires on the third invocation.
Restore everything between testssandbox-cleanup
const sandbox = sinon.createSandbox()
afterEach(() => {
sandbox.restore()
})
it('logs a warning', () => {
const warn = sandbox.stub(logger, 'warn')
doRiskyThing()
sinon.assert.calledOnce(warn)
})Since Sinon 5 the default export is itself a sandbox, so sinon.stub() plus sinon.restore() in afterEach also works. Skip the cleanup and stubs leak into every later test in the process.
Assert with readable failure outputassertions
sinon.assert.calledOnce(save)
sinon.assert.calledWithExactly(save, { id: 1, name: 'ada' })
sinon.assert.callOrder(validate, save, publish)
sinon.assert.notCalled(rollback)assert(save.calledWith(x)) only ever tells you it was false. sinon.assert prints every recorded call and the expected arguments, which is the difference between a five second and a twenty minute debug.
Match arguments looselyargument-matchers
sinon.assert.calledWith(save, sinon.match({ role: 'admin' }))
sinon.assert.calledWith(send, sinon.match.string, sinon.match.has('subject'))
sinon.assert.calledWith(track, sinon.match.number.and(sinon.match((n) => n > 0)))sinon.match({...}) is a partial deep match, so extra keys on the real argument are fine. Use calledWithExactly plus a full object when you want strictness.
Control time in a testfake-timers
const clock = sinon.useFakeTimers({
now: new Date('2026-01-01T00:00:00Z'),
toFake: ['setTimeout', 'setInterval', 'Date'],
})
scheduleRetry()
await clock.tickAsync(30_000)
clock.restore()From Sinon 19 onward every timer is faked by default, including process.nextTick and queueMicrotask. If awaited promises stop resolving, switch tick() to tickAsync() or narrow toFake as shown here.
Build a fully stubbed collaborator from a classstub-instance
class UserRepository {
async findById(id) {}
async save(user) {}
}
const repo = sinon.createStubInstance(UserRepository)
repo.findById.resolves({ id: 1 })
const service = new UserService(repo)The constructor is never invoked and only prototype methods are stubbed, so any field the real constructor would assign is undefined on the double.
Swap a plain property or a getterreplace-property
sinon.replace(config, 'apiUrl', 'http://localhost:1234')
sinon.replaceGetter(session, 'currentUser', () => ({ id: 1, role: 'admin' }))
sinon.replaceSetter(store, 'value', () => {})
sinon.restore()replace throws if the property is already replaced or is not configurable, which is deliberate: it stops two tests silently fighting over the same global.
Substitute your own implementation, or fall throughcalls-fake
const charge = sinon.stub(payments, 'charge')
charge.callsFake(async (amountCents) => ({ ok: true, amountCents }))
charge.withArgs(0).callThrough() // real method for this case onlycallThrough runs the wrapped original, so it only makes sense on a stub created from an existing object method, not on a bare sinon.stub().
Unwrap every double on one objectrestore-object
sinon.spy(mailer, 'send')
sinon.stub(mailer, 'queue').resolves()
sinon.restoreObject(mailer)Made idempotent in 22.1, so calling it twice is safe and a no-op on objects with nothing to restore. It lives in the Utilities section, which the docs warn is not covered by the public API guarantee.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| vitest | npm | You are picking a test stack now and want the runner, spies, fake timers, and module mocking from one project. |
| testdouble | npm | You prefer a smaller opinionated API with no spy/stub/mock distinction and terser failure output. |
| jest-mock | npm | You want Jest mock functions inside a non-Jest runner without adopting the rest of Jest. |