16.4 Mock 타입 안전성 — 제네릭 Mock과 타입 안전한 Spy
타입 안전한 Mock 패턴
import { vi } from 'vitest'
// 1. vi.fn() 타입 명시
const mockFn = vi.fn<[string, number], boolean>()
mockFn.mockReturnValue(true)
mockFn.mockImplementation((str, num) => str.length > num)
// 타입 추론 — 콜백에서 자동 추론
const mockCallback = vi.fn((event: MouseEvent) => event.clientX)
// 2. 타입 안전한 객체 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 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
// 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')
// vi.mocked로 타입 안전한 모킹
const mockedAxios = vi.mocked(axios)
describe('fetchUser', () => {
it('사용자를 반환한다', async () => {
const mockUser: User = { id: '1', name: 'Alice', email: 'alice@example.com' }
// 타입 안전하게 mockResolvedValue 사용
mockedAxios.get.mockResolvedValue({ data: mockUser })
const result = await fetchUser('1')
expect(result).toEqual(mockUser)
expect(mockedAxios.get).toHaveBeenCalledWith('/api/users/1')
})
})
클래스 Mock
// database/connection.ts
export class DatabaseConnection {
constructor(private url: string) {}
async query<T>(sql: string, params?: unknown[]): Promise<T[]> {
// 실제 DB 쿼리
throw new Error('실제 연결 필요')
}
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'
// 클래스 Mock — vi.fn()으로 메서드 직접 교체
function createMockDb(): DatabaseConnection {
const mockDb = {
query: vi.fn<[string, unknown[]?], Promise<unknown[]>>(),
transaction: vi.fn(),
} as unknown as DatabaseConnection
// transaction이 콜백을 실행하도록 구현
vi.mocked(mockDb.transaction).mockImplementation(
(fn) => fn(mockDb)
)
return mockDb
}
describe('DatabaseConnection Mock', () => {
let db: DatabaseConnection
beforeEach(() => {
db = createMockDb()
})
it('쿼리를 실행한다', 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 패턴 — 실제 구현 유지하며 감시
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(`사용자 삭제: ${id}`, 'warn')
// 삭제 로직...
return true
}
}
describe('Spy 패턴', () => {
let logger: Logger
let service: UserService
beforeEach(() => {
logger = new Logger()
service = new UserService(logger)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('삭제 시 경고 로그를 남긴다', async () => {
// 실제 구현은 유지하고 spy만 추가
const logSpy = vi.spyOn(logger, 'log')
await service.deleteUser('user-123')
expect(logSpy).toHaveBeenCalledWith('사용자 삭제: user-123', 'warn')
})
it('console.log 출력 확인', async () => {
// 외부 의존성 spy
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await service.deleteUser('user-123')
expect(consoleSpy).toHaveBeenCalledWith('[WARN] 사용자 삭제: user-123')
})
})
테스트 픽스처 타입 안전성
// 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: '테스트 글',
content: '내용',
published: true,
authorId: 'user-1',
createdAt: new Date('2024-01-01'),
},
createUser: (overrides) => ({ ...fixtures.user, ...overrides }),
createPost: (overrides) => ({ ...fixtures.post, ...overrides }),
}
// 테스트에서 사용
import { fixtures } from './test/fixtures'
describe('PostService', () => {
it('게시글을 생성한다', async () => {
const user = fixtures.createUser({ role: 'ADMIN' })
const post = fixtures.createPost({ authorId: user.id })
// ...
})
})
고수 팁
Mock 재사용 가능한 팩토리
// 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>
// 테스트에서 사용
const mockService = createMockUserService()
mockService.findById.mockResolvedValue(fixtures.user)