mockk란?
mockk는 Kotlin을 위한 mocking 라이브러리입니다. Java의 Mockito와 비슷한 역할을 하지만, Kotlin의 언어 특성(final class, extension function, coroutine, object 등)을 자연스럽게 지원한다는 차이가 있습니다.
val userRepository = mockk<UserRepository>()
every { userRepository.findById(1L) } returns User(1L, "Alice")
val service = UserService(userRepository)
val user = service.getUser(1L)
verify { userRepository.findById(1L) }기본적인 사용법은 직관적이지만, mockk()로 만든 mock에는 한 가지 불편함이 있습니다.
기본 mockk()의 한계: 모든 메서드를 stub 해야 한다
mockk<T>()로 mock을 만들면, stub하지 않은 메서드를 호출할 때 예외가 발생합니다.
interface NotificationSender {
fun sendEmail(to: String, subject: String)
fun sendSms(to: String, message: String)
fun sendPush(userId: Long, title: String)
}
val sender = mockk<NotificationSender>()
// 어떤 메서드도 stub하지 않은 상태
sender.sendEmail("a@b.c", "Hello")
// → io.mockk.MockKException: no answer found for NotificationSender(#1).sendEmail(...)테스트에서 검증하고 싶은 동작은 sendEmail 하나뿐인데, NotificationSender의 다른 메서드(sendSms, sendPush)가 호출될 가능성이 있다면 그것들도 모두 every {}로 stub해야 합니다.
val sender = mockk<NotificationSender>()
every { sender.sendEmail(any(), any()) } just Runs
every { sender.sendSms(any(), any()) } just Runs // 관심 없는데도 선언해야 함
every { sender.sendPush(any(), any()) } just Runs // 관심 없는데도 선언해야 함의존성이 큰 객체일수록 이 stub 선언이 길어지고, 테스트의 본질이 boilerplate에 묻혀버립니다.
relaxed = true
mockk(relaxed = true)는 이 문제를 해결합니다. stub하지 않은 메서드를 호출하면 반환 타입의 “기본값”을 자동으로 돌려줍니다.
val sender = mockk<NotificationSender>(relaxed = true)
sender.sendEmail("a@b.c", "Hello") // 정상 동작 (Unit 반환)
sender.sendSms("010", "msg") // 정상 동작
sender.sendPush(1L, "title") // 정상 동작타입별 기본값
relaxed mock이 자동으로 반환하는 값은 타입에 따라 다릅니다.
| 반환 타입 | 기본값 |
|---|---|
Unit |
Unit |
Int, Long |
0 |
Boolean |
false |
String |
"" (빈 문자열) |
List<T> |
emptyList() |
Map<K, V> |
emptyMap() |
일반 객체 T |
또 다른 relaxed mock |
nullable T? |
null |
마지막 줄이 중요합니다. 반환 타입이 또 다른 객체라면, mockk가 그 객체에 대해서도 재귀적으로 relaxed mock을 만들어 반환합니다. 이를 통해 깊은 호출 체인도 NPE 없이 동작하게 됩니다.
interface OrderService {
fun findOrder(id: Long): Order
}
class Order {
fun getCustomer(): Customer = ...
}
class Customer {
fun getName(): String = ...
}
val orderService = mockk<OrderService>(relaxed = true)
// 어떤 stub도 없지만 NPE 없이 동작
val name = orderService.findOrder(1L).getCustomer().getName()
// name == ""relaxUnitFun = true
전체를 relaxed로 만드는 게 부담스러울 때 쓰는 더 약한 옵션이 있습니다. relaxUnitFun = true는 Unit을 반환하는 함수만 relaxed로 처리합니다.
val sender = mockk<NotificationSender>(relaxUnitFun = true)
sender.sendEmail("a@b.c", "Hello") // OK (Unit 반환)
// 반환값이 있는 함수는 여전히 stub 필요
every { sender.someMethodReturningString() } returns "value"대부분의 의존성은 다음 패턴 중 하나입니다.
- 부수효과만 일으키는 메서드 (
Unit반환): 이메일 발송, 이벤트 발행, 로깅 등 - 데이터를 조회하는 메서드 (값 반환): Repository, Query Service 등
relaxUnitFun = true는 1번 케이스만 자동으로 통과시키고, 2번 케이스(값을 반환하는 메서드)는 명시적으로 stub하도록 강제합니다. 이 덕분에 “반환값이 있는 메서드는 테스트가 의도한 값을 명시적으로 선언했는가?”를 자연스럽게 유도합니다.
어떤 옵션을 언제 써야 할까?
| 상황 | 권장 옵션 |
|---|---|
| 모든 메서드를 명확히 통제하고 싶다 (엄격한 단위 테스트) | mockk<T>() |
| 부수효과 메서드는 신경 안 쓰고, 반환값만 통제하고 싶다 | mockk<T>(relaxUnitFun = true) |
| 의존성이 너무 크고, 호출 자체보다 결과 검증이 중심이다 | mockk<T>(relaxed = true) |
개인적으로는 relaxUnitFun = true를 기본값으로 두고, 정말 모든 메서드를 무시해도 되는 경우에만 relaxed = true를 사용하는 편을 선호합니다. 그 이유는 다음 절에서 설명합니다.
relaxed = true의 함정
편하지만 무분별하게 쓰면 테스트의 의미가 흐려집니다.
1. 잘못된 기본값이 테스트를 통과시킨다
relaxed = true로 만든 mock은 Boolean을 묻는 메서드에 무조건 false를 돌려줍니다.
interface PermissionChecker {
fun canDelete(userId: Long, postId: Long): Boolean
}
val checker = mockk<PermissionChecker>(relaxed = true)
val service = PostService(checker)
// 실제로는 권한이 없는 사용자도 통과되는지 검증하고 싶었는데,
// checker.canDelete()가 항상 false를 반환하므로
// "권한 없음" 케이스만 우연히 통과해버린다
service.deletePost(userId = 999L, postId = 1L)테스트는 통과하지만, 무엇을 검증했는지 모호해집니다. canDelete가 어떤 값을 돌려준 시점인지 명시되어 있지 않기 때문입니다.
2. 빈 컬렉션이 의도와 다르게 동작한다
List<T>를 반환하는 메서드는 기본적으로 emptyList()를 돌려줍니다. 이 때문에 “비어있을 때의 분기”가 항상 타게 됩니다.
interface OrderRepository {
fun findRecentOrders(userId: Long): List<Order>
}
val repo = mockk<OrderRepository>(relaxed = true)
val service = RecommendationService(repo)
// "최근 주문이 비어있을 때 인기 상품을 추천한다"는 분기만 실행됨
// 다른 분기는 테스트되지 않음
val recommendations = service.recommend(userId = 1L)이 테스트는 “주문 이력 기반 추천 로직”을 검증한다고 착각하기 쉽지만, 실제로는 fallback 경로만 검증하고 있습니다.
3. verify로 검증하기 모호해진다
relaxed = true는 호출 자체를 허용하기 때문에, “이 메서드가 정말 호출되어야 했는가?”를 별도로 verify하지 않으면 누락을 잡지 못합니다.
val sender = mockk<NotificationSender>(relaxed = true)
val service = OrderService(sender)
service.placeOrder(order)
// sendEmail이 호출되지 않아도 테스트는 통과한다
// verify { sender.sendEmail(any(), any()) }를 명시해야 검증됨relaxed = true를 쓸수록 verify의 책임이 무거워진다는 점을 기억해야 합니다.
실무에서의 권장 패턴
1. 기본은 relaxUnitFun = true로
부수효과 메서드(이벤트 발행, 로깅, 알림 등)는 대부분 Unit을 반환합니다. 이런 메서드까지 stub하는 건 noise이므로, relaxUnitFun = true로 자동 통과시키되, 값을 반환하는 메서드는 명시적으로 stub합니다.
@Test
fun `주문 생성 시 알림이 발송된다`() {
val orderRepository = mockk<OrderRepository>(relaxUnitFun = true)
val notificationSender = mockk<NotificationSender>(relaxUnitFun = true)
every { orderRepository.save(any()) } returns Order(id = 1L)
val service = OrderService(orderRepository, notificationSender)
service.placeOrder(orderRequest)
verify { notificationSender.sendEmail(any(), any()) }
}2. 검증 대상이 아닌 의존성에만 relaxed = true
테스트가 “이 메서드의 결과” 가 아니라 “이 메서드가 호출되는지” 에 관심이 있을 때, 또는 의존성이 너무 깊어 stub 비용이 큰 경우에만 relaxed = true를 씁니다.
@Test
fun `결제 실패 시 보상 트랜잭션이 실행된다`() {
// 로거는 어떻게 호출되든 상관없음 → relaxed
val logger = mockk<Logger>(relaxed = true)
// 결제 클라이언트는 명시적으로 실패 시나리오 설정
val paymentClient = mockk<PaymentClient>()
every { paymentClient.charge(any()) } throws PaymentException("declined")
val refundService = mockk<RefundService>(relaxUnitFun = true)
val service = OrderService(paymentClient, refundService, logger)
service.placeOrder(orderRequest)
verify { refundService.refund(any()) }
}Mock은 설계의 거울이다
mockk의 옵션들을 자세히 보면, 단순한 편의 기능을 넘어 두 가지 객체지향 원칙을 라이브러리 차원에서 권장하고 있다는 걸 알 수 있습니다.
재귀 mock과 Law of Demeter
앞서 relaxed = true가 객체 반환 메서드에 대해 재귀적으로 mock을 만든다고 설명했습니다. 이 동작이 편리하다고 느껴진다면, 한 번 의심해봐야 합니다.
// 이 코드가 stub 하나 없이 통과한다는 사실 자체가...
val name = orderService.findOrder(1L).getCustomer().getName()a.b().c().d() 형태의 깊은 체이닝은 Law of Demeter(최소 지식 원칙)를 위반합니다. “친구의 친구에게 말하지 말라” — 객체는 직접적인 협력자하고만 대화해야 한다는 원칙입니다.
mock 없이 자연스럽게 통과하는 깊은 체이닝은 프로덕션 코드에 다음과 같은 문제를 시사합니다.
- 호출자가
Customer의 내부 구조에 결합되어 있다 order.getCustomer().getName()대신order.customerName()처럼 위임된 인터페이스가 없다Customer구조가 바뀌면 호출하는 모든 코드가 깨진다
재귀 mock의 편의성을 “리팩토링이 필요하다는 신호” 로 해석할 수 있습니다.
relaxUnitFun과 CQS
relaxUnitFun 옵션이 별도로 존재한다는 사실은 Command-Query Separation(CQS) 원칙을 라이브러리 차원에서 권장한다고 볼 수 있습니다.
CQS는 메서드를 두 종류로 나눕니다.
- Command: 부수효과를 일으키고, 값을 반환하지 않는다 (
Unit) - Query: 값을 반환하고, 부수효과가 없다
relaxUnitFun = true는 Command는 자동으로 통과시키되, Query는 명시적으로 stub하도록 강제합니다. 이 구분은 mock 사용 패턴을 자연스럽게 유도합니다.
- Command는
verify로 검증한다 (“호출되었는가?“) - Query는
every로 통제한다 (“어떤 값을 돌려주는가?“)
같은 메서드에 every와 verify를 동시에 걸어야 한다면, 그 메서드는 Command와 Query를 혼합하고 있을 가능성이 높습니다.
// JPA Repository의 save는 영속화(Command)하면서 동시에 엔티티를 반환(Query)
interface UserRepository {
fun save(user: User): User
}
// 같은 메서드에 stub과 verify가 모두 필요해진다
every { userRepository.save(any()) } returns User(id = 1L, name = "Alice")
// ... 비즈니스 로직 실행
verify { userRepository.save(match { it.name == "Alice" }) }JPA save처럼 현실에서는 CQS를 엄격히 지키기 어려운 경우도 많습니다. 다만 mock 코드의 모양을 통해 “이 메서드는 두 역할을 동시에 하고 있다”는 점을 인지할 수 있습니다.
시그널 정리
mocking이 어색하게 느껴진다면, 그게 보내는 신호는 다음과 같습니다.
| mocking 시 느끼는 불편 | 시사하는 설계 문제 | 원칙 |
|---|---|---|
| 재귀 mock이 너무 편하다 | 깊은 체이닝, 객체 그래프 침투 | Law of Demeter |
| 같은 메서드에 every + verify가 필요 | Command와 Query 혼합 | CQS |
테스트가 어색하면, 옵션을 추가하기 전에 한 번 의심해보세요. 프로덕션 코드가 보내는 신호일 수 있습니다.
정리
| 옵션 | 동작 | 적합한 경우 |
|---|---|---|
mockk<T>() |
stub 없으면 예외 | 모든 호출을 명시적으로 통제하고 싶을 때 |
mockk<T>(relaxUnitFun = true) |
Unit 반환 메서드만 자동 통과 |
일반적인 단위 테스트의 기본값 |
mockk<T>(relaxed = true) |
모든 메서드가 타입별 기본값 반환 (재귀 mock 포함) | 검증 대상이 아닌 의존성, 큰 인터페이스 |
relaxed = true는 편의 기능이지 품질 도구가 아닙니다. 무엇을 검증하는 테스트인지 모호해지지 않도록, 가능한 좁은 범위에서 사용하는 것이 좋습니다.