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)