Skip to main content

sinon vs jest.mock vs vi.fn: Mocking in JavaScript Testing (2026)

·PkgPulse Team

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.mock are hoisted to the top of the file — order doesn't matter
  • Fake timers (sinon/jest/vi) replace setTimeout, Date for 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"))
  })
})
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

Featuresinonjest.fn/mockvi.fn/mock
FrameworkAny (Mocha, node:test)Jest onlyVitest 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 testsManual (sandbox)clearAllMocksclearAllMocks
Weekly downloads~3Mbundled with Jestbundled 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

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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.