모든 걸 mock 하면 진짜 로직은 누가 검증하는가?
TL;DR
단위 테스트에서 모든 의존성을 mock 처리하면 테스트는 통과하지만, 정작 중요한 도메인 로직은 검증되지 않는다. StockService를 mock에서 실제 구현체로 전환하며 느낀 "단위 테스트의 진짜 목적"에 대한 이야기.
문제를 발견한 순간
주문 기능의 단위 테스트를 작성하던 중, 이런 코드를 작성했다.
class OrderFacadeTest {
private val stockService: StockService = mockk()
@Test
fun throwsException_whenStockIsInsufficient() {
val currentStock = 50
val requestQuantity = 100
// StockService를 mock 처리
every { stockService.deductStock(productId, requestQuantity) } throws
CoreException(ErrorType.OUT_OF_STOCK, "재고가 부족합니다.")
// 테스트 실행
val exception = assertThrows<CoreException> {
orderFacade.createOrder(userId, orderItems)
}
assertThat(exception.errorType).isEqualTo(ErrorType.OUT_OF_STOCK)
}
}
테스트는 통과했다. 하지만 뭔가 찝찝했다.
"재고가 50인데 100을 요청하면 에러가 발생한다"는 걸 검증한 게 아니라, "내가 에러를 던지라고 설정했으니 에러가 발생한다"를 검증한 것뿐이었다.
실제 Stock 도메인의 재고 검증 로직은 전혀 실행되지 않았다.
Before - 모든 것을 Mock 했던 코드
class OrderFacadeTest {
private val productService: ProductService = mockk()
private val stockService: StockService = mockk() // 전부 mock
private val pointService: PointService = mockk()
@Test
fun throwsException_whenStockIsInsufficient() {
// StockService의 동작을 직접 정의
every { stockService.deductStock(productId, 100) } throws
CoreException(ErrorType.OUT_OF_STOCK, "재고가 부족합니다.")
// 테스트 실행...
}
}
왜 문제인가?
Stock 도메인의 실제 비즈니스 로직은 대략 이렇게 생겼다.
// Stock.kt
class Stock(
val productId: Long,
var quantity: Int,
) : BaseEntity() {
fun deduct(requestQuantity: Int) {
if (quantity < requestQuantity) {
throw CoreException(
ErrorType.OUT_OF_STOCK,
"재고가 부족합니다. [요청: $requestQuantity, 현재: $quantity]"
)
}
quantity -= requestQuantity
}
}
// StockService.kt
class StockService(
private val stockRepository: StockRepository,
) {
fun deductStock(productId: Long, quantity: Int) {
val stock = stockRepository.findByProductIdWithLock(productId)
?: throw CoreException(ErrorType.OUT_OF_STOCK, "재고 정보가 없습니다.")
stock.deduct(quantity) // 핵심 도메인 로직
}
}
Stock.deduct() 의 재고 검증 로직이 핵심인데, StockService를 통째로 mock 하면 이 로직이 실행조차 되지 않는다.
테스트는 "내가 설정한 대로 동작하는지"만 확인할 뿐, "실제 재고 검증 로직이 제대로 동작하는지"는 검증하지 못한다.
After - 핵심 로직은 실제로 동작시키기
고민 끝에 이렇게 변경했다.
class OrderFacadeTest {
private val productService: ProductService = mockk()
private val stockRepository: StockRepository = mockk() // Repository만 mock
private lateinit var stockService: StockService // 실제 구현체
private val pointService: PointService = mockk()
@BeforeEach
fun setUp() {
// StockService는 실제 구현체를 사용
stockService = StockService(stockRepository)
orderFacade = OrderFacade(
productService = productService,
stockService = stockService, // 실제 구현체 주입
pointService = pointService,
// ...
)
}
@Test
fun throwsException_whenStockIsInsufficient() {
val currentStock = 50
val requestQuantity = 100
// Repository에서 Stock 엔티티를 반환하도록 설정
val stock = StockFixture.create(
productId = productId,
quantity = currentStock, // 실제 재고 수량 설정
)
every { stockRepository.findByProductIdWithLock(productId) } returns stock
// 테스트 실행
val exception = assertThrows<CoreException> {
orderFacade.createOrder(userId, orderItems)
}
// 이제 Stock.deduct()가 실제로 동작해서 에러를 던진다
assertThat(exception.errorType).isEqualTo(ErrorType.OUT_OF_STOCK)
assertThat(exception.message).contains("재고가 부족합니다")
}
}
무엇이 달라졌나?
| Before | After |
|---|---|
| stockService.deductStock()를 mock | stockRepository.findByProductIdWithLock()만 mock |
| Stock 도메인 로직 실행 안 됨 | Stock.deduct()가 실제로 실행됨 |
| "에러를 던지라고 설정했으니 던진다" | "재고 50 < 요청 100 이므로 에러 발생" |
이제 테스트는 진짜 비즈니스 로직을 검증한다.
판단 기준 - 어디까지 Mock 하고 어디까지 실제로 동작시킬까?
이 경험을 통해 나만의 기준이 생겼다.
Infrastructure 계층 (DB, 외부 API) → Mock
Domain 계층 (비즈니스 로직) → 실제 동작
Application 계층 (조합/조정) → 테스트 대상
구체적인 예시
// 이렇게 하지 않기
private val stockService: StockService = mockk()
every { stockService.deductStock(...) } throws ...
// 이렇게 하기
private val stockRepository: StockRepository = mockk() // DB 접근만 mock
private lateinit var stockService: StockService // 비즈니스 로직은 실제 동작
@BeforeEach
fun setUp() {
stockService = StockService(stockRepository)
}
결과 - 테스트에 대한 신뢰도가 높아졌다
- 실제 버그를 잡을 수 있게 됨: Stock 도메인의 검증 로직에 버그가 있으면 테스트가 실패한다
- 리팩토링이 안전해짐: Stock 내부 로직을 변경해도 테스트가 제대로 검증한다
- 테스트 코드가 명확해짐: "재고 50, 요청 100"이라는 비즈니스 의미가 드러난다
배운 점
1. 단위 테스트의 "단위"는 클래스가 아니라 "동작"이다
처음엔 "단위 테스트 = 클래스 하나만 테스트"라고 생각했다. 그래서 모든 의존성을 mock 했다.
하지만 진짜 중요한 건 "의미 있는 동작 하나를 검증하는 것"이었다. StockService와 Stock을 함께 테스트해도 "재고 차감"이라는 하나의 동작을 검증한다면 충분히 단위 테스트다.
2. Mock은 도구일 뿐, 목적이 아니다
Mock을 많이 쓴다고 좋은 테스트가 아니다. 핵심 로직은 실제로 동작시키고, 외부 의존성(DB, API)만 격리하는 게 더 나은 전략이다.
3. 통합 테스트와의 균형
"그럼 모든 걸 실제로 동작시키면 통합 테스트 아닌가?"라는 의문에는
- 통합 테스트: DB도 실제로, 모든 Bean도 실제로 (느림)
- 이 방식의 단위 테스트: Repository는 mock, Domain은 실제로 (빠름)
여전히 빠르게 실행되면서도, 핵심 로직은 제대로 검증할 수 있다.
마무리
처음 테스트를 작성할 땐 "일단 mock으로 다 처리하고 테스트만 통과시키자"는 생각이었다. 하지만 그건 테스트를 위한 테스트였다.
"이 테스트가 실제로 버그를 잡을 수 있을까?"를 계속 고민하다 보니, 핵심 도메인 로직은 실제로 동작시키는 방향으로 자연스럽게 바뀌었다.
완벽한 정답은 아니지만, 적어도 이제는 테스트가 통과할 때 "진짜 로직이 검증됐다"는 확신이 든다.