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)