Skip to content

Mocking and Stubbing - Don't Touch the Network

Table of Contents


Why Mock : External Dependencies , Timing , Randomness

Real tests are deterministic. If your test hits a real API and that API is down , your test fails even though your code is fine. If your test depends on the current time , it passes at 3PM and fails at 4PM. If your test generates random data , you might hit a bug once every thousand runs

Mocks replace real dependencies with controlled simulations so your tests test the logic not the network

Things you should mock:

  • HTTP requests to external APIs
  • Database queries (in unit tests)
  • Filesystem operations
  • Random number generators
  • Date/time functions
  • Email/SMS delivery services

Things you should NOT mock:

  • Pure utility functions (math , string manipulation)
  • Your own value objects and data structures
  • Things you don't own the behavior of (mock the interface , not the implementation)

Mock vs Stub vs Spy vs Fake

Stub - provides canned answers to calls made during the test. You don't care how many times it's called or with what arguments. You just need it to return a specific value

// stub - just return a value
jest.spyOn(db, 'findUser').mockReturnValue({ id: 1, name: 'ali' })

Mock - stub plus expectations. You verify it was called with specific arguments , a specific number of times , in a specific order

// mock - stub + verification
const emailer = jest.fn()
emailer('ali@example.com', 'Welcome')
expect(emailer).toHaveBeenCalledWith('ali@example.com', expect.any(String))

Spy - wraps a real function. Tracks calls without replacing the implementation (unless you want to)

// spy - track calls on real method
const spy = jest.spyOn(console, 'log')
myFunction()
expect(console.log).toHaveBeenCalledTimes(1)
spy.mockRestore() // clean up

Fake - a lightweight working implementation that replaces a heavyweight one. An in-memory database instead of PostgreSQL. A local file store instead of S3

// fake in-memory database
class FakeDatabase {
  constructor() {
    this.data = new Map()
  }
  find(id) { return this.data.get(id) }
  save(id, record) { this.data.set(id, { id, ...record }) }
  delete(id) { return this.data.delete(id) }
}

// use in tests instead of real DB
const fakeDb = new FakeDatabase()
fakeDb.save('1', { name: 'omar' })
expect(fakeDb.find('1').name).toBe('omar')

Manual Mocks in mocks

Create a __mocks__/ directory next to the module you're mocking. Jest picks these up automatically when you call jest.mock()

// __mocks__/fs.js
const path = require('path')

const files = new Map()

module.exports = {
  promises: {
    readFile: jest.fn(async (filepath) => {
      if (!files.has(filepath)) {
        throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
      }
      return files.get(filepath)
    }),
    writeFile: jest.fn(async (filepath, content) => {
      files.set(filepath, content)
    }),
    unlink: jest.fn(async (filepath) => {
      files.delete(filepath)
    }),
  },
  // cleanup helper for tests
  __reset: () => files.clear(),
}
// test that uses the manual mock
jest.mock('fs')
const fs = require('fs/promises')
const { loadConfig } = require('./config')

test('loads config from file', async () => {
  await fs.writeFile('/tmp/test.json', JSON.stringify({ theme: 'dark' }))
  const config = await loadConfig('/tmp/test.json')
  expect(config.theme).toBe('dark')
})

jest.mock for Module Mocking

jest.mock() replaces all exports from a module with mock implementations. Use it at the top of your test file

const axios = require('axios')
const { getUserRepos } = require('./github')

// auto-mock the entire axios module
jest.mock('axios')

test('getUserRepos fetches repos from github', async () => {
  const mockRepos = [
    { name: 'project-a', stars: 42 },
    { name: 'project-b', stars: 7 },
  ]

  axios.get.mockResolvedValue({ data: mockRepos })

  const repos = await getUserRepos('0x1ris')

  expect(axios.get).toHaveBeenCalledWith('https://api.github.com/users/0x1ris/repos')
  expect(repos).toHaveLength(2)
  expect(repos[0].name).toBe('project-a')
})

test('getUserRepos handles API errors', async () => {
  axios.get.mockRejectedValue(new Error('Rate limited'))

  await expect(getUserRepos('0x1ris')).rejects.toThrow('Failed to fetch repos')
})

Partial mocking - mock one method but keep the rest real:

const db = require('./database')

// mock only the findUser method
jest.spyOn(db, 'findUser').mockImplementation((id) => {
  if (id === 'admin') return { id: 'admin', role: 'admin' }
  return null
})

// db.createUser still works normally

sinon for Older Codebases

Sinon was the standard before Jest built-in mocks. You'll find it in older projects and some Mocha-based setups

npm install --save-dev sinon
const sinon = require('sinon')
const { expect } = require('chai')

const db = require('./database')

describe('UserService', () => {
  let findUserStub

  afterEach(() => {
    sinon.restore()
  })

  it('returns user when found', async () => {
    findUserStub = sinon.stub(db, 'findUser').resolves({ id: '1', name: 'khaled' })
    const result = await getUser('1')
    expect(result.name).to.equal('khaled')
    sinon.assert.calledOnce(findUserStub)
  })

  it('throws when user not found', async () => {
    findUserStub = sinon.stub(db, 'findUser').resolves(null)
    try {
      await getUser('999')
      expect.fail('should have thrown')
    } catch (err) {
      expect(err.message).to.equal('User not found')
    }
  })
})

Sinon's clock manipulation is still useful for time-dependent code:

const clock = sinon.useFakeTimers(new Date('2026-01-15'))

// run test...
console.log(new Date().toISOString()) // "2026-01-15T00:00:00.000Z"

clock.tick(86400000) // advance 1 day
console.log(new Date().toISOString()) // "2026-01-16T00:00:00.000Z"

clock.restore()

Integration Testing with testcontainers

Sometimes mocking isn't enough. You need to test against a real database , Redis , or message queue. testcontainers spins up throwaway Docker containers for each test run

npm install --save-dev testcontainers
const { GenericContainer } = require('testcontainers')

let container
let redis

beforeAll(async () => {
  container = await new GenericContainer('redis:7-alpine')
    .withExposedPorts(6379)
    .start()

  const redis = require('redis').createClient({
    url: `redis://localhost:${container.getMappedPort(6379)}`
  })
  await redis.connect()
})

afterAll(async () => {
  await redis.quit()
  await container.stop()
})

test('stores and retrieves values from real Redis', async () => {
  await redis.set('key', 'test-value')
  const value = await redis.get('key')
  expect(value).toBe('test-value')
})

Testcontainers destroys the container after the test suite runs - no cleanup , no leftover state , no contamination between test runs. It's the closest you can get to production without leaving your dev machine

prerequisites

test_02_jest.md - Jest setup , matchers , async testing

next -> test_04_integration.md