Mocking
On this page 29
Mocking allows you to replace dependencies with controlled substitutes during testing. Stacks provides comprehensive mocking utilities for functions, modules, HTTP requests, and more.
Overview
Mocking helps you:
- Isolate code - Test units without dependencies
- Control behavior - Define exact responses
- Verify interactions - Assert function calls
- Avoid side effects - No real HTTP requests, emails, etc.
Function Mocking
Creating Mocks
import { describe, expect, it, mock } from 'bun:test'
describe('Mocking', () => {
it('creates a mock function', () => {
const mockFn = mock(() => 'mocked value')
const result = mockFn()
expect(result).toBe('mocked value')
expect(mockFn).toHaveBeenCalled()
expect(mockFn).toHaveBeenCalledTimes(1)
})
it('tracks call arguments', () => {
const mockFn = mock((a: number, b: number) => a + b)
mockFn(1, 2)
mockFn(3, 4)
expect(mockFn).toHaveBeenCalledWith(1, 2)
expect(mockFn).toHaveBeenCalledWith(3, 4)
expect(mockFn).toHaveBeenCalledTimes(2)
})
})
Spying on Functions
import { describe, expect, it, spyOn } from 'bun:test'
import * as mathUtils from '@/utils/math'
describe('Spying', () => {
it('spies on existing function', () => {
const spy = spyOn(mathUtils, 'add')
mathUtils.add(1, 2)
expect(spy).toHaveBeenCalledWith(1, 2)
expect(spy).toHaveReturned()
})
it('replaces implementation', () => {
const spy = spyOn(mathUtils, 'add').mockReturnValue(100)
const result = mathUtils.add(1, 2)
expect(result).toBe(100) // Not 3
expect(spy).toHaveBeenCalled()
})
})
Mock Return Values
import { mock } from 'bun:test'
// Return a value
const mockFn = mock().mockReturnValue('value')
// Return different values on successive calls
const mockFn = mock()
.mockReturnValueOnce('first')
.mockReturnValueOnce('second')
.mockReturnValue('default')
mockFn() // 'first'
mockFn() // 'second'
mockFn() // 'default'
mockFn() // 'default'
// Return resolved promise
const asyncMock = mock().mockResolvedValue({ data: 'test' })
await asyncMock() // { data: 'test' }
// Return rejected promise
const errorMock = mock().mockRejectedValue(new Error('Failed'))
await errorMock() // throws Error: Failed
Mock Implementations
import { mock } from 'bun:test'
// Custom implementation
const mockFn = mock().mockImplementation((x: number) => x * 2)
mockFn(5) // 10
// One-time implementation
const mockFn = mock()
.mockImplementationOnce(() => 'first call')
.mockImplementation(() => 'other calls')
mockFn() // 'first call'
mockFn() // 'other calls'
Module Mocking
Mocking Entire Modules
import { beforeAll, describe, expect, it, mock } from 'bun:test'
// Mock module before importing code that uses it
mock.module('@/services/email', () => ({
sendEmail: mock().mockResolvedValue({ sent: true }),
sendBulkEmail: mock().mockResolvedValue({ sent: 5 }),
}))
import { notifyUser } from '@/services/notification'
describe('Notification Service', () => {
it('sends email notification', async () => {
const result = await notifyUser(1, 'Hello!')
expect(result.sent).toBe(true)
})
})
Partial Module Mocking
import { mock } from 'bun:test'
// Keep original implementations except specified
mock.module('@/services/api', () => {
const actual = require('@/services/api')
return {
...actual,
fetchUser: mock().mockResolvedValue({ id: 1, name: 'Mock User' }),
}
})
Restoring Mocks
import { afterEach, describe, it, mock, spyOn } from 'bun:test'
import * as api from '@/services/api'
describe('API Tests', () => {
afterEach(() => {
// Restore all mocks
mock.restore()
})
it('mocks API call', () => {
const spy = spyOn(api, 'fetchData').mockReturnValue({ data: 'mocked' })
// Test code...
spy.mockRestore() // Restore just this spy
})
})
HTTP Mocking
Mocking Fetch
import { describe, expect, it, mock, spyOn } from 'bun:test'
describe('API Client', () => {
it('mocks fetch requests', async () => {
const mockFetch = spyOn(global, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ id: 1, name: 'Test' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
const response = await fetch('/api/users/1')
const data = await response.json()
expect(data.name).toBe('Test')
expect(mockFetch).toHaveBeenCalledWith('/api/users/1')
})
it('mocks different responses', async () => {
spyOn(global, 'fetch')
.mockResolvedValueOnce(new Response(JSON.stringify({ page: 1 })))
.mockResolvedValueOnce(new Response(JSON.stringify({ page: 2 })))
const first = await fetch('/api/items').then(r => r.json())
const second = await fetch('/api/items').then(r => r.json())
expect(first.page).toBe(1)
expect(second.page).toBe(2)
})
it('mocks fetch errors', async () => {
spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'))
await expect(fetch('/api/users')).rejects.toThrow('Network error')
})
})
Using a Real Server
There is no mockServer helper. Bun.serve starts a real one in a couple of
lines, and a real server on a real port exercises the parts a mock skips - the
fetch call, the status code, the JSON round-trip:
import type { Server } from 'bun'
import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
let server: Server
beforeAll(() => {
server = Bun.serve({
port: 0, // any free port, so parallel test files never collide
fetch(request) {
const { pathname } = new URL(request.url)
if (request.method = 'GET' && pathname = '/api/users')
return Response.json([{ id: 1, name: 'User 1' }])
if (request.method = 'POST' && pathname = '/api/users')
return request.json().then(body => Response.json({ id: 2, ...body }, { status: 201 }))
const match = pathname.match(/^\/api\/users\/(\d+)$/)
if (match)
return Response.json({ id: match[1], name: `User ${match[1]}` })
return new Response('Not Found', { status: 404 })
},
})
})
afterAll(() => {
server.stop()
})
describe('External API', () => {
it('fetches users', async () => {
const users = await apiClient.getUsers({ baseUrl: server.url.origin })
expect(users).toHaveLength(1)
})
it('creates user', async () => {
const user = await apiClient.createUser({ name: 'New User' }, { baseUrl: server.url.origin })
expect(user.id).toBe(2)
})
})
port: 0 matters: a hard-coded port makes two test files that both start a
server fail intermittently, and the failure looks like a bug in the code under
test.
For a client whose base URL is not injectable, mock the module instead:
import { mock } from 'bun:test'
mock.module('./api-client', () => ({
getUsers: async () => [{ id: 1, name: 'User 1' }],
}))
Note that mock.module is never rolled back between test files in the same
process - Bun does not restore it - so a file that mocks a module should mock
it for the whole file, not for one test.
Time Mocking
Mocking Date/Time
import { afterEach, beforeEach, describe, expect, it, setSystemTime } from 'bun:test'
describe('Time-dependent code', () => {
beforeEach(() => {
// Freeze time at specific date
setSystemTime(new Date('2024-01-15T12:00:00Z'))
})
afterEach(() => {
// Restore real time
setSystemTime()
})
it('uses mocked time', () => {
const now = new Date()
expect(now.toISOString()).toBe('2024-01-15T12:00:00.000Z')
})
it('calculates expiry correctly', () => {
const token = createToken({ expiresIn: 3600 }) // 1 hour
expect(token.expiresAt).toEqual(new Date('2024-01-15T13:00:00Z'))
})
it('detects expired tokens', () => {
const token = { expiresAt: new Date('2024-01-15T11:00:00Z') }
expect(isExpired(token)).toBe(true)
})
})
Advancing Time
import { setSystemTime } from 'bun:test'
it('handles time progression', () => {
setSystemTime(new Date('2024-01-15T12:00:00Z'))
const scheduler = new TaskScheduler()
scheduler.scheduleTask(() => {}, { delay: 3600000 }) // 1 hour
// Advance time by 1 hour
setSystemTime(new Date('2024-01-15T13:00:00Z'))
expect(scheduler.hasPendingTasks()).toBe(false)
})
Mocking External Services
Database Mocking
import { describe, expect, it, mock } from 'bun:test'
mock.module('@stacksjs/database', () => ({
db: {
selectFrom: mock().mockReturnValue({
where: mock().mockReturnValue({
selectAll: mock().mockReturnValue({
execute: mock().mockResolvedValue([
{ id: 1, name: 'Mock User' },
]),
}),
}),
}),
},
}))
import { UserService } from '@/services/UserService'
describe('UserService', () => {
it('returns users from database', async () => {
const users = await UserService.findAll()
expect(users).toHaveLength(1)
expect(users[0].name).toBe('Mock User')
})
})
Email Service Mocking
import { describe, expect, it } from 'bun:test'
import { emailsTo, lastEmail, mailFake, sentEmails } from '@stacksjs/testing'
describe('Email Notifications', () => {
// Redirects the shared `mail` singleton into memory. Restored automatically
// after each test, so a fake cannot swallow the next test's email.
beforeEach(() => mailFake())
it('sends welcome email', async () => {
await sendWelcomeEmail('user@example.com')
expect(sentEmails()).toHaveLength(1)
expect(lastEmail()?.to).toBe('user@example.com')
expect(lastEmail()?.subject).toContain('Welcome')
})
it('sends to every recipient', async () => {
await sendBulkNewsletter(['a@test.com', 'b@test.com'])
expect(sentEmails()).toHaveLength(2)
expect(emailsTo('a@test.com')).toHaveLength(1)
})
})
sentEmails() covers queued mail too. mail.queue() dispatches to the
SendEmailJob handler, which calls mail.send() - so a queued message is
captured once the worker runs it. To assert the DISPATCH rather than the send,
fake the queue instead:
import { fake, getFakeQueue } from '@stacksjs/queue'
fake()
await sendBulkNewsletter(['a@test.com', 'b@test.com'])
expect(getFakeQueue()?.dispatched('SendEmailJob')).toHaveLength(2)
Queue Mocking
import { describe, expect, it } from 'bun:test'
import { fake, getFakeQueue, restore } from '@stacksjs/queue'
describe('Order Processing', () => {
beforeEach(() => {
fake() // Enable queue faking
})
afterEach(() => {
restore() // Restore real queue
})
it('dispatches order processing job', async () => {
await createOrder({ productId: 1, quantity: 2 })
const fakeQueue = getFakeQueue()
expect(fakeQueue.hasDispatched('ProcessOrder')).toBe(true)
expect(fakeQueue.dispatched('ProcessOrder')[0].data).toEqual({
productId: 1,
quantity: 2,
})
})
})
Mock Assertions
Call Assertions
import { expect, mock } from 'bun:test'
const mockFn = mock()
mockFn('a')
mockFn('b', 'c')
// Called at all
expect(mockFn).toHaveBeenCalled()
// Called specific number of times
expect(mockFn).toHaveBeenCalledTimes(2)
// Called with specific arguments
expect(mockFn).toHaveBeenCalledWith('a')
expect(mockFn).toHaveBeenCalledWith('b', 'c')
// Last call arguments
expect(mockFn).toHaveBeenLastCalledWith('b', 'c')
// Nth call arguments
expect(mockFn).toHaveBeenNthCalledWith(1, 'a')
expect(mockFn).toHaveBeenNthCalledWith(2, 'b', 'c')
Return Value Assertions
const mockFn = mock()
.mockReturnValueOnce(1)
.mockReturnValueOnce(2)
mockFn() // 1
mockFn() // 2
expect(mockFn).toHaveReturned()
expect(mockFn).toHaveReturnedTimes(2)
expect(mockFn).toHaveReturnedWith(1)
expect(mockFn).toHaveLastReturnedWith(2)
expect(mockFn).toHaveNthReturnedWith(1, 1)
Clearing Mocks
const mockFn = mock()
mockFn('test')
expect(mockFn).toHaveBeenCalled()
// Clear call history but keep implementation
mockFn.mockClear()
expect(mockFn).not.toHaveBeenCalled()
// Reset everything (calls + implementation)
mockFn.mockReset()
// Restore original (for spies)
mockFn.mockRestore()
Best Practices
DO
- Mock at boundaries - External APIs, databases, file system
- Use minimal mocks - Only mock what's necessary
- Verify interactions - Assert mocks were called correctly
- Clean up mocks - Restore in afterEach/afterAll
- Mock consistently - Same mock behavior across related tests
DON'T
- Don't over-mock - Too many mocks indicate tight coupling
- Don't mock implementation - Mock behavior, not internals
- Don't share mock state - Reset between tests
- Don't mock what you own - Use real implementations where possible
Example: Good vs Bad Mocking
// BAD: Mocking implementation details
const mockInternalMethod = spyOn(userService, '_hashPassword')
// GOOD: Mocking external boundary
const mockBcrypt = spyOn(bcrypt, 'hash').mockResolvedValue('hashed')
// BAD: Testing mock, not real behavior
const mock = mock().mockReturnValue(true)
expect(mock()).toBe(true) // Testing the mock itself
// GOOD: Testing real code with mocked dependencies
spyOn(emailService, 'send').mockResolvedValue({ sent: true })
const result = await notifyUser(1) // Tests notifyUser, not mock
expect(result.notified).toBe(true)
Related Documentation
- Testing Overview - Getting started with testing
- Unit Tests - Testing isolated functions
- HTTP Tests - Testing API endpoints
- Database Testing - Database test utilities