Skip to main content
Advertisement

16.4 Mock Type Safety — Generic Mocks and Type-Safe Spies

Type-Safe Mock Patterns

import { vi } from 'vitest'

// 1. Explicit vi.fn() types
const mockFn = vi.fn<[string, number], boolean>()
mockFn.mockReturnValue(true)
mockFn.mockImplementation((str, num) => str.length > num)

// Type inference — auto-inferred from callback
const mockCallback = vi.fn((event: MouseEvent) => event.clientX)

// 2. Type-safe object Mock
type UserService = {
findById(id: string): Promise<User | null>
create(data: CreateUserDto): Promise<User>
update(id: string, data: UpdateUserDto): Promise<User>
delete(id: string): Promise<void>
}

// DeepMock type
type DeepMock<T> = {
[K in keyof T]: T[K] extends (...args: any[]) => any
? ReturnType<typeof vi.fn> & T[K]
: T[K]
}

function createMock<T>(overrides?: Partial<T>): DeepMock<T> {
return new Proxy({} as DeepMock<T>, {
get(target, prop) {
if (prop in target) return (target as any)[prop]
const mock = vi.fn()
;(target as any)[prop] = mock
return mock
},
})
}

const mockUserService = createMock<UserService>()
mockUserService.findById.mockResolvedValue(null)

vi.mocked — Mock Without Type Casting

// api/fetch-user.ts
import axios from 'axios'

export async function fetchUser(id: string): Promise<User> {
const { data } = await axios.get<User>(`/api/users/${id}`)
return data
}
// api/fetch-user.test.ts
import { describe, it, expect, vi } from 'vitest'
import axios from 'axios'
import { fetchUser } from './fetch-user'

vi.mock('axios')

// Type-safe mocking with vi.mocked
const mockedAxios = vi.mocked(axios)

describe('fetchUser', () => {
it('returns user', async () => {
const mockUser: User = { id: '1', name: 'Alice', email: 'alice@example.com' }

// Use mockResolvedValue type-safely
mockedAxios.get.mockResolvedValue({ data: mockUser })

const result = await fetchUser('1')

expect(result).toEqual(mockUser)
expect(mockedAxios.get).toHaveBeenCalledWith('/api/users/1')
})
})

Class Mocking

// database/connection.ts
export class DatabaseConnection {
constructor(private url: string) {}

async query<T>(sql: string, params?: unknown[]): Promise<T[]> {
// Actual DB query
throw new Error('Real connection required')
}

async transaction<T>(fn: (db: DatabaseConnection) => Promise<T>): Promise<T> {
return fn(this)
}
}
// database/connection.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { DatabaseConnection } from './connection'

// Class Mock — replace methods directly with vi.fn()
function createMockDb(): DatabaseConnection {
const mockDb = {
query: vi.fn<[string, unknown[]?], Promise<unknown[]>>(),
transaction: vi.fn(),
} as unknown as DatabaseConnection

// Make transaction execute the callback
vi.mocked(mockDb.transaction).mockImplementation(
(fn) => fn(mockDb)
)

return mockDb
}

describe('DatabaseConnection Mock', () => {
let db: DatabaseConnection

beforeEach(() => {
db = createMockDb()
})

it('executes query', async () => {
const mockResult = [{ id: 1, name: 'Alice' }]
vi.mocked(db.query).mockResolvedValue(mockResult)

const result = await db.query('SELECT * FROM users')

expect(result).toEqual(mockResult)
})
})

Spy Pattern — Monitor While Keeping Real Implementation

import { describe, it, expect, vi, afterEach } from 'vitest'

class Logger {
log(message: string, level: 'info' | 'warn' | 'error' = 'info') {
console.log(`[${level.toUpperCase()}] ${message}`)
}

error(message: string) {
this.log(message, 'error')
}
}

class UserService {
constructor(private logger: Logger) {}

async deleteUser(id: string) {
this.logger.log(`Deleting user: ${id}`, 'warn')
// Deletion logic...
return true
}
}

describe('Spy pattern', () => {
let logger: Logger
let service: UserService

beforeEach(() => {
logger = new Logger()
service = new UserService(logger)
})

afterEach(() => {
vi.restoreAllMocks()
})

it('logs warning when deleting', async () => {
// Add spy while keeping real implementation
const logSpy = vi.spyOn(logger, 'log')

await service.deleteUser('user-123')

expect(logSpy).toHaveBeenCalledWith('Deleting user: user-123', 'warn')
})

it('verifies console.log output', async () => {
// Spy on external dependency
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})

await service.deleteUser('user-123')

expect(consoleSpy).toHaveBeenCalledWith('[WARN] Deleting user: user-123')
})
})

Type-Safe Test Fixtures

// test/fixtures.ts
export interface TestFixtures {
user: User
adminUser: User
post: Post
createUser: (overrides?: Partial<User>) => User
createPost: (overrides?: Partial<Post>) => Post
}

export const fixtures: TestFixtures = {
user: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
role: 'USER',
createdAt: new Date('2024-01-01'),
},
adminUser: {
id: 'admin-1',
name: 'Admin',
email: 'admin@example.com',
role: 'ADMIN',
createdAt: new Date('2024-01-01'),
},
post: {
id: 'post-1',
title: 'Test Post',
content: 'Content',
published: true,
authorId: 'user-1',
createdAt: new Date('2024-01-01'),
},
createUser: (overrides) => ({ ...fixtures.user, ...overrides }),
createPost: (overrides) => ({ ...fixtures.post, ...overrides }),
}

// Usage in tests
import { fixtures } from './test/fixtures'

describe('PostService', () => {
it('creates post', async () => {
const user = fixtures.createUser({ role: 'ADMIN' })
const post = fixtures.createPost({ authorId: user.id })
// ...
})
})

Pro Tips

Reusable Mock Factories

// test/factories.ts
import { vi } from 'vitest'

export function createMockUserService() {
return {
findById: vi.fn<[string], Promise<User | null>>().mockResolvedValue(null),
findAll: vi.fn<[], Promise<User[]>>().mockResolvedValue([]),
create: vi.fn<[CreateUserDto], Promise<User>>(),
update: vi.fn<[string, UpdateUserDto], Promise<User>>(),
delete: vi.fn<[string], Promise<void>>().mockResolvedValue(undefined),
}
}

export type MockUserService = ReturnType<typeof createMockUserService>

// Usage in tests
const mockService = createMockUserService()
mockService.findById.mockResolvedValue(fixtures.user)
Advertisement