우리는 보통 Mock, Stub, Test Double(=테스트 대역) 을 이야기 할 때 사전적 정의를 따르지 않고 뭉뚱그려 이야기하는 경우가 많다. 개념상 Mock 과 Stub 이 모두 Test Double 이기도 하고, Mockito, MockK와 같은 대표적인 Mock 라이브러리 들을 이용해 테스트 대역(Test Double)을 작성하기 때문에, Mock 라이브러리를 테스트 대역의 동의어로 여기기도 하기 때문이다. 그러나 테스트 대역의 무분별한 사용은 테스트 취약성을 초래할 수 있다. 따라서 테스트 취약성 관점에서 Mock, Stub, Test Doouble 를 구분하고, 좋은 테스트를 작성하기 위해 이들을 어떻게 사용해야 하는지 알아보자. (이 글은 ⌜Unit Testing⌟(블라디미르 코리코프) 내용을 토대로 작성된 글입니다)
1. Test Double, Mock, Stub 의 정의
Test Double 은 우리말로 테스트 대역이다. 테스트 대역이란 실제 프로덕트에서는 사용되지 않지만 테스트 목적으로 사용되는 가짜 의존성을 설명하는 포괄적인 용어이다. 테스트 대역은 테스트 대상 시스템(SUT) 에 실제 의존대신 대역을 전달함으로써 테스트를 편리하게 하기 위해 사용된다. 그리고 이러한 테스트 대역에는 크게 두 가지 종류가 있는데 이 둘이 Mock 과 Stub 이다. 정리하자면 다음과 같다.
- Test Double = Mock + Stub
그렇다면 Mock 과 Stub 은 어떤 차이가 있을까? 책에서는 이 둘의 차이를 다음과 같이 정의하고 있다.
- Mock : 목은 외부로 나가는 상호작용을 모방하고 검사하는 데 도움이 된다. 이러한 상호작용은 SUT가 상태를 변경하기 위한 의존성을 호출하는 것에 해당한다.
- Stub : 스텁은 내부로 들어오는 상호작용을 모방하는 데 도움이 된다. 이러한 상호작용은 SUT가 입력 데이터를 얻기 위한 의존성을 호출하는 것에 해당한다.
이에 따르면 Mock 과 Stub 은 상호작용의 방향을 기준으로 구분할 수 있다. 가령 외부의 알림 단위시스템을 통해 고객에게 PUSH 를 발송한다고 할 때, 해당 알림시스템에 PUSH 발송을 요청하는 동작은 Mock 을 통해 모방할 수 있다. 반면, 데이터 베이스에서 데이터를 조회하는 동작은 Stub 을 통해 모방할 수 있는 것이다.
2. Stub 으로 상호작용을 검증하지 말자
이처럼 상호작용의 방향성을 Mock 과 Stub 을 구분하는 이유는 좋은 테스트를 작성하기 위한 지침과 연관이 있다. Stub 과의 상호 작용을 검증하는 것이 테스트 취약성을 유발하기 때문이다. 지난 글에서 식별할 수 있는 동작의 최종 결과를 검증하는 테스트가 좋은 테스트임을 설명했다. 이러한 관점에서 볼 때 Stub 과의 상호작용은 SUT가 최종 결과를 산출하기 위한 입력을 제공할 뿐 SUT가 생성하는 최종 결과가 아니기에, Stub 과의 상호작용을 검증하는 것은 좋은 테스트라고 할 수 없는 것이다. 위에 언급했던 예시 상황을 간단한 코드로 구현하면 다음과 같다.
@Service
class PromotionService(
private val userRepository: UserRepository,
private val notificationPort: NotificationPort
) {
fun sendBirthDayPromotion() {
val user = userRepository.getUser()
if (user.isBirthDay()) {
notificationPort.sendPush(user)
}
}
}
데이터베이스에서 회원정보를 조회하고, 해당 회원이 생일인 경우 프로모션 PUSH 를 보내주는 가상의 상황이다. 여기서 Stub 과의 상호작용을 검증하는 간단한 테스트를 작성해보면 다음과 같다. 이는 SUT가 PUSH 발송을 위한 데이터를 조회하는 방법에 대한 세부 구현사항일 뿐이다. 재차 강조하지만 이러한 세부 구현을 검증하는 것은 취약한 테스트를 만든다.
class PromotionServiceTest : FunSpec({
test("생일 프로모션 알림 발송") {
// Given
val stub = mockk<UserRepository>()
every { stub.getUser(1) } returns User(1, "홍길동", LocalDate.of(1990, 3, 1))
val mock = mockk<NotificationPort>()
every { mock.sendPush(any()) } just runs
val sut = PromotionService(stub, mock)
// When
sut.sendBirthDayPromotion(1)
// Then
// Stub 과의 상호작용을 검증하는 나쁜 테스트다
stub.getUser(1) shouldBe User(1, "홍길동", LocalDate.of(1990, 3, 1))
}
})
반면, Mock 과의 상호작용은 SUT가 생성하는 최종 결과라고 볼 수 있다. 이는 비즈니스 담당자도 알 수 있게끔 외부에 드러나기 때문이다. 따라서 이를 검증하는 테스트는 충분히 의미가 있다.
class PromotionServiceTest : FunSpec({
test("생일 프로모션 알림 발송") {
// Given
val stub = mockk<UserRepository>()
every { stub.getUser(1) } returns User(1, "홍길동", LocalDate.of(1990, 3, 1))
val mock = mockk<NotificationPort>()
every { mock.sendPush(any()) } just runs
val sut = PromotionService(stub, mock)
// When
sut.sendBirthDayPromotion(1)
// Then
// Mock 과의 상호작용을 검증하는 좋은 테스트
verify(exactly = 1) {
mock.sendPush(User(1, "홍길동", LocalDate.of(1990, 3, 1)))
}
}
})
3. 시스템 내부 통신은 Mock 을 사용하지 말자
그럼 모든 외부 의존성에 대해 Mock 을 사용하는 것이 좋을까? 물론 아니다. 책의 저자는 시스템 간(외부) 통신에만 Mock 을 사용하는 것이 좋다고 한다. 외부 시스템의 동작은 어디서든 식별할 수 있는 동작이기 때문이다. 가령 알림 시스템에 알림발송을 요청할 때, 우리는 알림시스템이 제공하는 API 스펙을 준수할 수밖에 없다. 즉, 외부의 시스템은 우리 어플리케이션 통제하에 있는 게 아니므로, 우리 어플리케이션을 리팩토링 해도 이러한 통신 유형은 그대로 유지되어야 한다. 따라서 시스템 간 통신에 Mock 을 활용하는 건 리팩터링 내성에 영향을 주지 않고, 테스트 취약성을 야기하지 않는다. 반면, 시스템 내부 통신은 식별할 수 없는 동작이기에 Mock 을 사용해서는 안 된다. 고객이 상점에서 제품을 구매하는 예제를 통해 왜 그런지 살펴보자.
class Customer(
val id: Long,
val name: String,
) {
fun purchase(store: Store, product: Product, quantity: Int) {
store.purchase(product, quantity)
}
}
class Store(
val id: Long,
val name: String,
private val inventory: MutableMap<Product, Int>
) {
private fun hasEnough(product: Product, quantity: Int): Boolean {
return inventory[product]?.let { it >= quantity }
?: throw IllegalArgumentException("판매하지 않는 상품입니다.")
}
fun purchase(product: Product, quantity: Int): Boolean {
if (hasEnough(product, quantity).not()) {
return false
}
TODO("재고량 감소 로직")
return true
}
}
가령 위의 예시에서 Customer 의 purchase() 는 식별할 수 있는 동작이다. 그러나 Store 의 hasEnough() 는 식별할 수 있는 동작이 아니다. 따라서 Store 를 Mock으로 생성해 Customer 의 purchase() 를 테스트하는 건 취약한 테스트가 되는 것이다(이건 앞서 살펴본 런던파와 고전파 얘기의 연장선이기도 하다).
다만 시스템 간 통신의 Mock 사용에 대해 한 가지 유의할 점이 있다. 앞서 외부 시스템 동작은 '어디서든 식별할 수 있는 동작'이라고 얘기했다. 그런데 완전한 통제권을 가진 외부 의존성과의 통신은 식별할 수 있는 동작보다는 구현 상세와 가깝다. 외부에서 관찰할 수 없는 프로세스 외부 의존성은 어플리케이션의 일부로 작용하는 것이다. 가령 데이터베이스를 떠올려보자. 데이터베이스는 외부 클라이언트의 시야에서 완전히 숨어있다. 따라서 외부 의존성임에도 그 통신 패턴은 어플리케이션에서 원하는 대로 수정할 수 있다. 따라서 이러한 외부 의존성은 Mock 을 사용할 경우 취약한 테스트가 되기 쉽다. 데이터베이스에서 테이블을 수정하거나 레퍼지토리 메서드의 파라미터 타입을 변경할 때마다 테스트가 깨지는 건 아무도 원치 않을 것이다. 결국 Mock 사용에 대해 정리하자면 다음과 같다.
- 외부 어플리케이션과의 통신
- 통제권을 갖지 않은 외부 의존성(식별할 수 있는 동작) → Mock 사용
- 통제권을 갖고 있는 외부 의존성(구현상세) → Mock 사용 X
- 어플리케이션 내부 통신 → Mock 사용 X
4. 정리
테스트를 작성할 때 외부 의존성을 대체하기 위해 테스트 대역을 사용할 수 있다. 그리고 이러한 테스트 대역에는 목과 스텁이 있다. 이 둘을 구분하는 이유는 테스트 취약성과 관련이 있다. 스텁은 테스트 대상 시스템이 최종결과를 만들어내기 위한 데이터를 제공할 뿐이지, 그 자체로 비즈니스적으로 유효한 최종 결과가 아니기 때문에 스텁과의 상호작용을 검증하는 것은 테스트 취약성을 야기한다. 반면 목은 경우에 따라 테스트 대상 시스템이 만들어내는 최종 결과가 될 수 있다. 따라서 목과의 상호작용을 검증하는 테스트는 가치가 있다. 그렇다고 목과의 상호작용을 검증하는 테스트가 항상 좋은 것은 아니다. 어플리케이션 내부의 통신과 통제권을 갖고 있는 외부 의존성은 목을 사용하지 않는 것이 좋다. 이들 모두 외부 클라이언트가 식별할 수 있는 동작이 아닌 구현상세에 해당하기 때문이다.
'Web Development > Testing' 카테고리의 다른 글
[Testing] 단위 테스트 - 좋은 단위 테스트 작성하기 (0) | 2024.08.11 |
---|
댓글