TL;DR
sinon is a standalone mocking library — works with any test framework (Mocha, Jasmine, Node test runner), provides spies, stubs, mocks, and fake timers in one package. jest.mock is Jest's built-in module mocking system — auto-mocks entire modules, works at the module resolution level (not just function level). vi.fn / vi.mock is Vitest's equivalent — nearly identical API to Jest but faster, ESM-native, and integrates with Vite. In 2026: use vi.fn/vi.mock for Vitest projects (the new standard), jest.mock for Jest projects, sinon when you're using a non-Jest/Vitest framework or need standalone mocking.
Key Takeaways
- sinon: ~3M weekly downloads — framework-agnostic, standalone spies/stubs/mocks/fakes
- jest-mock (jest.mock): bundled with Jest — module-level mocking,
jest.fn(),jest.spyOn() - vitest (vi.fn): bundled with Vitest — Jest-compatible API, ESM-native, faster HMR
- sinon works with any test runner; jest.mock / vi.mock require Jest / Vitest respectively
jest.mock/vi.mockare hoisted to the top of the file — order doesn't matter- Fake timers (sinon/jest/vi) replace
setTimeout,Datefor deterministic time-based tests
Core Concepts
Spy → wraps a real function, records calls, doesn't change behavior
Stub → replaces a function with a configurable fake, controls return values
Mock → strict spy/stub with built-in assertion expectations
Fake → complete replacement (fake timers, fake XHR)
Module mock → replaces an entire module (all exports) at resolution time
sinon
sinon — standalone test doubles library:
Spies
import sinon from "sinon"
import { describe, it, before, after } from "mocha"
import assert from "node:assert"
describe("PackageService", () => {
it("calls npm API once per package", async () => {
const service = new PackageService()
// Spy wraps the real method — still executes:
const spy = sinon.spy(service, "fetchFromNpm")
await service.getHealthScore("react")
// Assert on call behavior:
assert.ok(spy.calledOnce)
assert.ok(spy.calledWith("react"))
assert.ok(spy.returned(sinon.match.object)) // Return value was an object
spy.restore() // Restore original method
})
})
Stubs
import sinon from "sinon"
describe("HealthScoreCalculator", () => {
let npmStub: sinon.SinonStub
let githubStub: sinon.SinonStub
before(() => {
// Replace real API calls with controlled fakes:
npmStub = sinon.stub(npmClient, "getDownloads").resolves({
weekly: 5_000_000,
monthly: 20_000_000,
})
githubStub = sinon.stub(githubClient, "getRepoStats").resolves({
stars: 224_000,
openIssues: 847,
lastCommit: new Date("2026-01-15"),
})
})
after(() => {
npmStub.restore()
githubStub.restore()
})
it("calculates score from download and star data", async () => {
const score = await calculator.compute("react")
assert.strictEqual(score, 92.5)
// Assert stub was called:
assert.ok(npmStub.calledOnceWith("react"))
assert.ok(githubStub.calledOnceWith("facebook", "react"))
})
it("returns 0 on npm API error", async () => {
npmStub.rejects(new Error("npm API down"))
const score = await calculator.compute("react")
assert.strictEqual(score, 0)
})
})
Stub return value patterns
const stub = sinon.stub()
// Always return a value:
stub.returns(42)
stub.resolves({ data: "ok" }) // For async
// Return different values on consecutive calls:
stub.onFirstCall().returns(1)
stub.onSecondCall().returns(2)
stub.onThirdCall().throws(new Error("rate limit"))
// Return based on arguments:
stub.withArgs("react").returns({ score: 92 })
stub.withArgs("vue").returns({ score: 89 })
stub.withArgs(sinon.match.string).returns({ score: 50 }) // Default
// Yield to callback (for callback-style APIs):
stub.yields(null, { data: "result" }) // callback(null, data)
stub.callsFake((name, cb) => cb(null, { name }))
Fake timers
import sinon from "sinon"
describe("RateLimiter", () => {
let clock: sinon.SinonFakeTimers
before(() => {
// Replace setTimeout, setInterval, Date with fakes:
clock = sinon.useFakeTimers(new Date("2026-01-01T00:00:00Z"))
})
after(() => {
clock.restore()
})
it("resets window after 1 minute", async () => {
const limiter = new RateLimiter({ windowMs: 60_000, max: 100 })
// Exhaust the limit:
for (let i = 0; i < 100; i++) {
limiter.hit("user-123")
}
assert.ok(limiter.isLimited("user-123"))
// Fast-forward 60 seconds:
clock.tick(60_000)
// Window reset:
assert.ok(!limiter.isLimited("user-123"))
})
})
Sandbox (recommended for cleanup)
import sinon from "sinon"
describe("with sandbox", () => {
const sandbox = sinon.createSandbox()
afterEach(() => {
// Restore ALL stubs/spies created in this sandbox at once:
sandbox.restore()
})
it("stubs database", () => {
const dbStub = sandbox.stub(db, "query").resolves([{ id: 1 }])
const cacheStub = sandbox.stub(cache, "get").returns(null)
// No need to call restore() individually — sandbox handles it
})
})
jest.mock / jest.fn
Jest — built-in mocking:
jest.fn() — mock a function
import { describe, it, expect, jest } from "@jest/globals"
describe("PackageService", () => {
it("calls the fetch function", async () => {
// Create a mock function:
const mockFetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({ downloads: 5_000_000 }),
})
const service = new PackageService(mockFetch)
await service.getDownloads("react")
// Built-in matchers:
expect(mockFetch).toHaveBeenCalledOnce()
expect(mockFetch).toHaveBeenCalledWith(
"https://api.npmjs.org/downloads/point/last-week/react"
)
})
})
jest.mock() — auto-mock a module
// jest.mock is HOISTED — works even if called after imports:
jest.mock("../src/npmClient")
jest.mock("../src/githubClient", () => ({
getRepoStats: jest.fn().mockResolvedValue({ stars: 224_000 }),
}))
import { getDownloads } from "../src/npmClient"
import { getRepoStats } from "../src/githubClient"
import { HealthScoreCalculator } from "../src/calculator"
describe("HealthScoreCalculator", () => {
beforeEach(() => {
jest.clearAllMocks()
// Type assertion needed after jest.mock:
;(getDownloads as jest.Mock).mockResolvedValue({ weekly: 5_000_000 })
})
it("computes score from npm and github data", async () => {
const score = await HealthScoreCalculator.compute("react")
expect(score).toBe(92.5)
expect(getDownloads).toHaveBeenCalledWith("react")
expect(getRepoStats).toHaveBeenCalledWith("facebook", "react")
})
})
jest.spyOn() — spy on real methods
import * as fs from "node:fs"
describe("ConfigLoader", () => {
it("reads the config file", () => {
// Spy wraps the real function (still executes by default):
const readSpy = jest.spyOn(fs, "readFileSync").mockReturnValue(
JSON.stringify({ theme: "dark" })
)
const config = ConfigLoader.load("./.config.json")
expect(readSpy).toHaveBeenCalledWith("./.config.json", "utf-8")
expect(config.theme).toBe("dark")
readSpy.mockRestore()
})
})
Fake timers
describe("Debouncer", () => {
beforeEach(() => {
jest.useFakeTimers()
})
afterEach(() => {
jest.useRealTimers()
})
it("debounces rapidly fired calls", () => {
const callback = jest.fn()
const debounced = debounce(callback, 300)
debounced()
debounced()
debounced()
expect(callback).not.toHaveBeenCalled()
jest.advanceTimersByTime(300)
// Only called once after the debounce delay:
expect(callback).toHaveBeenCalledOnce()
})
})
vi.fn / vi.mock (Vitest)
Vitest — Jest-compatible API with Vite:
vi.fn() — identical to jest.fn()
import { describe, it, expect, vi, beforeEach } from "vitest"
describe("PackageService", () => {
it("fetches package data", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ name: "react", version: "19.0.0" }),
})
globalThis.fetch = mockFetch
const data = await PackageService.fetch("react")
expect(mockFetch).toHaveBeenCalledOnce()
expect(data.name).toBe("react")
})
})
vi.mock() — module mocking
import { describe, it, expect, vi, beforeEach } from "vitest"
// Auto-mock: replaces all exports with vi.fn():
vi.mock("../src/database")
// Factory function: define specific mock implementations:
vi.mock("../src/npmClient", () => ({
getNpmData: vi.fn(),
getDownloads: vi.fn(),
}))
import { getNpmData } from "../src/npmClient"
import { db } from "../src/database"
describe("PackageRepository", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getNpmData).mockResolvedValue({ downloads: 5_000_000 })
vi.mocked(db.query).mockResolvedValue([{ id: 1, name: "react" }])
})
it("combines npm and db data", async () => {
const result = await PackageRepository.getEnriched("react")
expect(result.downloads).toBe(5_000_000)
expect(getNpmData).toHaveBeenCalledWith("react")
})
})
vi.importActual — partial mocking
// Mock only specific exports, keep the rest real:
vi.mock("../src/utils", async (importActual) => {
const actual = await importActual<typeof import("../src/utils")>()
return {
...actual,
// Only mock this one function:
fetchWithRetry: vi.fn().mockResolvedValue({ data: "mocked" }),
}
})
Fake timers
import { vi } from "vitest"
describe("CacheExpiry", () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"))
})
afterEach(() => {
vi.useRealTimers()
})
it("expires cache after TTL", () => {
const cache = new Cache({ ttl: 60_000 })
cache.set("key", "value")
vi.advanceTimersByTime(61_000)
expect(cache.get("key")).toBeUndefined()
})
})
Feature Comparison
| Feature | sinon | jest.fn/mock | vi.fn/mock |
|---|---|---|---|
| Framework | Any (Mocha, node:test) | Jest only | Vitest only |
| Module mocking | ❌ (function-level only) | ✅ | ✅ |
| Spy / Stub / Mock | ✅ Full suite | ✅ (jest.fn, spyOn) | ✅ (vi.fn, spyOn) |
| Fake timers | ✅ | ✅ | ✅ |
| Fake XHR / server | ✅ (sinon-fakeserver) | ❌ | ❌ |
| ESM support | ✅ | ⚠️ (requires config) | ✅ Native |
| TypeScript types | ✅ | ✅ | ✅ |
| Auto-reset between tests | Manual (sandbox) | clearAllMocks | clearAllMocks |
| Weekly downloads | ~3M | bundled with Jest | bundled with Vitest |
When to Use Each
Choose sinon if:
- Using Mocha, node:test, or any non-Jest/Vitest framework
- Need fake XMLHttpRequest or fetch server for browser testing
- Migrating legacy Mocha test suites
- Want framework-agnostic mocking that works everywhere
Choose jest.fn / jest.mock if:
- Already using Jest (CRA projects, older Next.js setups)
- Need
jest.mock()hoisting to mock complex module graphs - Team is familiar with Jest's assertion API
Choose vi.fn / vi.mock if:
- Using Vitest (the recommended choice for new projects in 2026)
- Same API as Jest but faster, native ESM, works with Vite
- Building a Vite/SvelteKit/Nuxt/Astro project
Module Mocking Internals: Hoisting and ESM Challenges
One of the most important behavioral differences between these libraries is how — and whether — they can mock ES modules, and understanding the mechanics explains why the migration from Jest to Vitest is usually smooth but sinon users face more friction.
jest.mock() and vi.mock() are both hoisted to the top of the file by their respective test runners before the module's imports are resolved. This hoisting is performed by a Babel transform (for Jest) or Vite's module resolution layer (for Vitest). The practical effect is that even if you write import { fetchPackage } from './api' before jest.mock('./api') in your test file, the mock registration happens first, so the imported fetchPackage is already the mocked version. This hoisting is what makes module-level mocking work at all — without it, the real module would be imported before you could install a mock.
ESM complicates this significantly. Native ESM bindings are live and read-only, which means you cannot simply reassign an exported function the way CommonJS allows. Jest's support for native ESM (without Babel transforming it back to CJS) requires using jest.unstable_mockModule() and dynamic imports in your test file, which is a different and more verbose API. Vitest handles this more cleanly because Vite's module resolution pipeline intercepts imports and applies vi.mock() substitutions within its own ESM-aware module system. For teams using native ESM in 2026, Vitest is the substantially better experience.
Sinon does not perform module-level mocking at all. It operates at the object-property level: sinon.stub(obj, 'method') replaces obj.method with a stub. This means sinon requires dependency injection or careful module design to be effective — if your code imports a function directly (import { sendEmail } from './mailer') rather than calling it through an injectable object, sinon cannot intercept it without module-level help. Libraries like proxyquire or rewire can fill this gap for CommonJS, but for ESM the story is messy. Sinon is most effective in architectures where dependencies are passed as arguments or accessed through service objects rather than imported directly.
Assertion Ergonomics and the Spy API
Beyond the mock registration mechanism, the day-to-day experience of writing assertions against mocks differs meaningfully across the three libraries.
Sinon separates the spy assertion API from the main assert module. You use spy.calledOnce, spy.calledWith('arg'), and spy.returned(value) as boolean properties, often combined with assert.ok() from Node's built-in assert module or sinon's own sinon.assert.calledWith(spy, 'arg'). This works well with any assertion library but results in more verbose test code compared to Jest and Vitest's fluent expect().toHaveBeenCalledWith() syntax.
jest.fn() and vi.fn() integrate directly with their framework's expect API. expect(mockFn).toHaveBeenCalledTimes(1), expect(mockFn).toHaveBeenCalledWith('react'), and expect(mockFn).toHaveReturnedWith(42) read naturally and produce helpful failure messages that include the actual call arguments. The vi.mocked() helper in Vitest adds TypeScript type narrowing — it takes a real-typed function and tells TypeScript to treat it as a MockedFunction<typeof fn>, enabling autocomplete on mock methods without manual type assertions.
One sinon advantage that neither Jest nor Vitest replicates is the sinon.match API for flexible argument matching. sinon.match.object, sinon.match.has('key', value), sinon.match.typeOf('string'), and the ability to compose matchers with sinon.match.and() and sinon.match.or() provide fine-grained partial matching. Jest's expect.objectContaining() and expect.stringContaining() cover most cases, but sinon's matchers are composable in ways Jest's asymmetric matchers are not.
Migrating from sinon to vi.fn in Vitest Projects
Teams migrating from Mocha + sinon to Vitest encounter a translation layer that is mostly mechanical but has a few sharp edges worth knowing.
The common stub patterns map directly: sinon.stub().returns(value) becomes vi.fn().mockReturnValue(value), and sinon.stub().resolves(value) becomes vi.fn().mockResolvedValue(value). Sinon's onFirstCall().returns(x) pattern maps to vi.fn().mockReturnValueOnce(x). Sinon sandboxes become Vitest's beforeEach(() => vi.clearAllMocks()) combined with vi.restoreAllMocks() in afterEach.
The genuinely hard migration cases involve sinon's yields and callsFake patterns for callback-style APIs, sinon's fake XHR server (which has no equivalent in Vitest — use msw instead for HTTP mocking), and code that relies on sinon's spy.thisValues tracking to assert what this was bound to. For most application code that uses promises and dependency injection, the migration is straightforward and the resulting tests are cleaner.
Testing Time-Dependent Code with Fake Timers
Fake timers are one of the most valuable and underused features across all three libraries. Code that uses setTimeout, setInterval, Date.now(), or performance.now() is inherently non-deterministic in tests — real timers make tests slow (waiting for actual delays) and flaky (depending on system timing). Fake timers replace the entire timing infrastructure with controllable versions: you can advance time by exactly 1000ms, trigger all pending timers, or set the system clock to a specific date. sinon's useFakeTimers() accepts an initial date argument for pinning Date.now() to a specific moment — essential for testing code that formats dates or checks token expiry. Vitest's vi.setSystemTime() does the same. The common pitfall with all three is forgetting to restore real timers between tests — a test that installs fake timers and fails without restoring them leaves subsequent tests with stale fake timers, causing silent test-order dependencies. Configuring restoreAllMocks: true in Vitest or using afterEach(() => vi.useRealTimers()) prevents this class of intermittent failures.
Performance Impact of Mocking on Test Suites
The choice of mocking library affects test suite performance in ways that compound across large codebases. sinon's sandbox restore mechanism calls the original restore() on each stub individually, which is lightweight but requires discipline to invoke in afterEach. Forgetting to restore leaves stubs active across test files, causing hard-to-diagnose intermittent failures in large suites. Jest's jest.clearAllMocks(), jest.resetAllMocks(), and jest.restoreAllMocks() are configurable globally in jest.config.js via clearMocks, resetMocks, and restoreMocks booleans — enabling these globally eliminates the entire class of forgotten-restore bugs without per-test cleanup code. Vitest has the same three options in vitest.config.ts. The clearMocks option resets call history but preserves implementations; resetMocks also removes implementations; restoreMocks calls mockRestore() to return to the original implementation. For most projects, clearMocks: true is the right default — it prevents test pollution while keeping mock implementations intact for each test file's setup.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on sinon v18.x, jest v29.x, and vitest v3.x.
Compare testing libraries and developer tooling on PkgPulse →
See also: AVA vs Jest and Jest vs Vitest, acorn vs @babel/parser vs espree.