Mocking and Stubbing - Don't Touch the Network¶
Table of Contents¶
- Why Mock : External Dependencies , Timing , Randomness
- Mock vs Stub vs Spy vs Fake
- Manual Mocks in mocks
- jest.mock for Module Mocking
- sinon for Older Codebases
- Integration Testing with testcontainers
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